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 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
$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 @@ public function handle(StandaloneKeydb $database)
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 @@ public function handle(StandaloneRedis $database)
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 @@ public function handle(Server $server)
);
$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/CleanupUnreachableServers.php b/app/Console/Commands/CleanupUnreachableServers.php
index def01b265..09563a2c3 100644
--- a/app/Console/Commands/CleanupUnreachableServers.php
+++ b/app/Console/Commands/CleanupUnreachableServers.php
@@ -14,7 +14,7 @@ class CleanupUnreachableServers extends Command
public function handle()
{
echo "Running unreachable server cleanup...\n";
- $servers = Server::where('unreachable_count', 3)->where('unreachable_notification_sent', true)->where('updated_at', '<', now()->subDays(7))->get();
+ $servers = Server::where('unreachable_count', '>=', 3)->where('unreachable_notification_sent', true)->where('updated_at', '<', now()->subDays(7))->get();
if ($servers->count() > 0) {
foreach ($servers as $server) {
echo "Cleanup unreachable server ($server->id) with name $server->name";
diff --git a/app/Console/Commands/Cloud/SyncStripeSubscriptions.php b/app/Console/Commands/Cloud/SyncStripeSubscriptions.php
index e64f86926..46f6b4edd 100644
--- a/app/Console/Commands/Cloud/SyncStripeSubscriptions.php
+++ b/app/Console/Commands/Cloud/SyncStripeSubscriptions.php
@@ -36,7 +36,14 @@ public function handle(): int
$this->newLine();
$job = new SyncStripeSubscriptionsJob($fix);
- $result = $job->handle();
+ $fetched = 0;
+ $result = $job->handle(function (int $count) use (&$fetched): void {
+ $fetched = $count;
+ $this->output->write("\r Fetching subscriptions from Stripe... {$fetched}");
+ });
+ if ($fetched > 0) {
+ $this->output->write("\r".str_repeat(' ', 60)."\r");
+ }
if (isset($result['error'])) {
$this->error($result['error']);
@@ -68,6 +75,19 @@ public function handle(): int
$this->info('No discrepancies found. All subscriptions are in sync.');
}
+ if (count($result['resubscribed']) > 0) {
+ $this->newLine();
+ $this->warn('Resubscribed users (same email, different customer): '.count($result['resubscribed']));
+ $this->newLine();
+
+ foreach ($result['resubscribed'] as $resub) {
+ $this->line(" - Team ID: {$resub['team_id']} | Email: {$resub['email']}");
+ $this->line(" Old: {$resub['old_stripe_subscription_id']} (cus: {$resub['old_stripe_customer_id']})");
+ $this->line(" New: {$resub['new_stripe_subscription_id']} (cus: {$resub['new_stripe_customer_id']}) [{$resub['new_status']}]");
+ $this->newLine();
+ }
+ }
+
if (count($result['errors']) > 0) {
$this->newLine();
$this->error('Errors encountered: '.count($result['errors']));
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 @@ public function handle()
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 @@ protected function schedule(Schedule $schedule): void
}
// $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..4576c37d6 100644
--- a/app/Http/Controllers/Api/ApplicationsController.php
+++ b/app/Http/Controllers/Api/ApplicationsController.php
@@ -19,8 +19,8 @@
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 @@ private function create_application(Request $request, $type)
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 @@ private function create_application(Request $request, $type)
'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 @@ private function create_application(Request $request, $type)
'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 @@ private function create_application(Request $request, $type)
'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 @@ public function update_by_uuid(Request $request)
$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 @@ public function envs(Request $request)
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
- type: 'object',
- properties: [
- 'message' => ['type' => 'string', 'example' => 'Environment variable updated.'],
- ]
+ ref: '#/components/schemas/EnvironmentVariable'
)
),
]
@@ -2943,7 +2936,7 @@ public function envs(Request $request)
)]
public function update_env_by_uuid(Request $request)
{
- $allowedFields = ['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime'];
+ $allowedFields = ['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime', 'comment'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -2973,6 +2966,7 @@ public function update_env_by_uuid(Request $request)
'is_shown_once' => 'boolean',
'is_runtime' => 'boolean',
'is_buildtime' => 'boolean',
+ 'comment' => 'string|nullable|max:256',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
@@ -3014,6 +3008,9 @@ public function update_env_by_uuid(Request $request)
if ($request->has('is_buildtime') && $env->is_buildtime != $request->is_buildtime) {
$env->is_buildtime = $request->is_buildtime;
}
+ if ($request->has('comment') && $env->comment != $request->comment) {
+ $env->comment = $request->comment;
+ }
$env->save();
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
@@ -3044,6 +3041,9 @@ public function update_env_by_uuid(Request $request)
if ($request->has('is_buildtime') && $env->is_buildtime != $request->is_buildtime) {
$env->is_buildtime = $request->is_buildtime;
}
+ if ($request->has('comment') && $env->comment != $request->comment) {
+ $env->comment = $request->comment;
+ }
$env->save();
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
@@ -3336,7 +3336,7 @@ public function create_bulk_envs(Request $request)
)]
public function create_env(Request $request)
{
- $allowedFields = ['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime'];
+ $allowedFields = ['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime', 'comment'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -3361,6 +3361,7 @@ public function create_env(Request $request)
'is_shown_once' => 'boolean',
'is_runtime' => 'boolean',
'is_buildtime' => 'boolean',
+ 'comment' => 'string|nullable|max:256',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
@@ -3396,6 +3397,7 @@ public function create_env(Request $request)
'is_shown_once' => $request->is_shown_once ?? false,
'is_runtime' => $request->is_runtime ?? true,
'is_buildtime' => $request->is_buildtime ?? true,
+ 'comment' => $request->comment ?? null,
'resourceable_type' => get_class($application),
'resourceable_id' => $application->id,
]);
@@ -3420,6 +3422,7 @@ public function create_env(Request $request)
'is_shown_once' => $request->is_shown_once ?? false,
'is_runtime' => $request->is_runtime ?? true,
'is_buildtime' => $request->is_buildtime ?? true,
+ 'comment' => $request->comment ?? null,
'resourceable_type' => get_class($application),
'resourceable_id' => $application->id,
]);
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 @@ public function deployment_by_uuid(Request $request)
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 @@ public function domains_by_server(Request $request)
}
$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 @@ public function create_server(Request $request)
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..751f5824a 100644
--- a/app/Http/Controllers/Api/ServicesController.php
+++ b/app/Http/Controllers/Api/ServicesController.php
@@ -1141,10 +1141,7 @@ public function envs(Request $request)
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
- type: 'object',
- properties: [
- 'message' => ['type' => 'string', 'example' => 'Environment variable updated.'],
- ]
+ ref: '#/components/schemas/EnvironmentVariable'
)
),
]
@@ -1187,6 +1184,7 @@ public function update_env_by_uuid(Request $request)
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
'is_shown_once' => 'boolean',
+ 'comment' => 'string|nullable|max:256',
]);
if ($validator->fails()) {
@@ -1202,7 +1200,19 @@ public function update_env_by_uuid(Request $request)
return response()->json(['message' => 'Environment variable not found.'], 404);
}
- $env->fill($request->all());
+ $env->value = $request->value;
+ if ($request->has('is_literal')) {
+ $env->is_literal = $request->is_literal;
+ }
+ if ($request->has('is_multiline')) {
+ $env->is_multiline = $request->is_multiline;
+ }
+ if ($request->has('is_shown_once')) {
+ $env->is_shown_once = $request->is_shown_once;
+ }
+ if ($request->has('comment')) {
+ $env->comment = $request->comment;
+ }
$env->save();
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
@@ -1265,10 +1275,8 @@ public function update_env_by_uuid(Request $request)
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')
)
),
]
@@ -1430,6 +1438,7 @@ public function create_env(Request $request)
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
'is_shown_once' => 'boolean',
+ 'comment' => 'string|nullable|max:256',
]);
if ($validator->fails()) {
@@ -1447,7 +1456,14 @@ public function create_env(Request $request)
], 409);
}
- $env = $service->environment_variables()->create($request->all());
+ $env = $service->environment_variables()->create([
+ 'key' => $key,
+ 'value' => $request->value,
+ 'is_literal' => $request->is_literal ?? false,
+ 'is_multiline' => $request->is_multiline ?? false,
+ 'is_shown_once' => $request->is_shown_once ?? false,
+ 'comment' => $request->comment ?? null,
+ ]);
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
}
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 @@ public function handle(Request $request, Closure $next): Response
}
$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 @@ public function __construct(public int $application_deployment_queue_id)
}
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 @@ public function handle(): void
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 @@ private function detectBuildKitCapabilities(): void
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 @@ private function deploy_dockerimage_buildpack()
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 @@ private function deploy_docker_compose_buildpack()
// 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 @@ private function deploy_docker_compose_buildpack()
}
// 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 @@ private function deploy_docker_compose_buildpack()
// 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 @@ private function deploy_docker_compose_buildpack()
}
}
- $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 @@ private function deploy_docker_compose_buildpack()
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 @@ private function deploy_docker_compose_buildpack()
);
$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 @@ private function deploy_dockerfile_buildpack()
$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 @@ private function health_check()
$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 @@ private function generate_local_persistent_volumes_only_volume_names()
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 @@ private function build_static_image()
$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 @@ private function wrap_build_command_with_env_export(string $build_command): stri
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 @@ private function build_image()
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 @@ private function build_image()
} 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 @@ private function build_image()
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 @@ private function build_image()
$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 @@ private function build_image()
}
} 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 @@ private function build_image()
$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 @@ private function build_image()
} 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 @@ private function build_image()
} 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 @@ private function build_image()
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 @@ private function build_image()
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 @@ private function build_image()
$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 @@ private function build_image()
}
} 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 @@ private function generate_build_env_variables()
$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 @@ private function modify_dockerfiles_for_compose($composeFile)
$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 @@ private function add_build_secrets_to_compose($composeFile)
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/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php
index a585baa69..5fc9f6cd8 100644
--- a/app/Jobs/DatabaseBackupJob.php
+++ b/app/Jobs/DatabaseBackupJob.php
@@ -111,6 +111,12 @@ public function handle(): void
$status = str(data_get($this->database, 'status'));
if (! $status->startsWith('running') && $this->database->id !== 0) {
+ Log::info('DatabaseBackupJob skipped: database not running', [
+ 'backup_id' => $this->backup->id,
+ 'database_id' => $this->database->id,
+ 'status' => (string) $status,
+ ]);
+
return;
}
if (data_get($this->backup, 'database_type') === \App\Models\ServiceDatabase::class) {
@@ -472,7 +478,7 @@ private function backup_standalone_mongodb(string $databaseWithCollections): voi
throw new \Exception('MongoDB credentials not found. Ensure MONGO_INITDB_ROOT_USERNAME and MONGO_INITDB_ROOT_PASSWORD environment variables are available in the container.');
}
}
- \Log::info('MongoDB backup URL configured', ['has_url' => filled($url), 'using_env_vars' => blank($this->database->internal_db_url)]);
+ Log::info('MongoDB backup URL configured', ['has_url' => filled($url), 'using_env_vars' => blank($this->database->internal_db_url)]);
if ($databaseWithCollections === 'all') {
$commands[] = 'mkdir -p '.$this->backup_dir;
if (str($this->database->image)->startsWith('mongo:4')) {
diff --git a/app/Jobs/DockerCleanupJob.php b/app/Jobs/DockerCleanupJob.php
index f3f3a2ae4..78ef7f3a2 100644
--- a/app/Jobs/DockerCleanupJob.php
+++ b/app/Jobs/DockerCleanupJob.php
@@ -91,6 +91,8 @@ public function handle(): void
$this->server->team?->notify(new DockerCleanupSuccess($this->server, $message));
event(new DockerCleanupDone($this->execution_log));
+
+ return;
}
if ($this->usageBefore >= $this->server->settings->docker_cleanup_threshold) {
diff --git a/app/Jobs/PushServerUpdateJob.php b/app/Jobs/PushServerUpdateJob.php
index c02a7e3c5..85684ff19 100644
--- a/app/Jobs/PushServerUpdateJob.php
+++ b/app/Jobs/PushServerUpdateJob.php
@@ -24,6 +24,7 @@
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\Cache;
use Laravel\Horizon\Contracts\Silenced;
class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
@@ -130,7 +131,14 @@ public function handle()
$this->containers = collect(data_get($data, 'containers'));
$filesystemUsageRoot = data_get($data, 'filesystem_usage_root.used_percentage');
- ServerStorageCheckJob::dispatch($this->server, $filesystemUsageRoot);
+
+ // Only dispatch storage check when disk percentage actually changes
+ $storageCacheKey = 'storage-check:'.$this->server->id;
+ $lastPercentage = Cache::get($storageCacheKey);
+ if ($lastPercentage === null || (string) $lastPercentage !== (string) $filesystemUsageRoot) {
+ Cache::put($storageCacheKey, $filesystemUsageRoot, 600);
+ ServerStorageCheckJob::dispatch($this->server, $filesystemUsageRoot);
+ }
if ($this->containers->isEmpty()) {
return;
@@ -207,6 +215,9 @@ public function handle()
$serviceId = $labels->get('coolify.serviceId');
$subType = $labels->get('coolify.service.subType');
$subId = $labels->get('coolify.service.subId');
+ if (empty(trim((string) $subId))) {
+ continue;
+ }
if ($subType === 'application') {
$this->foundServiceApplicationIds->push($subId);
// Store container status for aggregation
@@ -324,6 +335,10 @@ private function aggregateServiceContainerStatuses()
// Parse key: serviceId:subType:subId
[$serviceId, $subType, $subId] = explode(':', $key);
+ if (empty($subId)) {
+ continue;
+ }
+
$service = $this->services->where('id', $serviceId)->first();
if (! $service) {
continue;
@@ -332,9 +347,9 @@ private function aggregateServiceContainerStatuses()
// Get the service sub-resource (ServiceApplication or ServiceDatabase)
$subResource = null;
if ($subType === 'application') {
- $subResource = $service->applications()->where('id', $subId)->first();
+ $subResource = $service->applications->where('id', $subId)->first();
} elseif ($subType === 'database') {
- $subResource = $service->databases()->where('id', $subId)->first();
+ $subResource = $service->databases->where('id', $subId)->first();
}
if (! $subResource) {
@@ -473,8 +488,13 @@ private function updateProxyStatus()
} catch (\Throwable $e) {
}
} else {
- // Connect proxy to networks asynchronously to avoid blocking the status update
- ConnectProxyToNetworksJob::dispatch($this->server);
+ // Connect proxy to networks periodically (every 10 min) to avoid excessive job dispatches.
+ // On-demand triggers (new network, service deploy) use dispatchSync() and bypass this.
+ $proxyCacheKey = 'connect-proxy:'.$this->server->id;
+ if (! Cache::has($proxyCacheKey)) {
+ Cache::put($proxyCacheKey, true, 600);
+ ConnectProxyToNetworksJob::dispatch($this->server);
+ }
}
}
}
@@ -542,7 +562,7 @@ private function updateServiceSubStatus(string $serviceId, string $subType, stri
return;
}
if ($subType === 'application') {
- $application = $service->applications()->where('id', $subId)->first();
+ $application = $service->applications->where('id', $subId)->first();
if ($application) {
if ($application->status !== $containerStatus) {
$application->status = $containerStatus;
@@ -550,7 +570,7 @@ private function updateServiceSubStatus(string $serviceId, string $subType, stri
}
}
} elseif ($subType === 'database') {
- $database = $service->databases()->where('id', $subId)->first();
+ $database = $service->databases->where('id', $subId)->first();
if ($database) {
if ($database->status !== $containerStatus) {
$database->status = $containerStatus;
diff --git a/app/Jobs/ScheduledJobManager.php b/app/Jobs/ScheduledJobManager.php
index 75ff883c2..e68e3b613 100644
--- a/app/Jobs/ScheduledJobManager.php
+++ b/app/Jobs/ScheduledJobManager.php
@@ -15,7 +15,9 @@
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 @@ private function determineQueue(): string
*/
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 @@ public function middleware(): array
];
}
+ /**
+ * 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 @@ public function handle(): void
'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,12 +160,20 @@ private function processScheduledBackups(): void
foreach ($backups as $backup) {
try {
- // Apply the same filtering logic as the original
- if (! $this->shouldProcessBackup($backup)) {
+ $server = $backup->server();
+ $skipReason = $this->getBackupSkipReason($backup, $server);
+ 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;
}
- $server = $backup->server();
$serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone'));
if (validate_timezone($serverTimezone) === false) {
@@ -118,8 +185,16 @@ private function processScheduledBackups(): void
$frequency = VALID_CRON_STRINGS[$frequency];
}
- if ($this->shouldRunNow($frequency, $serverTimezone)) {
+ if ($this->shouldRunNow($frequency, $serverTimezone, "scheduled-backup:{$backup->id}")) {
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,11 +213,21 @@ private function processScheduledTasks(): void
foreach ($tasks as $task) {
try {
- if (! $this->shouldProcessTask($task)) {
+ $server = $task->server();
+
+ // Phase 1: Critical checks (always — cheap, handles orphans and infra issues)
+ $criticalSkip = $this->getTaskCriticalSkipReason($task, $server);
+ if ($criticalSkip !== null) {
+ $this->skippedCount++;
+ $this->logSkip('task', $criticalSkip, [
+ 'task_id' => $task->id,
+ 'task_name' => $task->name,
+ 'team_id' => $server?->team_id,
+ ]);
+
continue;
}
- $server = $task->server();
$serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone'));
if (validate_timezone($serverTimezone) === false) {
@@ -154,9 +239,31 @@ private function processScheduledTasks(): void
$frequency = VALID_CRON_STRINGS[$frequency];
}
- if ($this->shouldRunNow($frequency, $serverTimezone)) {
- ScheduledTaskJob::dispatch($task);
+ if (! $this->shouldRunNow($frequency, $serverTimezone, "scheduled-task:{$task->id}")) {
+ continue;
}
+
+ // Phase 2: Runtime checks (only when cron is due — avoids noise for stopped resources)
+ $runtimeSkip = $this->getTaskRuntimeSkipReason($task);
+ if ($runtimeSkip !== null) {
+ $this->skippedCount++;
+ $this->logSkip('task', $runtimeSkip, [
+ 'task_id' => $task->id,
+ 'task_name' => $task->name,
+ 'team_id' => $server->team_id,
+ ]);
+
+ continue;
+ }
+
+ 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', [
'task_id' => $task->id,
@@ -166,79 +273,112 @@ private function processScheduledTasks(): void
}
}
- private function shouldProcessBackup(ScheduledDatabaseBackup $backup): bool
+ private function getBackupSkipReason(ScheduledDatabaseBackup $backup, ?Server $server): ?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 getTaskCriticalSkipReason(ScheduledTask $task, ?Server $server): ?string
{
- $service = $task->service;
- $application = $task->application;
-
- $server = $task->server();
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) {
+ if (! $task->service && ! $task->application) {
$task->delete();
- return false;
+ return 'resource_deleted';
}
- if ($application && str($application->status)->contains('running') === false) {
- return false;
- }
-
- if ($service && str($service->status)->contains('running') === false) {
- return false;
- }
-
- return true;
+ return null;
}
- private function shouldRunNow(string $frequency, string $timezone): bool
+ private function getTaskRuntimeSkipReason(ScheduledTask $task): ?string
+ {
+ if ($task->application && str($task->application->status)->contains('running') === false) {
+ return 'application_not_running';
+ }
+
+ if ($task->service && str($task->service->status)->contains('running') === false) {
+ return 'service_not_running';
+ }
+
+ return null;
+ }
+
+ /**
+ * Determine if a cron schedule should run now.
+ *
+ * When a dedupKey is provided, uses getPreviousRunDate() + last-dispatch tracking
+ * instead of isDue(). This is resilient to queue delays — even if the job is delayed
+ * by minutes, it still catches the missed cron window. Without dedupKey, falls back
+ * to simple isDue() check.
+ */
+ private function shouldRunNow(string $frequency, string $timezone, ?string $dedupKey = null): bool
{
$cron = new CronExpression($frequency);
-
- // Use the frozen execution time, not the current time
- // Fallback to current time if execution time is not set (shouldn't happen)
$baseTime = $this->executionTime ?? Carbon::now();
$executionTime = $baseTime->copy()->setTimezone($timezone);
- return $cron->isDue($executionTime);
+ // No dedup key → simple isDue check (used by docker cleanups)
+ if ($dedupKey === null) {
+ return $cron->isDue($executionTime);
+ }
+
+ // Get the most recent time this cron was due (including current minute)
+ $previousDue = Carbon::instance($cron->getPreviousRunDate($executionTime, allowCurrentDate: true));
+
+ $lastDispatched = Cache::get($dedupKey);
+
+ if ($lastDispatched === null) {
+ // First run after restart or cache loss: only fire if actually due right now.
+ // Seed the cache so subsequent runs can use tolerance/catch-up logic.
+ $isDue = $cron->isDue($executionTime);
+ if ($isDue) {
+ Cache::put($dedupKey, $executionTime->toIso8601String(), 86400);
+ }
+
+ return $isDue;
+ }
+
+ // Subsequent runs: fire if there's been a due time since last dispatch
+ if ($previousDue->gt(Carbon::parse($lastDispatched))) {
+ Cache::put($dedupKey, $executionTime->toIso8601String(), 86400);
+
+ return true;
+ }
+
+ return false;
}
private function processDockerCleanups(): void
@@ -248,7 +388,15 @@ private function processDockerCleanups(): void
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 +418,12 @@ private function processDockerCleanups(): void
$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 +450,28 @@ private function getServersForCleanup(): Collection
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/ScheduledTaskJob.php b/app/Jobs/ScheduledTaskJob.php
index b21bc11a1..49b9b9702 100644
--- a/app/Jobs/ScheduledTaskJob.php
+++ b/app/Jobs/ScheduledTaskJob.php
@@ -14,13 +14,14 @@
use App\Notifications\ScheduledTask\TaskSuccess;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
-class ScheduledTaskJob implements ShouldQueue
+class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
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\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 @@ public function middleware(): array
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 @@ public function handle()
'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/ServerManagerJob.php b/app/Jobs/ServerManagerJob.php
index a4619354d..c8219a2ea 100644
--- a/app/Jobs/ServerManagerJob.php
+++ b/app/Jobs/ServerManagerJob.php
@@ -64,11 +64,11 @@ public function handle(): void
private function getServers(): Collection
{
- $allServers = Server::where('ip', '!=', '1.2.3.4');
+ $allServers = Server::with('settings')->where('ip', '!=', '1.2.3.4');
if (isCloud()) {
$servers = $allServers->whereRelation('team.subscription', 'stripe_invoice_paid', true)->get();
- $own = Team::find(0)->servers;
+ $own = Team::find(0)->servers()->with('settings')->get();
return $servers->merge($own);
} else {
@@ -82,6 +82,10 @@ private function dispatchConnectionChecks(Collection $servers): void
if ($this->shouldRunNow($this->checkFrequency)) {
$servers->each(function (Server $server) {
try {
+ // Skip SSH connection check if Sentinel is healthy — its heartbeat already proves connectivity
+ if ($server->isSentinelEnabled() && $server->isSentinelLive()) {
+ return;
+ }
ServerConnectionCheckJob::dispatch($server);
} catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Failed to dispatch ServerConnectionCheck', [
@@ -134,9 +138,7 @@ private function processServerTasks(Server $server): void
// Dispatch Sentinel restart if due (daily for Sentinel-enabled servers)
if ($shouldRestartSentinel) {
- dispatch(function () use ($server) {
- $server->restartContainer('coolify-sentinel');
- });
+ CheckAndStartSentinelJob::dispatch($server);
}
// Dispatch ServerStorageCheckJob if due (only when Sentinel is out of sync or disabled)
@@ -160,11 +162,8 @@ private function processServerTasks(Server $server): void
ServerPatchCheckJob::dispatch($server);
}
- // Sentinel update checks (hourly) - check for updates to Sentinel version
- // No timezone needed for hourly - runs at top of every hour
- if ($isSentinelEnabled && $this->shouldRunNow('0 * * * *')) {
- CheckAndStartSentinelJob::dispatch($server);
- }
+ // Note: CheckAndStartSentinelJob is only dispatched daily (line above) for version updates.
+ // Crash recovery is handled by sentinelOutOfSync → ServerCheckJob → CheckAndStartSentinelJob.
}
private function shouldRunNow(string $frequency, ?string $timezone = null): bool
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\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 @@ public function backoff(): int
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/Jobs/SyncStripeSubscriptionsJob.php b/app/Jobs/SyncStripeSubscriptionsJob.php
index 9eb946e4d..4301a80d1 100644
--- a/app/Jobs/SyncStripeSubscriptionsJob.php
+++ b/app/Jobs/SyncStripeSubscriptionsJob.php
@@ -22,7 +22,7 @@ public function __construct(public bool $fix = false)
$this->onQueue('high');
}
- public function handle(): array
+ public function handle(?\Closure $onProgress = null): array
{
if (! isCloud() || ! isStripe()) {
return ['error' => 'Not running on Cloud or Stripe not configured'];
@@ -33,48 +33,73 @@ public function handle(): array
->get();
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
+
+ // Bulk fetch all valid subscription IDs from Stripe (active + past_due)
+ $validStripeIds = $this->fetchValidStripeSubscriptionIds($stripe, $onProgress);
+
+ // Find DB subscriptions not in the valid set
+ $staleSubscriptions = $subscriptions->filter(
+ fn (Subscription $sub) => ! in_array($sub->stripe_subscription_id, $validStripeIds)
+ );
+
+ // For each stale subscription, get the exact Stripe status and check for resubscriptions
$discrepancies = [];
+ $resubscribed = [];
$errors = [];
- foreach ($subscriptions as $subscription) {
+ foreach ($staleSubscriptions as $subscription) {
try {
$stripeSubscription = $stripe->subscriptions->retrieve(
$subscription->stripe_subscription_id
);
+ $stripeStatus = $stripeSubscription->status;
- // Check if Stripe says cancelled but we think it's active
- if (in_array($stripeSubscription->status, ['canceled', 'incomplete_expired', 'unpaid'])) {
- $discrepancies[] = [
- 'subscription_id' => $subscription->id,
- 'team_id' => $subscription->team_id,
- 'stripe_subscription_id' => $subscription->stripe_subscription_id,
- 'stripe_status' => $stripeSubscription->status,
- ];
-
- // Only fix if --fix flag is passed
- if ($this->fix) {
- $subscription->update([
- 'stripe_invoice_paid' => false,
- 'stripe_past_due' => false,
- ]);
-
- if ($stripeSubscription->status === 'canceled') {
- $subscription->team?->subscriptionEnded();
- }
- }
- }
-
- // Small delay to avoid Stripe rate limits
- usleep(100000); // 100ms
+ usleep(100000); // 100ms rate limit delay
} catch (\Exception $e) {
$errors[] = [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
];
+
+ continue;
+ }
+
+ // Check if this user resubscribed under a different customer/subscription
+ $activeSub = $this->findActiveSubscriptionByEmail($stripe, $stripeSubscription->customer);
+ if ($activeSub) {
+ $resubscribed[] = [
+ 'subscription_id' => $subscription->id,
+ 'team_id' => $subscription->team_id,
+ 'email' => $activeSub['email'],
+ 'old_stripe_subscription_id' => $subscription->stripe_subscription_id,
+ 'old_stripe_customer_id' => $stripeSubscription->customer,
+ 'new_stripe_subscription_id' => $activeSub['subscription_id'],
+ 'new_stripe_customer_id' => $activeSub['customer_id'],
+ 'new_status' => $activeSub['status'],
+ ];
+
+ continue;
+ }
+
+ $discrepancies[] = [
+ 'subscription_id' => $subscription->id,
+ 'team_id' => $subscription->team_id,
+ 'stripe_subscription_id' => $subscription->stripe_subscription_id,
+ 'stripe_status' => $stripeStatus,
+ ];
+
+ if ($this->fix) {
+ $subscription->update([
+ 'stripe_invoice_paid' => false,
+ 'stripe_past_due' => false,
+ ]);
+
+ if ($stripeStatus === 'canceled') {
+ $subscription->team?->subscriptionEnded();
+ }
}
}
- // Only notify if discrepancies found and fixed
if ($this->fix && count($discrepancies) > 0) {
send_internal_notification(
'SyncStripeSubscriptionsJob: Fixed '.count($discrepancies)." discrepancies:\n".
@@ -85,8 +110,88 @@ public function handle(): array
return [
'total_checked' => $subscriptions->count(),
'discrepancies' => $discrepancies,
+ 'resubscribed' => $resubscribed,
'errors' => $errors,
'fixed' => $this->fix,
];
}
+
+ /**
+ * Given a Stripe customer ID, get their email and search for other customers
+ * with the same email that have an active subscription.
+ *
+ * @return array{email: string, customer_id: string, subscription_id: string, status: string}|null
+ */
+ private function findActiveSubscriptionByEmail(\Stripe\StripeClient $stripe, string $customerId): ?array
+ {
+ try {
+ $customer = $stripe->customers->retrieve($customerId);
+ $email = $customer->email;
+
+ if (! $email) {
+ return null;
+ }
+
+ usleep(100000);
+
+ $customers = $stripe->customers->all([
+ 'email' => $email,
+ 'limit' => 10,
+ ]);
+
+ usleep(100000);
+
+ foreach ($customers->data as $matchingCustomer) {
+ if ($matchingCustomer->id === $customerId) {
+ continue;
+ }
+
+ $subs = $stripe->subscriptions->all([
+ 'customer' => $matchingCustomer->id,
+ 'limit' => 10,
+ ]);
+
+ usleep(100000);
+
+ foreach ($subs->data as $sub) {
+ if (in_array($sub->status, ['active', 'past_due'])) {
+ return [
+ 'email' => $email,
+ 'customer_id' => $matchingCustomer->id,
+ 'subscription_id' => $sub->id,
+ 'status' => $sub->status,
+ ];
+ }
+ }
+ }
+ } catch (\Exception $e) {
+ // Silently skip — will fall through to normal discrepancy
+ }
+
+ return null;
+ }
+
+ /**
+ * Bulk fetch all active and past_due subscription IDs from Stripe.
+ *
+ * @return array419
Sorry, we couldn't find the page you're looking - for. +
Your session has expired. Please log in again to continue.
A custom health check has been detected. If you enable this health check, it will disable the custom one and use this instead.
@endif + + {{-- Healthcheck Type Selector --}}This command runs inside the container on every health check interval. Shell operators (;, |, &, $, >, <) are not allowed.
+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 | +Resource | +Reason | +
|---|---|---|---|
| {{ $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'])) }} + + | ++ @if($skip['link'] ?? null) + + {{ $skip['resource_name'] }} + + @elseif($skip['resource_name'] ?? null) + {{ $skip['resource_name'] }} + @else + {{ $skip['context']['task_name'] ?? $skip['context']['server_name'] ?? 'Deleted' }} + @endif + | ++ @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 }} + | +
| + No skipped jobs found. This means all scheduled jobs passed their conditions. + | +|||