diff --git a/.ai/design-system.md b/.ai/design-system.md new file mode 100644 index 000000000..d22adf3c6 --- /dev/null +++ b/.ai/design-system.md @@ -0,0 +1,1666 @@ +# Coolify Design System + +> **Purpose**: AI/LLM-consumable reference for replicating Coolify's visual design in new applications. Contains design tokens, component styles, and interactive states — with both Tailwind CSS classes and plain CSS equivalents. + +--- + +## 1. Design Tokens + +### 1.1 Colors + +#### Brand / Accent + +| Token | Hex | Usage | +|---|---|---| +| `coollabs` | `#6b16ed` | Primary accent (light mode) | +| `coollabs-50` | `#f5f0ff` | Highlighted button bg (light) | +| `coollabs-100` | `#7317ff` | Highlighted button hover (dark) | +| `coollabs-200` | `#5a12c7` | Highlighted button text (light) | +| `coollabs-300` | `#4a0fa3` | Deepest brand shade | +| `warning` / `warning-400` | `#fcd452` | Primary accent (dark mode) | + +#### Warning Scale (used for dark-mode accent + callouts) + +| Token | Hex | +|---|---| +| `warning-50` | `#fefce8` | +| `warning-100` | `#fef9c3` | +| `warning-200` | `#fef08a` | +| `warning-300` | `#fde047` | +| `warning-400` | `#fcd452` | +| `warning-500` | `#facc15` | +| `warning-600` | `#ca8a04` | +| `warning-700` | `#a16207` | +| `warning-800` | `#854d0e` | +| `warning-900` | `#713f12` | + +#### Neutral Grays (dark mode backgrounds) + +| Token | Hex | Usage | +|---|---|---| +| `base` | `#101010` | Page background (dark) | +| `coolgray-100` | `#181818` | Component background (dark) | +| `coolgray-200` | `#202020` | Elevated surface / borders (dark) | +| `coolgray-300` | `#242424` | Input border shadow / hover (dark) | +| `coolgray-400` | `#282828` | Tooltip background (dark) | +| `coolgray-500` | `#323232` | Subtle hover overlays (dark) | + +#### Semantic + +| Token | Hex | Usage | +|---|---|---| +| `success` | `#22C55E` | Running status, success alerts | +| `error` | `#dc2626` | Stopped status, danger actions, error alerts | + +#### Light Mode Defaults + +| Element | Color | +|---|---| +| Page background | `gray-50` (`#f9fafb`) | +| Component background | `white` (`#ffffff`) | +| Borders | `neutral-200` (`#e5e5e5`) | +| Primary text | `black` (`#000000`) | +| Muted text | `neutral-500` (`#737373`) | +| Placeholder text | `neutral-300` (`#d4d4d4`) | + +### 1.2 Typography + +**Font family**: Inter, sans-serif (weights 100–900, woff2, `font-display: swap`) + +#### Heading Hierarchy + +> **CRITICAL**: All headings and titles (h1–h4, card titles, modal titles) MUST be `white` (`#fff`) in dark mode. The default body text color is `neutral-400` (`#a3a3a3`) — headings must override this to white or they will be nearly invisible on dark backgrounds. + +| Element | Tailwind | Plain CSS (light) | Plain CSS (dark) | +|---|---|---|---| +| `h1` | `text-3xl font-bold dark:text-white` | `font-size: 1.875rem; font-weight: 700; color: #000;` | `color: #fff;` | +| `h2` | `text-xl font-bold dark:text-white` | `font-size: 1.25rem; font-weight: 700; color: #000;` | `color: #fff;` | +| `h3` | `text-lg font-bold dark:text-white` | `font-size: 1.125rem; font-weight: 700; color: #000;` | `color: #fff;` | +| `h4` | `text-base font-bold dark:text-white` | `font-size: 1rem; font-weight: 700; color: #000;` | `color: #fff;` | + +#### Body Text + +| Context | Tailwind | Plain CSS | +|---|---|---| +| Body default | `text-sm antialiased` | `font-size: 0.875rem; line-height: 1.25rem; -webkit-font-smoothing: antialiased;` | +| Labels | `text-sm font-medium` | `font-size: 0.875rem; font-weight: 500;` | +| Badge/status text | `text-xs font-bold` | `font-size: 0.75rem; line-height: 1rem; font-weight: 700;` | +| Box description | `text-xs font-bold text-neutral-500` | `font-size: 0.75rem; font-weight: 700; color: #737373;` | + +### 1.3 Spacing Patterns + +| Context | Value | CSS | +|---|---|---| +| Component internal padding | `p-2` | `padding: 0.5rem;` | +| Callout padding | `p-4` | `padding: 1rem;` | +| Input vertical padding | `py-1.5` | `padding-top: 0.375rem; padding-bottom: 0.375rem;` | +| Button height | `h-8` | `height: 2rem;` | +| Button horizontal padding | `px-2` | `padding-left: 0.5rem; padding-right: 0.5rem;` | +| Button gap | `gap-2` | `gap: 0.5rem;` | +| Menu item padding | `px-2 py-1` | `padding: 0.25rem 0.5rem;` | +| Menu item gap | `gap-3` | `gap: 0.75rem;` | +| Section margin | `mb-12` | `margin-bottom: 3rem;` | +| Card min-height | `min-h-[4rem]` | `min-height: 4rem;` | + +### 1.4 Border Radius + +| Context | Tailwind | Plain CSS | +|---|---|---| +| Default (inputs, buttons, cards, modals) | `rounded-sm` | `border-radius: 0.125rem;` | +| Callouts | `rounded-lg` | `border-radius: 0.5rem;` | +| Badges | `rounded-full` | `border-radius: 9999px;` | +| Cards (coolbox variant) | `rounded` | `border-radius: 0.25rem;` | + +### 1.5 Shadows + +#### Input / Select Box-Shadow System + +Coolify uses **inset box-shadows instead of borders** for inputs and selects. This enables a unique "dirty indicator" — a colored left-edge bar. + +```css +/* Default state */ +box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px #e5e5e5; + +/* Default state (dark) */ +box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px #242424; + +/* Focus state (light) — purple left bar */ +box-shadow: inset 4px 0 0 #6b16ed, inset 0 0 0 2px #e5e5e5; + +/* Focus state (dark) — yellow left bar */ +box-shadow: inset 4px 0 0 #fcd452, inset 0 0 0 2px #242424; + +/* Dirty (modified) state — same as focus */ +box-shadow: inset 4px 0 0 #6b16ed, inset 0 0 0 2px #e5e5e5; /* light */ +box-shadow: inset 4px 0 0 #fcd452, inset 0 0 0 2px #242424; /* dark */ + +/* Disabled / Readonly */ +box-shadow: none; +``` + +#### Input-Sticky Variant (thinner border) + +```css +/* Uses 1px border instead of 2px */ +box-shadow: inset 4px 0 0 transparent, inset 0 0 0 1px #e5e5e5; +``` + +### 1.6 Focus Ring System + +All interactive elements (buttons, links, checkboxes) share this focus pattern: + +**Tailwind:** +``` +focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base +``` + +**Plain CSS:** +```css +:focus-visible { + outline: none; + box-shadow: 0 0 0 2px #101010, 0 0 0 4px #6b16ed; /* light */ +} + +/* dark mode */ +.dark :focus-visible { + box-shadow: 0 0 0 2px #101010, 0 0 0 4px #fcd452; +} +``` + +> **Note**: Inputs use the inset box-shadow system (section 1.5) instead of the ring system. + +--- + +## 2. Dark Mode Strategy + +- **Toggle method**: Class-based — `.dark` class on `` element +- **CSS variant**: `@custom-variant dark (&:where(.dark, .dark *));` +- **Default border override**: All elements default to `border-color: var(--color-coolgray-200)` (`#202020`) instead of `currentcolor` + +### Accent Color Swap + +| Context | Light | Dark | +|---|---|---| +| Primary accent | `coollabs` (`#6b16ed`) | `warning` (`#fcd452`) | +| Focus ring | `ring-coollabs` | `ring-warning` | +| Input focus bar | `#6b16ed` (purple) | `#fcd452` (yellow) | +| Active nav text | `text-black` | `text-warning` | +| Helper/highlight text | `text-coollabs` | `text-warning` | +| Loading spinner | `text-coollabs` | `text-warning` | +| Scrollbar thumb | `coollabs-100` | `coollabs-100` | + +### Background Hierarchy (dark) + +``` +#101010 (base) — page background + └─ #181818 (coolgray-100) — cards, inputs, components + └─ #202020 (coolgray-200) — elevated surfaces, borders, nav active + └─ #242424 (coolgray-300) — input borders (via box-shadow), button borders + └─ #282828 (coolgray-400) — tooltips, hover states + └─ #323232 (coolgray-500) — subtle overlays +``` + +### Background Hierarchy (light) + +``` +#f9fafb (gray-50) — page background + └─ #ffffff (white) — cards, inputs, components + └─ #e5e5e5 (neutral-200) — borders + └─ #f5f5f5 (neutral-100) — hover backgrounds + └─ #d4d4d4 (neutral-300) — deeper hover, nav active +``` + +--- + +## 3. Component Catalog + +### 3.1 Button + +#### Default + +**Tailwind:** +``` +flex gap-2 justify-center items-center px-2 h-8 text-sm text-black normal-case rounded-sm +border-2 outline-0 cursor-pointer font-medium bg-white border-neutral-200 hover:bg-neutral-100 +dark:bg-coolgray-100 dark:text-white dark:hover:text-white dark:hover:bg-coolgray-200 +dark:border-coolgray-300 hover:text-black disabled:cursor-not-allowed min-w-fit +dark:disabled:text-neutral-600 disabled:border-transparent disabled:hover:bg-transparent +disabled:bg-transparent disabled:text-neutral-300 +focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs +dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base +``` + +**Plain CSS:** +```css +.button { + display: flex; + gap: 0.5rem; + justify-content: center; + align-items: center; + padding: 0 0.5rem; + height: 2rem; + font-size: 0.875rem; + font-weight: 500; + text-transform: none; + color: #000; + background: #fff; + border: 2px solid #e5e5e5; + border-radius: 0.125rem; + outline: 0; + cursor: pointer; + min-width: fit-content; +} +.button:hover { background: #f5f5f5; } + +/* Dark */ +.dark .button { + background: #181818; + color: #fff; + border-color: #242424; +} +.dark .button:hover { + background: #202020; + color: #fff; +} + +/* Disabled */ +.button:disabled { + cursor: not-allowed; + border-color: transparent; + background: transparent; + color: #d4d4d4; +} +.dark .button:disabled { color: #525252; } +``` + +#### Highlighted (Primary Action) + +**Tailwind** (via `isHighlighted` attribute): +``` +text-coollabs-200 dark:text-white bg-coollabs-50 dark:bg-coollabs/20 +border-coollabs dark:border-coollabs-100 hover:bg-coollabs hover:text-white +dark:hover:bg-coollabs-100 dark:hover:text-white +``` + +**Plain CSS:** +```css +.button-highlighted { + color: #5a12c7; + background: #f5f0ff; + border-color: #6b16ed; +} +.button-highlighted:hover { + background: #6b16ed; + color: #fff; +} +.dark .button-highlighted { + color: #fff; + background: rgba(107, 22, 237, 0.2); + border-color: #7317ff; +} +.dark .button-highlighted:hover { + background: #7317ff; + color: #fff; +} +``` + +#### Error / Danger + +**Tailwind** (via `isError` attribute): +``` +text-red-800 dark:text-red-300 bg-red-50 dark:bg-red-900/30 +border-red-300 dark:border-red-800 hover:bg-red-300 hover:text-white +dark:hover:bg-red-800 dark:hover:text-white +``` + +**Plain CSS:** +```css +.button-error { + color: #991b1b; + background: #fef2f2; + border-color: #fca5a5; +} +.button-error:hover { + background: #fca5a5; + color: #fff; +} +.dark .button-error { + color: #fca5a5; + background: rgba(127, 29, 29, 0.3); + border-color: #991b1b; +} +.dark .button-error:hover { + background: #991b1b; + color: #fff; +} +``` + +#### Loading Indicator + +Buttons automatically show a spinner (SVG with `animate-spin`) next to their content during async operations. The spinner uses the accent color (`text-coollabs` / `text-warning`). + +--- + +### 3.2 Input + +**Tailwind:** +``` +block py-1.5 w-full text-sm text-black rounded-sm border-0 +dark:bg-coolgray-100 dark:text-white +disabled:bg-neutral-200 disabled:text-neutral-500 dark:disabled:bg-coolgray-100/40 +dark:read-only:text-neutral-500 dark:read-only:bg-coolgray-100/40 +placeholder:text-neutral-300 dark:placeholder:text-neutral-700 +read-only:text-neutral-500 read-only:bg-neutral-200 +focus-visible:outline-none +``` + +**Plain CSS:** +```css +.input { + display: block; + padding: 0.375rem 0.5rem; + width: 100%; + font-size: 0.875rem; + color: #000; + background: #fff; + border: 0; + border-radius: 0.125rem; + box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px #e5e5e5; +} +.input:focus-visible { + outline: none; + box-shadow: inset 4px 0 0 #6b16ed, inset 0 0 0 2px #e5e5e5; +} +.input::placeholder { color: #d4d4d4; } +.input:disabled { background: #e5e5e5; color: #737373; box-shadow: none; } +.input:read-only { color: #737373; background: #e5e5e5; box-shadow: none; } +.input[type="password"] { padding-right: 2.4rem; } + +/* Dark */ +.dark .input { + background: #181818; + color: #fff; + box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px #242424; +} +.dark .input:focus-visible { + box-shadow: inset 4px 0 0 #fcd452, inset 0 0 0 2px #242424; +} +.dark .input::placeholder { color: #404040; } +.dark .input:disabled { background: rgba(24, 24, 24, 0.4); box-shadow: none; } +.dark .input:read-only { color: #737373; background: rgba(24, 24, 24, 0.4); box-shadow: none; } +``` + +#### Dirty (Modified) State + +When an input value has been changed but not saved, a 4px colored left bar appears via box-shadow — same colors as focus state. This provides a visual indicator that the field has unsaved changes. + +--- + +### 3.3 Select + +Same base styles as Input, plus a custom dropdown arrow SVG: + +**Tailwind:** +``` +w-full block py-1.5 text-sm text-black rounded-sm border-0 +dark:bg-coolgray-100 dark:text-white +disabled:bg-neutral-200 disabled:text-neutral-500 dark:disabled:bg-coolgray-100/40 +focus-visible:outline-none +``` + +**Additional plain CSS for the dropdown arrow:** +```css +.select { + /* ...same as .input base... */ + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='1.5' stroke='%23000000'%3e%3cpath stroke-linecap='round' stroke-linejoin='round' d='M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9'/%3e%3c/svg%3e"); + background-position: right 0.5rem center; + background-repeat: no-repeat; + background-size: 1rem 1rem; + padding-right: 2.5rem; + appearance: none; +} + +/* Dark mode: white stroke arrow */ +.dark .select { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='1.5' stroke='%23ffffff'%3e%3cpath stroke-linecap='round' stroke-linejoin='round' d='M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9'/%3e%3c/svg%3e"); +} +``` + +--- + +### 3.4 Checkbox + +**Tailwind:** +``` +dark:border-neutral-700 text-coolgray-400 dark:bg-coolgray-100 rounded-sm cursor-pointer +dark:disabled:bg-base dark:disabled:cursor-not-allowed +focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs +dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base +``` + +**Container:** +``` +flex flex-row items-center gap-4 pr-2 py-1 form-control min-w-fit +dark:hover:bg-coolgray-100 cursor-pointer +``` + +**Plain CSS:** +```css +.checkbox { + border-color: #404040; + color: #282828; + background: #181818; + border-radius: 0.125rem; + cursor: pointer; +} +.checkbox:focus-visible { + outline: none; + box-shadow: 0 0 0 2px #101010, 0 0 0 4px #fcd452; +} + +.checkbox-container { + display: flex; + flex-direction: row; + align-items: center; + gap: 1rem; + padding: 0.25rem 0.5rem 0.25rem 0; + min-width: fit-content; + cursor: pointer; +} +.dark .checkbox-container:hover { background: #181818; } +``` + +--- + +### 3.5 Textarea + +Uses `font-mono` for monospace text. Supports tab key insertion (2 spaces). + +**Important**: Large/multiline textareas should NOT use the inset box-shadow left-border system from `.input`. Use a simple border instead: + +**Tailwind:** +``` +block w-full text-sm text-black rounded-sm border border-neutral-200 +dark:bg-coolgray-100 dark:text-white dark:border-coolgray-300 +font-mono focus-visible:outline-none focus-visible:ring-2 +focus-visible:ring-coollabs dark:focus-visible:ring-warning +focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base +``` + +**Plain CSS:** +```css +.textarea { + display: block; + width: 100%; + font-size: 0.875rem; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + color: #000; + background: #fff; + border: 1px solid #e5e5e5; + border-radius: 0.125rem; +} +.textarea:focus-visible { + outline: none; + box-shadow: 0 0 0 2px #fff, 0 0 0 4px #6b16ed; +} +.dark .textarea { + background: #181818; + color: #fff; + border-color: #242424; +} +.dark .textarea:focus-visible { + box-shadow: 0 0 0 2px #101010, 0 0 0 4px #fcd452; +} +``` + +> **Note**: The 4px inset left-border (dirty/focus indicator) is only for single-line inputs and selects, not textareas. + +--- + +### 3.6 Box / Card + +#### Standard Box + +**Tailwind:** +``` +relative flex lg:flex-row flex-col p-2 transition-colors cursor-pointer min-h-[4rem] +dark:bg-coolgray-100 shadow-sm bg-white border text-black dark:text-white hover:text-black +border-neutral-200 dark:border-coolgray-300 hover:bg-neutral-100 +dark:hover:bg-coollabs-100 dark:hover:text-white hover:no-underline rounded-sm +``` + +**Plain CSS:** +```css +.box { + position: relative; + display: flex; + flex-direction: column; + padding: 0.5rem; + min-height: 4rem; + background: #fff; + border: 1px solid #e5e5e5; + border-radius: 0.125rem; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + color: #000; + cursor: pointer; + transition: background-color 150ms, color 150ms; + text-decoration: none; +} +.box:hover { background: #f5f5f5; color: #000; } + +.dark .box { + background: #181818; + border-color: #242424; + color: #fff; +} +.dark .box:hover { + background: #7317ff; + color: #fff; +} + +/* IMPORTANT: child text must also turn white/black on hover, + since description text (#737373) is invisible on purple bg */ +.box:hover .box-title { color: #000; } +.box:hover .box-description { color: #000; } +.dark .box:hover .box-title { color: #fff; } +.dark .box:hover .box-description { color: #fff; } + +/* Desktop: row layout */ +@media (min-width: 1024px) { + .box { flex-direction: row; } +} +``` + +#### Coolbox (Ring Hover) + +**Tailwind:** +``` +relative flex transition-all duration-150 dark:bg-coolgray-100 bg-white p-2 rounded +border border-neutral-200 dark:border-coolgray-400 hover:ring-2 +dark:hover:ring-warning hover:ring-coollabs cursor-pointer min-h-[4rem] +``` + +**Plain CSS:** +```css +.coolbox { + position: relative; + display: flex; + padding: 0.5rem; + min-height: 4rem; + background: #fff; + border: 1px solid #e5e5e5; + border-radius: 0.25rem; + cursor: pointer; + transition: all 150ms; +} +.coolbox:hover { box-shadow: 0 0 0 2px #6b16ed; } + +.dark .coolbox { + background: #181818; + border-color: #282828; +} +.dark .coolbox:hover { box-shadow: 0 0 0 2px #fcd452; } +``` + +#### Box Text + +> **IMPORTANT — Dark mode titles**: Card/box titles MUST be `#fff` (white) in dark mode, not the default body text color (`#a3a3a3` / neutral-400). A black or grey title is nearly invisible on dark backgrounds (`#181818`). This applies to all heading-level text inside cards. + +```css +.box-title { + font-weight: 700; + color: #000; /* light mode: black */ +} +.dark .box-title { + color: #fff; /* dark mode: MUST be white, not grey */ +} + +.box-description { + font-size: 0.75rem; + font-weight: 700; + color: #737373; +} +/* On hover: description must become visible against colored bg */ +.box:hover .box-description { color: #000; } +.dark .box:hover .box-description { color: #fff; } +``` + +--- + +### 3.7 Badge / Status Indicator + +**Tailwind:** +``` +inline-block w-3 h-3 text-xs font-bold rounded-full leading-none +border border-neutral-200 dark:border-black +``` + +**Variants**: `badge-success` (`bg-success`), `badge-warning` (`bg-warning`), `badge-error` (`bg-error`) + +**Plain CSS:** +```css +.badge { + display: inline-block; + width: 0.75rem; + height: 0.75rem; + border-radius: 9999px; + border: 1px solid #e5e5e5; +} +.dark .badge { border-color: #000; } + +.badge-success { background: #22C55E; } +.badge-warning { background: #fcd452; } +.badge-error { background: #dc2626; } +``` + +#### Status Text Pattern + +Status indicators combine a badge dot with text: + +```html +
diff --git a/app/Actions/Database/StartDatabaseProxy.php b/app/Actions/Database/StartDatabaseProxy.php
index c634f14ba..4331c6ae7 100644
--- a/app/Actions/Database/StartDatabaseProxy.php
+++ b/app/Actions/Database/StartDatabaseProxy.php
@@ -112,12 +112,52 @@ class StartDatabaseProxy
$dockercompose_base64 = base64_encode(Yaml::dump($docker_compose, 4, 2));
$nginxconf_base64 = base64_encode($nginxconf);
instant_remote_process(["docker rm -f $proxyContainerName"], $server, false);
- instant_remote_process([
- "mkdir -p $configuration_dir",
- "echo '{$nginxconf_base64}' | base64 -d | tee $configuration_dir/nginx.conf > /dev/null",
- "echo '{$dockercompose_base64}' | base64 -d | tee $configuration_dir/docker-compose.yaml > /dev/null",
- "docker compose --project-directory {$configuration_dir} pull",
- "docker compose --project-directory {$configuration_dir} up -d",
- ], $server);
+
+ try {
+ instant_remote_process([
+ "mkdir -p $configuration_dir",
+ "echo '{$nginxconf_base64}' | base64 -d | tee $configuration_dir/nginx.conf > /dev/null",
+ "echo '{$dockercompose_base64}' | base64 -d | tee $configuration_dir/docker-compose.yaml > /dev/null",
+ "docker compose --project-directory {$configuration_dir} pull",
+ "docker compose --project-directory {$configuration_dir} up -d",
+ ], $server);
+ } catch (\RuntimeException $e) {
+ if ($this->isNonTransientError($e->getMessage())) {
+ $database->update(['is_public' => false]);
+
+ $team = data_get($database, 'environment.project.team')
+ ?? data_get($database, 'service.environment.project.team');
+
+ $team?->notify(
+ new \App\Notifications\Container\ContainerRestarted(
+ "TCP Proxy for {$database->name} database has been disabled due to error: {$e->getMessage()}",
+ $server,
+ )
+ );
+
+ ray("Database proxy for {$database->name} disabled due to non-transient error: {$e->getMessage()}");
+
+ return;
+ }
+
+ throw $e;
+ }
+ }
+
+ private function isNonTransientError(string $message): bool
+ {
+ $nonTransientPatterns = [
+ 'port is already allocated',
+ 'address already in use',
+ 'Bind for',
+ ];
+
+ foreach ($nonTransientPatterns as $pattern) {
+ if (str_contains($message, $pattern)) {
+ return true;
+ }
+ }
+
+ return false;
}
}
diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php
index 58f3cda4e..fe80a7d54 100644
--- a/app/Actions/Database/StartKeydb.php
+++ b/app/Actions/Database/StartKeydb.php
@@ -207,6 +207,9 @@ class StartKeydb
if ($this->database->enable_ssl) {
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
}
+ if (! is_null($this->database->keydb_conf) && ! empty($this->database->keydb_conf)) {
+ $this->commands[] = "chown 999:999 $this->configuration_dir/keydb.conf";
+ }
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php
index 4e4f3ce53..70df91054 100644
--- a/app/Actions/Database/StartRedis.php
+++ b/app/Actions/Database/StartRedis.php
@@ -204,6 +204,9 @@ class StartRedis
if ($this->database->enable_ssl) {
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
}
+ if (! is_null($this->database->redis_conf) && ! empty($this->database->redis_conf)) {
+ $this->commands[] = "chown 999:999 $this->configuration_dir/redis.conf";
+ }
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
diff --git a/app/Actions/Server/InstallDocker.php b/app/Actions/Server/InstallDocker.php
index d718d3735..31e582c9b 100644
--- a/app/Actions/Server/InstallDocker.php
+++ b/app/Actions/Server/InstallDocker.php
@@ -30,12 +30,14 @@ class InstallDocker
);
$caCertPath = config('constants.coolify.base_config_path').'/ssl/';
+ $base64Cert = base64_encode($serverCert->ssl_certificate);
+
$commands = collect([
"mkdir -p $caCertPath",
"chown -R 9999:root $caCertPath",
"chmod -R 700 $caCertPath",
"rm -rf $caCertPath/coolify-ca.crt",
- "echo '{$serverCert->ssl_certificate}' > $caCertPath/coolify-ca.crt",
+ "echo '{$base64Cert}' | base64 -d | tee $caCertPath/coolify-ca.crt > /dev/null",
"chmod 644 $caCertPath/coolify-ca.crt",
]);
remote_process($commands, $server);
diff --git a/app/Console/Commands/Generate/OpenApi.php b/app/Console/Commands/Generate/OpenApi.php
index 2b266c258..224c10792 100644
--- a/app/Console/Commands/Generate/OpenApi.php
+++ b/app/Console/Commands/Generate/OpenApi.php
@@ -32,7 +32,8 @@ class OpenApi extends Command
echo $process->output();
$yaml = file_get_contents('openapi.yaml');
- $json = json_encode(Yaml::parse($yaml), JSON_PRETTY_PRINT);
+
+ $json = json_encode(Yaml::parse($yaml), JSON_PRETTY_PRINT)."\n";
file_put_contents('openapi.json', $json);
echo "Converted OpenAPI YAML to JSON.\n";
}
diff --git a/app/Console/Commands/GenerateTestingSchema.php b/app/Console/Commands/GenerateTestingSchema.php
new file mode 100644
index 000000000..00fd90c25
--- /dev/null
+++ b/app/Console/Commands/GenerateTestingSchema.php
@@ -0,0 +1,222 @@
+ 'INTEGER',
+ '/\binteger\b/' => 'INTEGER',
+ '/\bsmallint\b/' => 'INTEGER',
+ '/\bboolean\b/' => 'INTEGER',
+ '/character varying\(\d+\)/' => 'TEXT',
+ '/timestamp\(\d+\) without time zone/' => 'TEXT',
+ '/timestamp\(\d+\) with time zone/' => 'TEXT',
+ '/\bjsonb\b/' => 'TEXT',
+ '/\bjson\b/' => 'TEXT',
+ '/\buuid\b/' => 'TEXT',
+ '/double precision/' => 'REAL',
+ '/numeric\(\d+,\d+\)/' => 'REAL',
+ '/\bdate\b/' => 'TEXT',
+ ];
+
+ private array $castRemovals = [
+ '::character varying',
+ '::text',
+ '::integer',
+ '::boolean',
+ '::timestamp without time zone',
+ '::timestamp with time zone',
+ '::numeric',
+ ];
+
+ public function handle(): int
+ {
+ $connection = $this->option('connection');
+
+ if (DB::connection($connection)->getDriverName() !== 'pgsql') {
+ $this->error("Connection '{$connection}' is not PostgreSQL.");
+
+ return self::FAILURE;
+ }
+
+ $this->info('Reading schema from PostgreSQL...');
+
+ $tables = $this->getTables($connection);
+ $lastMigration = DB::connection($connection)
+ ->table('migrations')
+ ->orderByDesc('id')
+ ->value('migration');
+
+ $output = [];
+ $output[] = '-- Generated by: php artisan schema:generate-testing';
+ $output[] = '-- Date: '.now()->format('Y-m-d H:i:s');
+ $output[] = '-- Last migration: '.($lastMigration ?? 'none');
+ $output[] = '';
+
+ foreach ($tables as $table) {
+ $columns = $this->getColumns($connection, $table);
+ $output[] = $this->generateCreateTable($table, $columns);
+ }
+
+ $indexes = $this->getIndexes($connection, $tables);
+ foreach ($indexes as $index) {
+ $output[] = $index;
+ }
+
+ $output[] = '';
+ $output[] = '-- Migration records';
+
+ $migrations = DB::connection($connection)->table('migrations')->orderBy('id')->get();
+ foreach ($migrations as $m) {
+ $migration = str_replace("'", "''", $m->migration);
+ $output[] = "INSERT INTO \"migrations\" (\"id\", \"migration\", \"batch\") VALUES ({$m->id}, '{$migration}', {$m->batch});";
+ }
+
+ $path = database_path('schema/testing-schema.sql');
+
+ if (! is_dir(dirname($path))) {
+ mkdir(dirname($path), 0755, true);
+ }
+
+ file_put_contents($path, implode("\n", $output)."\n");
+
+ $this->info("Schema written to {$path}");
+ $this->info(count($tables).' tables, '.count($migrations).' migration records.');
+
+ return self::SUCCESS;
+ }
+
+ private function getTables(string $connection): array
+ {
+ return collect(DB::connection($connection)->select(
+ "SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename"
+ ))->pluck('tablename')->toArray();
+ }
+
+ private function getColumns(string $connection, string $table): array
+ {
+ return DB::connection($connection)->select(
+ "SELECT column_name, data_type, character_maximum_length, column_default,
+ is_nullable, udt_name, numeric_precision, numeric_scale, datetime_precision
+ FROM information_schema.columns
+ WHERE table_schema = 'public' AND table_name = ?
+ ORDER BY ordinal_position",
+ [$table]
+ );
+ }
+
+ private function generateCreateTable(string $table, array $columns): string
+ {
+ $lines = [];
+
+ foreach ($columns as $col) {
+ $lines[] = ' '.$this->generateColumnDef($table, $col);
+ }
+
+ return "CREATE TABLE IF NOT EXISTS \"{$table}\" (\n".implode(",\n", $lines)."\n);\n";
+ }
+
+ private function generateColumnDef(string $table, object $col): string
+ {
+ $name = $col->column_name;
+ $sqliteType = $this->convertType($col);
+
+ // Auto-increment primary key for id columns
+ if ($name === 'id' && $sqliteType === 'INTEGER' && $col->is_nullable === 'NO' && str_contains((string) $col->column_default, 'nextval')) {
+ return "\"{$name}\" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL";
+ }
+
+ $parts = ["\"{$name}\"", $sqliteType];
+
+ // Default value
+ $default = $col->column_default;
+ if ($default !== null && ! str_contains($default, 'nextval')) {
+ $default = $this->cleanDefault($default);
+ $parts[] = "DEFAULT {$default}";
+ }
+
+ // NOT NULL
+ if ($col->is_nullable === 'NO') {
+ $parts[] = 'NOT NULL';
+ }
+
+ return implode(' ', $parts);
+ }
+
+ private function convertType(object $col): string
+ {
+ $pgType = $col->data_type;
+
+ return match (true) {
+ in_array($pgType, ['bigint', 'integer', 'smallint']) => 'INTEGER',
+ $pgType === 'boolean' => 'INTEGER',
+ in_array($pgType, ['character varying', 'text', 'USER-DEFINED']) => 'TEXT',
+ str_contains($pgType, 'timestamp') => 'TEXT',
+ in_array($pgType, ['json', 'jsonb']) => 'TEXT',
+ $pgType === 'uuid' => 'TEXT',
+ $pgType === 'double precision' => 'REAL',
+ $pgType === 'numeric' => 'REAL',
+ $pgType === 'date' => 'TEXT',
+ default => 'TEXT',
+ };
+ }
+
+ private function cleanDefault(string $default): string
+ {
+ foreach ($this->castRemovals as $cast) {
+ $default = str_replace($cast, '', $default);
+ }
+
+ // Remove array type casts like ::text[]
+ $default = preg_replace('/::[\w\s]+(\[\])?/', '', $default);
+
+ return $default;
+ }
+
+ private function getIndexes(string $connection, array $tables): array
+ {
+ $results = [];
+
+ $indexes = DB::connection($connection)->select(
+ "SELECT indexname, tablename, indexdef FROM pg_indexes
+ WHERE schemaname = 'public'
+ ORDER BY tablename, indexname"
+ );
+
+ foreach ($indexes as $idx) {
+ $def = $idx->indexdef;
+
+ // Skip primary key indexes
+ if (str_contains($def, '_pkey')) {
+ continue;
+ }
+
+ // Skip PG-specific indexes (GIN, GIST, expression indexes)
+ if (preg_match('/USING (gin|gist)/i', $def)) {
+ continue;
+ }
+ if (str_contains($def, '->>') || str_contains($def, '::')) {
+ continue;
+ }
+
+ // Convert to SQLite-compatible CREATE INDEX
+ $unique = str_contains($def, 'UNIQUE') ? 'UNIQUE ' : '';
+
+ // Extract columns from the index definition
+ if (preg_match('/\((.+)\)$/', $def, $m)) {
+ $cols = $m[1];
+ $results[] = "CREATE {$unique}INDEX IF NOT EXISTS \"{$idx->indexname}\" ON \"{$idx->tablename}\" ({$cols});";
+ }
+ }
+
+ return $results;
+ }
+}
diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php
index d82d3a1b9..c5e12b7ee 100644
--- a/app/Console/Kernel.php
+++ b/app/Console/Kernel.php
@@ -40,7 +40,7 @@ class Kernel extends ConsoleKernel
}
// $this->scheduleInstance->job(new CleanupStaleMultiplexedConnections)->hourly();
- $this->scheduleInstance->command('cleanup:redis')->weekly();
+ $this->scheduleInstance->command('cleanup:redis --clear-locks')->daily();
if (isDev()) {
// Instance Jobs
diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php
index 1e045ff5a..ddef74226 100644
--- a/app/Http/Controllers/Api/ApplicationsController.php
+++ b/app/Http/Controllers/Api/ApplicationsController.php
@@ -19,8 +19,8 @@ use App\Rules\ValidGitBranch;
use App\Rules\ValidGitRepositoryUrl;
use App\Services\DockerImageParser;
use Illuminate\Http\Request;
-use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\Http;
+use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use OpenApi\Attributes as OA;
use Spatie\Url\Url;
@@ -1002,7 +1002,7 @@ class ApplicationsController extends Controller
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
}
- $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled'];
+ $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled'];
$validator = customApiValidator($request->all(), [
'name' => 'string|max:255',
@@ -1101,7 +1101,6 @@ class ApplicationsController extends Controller
'git_branch' => ['string', 'required', new ValidGitBranch],
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
- 'docker_compose_location' => 'string',
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required',
@@ -1297,7 +1296,6 @@ class ApplicationsController extends Controller
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
'github_app_uuid' => 'string|required',
'watch_paths' => 'string|nullable',
- 'docker_compose_location' => 'string',
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required',
@@ -1525,7 +1523,6 @@ class ApplicationsController extends Controller
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
'private_key_uuid' => 'string|required',
'watch_paths' => 'string|nullable',
- 'docker_compose_location' => 'string',
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required',
@@ -2463,14 +2460,13 @@ class ApplicationsController extends Controller
$this->authorize('update', $application);
$server = $application->destination->server;
- $allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled'];
+ $allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled'];
$validationRules = [
'name' => 'string|max:255',
'description' => 'string|nullable',
'static_image' => 'string',
'watch_paths' => 'string|nullable',
- 'docker_compose_location' => 'string',
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required',
@@ -2919,10 +2915,7 @@ class ApplicationsController extends Controller
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
- type: 'object',
- properties: [
- 'message' => ['type' => 'string', 'example' => 'Environment variable updated.'],
- ]
+ ref: '#/components/schemas/EnvironmentVariable'
)
),
]
diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php
index baff3ec4f..a21940257 100644
--- a/app/Http/Controllers/Api/DeployController.php
+++ b/app/Http/Controllers/Api/DeployController.php
@@ -127,6 +127,10 @@ class DeployController extends Controller
if (! $deployment) {
return response()->json(['message' => 'Deployment not found.'], 404);
}
+ $application = $deployment->application;
+ if (! $application || data_get($application->team(), 'id') !== $teamId) {
+ return response()->json(['message' => 'Deployment not found.'], 404);
+ }
return response()->json($this->removeSensitiveData($deployment));
}
diff --git a/app/Http/Controllers/Api/ScheduledTasksController.php b/app/Http/Controllers/Api/ScheduledTasksController.php
new file mode 100644
index 000000000..6245dc2ec
--- /dev/null
+++ b/app/Http/Controllers/Api/ScheduledTasksController.php
@@ -0,0 +1,922 @@
+makeHidden([
+ 'id',
+ 'team_id',
+ 'application_id',
+ 'service_id',
+ ]);
+
+ return serializeApiResponse($task);
+ }
+
+ private function resolveApplication(Request $request, int $teamId): ?Application
+ {
+ return Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
+ }
+
+ private function resolveService(Request $request, int $teamId): ?Service
+ {
+ return Service::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first();
+ }
+
+ private function listTasks(Application|Service $resource): \Illuminate\Http\JsonResponse
+ {
+ $this->authorize('view', $resource);
+
+ $tasks = $resource->scheduled_tasks->map(function ($task) {
+ return $this->removeSensitiveData($task);
+ });
+
+ return response()->json($tasks);
+ }
+
+ private function createTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
+ {
+ $this->authorize('update', $resource);
+
+ $return = validateIncomingRequest($request);
+ if ($return instanceof \Illuminate\Http\JsonResponse) {
+ return $return;
+ }
+
+ $allowedFields = ['name', 'command', 'frequency', 'container', 'timeout', 'enabled'];
+
+ $validator = customApiValidator($request->all(), [
+ 'name' => 'required|string|max:255',
+ 'command' => 'required|string',
+ 'frequency' => 'required|string',
+ 'container' => 'string|nullable',
+ 'timeout' => 'integer|min:1',
+ 'enabled' => 'boolean',
+ ]);
+
+ $extraFields = array_diff(array_keys($request->all()), $allowedFields);
+ if ($validator->fails() || ! empty($extraFields)) {
+ $errors = $validator->errors();
+ if (! empty($extraFields)) {
+ foreach ($extraFields as $field) {
+ $errors->add($field, 'This field is not allowed.');
+ }
+ }
+
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => $errors,
+ ], 422);
+ }
+
+ if (! validate_cron_expression($request->frequency)) {
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => ['frequency' => ['Invalid cron expression or frequency format.']],
+ ], 422);
+ }
+
+ $teamId = getTeamIdFromToken();
+
+ $task = new ScheduledTask;
+ $task->name = $request->name;
+ $task->command = $request->command;
+ $task->frequency = $request->frequency;
+ $task->container = $request->container;
+ $task->timeout = $request->has('timeout') ? $request->timeout : 300;
+ $task->enabled = $request->has('enabled') ? $request->enabled : true;
+ $task->team_id = $teamId;
+
+ if ($resource instanceof Application) {
+ $task->application_id = $resource->id;
+ } elseif ($resource instanceof Service) {
+ $task->service_id = $resource->id;
+ }
+
+ $task->save();
+
+ return response()->json($this->removeSensitiveData($task), 201);
+ }
+
+ private function updateTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
+ {
+ $this->authorize('update', $resource);
+
+ $return = validateIncomingRequest($request);
+ if ($return instanceof \Illuminate\Http\JsonResponse) {
+ return $return;
+ }
+
+ if ($request->all() === []) {
+ return response()->json(['message' => 'At least one field must be provided.'], 422);
+ }
+
+ $allowedFields = ['name', 'command', 'frequency', 'container', 'timeout', 'enabled'];
+
+ $validator = customApiValidator($request->all(), [
+ 'name' => 'string|max:255',
+ 'command' => 'string',
+ 'frequency' => 'string',
+ 'container' => 'string|nullable',
+ 'timeout' => 'integer|min:1',
+ 'enabled' => 'boolean',
+ ]);
+
+ $extraFields = array_diff(array_keys($request->all()), $allowedFields);
+ if ($validator->fails() || ! empty($extraFields)) {
+ $errors = $validator->errors();
+ if (! empty($extraFields)) {
+ foreach ($extraFields as $field) {
+ $errors->add($field, 'This field is not allowed.');
+ }
+ }
+
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => $errors,
+ ], 422);
+ }
+
+ if ($request->has('frequency') && ! validate_cron_expression($request->frequency)) {
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => ['frequency' => ['Invalid cron expression or frequency format.']],
+ ], 422);
+ }
+
+ $task = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->first();
+ if (! $task) {
+ return response()->json(['message' => 'Scheduled task not found.'], 404);
+ }
+
+ $task->update($request->only($allowedFields));
+
+ return response()->json($this->removeSensitiveData($task), 200);
+ }
+
+ private function deleteTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
+ {
+ $this->authorize('update', $resource);
+
+ $deleted = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->delete();
+ if (! $deleted) {
+ return response()->json(['message' => 'Scheduled task not found.'], 404);
+ }
+
+ return response()->json(['message' => 'Scheduled task deleted.']);
+ }
+
+ private function getExecutions(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
+ {
+ $this->authorize('view', $resource);
+
+ $task = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->first();
+ if (! $task) {
+ return response()->json(['message' => 'Scheduled task not found.'], 404);
+ }
+
+ $executions = $task->executions()->get()->map(function ($execution) {
+ $execution->makeHidden(['id', 'scheduled_task_id']);
+
+ return serializeApiResponse($execution);
+ });
+
+ return response()->json($executions);
+ }
+
+ #[OA\Get(
+ summary: 'List Tasks',
+ description: 'List all scheduled tasks for an application.',
+ path: '/applications/{uuid}/scheduled-tasks',
+ operationId: 'list-scheduled-tasks-by-application-uuid',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ tags: ['Scheduled Tasks'],
+ parameters: [
+ new OA\Parameter(
+ name: 'uuid',
+ in: 'path',
+ description: 'UUID of the application.',
+ required: true,
+ schema: new OA\Schema(
+ type: 'string',
+ )
+ ),
+ ],
+ responses: [
+ new OA\Response(
+ response: 200,
+ description: 'Get all scheduled tasks for an application.',
+ content: [
+ new OA\MediaType(
+ mediaType: 'application/json',
+ schema: new OA\Schema(
+ type: 'array',
+ items: new OA\Items(ref: '#/components/schemas/ScheduledTask')
+ )
+ ),
+ ]
+ ),
+ new OA\Response(
+ response: 401,
+ ref: '#/components/responses/401',
+ ),
+ new OA\Response(
+ response: 404,
+ ref: '#/components/responses/404',
+ ),
+ ]
+ )]
+ public function scheduled_tasks_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $application = $this->resolveApplication($request, $teamId);
+ if (! $application) {
+ return response()->json(['message' => 'Application not found.'], 404);
+ }
+
+ return $this->listTasks($application);
+ }
+
+ #[OA\Post(
+ summary: 'Create Task',
+ description: 'Create a new scheduled task for an application.',
+ path: '/applications/{uuid}/scheduled-tasks',
+ operationId: 'create-scheduled-task-by-application-uuid',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ tags: ['Scheduled Tasks'],
+ parameters: [
+ new OA\Parameter(
+ name: 'uuid',
+ in: 'path',
+ description: 'UUID of the application.',
+ required: true,
+ schema: new OA\Schema(
+ type: 'string',
+ )
+ ),
+ ],
+ requestBody: new OA\RequestBody(
+ description: 'Scheduled task data',
+ required: true,
+ content: new OA\MediaType(
+ mediaType: 'application/json',
+ schema: new OA\Schema(
+ type: 'object',
+ required: ['name', 'command', 'frequency'],
+ properties: [
+ 'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'],
+ 'command' => ['type' => 'string', 'description' => 'The command to execute.'],
+ 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'],
+ 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'],
+ 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300],
+ 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true],
+ ],
+ ),
+ )
+ ),
+ responses: [
+ new OA\Response(
+ response: 201,
+ description: 'Scheduled task created.',
+ content: [
+ new OA\MediaType(
+ mediaType: 'application/json',
+ schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask')
+ ),
+ ]
+ ),
+ new OA\Response(
+ response: 401,
+ ref: '#/components/responses/401',
+ ),
+ new OA\Response(
+ response: 404,
+ ref: '#/components/responses/404',
+ ),
+ new OA\Response(
+ response: 422,
+ ref: '#/components/responses/422',
+ ),
+ ]
+ )]
+ public function create_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $application = $this->resolveApplication($request, $teamId);
+ if (! $application) {
+ return response()->json(['message' => 'Application not found.'], 404);
+ }
+
+ return $this->createTask($request, $application);
+ }
+
+ #[OA\Patch(
+ summary: 'Update Task',
+ description: 'Update a scheduled task for an application.',
+ path: '/applications/{uuid}/scheduled-tasks/{task_uuid}',
+ operationId: 'update-scheduled-task-by-application-uuid',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ tags: ['Scheduled Tasks'],
+ parameters: [
+ new OA\Parameter(
+ name: 'uuid',
+ in: 'path',
+ description: 'UUID of the application.',
+ required: true,
+ schema: new OA\Schema(
+ type: 'string',
+ )
+ ),
+ new OA\Parameter(
+ name: 'task_uuid',
+ in: 'path',
+ description: 'UUID of the scheduled task.',
+ required: true,
+ schema: new OA\Schema(
+ type: 'string',
+ )
+ ),
+ ],
+ requestBody: new OA\RequestBody(
+ description: 'Scheduled task data',
+ required: true,
+ content: new OA\MediaType(
+ mediaType: 'application/json',
+ schema: new OA\Schema(
+ type: 'object',
+ properties: [
+ 'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'],
+ 'command' => ['type' => 'string', 'description' => 'The command to execute.'],
+ 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'],
+ 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'],
+ 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300],
+ 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true],
+ ],
+ ),
+ )
+ ),
+ responses: [
+ new OA\Response(
+ response: 200,
+ description: 'Scheduled task updated.',
+ content: [
+ new OA\MediaType(
+ mediaType: 'application/json',
+ schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask')
+ ),
+ ]
+ ),
+ new OA\Response(
+ response: 401,
+ ref: '#/components/responses/401',
+ ),
+ new OA\Response(
+ response: 404,
+ ref: '#/components/responses/404',
+ ),
+ new OA\Response(
+ response: 422,
+ ref: '#/components/responses/422',
+ ),
+ ]
+ )]
+ public function update_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $application = $this->resolveApplication($request, $teamId);
+ if (! $application) {
+ return response()->json(['message' => 'Application not found.'], 404);
+ }
+
+ return $this->updateTask($request, $application);
+ }
+
+ #[OA\Delete(
+ summary: 'Delete Task',
+ description: 'Delete a scheduled task for an application.',
+ path: '/applications/{uuid}/scheduled-tasks/{task_uuid}',
+ operationId: 'delete-scheduled-task-by-application-uuid',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ tags: ['Scheduled Tasks'],
+ parameters: [
+ new OA\Parameter(
+ name: 'uuid',
+ in: 'path',
+ description: 'UUID of the application.',
+ required: true,
+ schema: new OA\Schema(
+ type: 'string',
+ )
+ ),
+ new OA\Parameter(
+ name: 'task_uuid',
+ in: 'path',
+ description: 'UUID of the scheduled task.',
+ required: true,
+ schema: new OA\Schema(
+ type: 'string',
+ )
+ ),
+ ],
+ responses: [
+ new OA\Response(
+ response: 200,
+ description: 'Scheduled task deleted.',
+ content: [
+ new OA\MediaType(
+ mediaType: 'application/json',
+ schema: new OA\Schema(
+ type: 'object',
+ properties: [
+ 'message' => ['type' => 'string', 'example' => 'Scheduled task deleted.'],
+ ]
+ )
+ ),
+ ]
+ ),
+ new OA\Response(
+ response: 401,
+ ref: '#/components/responses/401',
+ ),
+ new OA\Response(
+ response: 404,
+ ref: '#/components/responses/404',
+ ),
+ ]
+ )]
+ public function delete_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $application = $this->resolveApplication($request, $teamId);
+ if (! $application) {
+ return response()->json(['message' => 'Application not found.'], 404);
+ }
+
+ return $this->deleteTask($request, $application);
+ }
+
+ #[OA\Get(
+ summary: 'List Executions',
+ description: 'List all executions for a scheduled task on an application.',
+ path: '/applications/{uuid}/scheduled-tasks/{task_uuid}/executions',
+ operationId: 'list-scheduled-task-executions-by-application-uuid',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ tags: ['Scheduled Tasks'],
+ parameters: [
+ new OA\Parameter(
+ name: 'uuid',
+ in: 'path',
+ description: 'UUID of the application.',
+ required: true,
+ schema: new OA\Schema(
+ type: 'string',
+ )
+ ),
+ new OA\Parameter(
+ name: 'task_uuid',
+ in: 'path',
+ description: 'UUID of the scheduled task.',
+ required: true,
+ schema: new OA\Schema(
+ type: 'string',
+ )
+ ),
+ ],
+ responses: [
+ new OA\Response(
+ response: 200,
+ description: 'Get all executions for a scheduled task.',
+ content: [
+ new OA\MediaType(
+ mediaType: 'application/json',
+ schema: new OA\Schema(
+ type: 'array',
+ items: new OA\Items(ref: '#/components/schemas/ScheduledTaskExecution')
+ )
+ ),
+ ]
+ ),
+ new OA\Response(
+ response: 401,
+ ref: '#/components/responses/401',
+ ),
+ new OA\Response(
+ response: 404,
+ ref: '#/components/responses/404',
+ ),
+ ]
+ )]
+ public function executions_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $application = $this->resolveApplication($request, $teamId);
+ if (! $application) {
+ return response()->json(['message' => 'Application not found.'], 404);
+ }
+
+ return $this->getExecutions($request, $application);
+ }
+
+ #[OA\Get(
+ summary: 'List Tasks',
+ description: 'List all scheduled tasks for a service.',
+ path: '/services/{uuid}/scheduled-tasks',
+ operationId: 'list-scheduled-tasks-by-service-uuid',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ tags: ['Scheduled Tasks'],
+ parameters: [
+ new OA\Parameter(
+ name: 'uuid',
+ in: 'path',
+ description: 'UUID of the service.',
+ required: true,
+ schema: new OA\Schema(
+ type: 'string',
+ )
+ ),
+ ],
+ responses: [
+ new OA\Response(
+ response: 200,
+ description: 'Get all scheduled tasks for a service.',
+ content: [
+ new OA\MediaType(
+ mediaType: 'application/json',
+ schema: new OA\Schema(
+ type: 'array',
+ items: new OA\Items(ref: '#/components/schemas/ScheduledTask')
+ )
+ ),
+ ]
+ ),
+ new OA\Response(
+ response: 401,
+ ref: '#/components/responses/401',
+ ),
+ new OA\Response(
+ response: 404,
+ ref: '#/components/responses/404',
+ ),
+ ]
+ )]
+ public function scheduled_tasks_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $service = $this->resolveService($request, $teamId);
+ if (! $service) {
+ return response()->json(['message' => 'Service not found.'], 404);
+ }
+
+ return $this->listTasks($service);
+ }
+
+ #[OA\Post(
+ summary: 'Create Task',
+ description: 'Create a new scheduled task for a service.',
+ path: '/services/{uuid}/scheduled-tasks',
+ operationId: 'create-scheduled-task-by-service-uuid',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ tags: ['Scheduled Tasks'],
+ parameters: [
+ new OA\Parameter(
+ name: 'uuid',
+ in: 'path',
+ description: 'UUID of the service.',
+ required: true,
+ schema: new OA\Schema(
+ type: 'string',
+ )
+ ),
+ ],
+ requestBody: new OA\RequestBody(
+ description: 'Scheduled task data',
+ required: true,
+ content: new OA\MediaType(
+ mediaType: 'application/json',
+ schema: new OA\Schema(
+ type: 'object',
+ required: ['name', 'command', 'frequency'],
+ properties: [
+ 'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'],
+ 'command' => ['type' => 'string', 'description' => 'The command to execute.'],
+ 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'],
+ 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'],
+ 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300],
+ 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true],
+ ],
+ ),
+ )
+ ),
+ responses: [
+ new OA\Response(
+ response: 201,
+ description: 'Scheduled task created.',
+ content: [
+ new OA\MediaType(
+ mediaType: 'application/json',
+ schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask')
+ ),
+ ]
+ ),
+ new OA\Response(
+ response: 401,
+ ref: '#/components/responses/401',
+ ),
+ new OA\Response(
+ response: 404,
+ ref: '#/components/responses/404',
+ ),
+ new OA\Response(
+ response: 422,
+ ref: '#/components/responses/422',
+ ),
+ ]
+ )]
+ public function create_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $service = $this->resolveService($request, $teamId);
+ if (! $service) {
+ return response()->json(['message' => 'Service not found.'], 404);
+ }
+
+ return $this->createTask($request, $service);
+ }
+
+ #[OA\Patch(
+ summary: 'Update Task',
+ description: 'Update a scheduled task for a service.',
+ path: '/services/{uuid}/scheduled-tasks/{task_uuid}',
+ operationId: 'update-scheduled-task-by-service-uuid',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ tags: ['Scheduled Tasks'],
+ parameters: [
+ new OA\Parameter(
+ name: 'uuid',
+ in: 'path',
+ description: 'UUID of the service.',
+ required: true,
+ schema: new OA\Schema(
+ type: 'string',
+ )
+ ),
+ new OA\Parameter(
+ name: 'task_uuid',
+ in: 'path',
+ description: 'UUID of the scheduled task.',
+ required: true,
+ schema: new OA\Schema(
+ type: 'string',
+ )
+ ),
+ ],
+ requestBody: new OA\RequestBody(
+ description: 'Scheduled task data',
+ required: true,
+ content: new OA\MediaType(
+ mediaType: 'application/json',
+ schema: new OA\Schema(
+ type: 'object',
+ properties: [
+ 'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'],
+ 'command' => ['type' => 'string', 'description' => 'The command to execute.'],
+ 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'],
+ 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'],
+ 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300],
+ 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true],
+ ],
+ ),
+ )
+ ),
+ responses: [
+ new OA\Response(
+ response: 200,
+ description: 'Scheduled task updated.',
+ content: [
+ new OA\MediaType(
+ mediaType: 'application/json',
+ schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask')
+ ),
+ ]
+ ),
+ new OA\Response(
+ response: 401,
+ ref: '#/components/responses/401',
+ ),
+ new OA\Response(
+ response: 404,
+ ref: '#/components/responses/404',
+ ),
+ new OA\Response(
+ response: 422,
+ ref: '#/components/responses/422',
+ ),
+ ]
+ )]
+ public function update_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $service = $this->resolveService($request, $teamId);
+ if (! $service) {
+ return response()->json(['message' => 'Service not found.'], 404);
+ }
+
+ return $this->updateTask($request, $service);
+ }
+
+ #[OA\Delete(
+ summary: 'Delete Task',
+ description: 'Delete a scheduled task for a service.',
+ path: '/services/{uuid}/scheduled-tasks/{task_uuid}',
+ operationId: 'delete-scheduled-task-by-service-uuid',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ tags: ['Scheduled Tasks'],
+ parameters: [
+ new OA\Parameter(
+ name: 'uuid',
+ in: 'path',
+ description: 'UUID of the service.',
+ required: true,
+ schema: new OA\Schema(
+ type: 'string',
+ )
+ ),
+ new OA\Parameter(
+ name: 'task_uuid',
+ in: 'path',
+ description: 'UUID of the scheduled task.',
+ required: true,
+ schema: new OA\Schema(
+ type: 'string',
+ )
+ ),
+ ],
+ responses: [
+ new OA\Response(
+ response: 200,
+ description: 'Scheduled task deleted.',
+ content: [
+ new OA\MediaType(
+ mediaType: 'application/json',
+ schema: new OA\Schema(
+ type: 'object',
+ properties: [
+ 'message' => ['type' => 'string', 'example' => 'Scheduled task deleted.'],
+ ]
+ )
+ ),
+ ]
+ ),
+ new OA\Response(
+ response: 401,
+ ref: '#/components/responses/401',
+ ),
+ new OA\Response(
+ response: 404,
+ ref: '#/components/responses/404',
+ ),
+ ]
+ )]
+ public function delete_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $service = $this->resolveService($request, $teamId);
+ if (! $service) {
+ return response()->json(['message' => 'Service not found.'], 404);
+ }
+
+ return $this->deleteTask($request, $service);
+ }
+
+ #[OA\Get(
+ summary: 'List Executions',
+ description: 'List all executions for a scheduled task on a service.',
+ path: '/services/{uuid}/scheduled-tasks/{task_uuid}/executions',
+ operationId: 'list-scheduled-task-executions-by-service-uuid',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ tags: ['Scheduled Tasks'],
+ parameters: [
+ new OA\Parameter(
+ name: 'uuid',
+ in: 'path',
+ description: 'UUID of the service.',
+ required: true,
+ schema: new OA\Schema(
+ type: 'string',
+ )
+ ),
+ new OA\Parameter(
+ name: 'task_uuid',
+ in: 'path',
+ description: 'UUID of the scheduled task.',
+ required: true,
+ schema: new OA\Schema(
+ type: 'string',
+ )
+ ),
+ ],
+ responses: [
+ new OA\Response(
+ response: 200,
+ description: 'Get all executions for a scheduled task.',
+ content: [
+ new OA\MediaType(
+ mediaType: 'application/json',
+ schema: new OA\Schema(
+ type: 'array',
+ items: new OA\Items(ref: '#/components/schemas/ScheduledTaskExecution')
+ )
+ ),
+ ]
+ ),
+ new OA\Response(
+ response: 401,
+ ref: '#/components/responses/401',
+ ),
+ new OA\Response(
+ response: 404,
+ ref: '#/components/responses/404',
+ ),
+ ]
+ )]
+ public function executions_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $service = $this->resolveService($request, $teamId);
+ if (! $service) {
+ return response()->json(['message' => 'Service not found.'], 404);
+ }
+
+ return $this->getExecutions($request, $service);
+ }
+}
diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php
index 2ee5455b6..29c6b854a 100644
--- a/app/Http/Controllers/Api/ServersController.php
+++ b/app/Http/Controllers/Api/ServersController.php
@@ -290,9 +290,12 @@ class ServersController extends Controller
}
$uuid = $request->get('uuid');
if ($uuid) {
- $domains = Application::getDomainsByUuid($uuid);
+ $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid)->first();
+ if (! $application) {
+ return response()->json(['message' => 'Application not found.'], 404);
+ }
- return response()->json(serializeApiResponse($domains));
+ return response()->json(serializeApiResponse($application->fqdns));
}
$projects = Project::where('team_id', $teamId)->get();
$domains = collect();
@@ -519,9 +522,13 @@ class ServersController extends Controller
if (! $privateKey) {
return response()->json(['message' => 'Private key not found.'], 404);
}
- $allServers = ModelsServer::whereIp($request->ip)->get();
- if ($allServers->count() > 0) {
- return response()->json(['message' => 'Server with this IP already exists.'], 400);
+ $foundServer = ModelsServer::whereIp($request->ip)->first();
+ if ($foundServer) {
+ if ($foundServer->team_id === $teamId) {
+ return response()->json(['message' => 'A server with this IP/Domain already exists in your team.'], 400);
+ }
+
+ return response()->json(['message' => 'A server with this IP/Domain is already in use by another team.'], 400);
}
$proxyType = $request->proxy_type ? str($request->proxy_type)->upper() : ProxyTypes::TRAEFIK->value;
diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php
index 27fdb1ba8..ee4d84f10 100644
--- a/app/Http/Controllers/Api/ServicesController.php
+++ b/app/Http/Controllers/Api/ServicesController.php
@@ -1141,10 +1141,7 @@ class ServicesController extends Controller
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
- type: 'object',
- properties: [
- 'message' => ['type' => 'string', 'example' => 'Environment variable updated.'],
- ]
+ ref: '#/components/schemas/EnvironmentVariable'
)
),
]
@@ -1265,10 +1262,8 @@ class ServicesController extends Controller
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
- type: 'object',
- properties: [
- 'message' => ['type' => 'string', 'example' => 'Environment variables updated.'],
- ]
+ type: 'array',
+ items: new OA\Items(ref: '#/components/schemas/EnvironmentVariable')
)
),
]
diff --git a/app/Http/Middleware/CheckForcePasswordReset.php b/app/Http/Middleware/CheckForcePasswordReset.php
index 78b1f896c..c857cb836 100644
--- a/app/Http/Middleware/CheckForcePasswordReset.php
+++ b/app/Http/Middleware/CheckForcePasswordReset.php
@@ -25,7 +25,7 @@ class CheckForcePasswordReset
}
$force_password_reset = auth()->user()->force_password_reset;
if ($force_password_reset) {
- if ($request->routeIs('auth.force-password-reset') || $request->path() === 'force-password-reset' || $request->path() === 'livewire/update' || $request->path() === 'logout') {
+ if ($request->routeIs('auth.force-password-reset') || $request->path() === 'force-password-reset' || $request->path() === 'two-factor-challenge' || $request->path() === 'livewire/update' || $request->path() === 'logout') {
return $next($request);
}
diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php
index eaee7e221..dfcf9ee09 100644
--- a/app/Jobs/ApplicationDeploymentJob.php
+++ b/app/Jobs/ApplicationDeploymentJob.php
@@ -171,6 +171,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private bool $dockerBuildkitSupported = false;
+ private bool $dockerSecretsSupported = false;
+
private bool $skip_build = false;
private Collection|string $build_secrets;
@@ -251,7 +253,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
if ($this->application->build_pack === 'dockerfile') {
if (data_get($this->application, 'dockerfile_location')) {
- $this->dockerfile_location = $this->application->dockerfile_location;
+ $this->dockerfile_location = $this->validatePathField($this->application->dockerfile_location, 'dockerfile_location');
}
}
}
@@ -381,13 +383,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private function detectBuildKitCapabilities(): void
{
- // If build secrets are not enabled, skip detection and use traditional args
- if (! $this->application->settings->use_build_secrets) {
- $this->dockerBuildkitSupported = false;
-
- return;
- }
-
$serverToCheck = $this->use_build_server ? $this->build_server : $this->server;
$serverName = $this->use_build_server ? "build server ({$serverToCheck->name})" : "deployment server ({$serverToCheck->name})";
@@ -403,53 +398,55 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
if ($majorVersion < 18 || ($majorVersion == 18 && $minorVersion < 9)) {
$this->dockerBuildkitSupported = false;
- $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not support BuildKit (requires 18.09+). Build secrets feature disabled.");
+ $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not support BuildKit (requires 18.09+).");
return;
}
- $buildkitEnabled = instant_remote_process(
+ // Check buildx availability (always installed by Coolify on Docker 24.0+)
+ $buildxAvailable = instant_remote_process(
["docker buildx version >/dev/null 2>&1 && echo 'available' || echo 'not-available'"],
$serverToCheck
);
- if (trim($buildkitEnabled) !== 'available') {
+ if (trim($buildxAvailable) === 'available') {
+ $this->dockerBuildkitSupported = true;
+ $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit and Buildx detected on {$serverName}.");
+ } else {
+ // Fallback: test DOCKER_BUILDKIT=1 support via --progress flag
$buildkitTest = instant_remote_process(
- ["DOCKER_BUILDKIT=1 docker build --help 2>&1 | grep -q 'secret' && echo 'supported' || echo 'not-supported'"],
+ ["DOCKER_BUILDKIT=1 docker build --help 2>&1 | grep -q '\\-\\-progress' && echo 'supported' || echo 'not-supported'"],
$serverToCheck
);
if (trim($buildkitTest) === 'supported') {
$this->dockerBuildkitSupported = true;
- $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit secrets support detected on {$serverName}.");
- $this->application_deployment_queue->addLogEntry('Build secrets are enabled and will be used for enhanced security.');
+ $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit support detected on {$serverName}.");
} else {
$this->dockerBuildkitSupported = false;
- $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not have BuildKit secrets support.");
- $this->application_deployment_queue->addLogEntry('Build secrets feature is enabled but not supported. Using traditional build arguments.');
+ $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not support BuildKit. Build output progress will be limited.");
}
- } else {
- // Buildx is available, which means BuildKit is available
- // Now specifically test for secrets support
+ }
+
+ // If build secrets are enabled and BuildKit is available, verify --secret flag support
+ if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported) {
$secretsTest = instant_remote_process(
["docker build --help 2>&1 | grep -q 'secret' && echo 'supported' || echo 'not-supported'"],
$serverToCheck
);
if (trim($secretsTest) === 'supported') {
- $this->dockerBuildkitSupported = true;
- $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit and Buildx detected on {$serverName}.");
+ $this->dockerSecretsSupported = true;
$this->application_deployment_queue->addLogEntry('Build secrets are enabled and will be used for enhanced security.');
} else {
- $this->dockerBuildkitSupported = false;
- $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with Buildx on {$serverName}, but secrets not supported.");
- $this->application_deployment_queue->addLogEntry('Build secrets feature is enabled but not supported. Using traditional build arguments.');
+ $this->dockerSecretsSupported = false;
+ $this->application_deployment_queue->addLogEntry("Docker on {$serverName} does not support build secrets. Using traditional build arguments.");
}
}
} catch (\Exception $e) {
$this->dockerBuildkitSupported = false;
+ $this->dockerSecretsSupported = false;
$this->application_deployment_queue->addLogEntry("Could not detect BuildKit capabilities on {$serverName}: {$e->getMessage()}");
- $this->application_deployment_queue->addLogEntry('Build secrets feature is enabled but detection failed. Using traditional build arguments.');
}
}
@@ -571,7 +568,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private function deploy_docker_compose_buildpack()
{
if (data_get($this->application, 'docker_compose_location')) {
- $this->docker_compose_location = $this->application->docker_compose_location;
+ $this->docker_compose_location = $this->validatePathField($this->application->docker_compose_location, 'docker_compose_location');
}
if (data_get($this->application, 'docker_compose_custom_start_command')) {
$this->docker_compose_custom_start_command = $this->application->docker_compose_custom_start_command;
@@ -632,7 +629,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
// For raw compose, we cannot automatically add secrets configuration
// User must define it manually in their docker-compose file
- if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported && ! empty($this->build_secrets)) {
+ if ($this->dockerSecretsSupported && ! empty($this->build_secrets)) {
$this->application_deployment_queue->addLogEntry('Build secrets are configured. Ensure your docker-compose file includes build.secrets configuration for services that need them.');
}
} else {
@@ -653,7 +650,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
// Add build secrets to compose file if enabled and BuildKit is supported
- if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported && ! empty($this->build_secrets)) {
+ if ($this->dockerSecretsSupported && ! empty($this->build_secrets)) {
$composeFile = $this->add_build_secrets_to_compose($composeFile);
}
@@ -689,8 +686,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
// Inject build arguments after build subcommand if not using build secrets
if (! $this->application->settings->use_build_secrets && $this->build_args instanceof \Illuminate\Support\Collection && $this->build_args->isNotEmpty()) {
$build_args_string = $this->build_args->implode(' ');
- // Escape single quotes for bash -c context used by executeInDocker
- $build_args_string = str_replace("'", "'\\''", $build_args_string);
// Inject build args right after 'build' subcommand (not at the end)
$original_command = $build_command;
@@ -702,9 +697,17 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
}
- $this->execute_remote_command(
- [executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$build_command}"), 'hidden' => true],
- );
+ try {
+ $this->execute_remote_command(
+ [executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$build_command}"), 'hidden' => true],
+ );
+ } catch (\RuntimeException $e) {
+ if (str_contains($e->getMessage(), "matching `'") || str_contains($e->getMessage(), 'unexpected EOF')) {
+ throw new DeploymentException("Custom build command failed due to shell syntax error. Please check your command for special characters (like unmatched quotes): {$this->docker_compose_custom_build_command}");
+ }
+
+ throw $e;
+ }
} else {
$command = "{$this->coolify_variables} docker compose";
// Prepend DOCKER_BUILDKIT=1 if BuildKit is supported
@@ -721,8 +724,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
if (! $this->application->settings->use_build_secrets && $this->build_args instanceof \Illuminate\Support\Collection && $this->build_args->isNotEmpty()) {
$build_args_string = $this->build_args->implode(' ');
- // Escape single quotes for bash -c context used by executeInDocker
- $build_args_string = str_replace("'", "'\\''", $build_args_string);
$command .= " {$build_args_string}";
$this->application_deployment_queue->addLogEntry('Adding build arguments to Docker Compose build command.');
}
@@ -768,9 +769,18 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
);
$this->write_deployment_configurations();
- $this->execute_remote_command(
- [executeInDocker($this->deployment_uuid, "cd {$this->workdir} && {$start_command}"), 'hidden' => true],
- );
+
+ try {
+ $this->execute_remote_command(
+ [executeInDocker($this->deployment_uuid, "cd {$this->workdir} && {$start_command}"), 'hidden' => true],
+ );
+ } catch (\RuntimeException $e) {
+ if (str_contains($e->getMessage(), "matching `'") || str_contains($e->getMessage(), 'unexpected EOF')) {
+ throw new DeploymentException("Custom start command failed due to shell syntax error. Please check your command for special characters (like unmatched quotes): {$this->docker_compose_custom_start_command}");
+ }
+
+ throw $e;
+ }
} else {
$this->write_deployment_configurations();
$this->docker_compose_location = '/docker-compose.yaml';
@@ -831,7 +841,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->server = $this->build_server;
}
if (data_get($this->application, 'dockerfile_location')) {
- $this->dockerfile_location = $this->application->dockerfile_location;
+ $this->dockerfile_location = $this->validatePathField($this->application->dockerfile_location, 'dockerfile_location');
}
$this->prepare_builder_image();
$this->check_git_if_build_needed();
@@ -1800,7 +1810,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$counter = 1;
$this->application_deployment_queue->addLogEntry('Waiting for healthcheck to pass on the new container.');
if ($this->full_healthcheck_url && ! $this->application->custom_healthcheck_found) {
- $this->application_deployment_queue->addLogEntry("Healthcheck URL (inside the container): {$this->full_healthcheck_url}");
+ $healthcheckLabel = $this->application->health_check_type === 'cmd' ? 'Healthcheck command' : 'Healthcheck URL';
+ $this->application_deployment_queue->addLogEntry("{$healthcheckLabel} (inside the container): {$this->full_healthcheck_url}");
}
$this->application_deployment_queue->addLogEntry("Waiting for the start period ({$this->application->health_check_start_period} seconds) before starting healthcheck.");
$sleeptime = 0;
@@ -2758,29 +2769,55 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private function generate_healthcheck_commands()
{
+ // Handle CMD type healthcheck
+ if ($this->application->health_check_type === 'cmd' && ! empty($this->application->health_check_command)) {
+ $this->full_healthcheck_url = $this->application->health_check_command;
+
+ return $this->application->health_check_command;
+ }
+
+ // HTTP type healthcheck (default)
if (! $this->application->health_check_port) {
- $health_check_port = $this->application->ports_exposes_array[0];
+ $health_check_port = (int) $this->application->ports_exposes_array[0];
} else {
- $health_check_port = $this->application->health_check_port;
+ $health_check_port = (int) $this->application->health_check_port;
}
if ($this->application->settings->is_static || $this->application->build_pack === 'static') {
$health_check_port = 80;
}
- if ($this->application->health_check_path) {
- $this->full_healthcheck_url = "{$this->application->health_check_method}: {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}{$this->application->health_check_path}";
- $generated_healthchecks_commands = [
- "curl -s -X {$this->application->health_check_method} -f {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}{$this->application->health_check_path} > /dev/null || wget -q -O- {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}{$this->application->health_check_path} > /dev/null || exit 1",
- ];
+
+ $method = $this->sanitizeHealthCheckValue($this->application->health_check_method, '/^[A-Z]+$/', 'GET');
+ $scheme = $this->sanitizeHealthCheckValue($this->application->health_check_scheme, '/^https?$/', 'http');
+ $host = $this->sanitizeHealthCheckValue($this->application->health_check_host, '/^[a-zA-Z0-9.\-_]+$/', 'localhost');
+ $path = $this->application->health_check_path
+ ? $this->sanitizeHealthCheckValue($this->application->health_check_path, '#^[a-zA-Z0-9/\-_.~%]+$#', '/')
+ : null;
+
+ $url = escapeshellarg("{$scheme}://{$host}:{$health_check_port}".($path ?? '/'));
+ $method = escapeshellarg($method);
+
+ if ($path) {
+ $this->full_healthcheck_url = "{$this->application->health_check_method}: {$scheme}://{$host}:{$health_check_port}{$path}";
} else {
- $this->full_healthcheck_url = "{$this->application->health_check_method}: {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}/";
- $generated_healthchecks_commands = [
- "curl -s -X {$this->application->health_check_method} -f {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}/ > /dev/null || wget -q -O- {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}/ > /dev/null || exit 1",
- ];
+ $this->full_healthcheck_url = "{$this->application->health_check_method}: {$scheme}://{$host}:{$health_check_port}/";
}
+ $generated_healthchecks_commands = [
+ "curl -s -X {$method} -f {$url} > /dev/null || wget -q -O- {$url} > /dev/null || exit 1",
+ ];
+
return implode(' ', $generated_healthchecks_commands);
}
+ private function sanitizeHealthCheckValue(string $value, string $pattern, string $default): string
+ {
+ if (preg_match($pattern, $value)) {
+ return $value;
+ }
+
+ return $default;
+ }
+
private function pull_latest_image($image)
{
$this->application_deployment_queue->addLogEntry("Pulling latest image ($image) from the registry.");
@@ -2817,7 +2854,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$nginx_config = base64_encode(defaultNginxConfiguration());
}
}
- $build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile --progress plain -t {$this->production_image_name} {$this->workdir}";
+ if ($this->dockerBuildkitSupported) {
+ $build_command = "DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile --progress plain -t {$this->production_image_name} {$this->workdir}";
+ } else {
+ $build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile -t {$this->production_image_name} {$this->workdir}";
+ }
$base64_build_command = base64_encode($build_command);
$this->execute_remote_command(
[
@@ -2857,21 +2898,19 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private function build_image()
{
// Add Coolify related variables to the build args/secrets
- if ($this->dockerBuildkitSupported) {
- // Coolify variables are already included in the secrets from generate_build_env_variables
- // build_secrets is already a string at this point
- } else {
+ if (! $this->dockerBuildkitSupported) {
// Traditional build args approach - generate COOLIFY_ variables locally
- // Generate COOLIFY_ variables locally for build args
$coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true);
$coolify_envs->each(function ($value, $key) {
$this->build_args->push("--build-arg '{$key}'");
});
- $this->build_args = $this->build_args instanceof \Illuminate\Support\Collection
- ? $this->build_args->implode(' ')
- : (string) $this->build_args;
}
+ // Always convert build_args Collection to string for command interpolation
+ $this->build_args = $this->build_args instanceof \Illuminate\Support\Collection
+ ? $this->build_args->implode(' ')
+ : (string) $this->build_args;
+
$this->application_deployment_queue->addLogEntry('----------------------------------------');
if ($this->disableBuildCache) {
$this->application_deployment_queue->addLogEntry('Docker build cache is disabled. It will not be used during the build process.');
@@ -2899,7 +2938,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
'hidden' => true,
]);
- if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
+ if ($this->dockerSecretsSupported) {
// Modify the nixpacks Dockerfile to use build secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
@@ -2907,9 +2946,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
} elseif ($this->dockerBuildkitSupported) {
// BuildKit without secrets
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
- ray($build_command);
} else {
- $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
+ $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
}
} else {
$this->execute_remote_command([
@@ -2919,18 +2957,16 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
'hidden' => true,
]);
- if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
+ if ($this->dockerSecretsSupported) {
// Modify the nixpacks Dockerfile to use build secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->workdir}");
} elseif ($this->dockerBuildkitSupported) {
// BuildKit without secrets
- $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
- $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
- $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
+ $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
} else {
- $build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
+ $build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
}
}
@@ -2952,7 +2988,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm '.self::NIXPACKS_PLAN_PATH), 'hidden' => true]);
} else {
// Dockerfile buildpack
- if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
+ if ($this->dockerSecretsSupported) {
// Modify the Dockerfile to use build secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
@@ -2963,19 +2999,17 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
} elseif ($this->dockerBuildkitSupported) {
// BuildKit without secrets
- $this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
- $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
if ($this->force_rebuild) {
- $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}");
+ $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}");
} else {
- $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}");
+ $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}");
}
} else {
// Traditional build with args
if ($this->force_rebuild) {
- $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t $this->build_image_name {$this->workdir}");
+ $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}");
} else {
- $build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t $this->build_image_name {$this->workdir}");
+ $build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}");
}
}
$base64_build_command = base64_encode($build_command);
@@ -3010,7 +3044,11 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
$nginx_config = base64_encode(defaultNginxConfiguration());
}
}
- $build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}");
+ if ($this->dockerBuildkitSupported) {
+ $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}");
+ } else {
+ $build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile {$this->build_args} -t {$this->production_image_name} {$this->workdir}");
+ }
$base64_build_command = base64_encode($build_command);
$this->execute_remote_command(
[
@@ -3035,7 +3073,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
} else {
// Pure Dockerfile based deployment
if ($this->application->dockerfile) {
- if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
+ if ($this->dockerSecretsSupported) {
// Modify the Dockerfile to use build secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
@@ -3044,12 +3082,19 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
} else {
$build_command = "DOCKER_BUILDKIT=1 docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}";
}
- } else {
- // Traditional build with args
+ } elseif ($this->dockerBuildkitSupported) {
+ // BuildKit without secrets
if ($this->force_rebuild) {
- $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}");
+ $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
} else {
- $build_command = $this->wrap_build_command_with_env_export("docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}");
+ $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
+ }
+ } else {
+ // Traditional build with args (no --progress for legacy builder compatibility)
+ if ($this->force_rebuild) {
+ $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t {$this->production_image_name} {$this->workdir}");
+ } else {
+ $build_command = $this->wrap_build_command_with_env_export("docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t {$this->production_image_name} {$this->workdir}");
}
}
$base64_build_command = base64_encode($build_command);
@@ -3079,18 +3124,16 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
'hidden' => true,
]);
- if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
+ if ($this->dockerSecretsSupported) {
// Modify the nixpacks Dockerfile to use build secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}");
} elseif ($this->dockerBuildkitSupported) {
// BuildKit without secrets
- $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
- $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
- $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
+ $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
} else {
- $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
+ $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
}
} else {
$this->execute_remote_command([
@@ -3100,18 +3143,16 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
'hidden' => true,
]);
- if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
+ if ($this->dockerSecretsSupported) {
// Modify the nixpacks Dockerfile to use build secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}");
} elseif ($this->dockerBuildkitSupported) {
// BuildKit without secrets
- $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
- $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
- $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
+ $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
} else {
- $build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
+ $build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
}
}
$base64_build_command = base64_encode($build_command);
@@ -3132,7 +3173,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
$this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm '.self::NIXPACKS_PLAN_PATH), 'hidden' => true]);
} else {
// Dockerfile buildpack
- if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
+ if ($this->dockerSecretsSupported) {
// Modify the Dockerfile to use build secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
// Use BuildKit with secrets
@@ -3144,19 +3185,17 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
}
} elseif ($this->dockerBuildkitSupported) {
// BuildKit without secrets
- $this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
- $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
if ($this->force_rebuild) {
- $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
+ $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
} else {
- $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
+ $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
}
} else {
// Traditional build with args
if ($this->force_rebuild) {
- $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}");
+ $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t {$this->production_image_name} {$this->workdir}");
} else {
- $build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}");
+ $build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t {$this->production_image_name} {$this->workdir}");
}
}
$base64_build_command = base64_encode($build_command);
@@ -3332,7 +3371,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
$this->analyzeBuildTimeVariables($variables);
}
- if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
+ if ($this->dockerSecretsSupported) {
$this->generate_build_secrets($variables);
$this->build_args = '';
} else {
@@ -3819,7 +3858,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
$this->application_deployment_queue->addLogEntry("Service {$serviceName}: All required ARG declarations already exist.");
}
- if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported && ! empty($this->build_secrets)) {
+ if ($this->dockerSecretsSupported && ! empty($this->build_secrets)) {
$fullDockerfilePath = "{$this->workdir}/{$dockerfilePath}";
$this->modify_dockerfile_for_secrets($fullDockerfilePath);
$this->application_deployment_queue->addLogEntry("Modified Dockerfile for service {$serviceName} to use build secrets.");
@@ -3879,6 +3918,18 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
return $composeFile;
}
+ private function validatePathField(string $value, string $fieldName): string
+ {
+ if (! preg_match('/^\/[a-zA-Z0-9._\-\/]+$/', $value)) {
+ throw new \RuntimeException("Invalid {$fieldName}: contains forbidden characters.");
+ }
+ if (str_contains($value, '..')) {
+ throw new \RuntimeException("Invalid {$fieldName}: path traversal detected.");
+ }
+
+ return $value;
+ }
+
private function run_pre_deployment_command()
{
if (empty($this->application->pre_deployment_command)) {
diff --git a/app/Jobs/PushServerUpdateJob.php b/app/Jobs/PushServerUpdateJob.php
index c02a7e3c5..5d018cf19 100644
--- a/app/Jobs/PushServerUpdateJob.php
+++ b/app/Jobs/PushServerUpdateJob.php
@@ -207,6 +207,9 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
$serviceId = $labels->get('coolify.serviceId');
$subType = $labels->get('coolify.service.subType');
$subId = $labels->get('coolify.service.subId');
+ if (empty($subId)) {
+ continue;
+ }
if ($subType === 'application') {
$this->foundServiceApplicationIds->push($subId);
// Store container status for aggregation
diff --git a/app/Jobs/ScheduledJobManager.php b/app/Jobs/ScheduledJobManager.php
index 75ff883c2..de782b96d 100644
--- a/app/Jobs/ScheduledJobManager.php
+++ b/app/Jobs/ScheduledJobManager.php
@@ -15,7 +15,9 @@ use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Redis;
class ScheduledJobManager implements ShouldQueue
{
@@ -27,6 +29,10 @@ class ScheduledJobManager implements ShouldQueue
*/
private ?Carbon $executionTime = null;
+ private int $dispatchedCount = 0;
+
+ private int $skippedCount = 0;
+
/**
* Create a new job instance.
*/
@@ -50,6 +56,11 @@ class ScheduledJobManager implements ShouldQueue
*/
public function middleware(): array
{
+ // Self-healing: clear any stale lock before WithoutOverlapping tries to acquire it.
+ // Stale locks (TTL = -1) can occur during upgrades, Redis restarts, or edge cases.
+ // @see https://github.com/coollabsio/coolify/issues/8327
+ self::clearStaleLockIfPresent();
+
return [
(new WithoutOverlapping('scheduled-job-manager'))
->expireAfter(90) // Lock expires after 90s to handle high-load environments with many tasks
@@ -57,10 +68,44 @@ class ScheduledJobManager implements ShouldQueue
];
}
+ /**
+ * Clear a stale WithoutOverlapping lock if it has no TTL (TTL = -1).
+ *
+ * This provides continuous self-healing since it runs every time the job is dispatched.
+ * Stale locks permanently block all scheduled job executions with no user-visible error.
+ */
+ private static function clearStaleLockIfPresent(): void
+ {
+ try {
+ $cachePrefix = config('cache.prefix', '');
+ $lockKey = $cachePrefix.'laravel-queue-overlap:'.self::class.':scheduled-job-manager';
+
+ $ttl = Redis::connection('default')->ttl($lockKey);
+
+ if ($ttl === -1) {
+ Redis::connection('default')->del($lockKey);
+ Log::channel('scheduled')->warning('Cleared stale ScheduledJobManager lock', [
+ 'lock_key' => $lockKey,
+ ]);
+ }
+ } catch (\Throwable $e) {
+ // Never let lock cleanup failure prevent the job from running
+ Log::channel('scheduled-errors')->error('Failed to check/clear stale lock', [
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+
public function handle(): void
{
// Freeze the execution time at the start of the job
$this->executionTime = Carbon::now();
+ $this->dispatchedCount = 0;
+ $this->skippedCount = 0;
+
+ Log::channel('scheduled')->info('ScheduledJobManager started', [
+ 'execution_time' => $this->executionTime->toIso8601String(),
+ ]);
// Process backups - don't let failures stop task processing
try {
@@ -91,6 +136,20 @@ class ScheduledJobManager implements ShouldQueue
'trace' => $e->getTraceAsString(),
]);
}
+
+ Log::channel('scheduled')->info('ScheduledJobManager completed', [
+ 'execution_time' => $this->executionTime->toIso8601String(),
+ 'duration_ms' => $this->executionTime->diffInMilliseconds(Carbon::now()),
+ 'dispatched' => $this->dispatchedCount,
+ 'skipped' => $this->skippedCount,
+ ]);
+
+ // Write heartbeat so the UI can detect when the scheduler has stopped
+ try {
+ Cache::put('scheduled-job-manager:heartbeat', now()->toIso8601String(), 300);
+ } catch (\Throwable) {
+ // Non-critical; don't let heartbeat failure affect the job
+ }
}
private function processScheduledBackups(): void
@@ -101,8 +160,16 @@ class ScheduledJobManager implements ShouldQueue
foreach ($backups as $backup) {
try {
- // Apply the same filtering logic as the original
- if (! $this->shouldProcessBackup($backup)) {
+ $skipReason = $this->getBackupSkipReason($backup);
+ if ($skipReason !== null) {
+ $this->skippedCount++;
+ $this->logSkip('backup', $skipReason, [
+ 'backup_id' => $backup->id,
+ 'database_id' => $backup->database_id,
+ 'database_type' => $backup->database_type,
+ 'team_id' => $backup->team_id ?? null,
+ ]);
+
continue;
}
@@ -120,6 +187,14 @@ class ScheduledJobManager implements ShouldQueue
if ($this->shouldRunNow($frequency, $serverTimezone)) {
DatabaseBackupJob::dispatch($backup);
+ $this->dispatchedCount++;
+ Log::channel('scheduled')->info('Backup dispatched', [
+ 'backup_id' => $backup->id,
+ 'database_id' => $backup->database_id,
+ 'database_type' => $backup->database_type,
+ 'team_id' => $backup->team_id ?? null,
+ 'server_id' => $server->id,
+ ]);
}
} catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Error processing backup', [
@@ -138,7 +213,15 @@ class ScheduledJobManager implements ShouldQueue
foreach ($tasks as $task) {
try {
- if (! $this->shouldProcessTask($task)) {
+ $skipReason = $this->getTaskSkipReason($task);
+ if ($skipReason !== null) {
+ $this->skippedCount++;
+ $this->logSkip('task', $skipReason, [
+ 'task_id' => $task->id,
+ 'task_name' => $task->name,
+ 'team_id' => $task->server()?->team_id,
+ ]);
+
continue;
}
@@ -156,6 +239,13 @@ class ScheduledJobManager implements ShouldQueue
if ($this->shouldRunNow($frequency, $serverTimezone)) {
ScheduledTaskJob::dispatch($task);
+ $this->dispatchedCount++;
+ Log::channel('scheduled')->info('Task dispatched', [
+ 'task_id' => $task->id,
+ 'task_name' => $task->name,
+ 'team_id' => $server->team_id,
+ 'server_id' => $server->id,
+ ]);
}
} catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Error processing task', [
@@ -166,33 +256,33 @@ class ScheduledJobManager implements ShouldQueue
}
}
- private function shouldProcessBackup(ScheduledDatabaseBackup $backup): bool
+ private function getBackupSkipReason(ScheduledDatabaseBackup $backup): ?string
{
if (blank(data_get($backup, 'database'))) {
$backup->delete();
- return false;
+ return 'database_deleted';
}
$server = $backup->server();
if (blank($server)) {
$backup->delete();
- return false;
+ return 'server_deleted';
}
if ($server->isFunctional() === false) {
- return false;
+ return 'server_not_functional';
}
if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) {
- return false;
+ return 'subscription_unpaid';
}
- return true;
+ return null;
}
- private function shouldProcessTask(ScheduledTask $task): bool
+ private function getTaskSkipReason(ScheduledTask $task): ?string
{
$service = $task->service;
$application = $task->application;
@@ -201,32 +291,32 @@ class ScheduledJobManager implements ShouldQueue
if (blank($server)) {
$task->delete();
- return false;
+ return 'server_deleted';
}
if ($server->isFunctional() === false) {
- return false;
+ return 'server_not_functional';
}
if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) {
- return false;
+ return 'subscription_unpaid';
}
if (! $service && ! $application) {
$task->delete();
- return false;
+ return 'resource_deleted';
}
if ($application && str($application->status)->contains('running') === false) {
- return false;
+ return 'application_not_running';
}
if ($service && str($service->status)->contains('running') === false) {
- return false;
+ return 'service_not_running';
}
- return true;
+ return null;
}
private function shouldRunNow(string $frequency, string $timezone): bool
@@ -248,7 +338,15 @@ class ScheduledJobManager implements ShouldQueue
foreach ($servers as $server) {
try {
- if (! $this->shouldProcessDockerCleanup($server)) {
+ $skipReason = $this->getDockerCleanupSkipReason($server);
+ if ($skipReason !== null) {
+ $this->skippedCount++;
+ $this->logSkip('docker_cleanup', $skipReason, [
+ 'server_id' => $server->id,
+ 'server_name' => $server->name,
+ 'team_id' => $server->team_id,
+ ]);
+
continue;
}
@@ -270,6 +368,12 @@ class ScheduledJobManager implements ShouldQueue
$server->settings->delete_unused_volumes,
$server->settings->delete_unused_networks
);
+ $this->dispatchedCount++;
+ Log::channel('scheduled')->info('Docker cleanup dispatched', [
+ 'server_id' => $server->id,
+ 'server_name' => $server->name,
+ 'team_id' => $server->team_id,
+ ]);
}
} catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Error processing docker cleanup', [
@@ -296,19 +400,28 @@ class ScheduledJobManager implements ShouldQueue
return $query->get();
}
- private function shouldProcessDockerCleanup(Server $server): bool
+ private function getDockerCleanupSkipReason(Server $server): ?string
{
if (! $server->isFunctional()) {
- return false;
+ return 'server_not_functional';
}
// In cloud, check subscription status (except team 0)
if (isCloud() && $server->team_id !== 0) {
if (data_get($server->team->subscription, 'stripe_invoice_paid', false) === false) {
- return false;
+ return 'subscription_unpaid';
}
}
- return true;
+ return null;
+ }
+
+ private function logSkip(string $type, string $reason, array $context = []): void
+ {
+ Log::channel('scheduled')->info(ucfirst(str_replace('_', ' ', $type)).' skipped', array_merge([
+ 'type' => $type,
+ 'skip_reason' => $reason,
+ 'execution_time' => $this->executionTime?->toIso8601String(),
+ ], $context));
}
}
diff --git a/app/Jobs/ServerCheckJob.php b/app/Jobs/ServerCheckJob.php
index 2ac92e72d..a18d45b9a 100644
--- a/app/Jobs/ServerCheckJob.php
+++ b/app/Jobs/ServerCheckJob.php
@@ -15,6 +15,7 @@ use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Facades\Log;
class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue
{
@@ -33,6 +34,19 @@ class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue
public function __construct(public Server $server) {}
+ public function failed(?\Throwable $exception): void
+ {
+ if ($exception instanceof \Illuminate\Queue\TimeoutExceededException) {
+ Log::warning('ServerCheckJob timed out', [
+ 'server_id' => $this->server->id,
+ 'server_name' => $this->server->name,
+ ]);
+
+ // Delete the queue job so it doesn't appear in Horizon's failed list.
+ $this->job?->delete();
+ }
+ }
+
public function handle()
{
try {
diff --git a/app/Jobs/ServerConnectionCheckJob.php b/app/Jobs/ServerConnectionCheckJob.php
index 9dbce4bfe..d4a499865 100644
--- a/app/Jobs/ServerConnectionCheckJob.php
+++ b/app/Jobs/ServerConnectionCheckJob.php
@@ -101,12 +101,31 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
'is_usable' => false,
]);
- throw $e;
+ return;
+ }
+ }
+
+ public function failed(?\Throwable $exception): void
+ {
+ if ($exception instanceof \Illuminate\Queue\TimeoutExceededException) {
+ Log::warning('ServerConnectionCheckJob timed out', [
+ 'server_id' => $this->server->id,
+ 'server_name' => $this->server->name,
+ ]);
+ $this->server->settings->update([
+ 'is_reachable' => false,
+ 'is_usable' => false,
+ ]);
+
+ // Delete the queue job so it doesn't appear in Horizon's failed list.
+ $this->job?->delete();
}
}
private function checkHetznerStatus(): void
{
+ $status = null;
+
try {
$hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token);
$serverData = $hetznerService->getServer($this->server->hetzner_server_id);
diff --git a/app/Jobs/ServerStorageCheckJob.php b/app/Jobs/ServerStorageCheckJob.php
index 9d45491c6..51426d880 100644
--- a/app/Jobs/ServerStorageCheckJob.php
+++ b/app/Jobs/ServerStorageCheckJob.php
@@ -10,6 +10,7 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\RateLimiter;
use Laravel\Horizon\Contracts\Silenced;
@@ -28,6 +29,19 @@ class ServerStorageCheckJob implements ShouldBeEncrypted, ShouldQueue, Silenced
public function __construct(public Server $server, public int|string|null $percentage = null) {}
+ public function failed(?\Throwable $exception): void
+ {
+ if ($exception instanceof \Illuminate\Queue\TimeoutExceededException) {
+ Log::warning('ServerStorageCheckJob timed out', [
+ 'server_id' => $this->server->id,
+ 'server_name' => $this->server->name,
+ ]);
+
+ // Delete the queue job so it doesn't appear in Horizon's failed list.
+ $this->job?->delete();
+ }
+ }
+
public function handle()
{
try {
diff --git a/app/Livewire/Boarding/Index.php b/app/Livewire/Boarding/Index.php
index ab1a1aae9..0f6f45d83 100644
--- a/app/Livewire/Boarding/Index.php
+++ b/app/Livewire/Boarding/Index.php
@@ -283,7 +283,11 @@ class Index extends Component
$this->privateKey = formatPrivateKey($this->privateKey);
$foundServer = Server::whereIp($this->remoteServerHost)->first();
if ($foundServer) {
- return $this->dispatch('error', 'IP address is already in use by another team.');
+ if ($foundServer->team_id === currentTeam()->id) {
+ return $this->dispatch('error', 'A server with this IP/Domain already exists in your team.');
+ }
+
+ return $this->dispatch('error', 'A server with this IP/Domain is already in use by another team.');
}
$this->createdServer = Server::create([
'name' => $this->remoteServerName,
diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php
index ed9115093..f910110dc 100644
--- a/app/Livewire/GlobalSearch.php
+++ b/app/Livewire/GlobalSearch.php
@@ -1495,6 +1495,7 @@ class GlobalSearch extends Component
'type' => 'one-click-service-'.$serviceKey,
'category' => 'Services',
'resourceType' => 'service',
+ 'logo' => data_get($service, 'logo'),
]);
}
diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php
index b7c17fcc3..008bd3905 100644
--- a/app/Livewire/Project/Application/General.php
+++ b/app/Livewire/Project/Application/General.php
@@ -73,7 +73,7 @@ class General extends Component
#[Validate(['string', 'nullable'])]
public ?string $dockerfile = null;
- #[Validate(['string', 'nullable'])]
+ #[Validate(['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'])]
public ?string $dockerfileLocation = null;
#[Validate(['string', 'nullable'])]
@@ -85,7 +85,7 @@ class General extends Component
#[Validate(['string', 'nullable'])]
public ?string $dockerRegistryImageTag = null;
- #[Validate(['string', 'nullable'])]
+ #[Validate(['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'])]
public ?string $dockerComposeLocation = null;
#[Validate(['string', 'nullable'])]
diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php
index 40d2674e2..1bb276b89 100644
--- a/app/Livewire/Project/New/GithubPrivateRepository.php
+++ b/app/Livewire/Project/New/GithubPrivateRepository.php
@@ -75,6 +75,11 @@ class GithubPrivateRepository extends Component
$this->github_apps = GithubApp::private();
}
+ public function updatedSelectedRepositoryId(): void
+ {
+ $this->loadBranches();
+ }
+
public function updatedBuildPack()
{
if ($this->build_pack === 'nixpacks') {
@@ -158,10 +163,12 @@ class GithubPrivateRepository extends Component
'selected_repository_owner' => $this->selected_repository_owner,
'selected_repository_repo' => $this->selected_repository_repo,
'selected_branch_name' => $this->selected_branch_name,
+ 'docker_compose_location' => $this->docker_compose_location,
], [
'selected_repository_owner' => 'required|string|regex:/^[a-zA-Z0-9\-_]+$/',
'selected_repository_repo' => 'required|string|regex:/^[a-zA-Z0-9\-_\.]+$/',
'selected_branch_name' => ['required', 'string', new ValidGitBranch],
+ 'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'],
]);
if ($validator->fails()) {
diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php
index 77b106200..f52c01e91 100644
--- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php
+++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php
@@ -64,6 +64,7 @@ class GithubPrivateRepositoryDeployKey extends Component
'is_static' => 'required|boolean',
'publish_directory' => 'nullable|string',
'build_pack' => 'required|string',
+ 'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'],
];
protected function rules()
@@ -75,6 +76,7 @@ class GithubPrivateRepositoryDeployKey extends Component
'is_static' => 'required|boolean',
'publish_directory' => 'nullable|string',
'build_pack' => 'required|string',
+ 'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'],
];
}
diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php
index 2fffff6b9..a08c448dd 100644
--- a/app/Livewire/Project/New/PublicGitRepository.php
+++ b/app/Livewire/Project/New/PublicGitRepository.php
@@ -70,7 +70,7 @@ class PublicGitRepository extends Component
'publish_directory' => 'nullable|string',
'build_pack' => 'required|string',
'base_directory' => 'nullable|string',
- 'docker_compose_location' => 'nullable|string',
+ 'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'],
];
protected function rules()
@@ -82,7 +82,7 @@ class PublicGitRepository extends Component
'publish_directory' => 'nullable|string',
'build_pack' => 'required|string',
'base_directory' => 'nullable|string',
- 'docker_compose_location' => 'nullable|string',
+ 'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'],
'git_branch' => ['required', 'string', new ValidGitBranch],
];
}
diff --git a/app/Livewire/Project/Shared/HealthChecks.php b/app/Livewire/Project/Shared/HealthChecks.php
index 05f786690..0d5d71b45 100644
--- a/app/Livewire/Project/Shared/HealthChecks.php
+++ b/app/Livewire/Project/Shared/HealthChecks.php
@@ -16,19 +16,25 @@ class HealthChecks extends Component
#[Validate(['boolean'])]
public bool $healthCheckEnabled = false;
- #[Validate(['string'])]
+ #[Validate(['string', 'in:http,cmd'])]
+ public string $healthCheckType = 'http';
+
+ #[Validate(['nullable', 'required_if:healthCheckType,cmd', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'])]
+ public ?string $healthCheckCommand = null;
+
+ #[Validate(['required', 'string', 'in:GET,HEAD,POST,OPTIONS'])]
public string $healthCheckMethod;
- #[Validate(['string'])]
+ #[Validate(['required', 'string', 'in:http,https'])]
public string $healthCheckScheme;
- #[Validate(['string'])]
+ #[Validate(['required', 'string', 'regex:/^[a-zA-Z0-9.\-_]+$/'])]
public string $healthCheckHost;
- #[Validate(['nullable', 'string'])]
+ #[Validate(['nullable', 'integer', 'min:1', 'max:65535'])]
public ?string $healthCheckPort = null;
- #[Validate(['string'])]
+ #[Validate(['required', 'string', 'regex:#^[a-zA-Z0-9/\-_.~%]+$#'])]
public string $healthCheckPath;
#[Validate(['integer'])]
@@ -54,12 +60,14 @@ class HealthChecks extends Component
protected $rules = [
'healthCheckEnabled' => 'boolean',
- 'healthCheckPath' => 'string',
- 'healthCheckPort' => 'nullable|string',
- 'healthCheckHost' => 'string',
- 'healthCheckMethod' => 'string',
+ 'healthCheckType' => 'string|in:http,cmd',
+ 'healthCheckCommand' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'],
+ 'healthCheckPath' => ['required', 'string', 'regex:#^[a-zA-Z0-9/\-_.~%]+$#'],
+ 'healthCheckPort' => 'nullable|integer|min:1|max:65535',
+ 'healthCheckHost' => ['required', 'string', 'regex:/^[a-zA-Z0-9.\-_]+$/'],
+ 'healthCheckMethod' => 'required|string|in:GET,HEAD,POST,OPTIONS',
'healthCheckReturnCode' => 'integer',
- 'healthCheckScheme' => 'string',
+ 'healthCheckScheme' => 'required|string|in:http,https',
'healthCheckResponseText' => 'nullable|string',
'healthCheckInterval' => 'integer|min:1',
'healthCheckTimeout' => 'integer|min:1',
@@ -81,6 +89,8 @@ class HealthChecks extends Component
// Sync to model
$this->resource->health_check_enabled = $this->healthCheckEnabled;
+ $this->resource->health_check_type = $this->healthCheckType;
+ $this->resource->health_check_command = $this->healthCheckCommand;
$this->resource->health_check_method = $this->healthCheckMethod;
$this->resource->health_check_scheme = $this->healthCheckScheme;
$this->resource->health_check_host = $this->healthCheckHost;
@@ -98,6 +108,8 @@ class HealthChecks extends Component
} else {
// Sync from model
$this->healthCheckEnabled = $this->resource->health_check_enabled;
+ $this->healthCheckType = $this->resource->health_check_type ?? 'http';
+ $this->healthCheckCommand = $this->resource->health_check_command;
$this->healthCheckMethod = $this->resource->health_check_method;
$this->healthCheckScheme = $this->resource->health_check_scheme;
$this->healthCheckHost = $this->resource->health_check_host;
@@ -116,9 +128,12 @@ class HealthChecks extends Component
public function instantSave()
{
$this->authorize('update', $this->resource);
+ $this->validate();
// Sync component properties to model
$this->resource->health_check_enabled = $this->healthCheckEnabled;
+ $this->resource->health_check_type = $this->healthCheckType;
+ $this->resource->health_check_command = $this->healthCheckCommand;
$this->resource->health_check_method = $this->healthCheckMethod;
$this->resource->health_check_scheme = $this->healthCheckScheme;
$this->resource->health_check_host = $this->healthCheckHost;
@@ -143,6 +158,8 @@ class HealthChecks extends Component
// Sync component properties to model
$this->resource->health_check_enabled = $this->healthCheckEnabled;
+ $this->resource->health_check_type = $this->healthCheckType;
+ $this->resource->health_check_command = $this->healthCheckCommand;
$this->resource->health_check_method = $this->healthCheckMethod;
$this->resource->health_check_scheme = $this->healthCheckScheme;
$this->resource->health_check_host = $this->healthCheckHost;
@@ -171,6 +188,8 @@ class HealthChecks extends Component
// Sync component properties to model
$this->resource->health_check_enabled = $this->healthCheckEnabled;
+ $this->resource->health_check_type = $this->healthCheckType;
+ $this->resource->health_check_command = $this->healthCheckCommand;
$this->resource->health_check_method = $this->healthCheckMethod;
$this->resource->health_check_scheme = $this->healthCheckScheme;
$this->resource->health_check_host = $this->healthCheckHost;
diff --git a/app/Livewire/Project/Shared/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php
index 4ba961dfd..e769e4bcb 100644
--- a/app/Livewire/Project/Shared/ResourceOperations.php
+++ b/app/Livewire/Project/Shared/ResourceOperations.php
@@ -49,9 +49,10 @@ class ResourceOperations extends Component
{
$this->authorize('update', $this->resource);
- $new_destination = StandaloneDocker::find($destination_id);
+ $teamScope = fn ($q) => $q->where('team_id', currentTeam()->id);
+ $new_destination = StandaloneDocker::whereHas('server', $teamScope)->find($destination_id);
if (! $new_destination) {
- $new_destination = SwarmDocker::find($destination_id);
+ $new_destination = SwarmDocker::whereHas('server', $teamScope)->find($destination_id);
}
if (! $new_destination) {
return $this->addError('destination_id', 'Destination not found.');
@@ -352,7 +353,7 @@ class ResourceOperations extends Component
{
try {
$this->authorize('update', $this->resource);
- $new_environment = Environment::findOrFail($environment_id);
+ $new_environment = Environment::ownedByCurrentTeam()->findOrFail($environment_id);
$this->resource->update([
'environment_id' => $environment_id,
]);
diff --git a/app/Livewire/Server/CaCertificate/Show.php b/app/Livewire/Server/CaCertificate/Show.php
index c929d9b3d..57aaaa945 100644
--- a/app/Livewire/Server/CaCertificate/Show.php
+++ b/app/Livewire/Server/CaCertificate/Show.php
@@ -60,10 +60,16 @@ class Show extends Component
throw new \Exception('Certificate content cannot be empty.');
}
- if (! openssl_x509_read($this->certificateContent)) {
+ $parsedCert = openssl_x509_read($this->certificateContent);
+ if (! $parsedCert) {
throw new \Exception('Invalid certificate format.');
}
+ if (! openssl_x509_export($parsedCert, $cleanedCertificate)) {
+ throw new \Exception('Failed to process certificate.');
+ }
+ $this->certificateContent = $cleanedCertificate;
+
if ($this->caCertificate) {
$this->caCertificate->ssl_certificate = $this->certificateContent;
$this->caCertificate->save();
@@ -114,12 +120,14 @@ class Show extends Component
{
$caCertPath = config('constants.coolify.base_config_path').'/ssl/';
+ $base64Cert = base64_encode($this->certificateContent);
+
$commands = collect([
"mkdir -p $caCertPath",
"chown -R 9999:root $caCertPath",
"chmod -R 700 $caCertPath",
"rm -rf $caCertPath/coolify-ca.crt",
- "echo '{$this->certificateContent}' > $caCertPath/coolify-ca.crt",
+ "echo '{$base64Cert}' | base64 -d | tee $caCertPath/coolify-ca.crt > /dev/null",
"chmod 644 $caCertPath/coolify-ca.crt",
]);
diff --git a/app/Livewire/Server/DockerCleanup.php b/app/Livewire/Server/DockerCleanup.php
index 92094c950..12d111d21 100644
--- a/app/Livewire/Server/DockerCleanup.php
+++ b/app/Livewire/Server/DockerCleanup.php
@@ -3,8 +3,13 @@
namespace App\Livewire\Server;
use App\Jobs\DockerCleanupJob;
+use App\Models\DockerCleanupExecution;
use App\Models\Server;
+use Cron\CronExpression;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+use Illuminate\Support\Carbon;
+use Illuminate\Support\Facades\Cache;
+use Livewire\Attributes\Computed;
use Livewire\Attributes\Validate;
use Livewire\Component;
@@ -34,6 +39,53 @@ class DockerCleanup extends Component
#[Validate('boolean')]
public bool $disableApplicationImageRetention = false;
+ #[Computed]
+ public function isCleanupStale(): bool
+ {
+ try {
+ $lastExecution = DockerCleanupExecution::where('server_id', $this->server->id)
+ ->orderBy('created_at', 'desc')
+ ->first();
+
+ if (! $lastExecution) {
+ return false;
+ }
+
+ $frequency = $this->server->settings->docker_cleanup_frequency ?? '0 0 * * *';
+ if (isset(VALID_CRON_STRINGS[$frequency])) {
+ $frequency = VALID_CRON_STRINGS[$frequency];
+ }
+
+ $cron = new CronExpression($frequency);
+ $now = Carbon::now();
+ $nextRun = Carbon::parse($cron->getNextRunDate($now));
+ $afterThat = Carbon::parse($cron->getNextRunDate($nextRun));
+ $intervalMinutes = $nextRun->diffInMinutes($afterThat);
+
+ $threshold = max($intervalMinutes * 2, 10);
+
+ return Carbon::parse($lastExecution->created_at)->diffInMinutes($now) > $threshold;
+ } catch (\Throwable) {
+ return false;
+ }
+ }
+
+ #[Computed]
+ public function lastExecutionTime(): ?string
+ {
+ return DockerCleanupExecution::where('server_id', $this->server->id)
+ ->orderBy('created_at', 'desc')
+ ->first()
+ ?->created_at
+ ?->diffForHumans();
+ }
+
+ #[Computed]
+ public function isSchedulerHealthy(): bool
+ {
+ return Cache::get('scheduled-job-manager:heartbeat') !== null;
+ }
+
public function mount(string $server_uuid)
{
try {
diff --git a/app/Livewire/Server/New/ByIp.php b/app/Livewire/Server/New/ByIp.php
index 1f4cdf607..eecdfb4d0 100644
--- a/app/Livewire/Server/New/ByIp.php
+++ b/app/Livewire/Server/New/ByIp.php
@@ -97,10 +97,13 @@ class ByIp extends Component
$this->validate();
try {
$this->authorize('create', Server::class);
- if (Server::where('team_id', currentTeam()->id)
- ->where('ip', $this->ip)
- ->exists()) {
- return $this->dispatch('error', 'This IP/Domain is already in use by another server in your team.');
+ $foundServer = Server::whereIp($this->ip)->first();
+ if ($foundServer) {
+ if ($foundServer->team_id === currentTeam()->id) {
+ return $this->dispatch('error', 'A server with this IP/Domain already exists in your team.');
+ }
+
+ return $this->dispatch('error', 'A server with this IP/Domain is already in use by another team.');
}
if (is_null($this->private_key_id)) {
diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php
index cc55da491..83c63a81c 100644
--- a/app/Livewire/Server/Show.php
+++ b/app/Livewire/Server/Show.php
@@ -189,12 +189,16 @@ class Show extends Component
$this->validate();
$this->authorize('update', $this->server);
- if (Server::where('team_id', currentTeam()->id)
- ->where('ip', $this->ip)
+ $foundServer = Server::where('ip', $this->ip)
->where('id', '!=', $this->server->id)
- ->exists()) {
+ ->first();
+ if ($foundServer) {
$this->ip = $this->server->ip;
- throw new \Exception('This IP/Domain is already in use by another server in your team.');
+ if ($foundServer->team_id === currentTeam()->id) {
+ throw new \Exception('A server with this IP/Domain already exists in your team.');
+ }
+
+ throw new \Exception('A server with this IP/Domain is already in use by another team.');
}
$this->server->name = $this->name;
diff --git a/app/Livewire/Settings/ScheduledJobs.php b/app/Livewire/Settings/ScheduledJobs.php
new file mode 100644
index 000000000..66480cd8d
--- /dev/null
+++ b/app/Livewire/Settings/ScheduledJobs.php
@@ -0,0 +1,211 @@
+executions = collect();
+ $this->skipLogs = collect();
+ $this->managerRuns = collect();
+ }
+
+ public function mount(): void
+ {
+ if (! isInstanceAdmin()) {
+ redirect()->route('dashboard');
+
+ return;
+ }
+
+ $this->loadData();
+ }
+
+ public function updatedFilterType(): void
+ {
+ $this->loadData();
+ }
+
+ public function updatedFilterDate(): void
+ {
+ $this->loadData();
+ }
+
+ public function refresh(): void
+ {
+ $this->loadData();
+ }
+
+ public function render()
+ {
+ return view('livewire.settings.scheduled-jobs', [
+ 'executions' => $this->executions,
+ 'skipLogs' => $this->skipLogs,
+ 'managerRuns' => $this->managerRuns,
+ ]);
+ }
+
+ private function loadData(?int $teamId = null): void
+ {
+ $this->executions = $this->getExecutions($teamId);
+
+ $parser = new SchedulerLogParser;
+ $this->skipLogs = $parser->getRecentSkips(50, $teamId);
+ $this->managerRuns = $parser->getRecentRuns(30, $teamId);
+ }
+
+ private function getExecutions(?int $teamId = null): Collection
+ {
+ $dateFrom = $this->getDateFrom();
+
+ $backups = collect();
+ $tasks = collect();
+ $cleanups = collect();
+
+ if ($this->filterType === 'all' || $this->filterType === 'backup') {
+ $backups = $this->getBackupExecutions($dateFrom, $teamId);
+ }
+
+ if ($this->filterType === 'all' || $this->filterType === 'task') {
+ $tasks = $this->getTaskExecutions($dateFrom, $teamId);
+ }
+
+ if ($this->filterType === 'all' || $this->filterType === 'cleanup') {
+ $cleanups = $this->getCleanupExecutions($dateFrom, $teamId);
+ }
+
+ return $backups->concat($tasks)->concat($cleanups)
+ ->sortByDesc('created_at')
+ ->values()
+ ->take(100);
+ }
+
+ private function getBackupExecutions(?Carbon $dateFrom, ?int $teamId): Collection
+ {
+ $query = ScheduledDatabaseBackupExecution::with(['scheduledDatabaseBackup.database', 'scheduledDatabaseBackup.team'])
+ ->where('status', 'failed')
+ ->when($dateFrom, fn ($q) => $q->where('created_at', '>=', $dateFrom))
+ ->when($teamId, fn ($q) => $q->whereRelation('scheduledDatabaseBackup.team', 'id', $teamId))
+ ->orderBy('created_at', 'desc')
+ ->limit(100)
+ ->get();
+
+ return $query->map(function ($execution) {
+ $backup = $execution->scheduledDatabaseBackup;
+ $database = $backup?->database;
+ $server = $backup?->server();
+
+ return [
+ 'id' => $execution->id,
+ 'type' => 'backup',
+ 'status' => $execution->status ?? 'unknown',
+ 'resource_name' => $database?->name ?? 'Deleted database',
+ 'resource_type' => $database ? class_basename($database) : null,
+ 'server_name' => $server?->name ?? 'Unknown',
+ 'server_id' => $server?->id,
+ 'team_id' => $backup?->team_id,
+ 'created_at' => $execution->created_at,
+ 'finished_at' => $execution->updated_at,
+ 'message' => $execution->message,
+ 'size' => $execution->size ?? null,
+ ];
+ });
+ }
+
+ private function getTaskExecutions(?Carbon $dateFrom, ?int $teamId): Collection
+ {
+ $query = ScheduledTaskExecution::with(['scheduledTask.application', 'scheduledTask.service'])
+ ->where('status', 'failed')
+ ->when($dateFrom, fn ($q) => $q->where('created_at', '>=', $dateFrom))
+ ->when($teamId, function ($q) use ($teamId) {
+ $q->where(function ($sub) use ($teamId) {
+ $sub->whereRelation('scheduledTask.application.environment.project.team', 'id', $teamId)
+ ->orWhereRelation('scheduledTask.service.environment.project.team', 'id', $teamId);
+ });
+ })
+ ->orderBy('created_at', 'desc')
+ ->limit(100)
+ ->get();
+
+ return $query->map(function ($execution) {
+ $task = $execution->scheduledTask;
+ $resource = $task?->application ?? $task?->service;
+ $server = $task?->server();
+ $teamId = $server?->team_id;
+
+ return [
+ 'id' => $execution->id,
+ 'type' => 'task',
+ 'status' => $execution->status ?? 'unknown',
+ 'resource_name' => $task?->name ?? 'Deleted task',
+ 'resource_type' => $resource ? class_basename($resource) : null,
+ 'server_name' => $server?->name ?? 'Unknown',
+ 'server_id' => $server?->id,
+ 'team_id' => $teamId,
+ 'created_at' => $execution->created_at,
+ 'finished_at' => $execution->finished_at,
+ 'message' => $execution->message,
+ 'size' => null,
+ ];
+ });
+ }
+
+ private function getCleanupExecutions(?Carbon $dateFrom, ?int $teamId): Collection
+ {
+ $query = DockerCleanupExecution::with(['server'])
+ ->where('status', 'failed')
+ ->when($dateFrom, fn ($q) => $q->where('created_at', '>=', $dateFrom))
+ ->when($teamId, fn ($q) => $q->whereRelation('server', 'team_id', $teamId))
+ ->orderBy('created_at', 'desc')
+ ->limit(100)
+ ->get();
+
+ return $query->map(function ($execution) {
+ $server = $execution->server;
+
+ return [
+ 'id' => $execution->id,
+ 'type' => 'cleanup',
+ 'status' => $execution->status ?? 'unknown',
+ 'resource_name' => $server?->name ?? 'Deleted server',
+ 'resource_type' => 'Server',
+ 'server_name' => $server?->name ?? 'Unknown',
+ 'server_id' => $server?->id,
+ 'team_id' => $server?->team_id,
+ 'created_at' => $execution->created_at,
+ 'finished_at' => $execution->finished_at ?? $execution->updated_at,
+ 'message' => $execution->message,
+ 'size' => null,
+ ];
+ });
+ }
+
+ private function getDateFrom(): ?Carbon
+ {
+ return match ($this->filterDate) {
+ 'last_24h' => now()->subDay(),
+ 'last_7d' => now()->subWeek(),
+ 'last_30d' => now()->subMonth(),
+ default => null,
+ };
+ }
+}
diff --git a/app/Models/Application.php b/app/Models/Application.php
index d6c222a97..a239c34db 100644
--- a/app/Models/Application.php
+++ b/app/Models/Application.php
@@ -61,6 +61,8 @@ use Visus\Cuid2\Cuid2;
'health_check_timeout' => ['type' => 'integer', 'description' => 'Health check timeout in seconds.'],
'health_check_retries' => ['type' => 'integer', 'description' => 'Health check retries count.'],
'health_check_start_period' => ['type' => 'integer', 'description' => 'Health check start period in seconds.'],
+ 'health_check_type' => ['type' => 'string', 'description' => 'Health check type: http or cmd.', 'enum' => ['http', 'cmd']],
+ 'health_check_command' => ['type' => 'string', 'nullable' => true, 'description' => 'Health check command for CMD type.'],
'limits_memory' => ['type' => 'string', 'description' => 'Memory limit.'],
'limits_memory_swap' => ['type' => 'string', 'description' => 'Memory swap limit.'],
'limits_memory_swappiness' => ['type' => 'integer', 'description' => 'Memory swappiness.'],
@@ -990,7 +992,7 @@ class Application extends BaseModel
if (isDev() && data_get($this, 'private_key_id') === 0) {
return 'deploy_key';
}
- if (data_get($this, 'private_key_id')) {
+ if (! is_null(data_get($this, 'private_key_id'))) {
return 'deploy_key';
} elseif (data_get($this, 'source')) {
return 'source';
@@ -1959,16 +1961,6 @@ class Application extends BaseModel
}
}
- public static function getDomainsByUuid(string $uuid): array
- {
- $application = self::where('uuid', $uuid)->first();
-
- if ($application) {
- return $application->fqdns;
- }
-
- return [];
- }
public function getLimits(): array
{
diff --git a/app/Models/PrivateKey.php b/app/Models/PrivateKey.php
index bb76d5ed6..7163ae7b5 100644
--- a/app/Models/PrivateKey.php
+++ b/app/Models/PrivateKey.php
@@ -237,7 +237,7 @@ class PrivateKey extends BaseModel
$testSuccess = $disk->put($testFilename, 'test');
if (! $testSuccess) {
- throw new \Exception('SSH keys storage directory is not writable');
+ throw new \Exception('SSH keys storage directory is not writable. Run on the host: sudo chown -R 9999 /data/coolify/ssh && sudo chmod -R 700 /data/coolify/ssh && docker restart coolify');
}
// Clean up test file
diff --git a/app/Models/ScheduledTask.php b/app/Models/ScheduledTask.php
index bada0b7a5..272638a81 100644
--- a/app/Models/ScheduledTask.php
+++ b/app/Models/ScheduledTask.php
@@ -5,13 +5,35 @@ namespace App\Models;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
+use OpenApi\Attributes as OA;
+#[OA\Schema(
+ description: 'Scheduled Task model',
+ type: 'object',
+ properties: [
+ 'id' => ['type' => 'integer', 'description' => 'The unique identifier of the scheduled task in the database.'],
+ 'uuid' => ['type' => 'string', 'description' => 'The unique identifier of the scheduled task.'],
+ 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.'],
+ 'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'],
+ 'command' => ['type' => 'string', 'description' => 'The command to execute.'],
+ 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'],
+ 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'],
+ 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.'],
+ 'created_at' => ['type' => 'string', 'format' => 'date-time', 'description' => 'The date and time when the scheduled task was created.'],
+ 'updated_at' => ['type' => 'string', 'format' => 'date-time', 'description' => 'The date and time when the scheduled task was last updated.'],
+ ],
+)]
class ScheduledTask extends BaseModel
{
use HasSafeStringAttribute;
protected $guarded = [];
+ public static function ownedByCurrentTeamAPI(int $teamId)
+ {
+ return static::where('team_id', $teamId)->orderBy('created_at', 'desc');
+ }
+
protected function casts(): array
{
return [
diff --git a/app/Models/ScheduledTaskExecution.php b/app/Models/ScheduledTaskExecution.php
index 02fd6917a..c0601a4c9 100644
--- a/app/Models/ScheduledTaskExecution.php
+++ b/app/Models/ScheduledTaskExecution.php
@@ -3,7 +3,23 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use OpenApi\Attributes as OA;
+#[OA\Schema(
+ description: 'Scheduled Task Execution model',
+ type: 'object',
+ properties: [
+ 'uuid' => ['type' => 'string', 'description' => 'The unique identifier of the execution.'],
+ 'status' => ['type' => 'string', 'enum' => ['success', 'failed', 'running'], 'description' => 'The status of the execution.'],
+ 'message' => ['type' => 'string', 'nullable' => true, 'description' => 'The output message of the execution.'],
+ 'retry_count' => ['type' => 'integer', 'description' => 'The number of retries.'],
+ 'duration' => ['type' => 'number', 'nullable' => true, 'description' => 'Duration in seconds.'],
+ 'started_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true, 'description' => 'When the execution started.'],
+ 'finished_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true, 'description' => 'When the execution finished.'],
+ 'created_at' => ['type' => 'string', 'format' => 'date-time', 'description' => 'When the record was created.'],
+ 'updated_at' => ['type' => 'string', 'format' => 'date-time', 'description' => 'When the record was last updated.'],
+ ],
+)]
class ScheduledTaskExecution extends BaseModel
{
protected $guarded = [];
diff --git a/app/Models/Server.php b/app/Models/Server.php
index d693aea6d..49d9c3289 100644
--- a/app/Models/Server.php
+++ b/app/Models/Server.php
@@ -1452,12 +1452,14 @@ $schema://$host {
$certificateContent = $caCertificate->ssl_certificate;
$caCertPath = config('constants.coolify.base_config_path').'/ssl/';
+ $base64Cert = base64_encode($certificateContent);
+
$commands = collect([
"mkdir -p $caCertPath",
"chown -R 9999:root $caCertPath",
"chmod -R 700 $caCertPath",
"rm -rf $caCertPath/coolify-ca.crt",
- "echo '{$certificateContent}' > $caCertPath/coolify-ca.crt",
+ "echo '{$base64Cert}' | base64 -d | tee $caCertPath/coolify-ca.crt > /dev/null",
"chmod 644 $caCertPath/coolify-ca.crt",
]);
diff --git a/app/Models/ServiceApplication.php b/app/Models/ServiceApplication.php
index 7b8b46812..4bf78085e 100644
--- a/app/Models/ServiceApplication.php
+++ b/app/Models/ServiceApplication.php
@@ -88,7 +88,7 @@ class ServiceApplication extends BaseModel
public function team()
{
- return data_get($this, 'environment.project.team');
+ return data_get($this, 'service.environment.project.team');
}
public function workdir()
diff --git a/app/Models/ServiceDatabase.php b/app/Models/ServiceDatabase.php
index f6a39cfe4..7b0abe59e 100644
--- a/app/Models/ServiceDatabase.php
+++ b/app/Models/ServiceDatabase.php
@@ -124,7 +124,7 @@ class ServiceDatabase extends BaseModel
public function team()
{
- return data_get($this, 'environment.project.team');
+ return data_get($this, 'service.environment.project.team');
}
public function workdir()
diff --git a/app/Models/Team.php b/app/Models/Team.php
index 5cb186942..e32526169 100644
--- a/app/Models/Team.php
+++ b/app/Models/Team.php
@@ -191,7 +191,8 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
$this->getNotificationSettings('discord')?->isEnabled() ||
$this->getNotificationSettings('slack')?->isEnabled() ||
$this->getNotificationSettings('telegram')?->isEnabled() ||
- $this->getNotificationSettings('pushover')?->isEnabled();
+ $this->getNotificationSettings('pushover')?->isEnabled() ||
+ $this->getNotificationSettings('webhook')?->isEnabled();
}
public function subscriptionEnded()
diff --git a/app/Policies/StandaloneDockerPolicy.php b/app/Policies/StandaloneDockerPolicy.php
index 154648599..3e1f83d12 100644
--- a/app/Policies/StandaloneDockerPolicy.php
+++ b/app/Policies/StandaloneDockerPolicy.php
@@ -37,8 +37,7 @@ class StandaloneDockerPolicy
*/
public function update(User $user, StandaloneDocker $standaloneDocker): bool
{
- // return $user->isAdmin() && $user->teams->contains('id', $standaloneDocker->server->team_id);
- return true;
+ return $user->teams->contains('id', $standaloneDocker->server->team_id);
}
/**
@@ -46,8 +45,7 @@ class StandaloneDockerPolicy
*/
public function delete(User $user, StandaloneDocker $standaloneDocker): bool
{
- // return $user->isAdmin() && $user->teams->contains('id', $standaloneDocker->server->team_id);
- return true;
+ return $user->teams->contains('id', $standaloneDocker->server->team_id);
}
/**
@@ -55,8 +53,7 @@ class StandaloneDockerPolicy
*/
public function restore(User $user, StandaloneDocker $standaloneDocker): bool
{
- // return false;
- return true;
+ return false;
}
/**
@@ -64,7 +61,6 @@ class StandaloneDockerPolicy
*/
public function forceDelete(User $user, StandaloneDocker $standaloneDocker): bool
{
- // return false;
- return true;
+ return false;
}
}
diff --git a/app/Policies/SwarmDockerPolicy.php b/app/Policies/SwarmDockerPolicy.php
index 979bb5889..82a75910b 100644
--- a/app/Policies/SwarmDockerPolicy.php
+++ b/app/Policies/SwarmDockerPolicy.php
@@ -37,8 +37,7 @@ class SwarmDockerPolicy
*/
public function update(User $user, SwarmDocker $swarmDocker): bool
{
- // return $user->isAdmin() && $user->teams->contains('id', $swarmDocker->server->team_id);
- return true;
+ return $user->teams->contains('id', $swarmDocker->server->team_id);
}
/**
@@ -46,8 +45,7 @@ class SwarmDockerPolicy
*/
public function delete(User $user, SwarmDocker $swarmDocker): bool
{
- // return $user->isAdmin() && $user->teams->contains('id', $swarmDocker->server->team_id);
- return true;
+ return $user->teams->contains('id', $swarmDocker->server->team_id);
}
/**
@@ -55,8 +53,7 @@ class SwarmDockerPolicy
*/
public function restore(User $user, SwarmDocker $swarmDocker): bool
{
- // return false;
- return true;
+ return false;
}
/**
@@ -64,7 +61,6 @@ class SwarmDockerPolicy
*/
public function forceDelete(User $user, SwarmDocker $swarmDocker): bool
{
- // return false;
- return true;
+ return false;
}
}
diff --git a/app/Services/SchedulerLogParser.php b/app/Services/SchedulerLogParser.php
new file mode 100644
index 000000000..a735a11c3
--- /dev/null
+++ b/app/Services/SchedulerLogParser.php
@@ -0,0 +1,188 @@
+
+ */
+ public function getRecentSkips(int $limit = 100, ?int $teamId = null): Collection
+ {
+ $logFiles = $this->getLogFiles();
+
+ $skips = collect();
+
+ foreach ($logFiles as $logFile) {
+ $lines = $this->readLastLines($logFile, 2000);
+
+ foreach ($lines as $line) {
+ $entry = $this->parseLogLine($line);
+ if ($entry === null || ! isset($entry['context']['skip_reason'])) {
+ continue;
+ }
+
+ if ($teamId !== null && ($entry['context']['team_id'] ?? null) !== $teamId) {
+ continue;
+ }
+
+ $skips->push([
+ 'timestamp' => $entry['timestamp'],
+ 'type' => $entry['context']['type'] ?? 'unknown',
+ 'reason' => $entry['context']['skip_reason'],
+ 'team_id' => $entry['context']['team_id'] ?? null,
+ 'context' => $entry['context'],
+ ]);
+ }
+ }
+
+ return $skips->sortByDesc('timestamp')->values()->take($limit);
+ }
+
+ /**
+ * Get recent manager execution logs (start/complete events).
+ *
+ * @return Collection419
Sorry, we couldn't find the page you're looking - for. +
Your session has expired. Please log in again to continue.
The last Docker cleanup ran {{ $this->lastExecutionTime ?? 'unknown time' }} ago, + which is longer than expected for the configured frequency.
+ @if (!$this->isSchedulerHealthy) +The scheduled job manager appears to be inactive. This may indicate + a stale Redis lock is blocking all scheduled jobs.
+ @endif +To resolve, run on your Coolify instance:
+ php artisan cleanup:redis --clear-locks
+
| Type | +Resource | +Server | +Started | +Duration | +Message | +
|---|---|---|---|---|---|
| + @php + $typeLabel = match($execution['type']) { + 'backup' => 'Backup', + 'task' => 'Task', + 'cleanup' => 'Cleanup', + default => ucfirst($execution['type']), + }; + $typeBg = match($execution['type']) { + 'backup' => 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300', + 'task' => 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300', + 'cleanup' => 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300', + default => 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300', + }; + @endphp + + {{ $typeLabel }} + + | ++ {{ $execution['resource_name'] }} + @if($execution['resource_type']) + ({{ $execution['resource_type'] }}) + @endif + | +{{ $execution['server_name'] }} | ++ {{ $execution['created_at']->diffForHumans() }} + {{ $execution['created_at']->format('M d H:i') }} + | +
+ @if($execution['finished_at'] && $execution['created_at'])
+ {{ \Carbon\Carbon::parse($execution['created_at'])->diffInSeconds(\Carbon\Carbon::parse($execution['finished_at'])) }}s
+ @elseif($execution['status'] === 'running')
+ |
+ + {{ \Illuminate\Support\Str::limit($execution['message'], 80) }} + | +
| + No failures found for the selected filters. + | +|||||
| Time | +Event | +Duration | +Dispatched | +Skipped | +
|---|---|---|---|---|
| {{ $run['timestamp'] }} | +{{ $run['message'] }} | ++ @if($run['duration_ms'] !== null) + {{ $run['duration_ms'] }}ms + @else + - + @endif + | +{{ $run['dispatched'] ?? '-' }} | ++ @if(($run['skipped'] ?? 0) > 0) + {{ $run['skipped'] }} + @else + {{ $run['skipped'] ?? '-' }} + @endif + | +
| + No scheduler run logs found. Logs appear after the ScheduledJobManager runs. + | +||||
| Time | +Type | +Reason | +Details | +
|---|---|---|---|
| {{ $skip['timestamp'] }} | ++ @php + $skipTypeBg = match($skip['type']) { + 'backup' => 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300', + 'task' => 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300', + 'docker_cleanup' => 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300', + default => 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300', + }; + @endphp + + {{ ucfirst(str_replace('_', ' ', $skip['type'])) }} + + | ++ @php + $reasonLabel = match($skip['reason']) { + 'server_not_functional' => 'Server not functional', + 'subscription_unpaid' => 'Subscription unpaid', + 'database_deleted' => 'Database deleted', + 'server_deleted' => 'Server deleted', + 'resource_deleted' => 'Resource deleted', + 'application_not_running' => 'Application not running', + 'service_not_running' => 'Service not running', + default => ucfirst(str_replace('_', ' ', $skip['reason'])), + }; + $reasonBg = match($skip['reason']) { + 'server_not_functional', 'database_deleted', 'server_deleted', 'resource_deleted' => 'text-red-600 dark:text-red-400', + 'subscription_unpaid' => 'text-warning', + 'application_not_running', 'service_not_running' => 'text-orange-600 dark:text-orange-400', + default => '', + }; + @endphp + {{ $reasonLabel }} + | ++ @php + $details = collect($skip['context']) + ->except(['type', 'skip_reason', 'execution_time']) + ->map(fn($v, $k) => str_replace('_', ' ', $k) . ': ' . $v) + ->implode(', '); + @endphp + {{ $details }} + | +
| + No skipped jobs found. This means all scheduled jobs passed their conditions. + | +|||