mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-21 06:26:22 +00:00
feat(v5): add Inertia React infrastructure management (#10987)
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
---
|
||||
name: shadcn
|
||||
description: Manages shadcn components and projects — adding, searching, fixing, debugging, styling, and composing UI. Provides project context, component docs, and usage examples. Applies when working with shadcn/ui, component registries, presets, --preset codes, or any project with a components.json file. Also triggers for "shadcn init", "create an app with --preset", or "switch to --preset".
|
||||
user-invocable: false
|
||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||
---
|
||||
|
||||
# shadcn/ui
|
||||
|
||||
A framework for building ui, components and design systems. Components are added as source code to the user's project via the CLI.
|
||||
|
||||
> **IMPORTANT:** Run all CLI commands using the project's package runner: `npx shadcn@latest`, `pnpm dlx shadcn@latest`, or `bunx --bun shadcn@latest` — based on the project's `packageManager`. Examples below use `npx shadcn@latest` but substitute the correct runner for the project.
|
||||
|
||||
## Current Project Context
|
||||
|
||||
```json
|
||||
!`npx shadcn@latest info --json`
|
||||
```
|
||||
|
||||
The JSON above contains the project config and installed components. Use `npx shadcn@latest docs <component>` to get documentation and example URLs for any component.
|
||||
|
||||
## Principles
|
||||
|
||||
1. **Use existing components first.** Use `npx shadcn@latest search` to check registries before writing custom UI. Check community registries too.
|
||||
2. **Compose, don't reinvent.** Settings page = Tabs + Card + form controls. Dashboard = Sidebar + Card + Chart + Table.
|
||||
3. **Use built-in variants before custom styles.** `variant="outline"`, `size="sm"`, etc.
|
||||
4. **Use semantic colors.** `bg-primary`, `text-muted-foreground` — never raw values like `bg-blue-500`.
|
||||
|
||||
## Critical Rules
|
||||
|
||||
These rules are **always enforced**. Each links to a file with Incorrect/Correct code pairs.
|
||||
|
||||
### Styling & Tailwind → [styling.md](./rules/styling.md)
|
||||
|
||||
- **`className` for layout, not styling.** Never override component colors or typography.
|
||||
- **No `space-x-*` or `space-y-*`.** Use `flex` with `gap-*`. For vertical stacks, `flex flex-col gap-*`.
|
||||
- **Use `size-*` when width and height are equal.** `size-10` not `w-10 h-10`.
|
||||
- **Use `truncate` shorthand.** Not `overflow-hidden text-ellipsis whitespace-nowrap`.
|
||||
- **No manual `dark:` color overrides.** Use semantic tokens (`bg-background`, `text-muted-foreground`).
|
||||
- **Use `cn()` for conditional classes.** Don't write manual template literal ternaries.
|
||||
- **No manual `z-index` on overlay components.** Dialog, Sheet, Popover, etc. handle their own stacking.
|
||||
|
||||
### Forms & Inputs → [forms.md](./rules/forms.md)
|
||||
|
||||
- **Forms use `FieldGroup` + `Field`.** Never use raw `div` with `space-y-*` or `grid gap-*` for form layout.
|
||||
- **`InputGroup` uses `InputGroupInput`/`InputGroupTextarea`.** Never raw `Input`/`Textarea` inside `InputGroup`.
|
||||
- **Buttons inside inputs use `InputGroup` + `InputGroupAddon`.**
|
||||
- **Option sets (2–7 choices) use `ToggleGroup`.** Don't loop `Button` with manual active state.
|
||||
- **`FieldSet` + `FieldLegend` for grouping related checkboxes/radios.** Don't use a `div` with a heading.
|
||||
- **Field validation uses `data-invalid` + `aria-invalid`.** `data-invalid` on `Field`, `aria-invalid` on the control. For disabled: `data-disabled` on `Field`, `disabled` on the control.
|
||||
|
||||
### Component Structure → [composition.md](./rules/composition.md)
|
||||
|
||||
- **Items always inside their Group.** `SelectItem` → `SelectGroup`. `DropdownMenuItem` → `DropdownMenuGroup`. `CommandItem` → `CommandGroup`.
|
||||
- **Use `asChild` (radix) or `render` (base) for custom triggers.** Check `base` field from `npx shadcn@latest info`. → [base-vs-radix.md](./rules/base-vs-radix.md)
|
||||
- **Dialog, Sheet, and Drawer always need a Title.** `DialogTitle`, `SheetTitle`, `DrawerTitle` required for accessibility. Use `className="sr-only"` if visually hidden.
|
||||
- **Use full Card composition.** `CardHeader`/`CardTitle`/`CardDescription`/`CardContent`/`CardFooter`. Don't dump everything in `CardContent`.
|
||||
- **Button has no `isPending`/`isLoading`.** Compose with `Spinner` + `data-icon` + `disabled`.
|
||||
- **`TabsTrigger` must be inside `TabsList`.** Never render triggers directly in `Tabs`.
|
||||
- **`Avatar` always needs `AvatarFallback`.** For when the image fails to load.
|
||||
|
||||
### Use Components, Not Custom Markup → [composition.md](./rules/composition.md)
|
||||
|
||||
- **Use existing components before custom markup.** Check if a component exists before writing a styled `div`.
|
||||
- **Callouts use `Alert`.** Don't build custom styled divs.
|
||||
- **Empty states use `Empty`.** Don't build custom empty state markup.
|
||||
- **Toast via `sonner`.** Use `toast()` from `sonner`.
|
||||
- **Use `Separator`** instead of `<hr>` or `<div className="border-t">`.
|
||||
- **Use `Skeleton`** for loading placeholders. No custom `animate-pulse` divs.
|
||||
- **Use `Badge`** instead of custom styled spans.
|
||||
|
||||
### Icons → [icons.md](./rules/icons.md)
|
||||
|
||||
- **Icons in `Button` use `data-icon`.** `data-icon="inline-start"` or `data-icon="inline-end"` on the icon.
|
||||
- **No sizing classes on icons inside components.** Components handle icon sizing via CSS. No `size-4` or `w-4 h-4`.
|
||||
- **Pass icons as objects, not string keys.** `icon={CheckIcon}`, not a string lookup.
|
||||
|
||||
### CLI
|
||||
|
||||
- **Never decode preset codes or build preset URLs manually.** Use `npx shadcn@latest preset decode <code>`, `preset url <code>`, or `preset open <code>`. For project-aware preset detection, use `npx shadcn@latest preset resolve`.
|
||||
- **Apply preset codes directly with the CLI.** Use `npx shadcn@latest apply <code>` for existing projects, or `npx shadcn@latest init --preset <code>` when initializing.
|
||||
|
||||
## Key Patterns
|
||||
|
||||
These are the most common patterns that differentiate correct shadcn/ui code. For edge cases, see the linked rule files above.
|
||||
|
||||
```tsx
|
||||
// Form layout: FieldGroup + Field, not div + Label.
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||
<Input id="email" />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
||||
// Validation: data-invalid on Field, aria-invalid on the control.
|
||||
<Field data-invalid>
|
||||
<FieldLabel>Email</FieldLabel>
|
||||
<Input aria-invalid />
|
||||
<FieldDescription>Invalid email.</FieldDescription>
|
||||
</Field>
|
||||
|
||||
// Icons in buttons: data-icon, no sizing classes.
|
||||
<Button>
|
||||
<SearchIcon data-icon="inline-start" />
|
||||
Search
|
||||
</Button>
|
||||
|
||||
// Spacing: gap-*, not space-y-*.
|
||||
<div className="flex flex-col gap-4"> // correct
|
||||
<div className="space-y-4"> // wrong
|
||||
|
||||
// Equal dimensions: size-*, not w-* h-*.
|
||||
<Avatar className="size-10"> // correct
|
||||
<Avatar className="w-10 h-10"> // wrong
|
||||
|
||||
// Status colors: Badge variants or semantic tokens, not raw colors.
|
||||
<Badge variant="secondary">+20.1%</Badge> // correct
|
||||
<span className="text-emerald-600">+20.1%</span> // wrong
|
||||
```
|
||||
|
||||
## Component Selection
|
||||
|
||||
| Need | Use |
|
||||
| -------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| Button/action | `Button` with appropriate variant |
|
||||
| Form inputs | `Input`, `Select`, `Combobox`, `Switch`, `Checkbox`, `RadioGroup`, `Textarea`, `InputOTP`, `Slider` |
|
||||
| Toggle between 2–5 options | `ToggleGroup` + `ToggleGroupItem` |
|
||||
| Data display | `Table`, `Card`, `Badge`, `Avatar` |
|
||||
| Navigation | `Sidebar`, `NavigationMenu`, `Breadcrumb`, `Tabs`, `Pagination` |
|
||||
| Overlays | `Dialog` (modal), `Sheet` (side panel), `Drawer` (bottom sheet), `AlertDialog` (confirmation) |
|
||||
| Feedback | `sonner` (toast), `Alert`, `Progress`, `Skeleton`, `Spinner` |
|
||||
| Command palette | `Command` inside `Dialog` |
|
||||
| Charts | `Chart` (wraps Recharts) |
|
||||
| Layout | `Card`, `Separator`, `Resizable`, `ScrollArea`, `Accordion`, `Collapsible` |
|
||||
| Empty states | `Empty` |
|
||||
| Menus | `DropdownMenu`, `ContextMenu`, `Menubar` |
|
||||
| Tooltips/info | `Tooltip`, `HoverCard`, `Popover` |
|
||||
|
||||
## Key Fields
|
||||
|
||||
The injected project context contains these key fields:
|
||||
|
||||
- **`aliases`** → use the actual alias prefix for imports (e.g. `@/`, `~/`), never hardcode.
|
||||
- **`isRSC`** → when `true`, components using `useState`, `useEffect`, event handlers, or browser APIs need `"use client"` at the top of the file. Always reference this field when advising on the directive.
|
||||
- **`tailwindVersion`** → `"v4"` uses `@theme inline` blocks; `"v3"` uses `tailwind.config.js`.
|
||||
- **`tailwindCssFile`** → the global CSS file where custom CSS variables are defined. Always edit this file, never create a new one.
|
||||
- **`style`** → component visual treatment (e.g. `nova`, `vega`).
|
||||
- **`base`** → primitive library (`radix` or `base`). Affects component APIs and available props.
|
||||
- **`iconLibrary`** → determines icon imports. Use `lucide-react` for `lucide`, `@tabler/icons-react` for `tabler`, etc. Never assume `lucide-react`.
|
||||
- **`resolvedPaths`** → exact file-system destinations for components, utils, hooks, etc.
|
||||
- **`framework`** → routing and file conventions (e.g. Next.js App Router vs Vite SPA).
|
||||
- **`packageManager`** → use this for any non-shadcn dependency installs (e.g. `pnpm add date-fns` vs `npm install date-fns`).
|
||||
- **`preset`** → resolved preset code and values for the current project. Use `npx shadcn@latest preset resolve --json` when you only need preset information.
|
||||
|
||||
See [cli.md — `info` command](./cli.md) for the full field reference.
|
||||
|
||||
## Component Docs, Examples, and Usage
|
||||
|
||||
Run `npx shadcn@latest docs <component>` to get the URLs for a component's documentation, examples, and API reference. Fetch these URLs to get the actual content.
|
||||
|
||||
```bash
|
||||
npx shadcn@latest docs button dialog select
|
||||
```
|
||||
|
||||
**When creating, fixing, debugging, or using a component, always run `npx shadcn@latest docs` and fetch the URLs first.** This ensures you're working with the correct API and usage patterns rather than guessing.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Get project context** — already injected above. Run `npx shadcn@latest info` again if you need to refresh.
|
||||
2. **Check installed components first** — before running `add`, always check the `components` list from project context or list the `resolvedPaths.ui` directory. Don't import components that haven't been added, and don't re-add ones already installed.
|
||||
3. **Find components** — `npx shadcn@latest search`.
|
||||
4. **Get docs and examples** — run `npx shadcn@latest docs <component>` to get URLs, then fetch them. Use `npx shadcn@latest view` to browse registry items you haven't installed. To preview changes to installed components, use `npx shadcn@latest add --diff`.
|
||||
5. **Install or update** — `npx shadcn@latest add`. When updating existing components, use `--dry-run` and `--diff` to preview changes first (see [Updating Components](#updating-components) below).
|
||||
6. **Fix imports in third-party components** — After adding components from community registries (e.g. `@bundui`, `@magicui`), check the added non-UI files for hardcoded import paths like `@/components/ui/...`. These won't match the project's actual aliases. Use `npx shadcn@latest info` to get the correct `ui` alias (e.g. `@workspace/ui/components`) and rewrite the imports accordingly. The CLI rewrites imports for its own UI files, but third-party registry components may use default paths that don't match the project.
|
||||
7. **Review added components** — After adding a component or block from any registry, **always read the added files and verify they are correct**. Check for missing sub-components (e.g. `SelectItem` without `SelectGroup`), missing imports, incorrect composition, or violations of the [Critical Rules](#critical-rules). Also replace any icon imports with the project's `iconLibrary` from the project context (e.g. if the registry item uses `lucide-react` but the project uses `hugeicons`, swap the imports and icon names accordingly). Fix all issues before moving on.
|
||||
8. **Registry must be explicit** — When the user asks to add a block or component, **do not guess the registry**. If no registry is specified (e.g. user says "add a login block" without specifying `@shadcn`, `@tailark`, `owner/repo`, etc.), ask which registry to use. Never default to a registry on behalf of the user.
|
||||
9. **Switching presets** — Ask the user first: **overwrite**, **partial**, **merge**, or **skip**?
|
||||
- **Inspect current preset**: `npx shadcn@latest preset resolve`. Use `--json` when you need structured values.
|
||||
- **Inspect incoming preset**: `npx shadcn@latest preset decode <code>`. Use `preset url <code>` or `preset open <code>` to share or open the preset builder.
|
||||
- **Overwrite**: `npx shadcn@latest apply <code>`. Overwrites detected components, fonts, and CSS variables.
|
||||
- **Partial**: `npx shadcn@latest apply <code> --only theme,font`. Updates only the selected preset parts without reinstalling UI components. Supported values are `theme` and `font`; comma-separated combinations are allowed. `icon` is intentionally not supported, because icon changes may require full component reinstall and transforms.
|
||||
- **Merge**: `npx shadcn@latest init --preset <code> --force --no-reinstall`, then run `npx shadcn@latest info` to list installed components, then for each installed component use `--dry-run` and `--diff` to [smart merge](#updating-components) it individually.
|
||||
- **Skip**: `npx shadcn@latest init --preset <code> --force --no-reinstall`. Only updates config and CSS, leaves components as-is.
|
||||
- **Important**: Always run preset commands inside the user's project directory. `apply` only works in an existing project with a `components.json` file. The CLI automatically preserves the current base (`base` vs `radix`) from `components.json`. If you must use a scratch/temp directory (e.g. for `--dry-run` comparisons), pass `--base <current-base>` explicitly — preset codes do not encode the base.
|
||||
|
||||
## Updating Components
|
||||
|
||||
When the user asks to update a component from upstream while keeping their local changes, use `--dry-run` and `--diff` to intelligently merge. **NEVER fetch raw files from GitHub manually — always use the CLI.**
|
||||
|
||||
1. Run `npx shadcn@latest add <component> --dry-run` to see all files that would be affected.
|
||||
2. For each file, run `npx shadcn@latest add <component> --diff <file>` to see what changed upstream vs local.
|
||||
3. Decide per file based on the diff:
|
||||
- No local changes → safe to overwrite.
|
||||
- Has local changes → read the local file, analyze the diff, and apply upstream updates while preserving local modifications.
|
||||
- User says "just update everything" → use `--overwrite`, but confirm first.
|
||||
4. **Never use `--overwrite` without the user's explicit approval.**
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
# Create a new project.
|
||||
npx shadcn@latest init --name my-app --preset base-nova
|
||||
npx shadcn@latest init --name my-app --preset a2r6bw --template vite
|
||||
|
||||
# Create a monorepo project.
|
||||
npx shadcn@latest init --name my-app --preset base-nova --monorepo
|
||||
npx shadcn@latest init --name my-app --preset base-nova --template next --monorepo
|
||||
|
||||
# Initialize existing project.
|
||||
npx shadcn@latest init --preset base-nova
|
||||
npx shadcn@latest init --defaults # shortcut: --template=next --preset=nova (base style implied)
|
||||
|
||||
# Apply a preset to an existing project.
|
||||
npx shadcn@latest apply a2r6bw
|
||||
npx shadcn@latest apply a2r6bw --only theme
|
||||
npx shadcn@latest apply a2r6bw --only font
|
||||
npx shadcn@latest apply a2r6bw --only theme,font
|
||||
|
||||
# Inspect preset codes and project preset state.
|
||||
npx shadcn@latest preset decode a2r6bw
|
||||
npx shadcn@latest preset url a2r6bw
|
||||
npx shadcn@latest preset open a2r6bw
|
||||
npx shadcn@latest preset resolve
|
||||
npx shadcn@latest preset resolve --json
|
||||
|
||||
# Add components.
|
||||
npx shadcn@latest add button card dialog
|
||||
npx shadcn@latest add @magicui/shimmer-button
|
||||
npx shadcn@latest add owner/repo/item
|
||||
npx shadcn@latest add --all
|
||||
|
||||
# Preview changes before adding/updating.
|
||||
npx shadcn@latest add button --dry-run
|
||||
npx shadcn@latest add button --diff button.tsx
|
||||
npx shadcn@latest add @acme/form --view button.tsx
|
||||
npx shadcn@latest add owner/repo/item --dry-run
|
||||
|
||||
# Search registries.
|
||||
npx shadcn@latest search @shadcn -q "sidebar"
|
||||
npx shadcn@latest search @tailark -q "stats"
|
||||
npx shadcn@latest search owner/repo -q "login"
|
||||
npx shadcn@latest search # all configured registries
|
||||
npx shadcn@latest search @shadcn -q "menu" -t ui # filter by item type
|
||||
|
||||
# Get component docs and example URLs.
|
||||
npx shadcn@latest docs button dialog select
|
||||
|
||||
# View registry item details (for items not yet installed).
|
||||
npx shadcn@latest view @shadcn/button
|
||||
npx shadcn@latest view owner/repo/item
|
||||
```
|
||||
|
||||
**Named presets:** `nova`, `vega`, `maia`, `lyra`, `mira`, `luma`
|
||||
**Templates:** `next`, `vite`, `start`, `react-router`, `astro` (all support `--monorepo`) and `laravel` (not supported for monorepo)
|
||||
**Preset codes:** Version-prefixed base62 strings (e.g. `a2r6bw` or `b0`), from [ui.shadcn.com](https://ui.shadcn.com).
|
||||
|
||||
## Detailed References
|
||||
|
||||
- [rules/forms.md](./rules/forms.md) — FieldGroup, Field, InputGroup, ToggleGroup, FieldSet, validation states
|
||||
- [rules/composition.md](./rules/composition.md) — Groups, overlays, Card, Tabs, Avatar, Alert, Empty, Toast, Separator, Skeleton, Badge, Button loading
|
||||
- [rules/icons.md](./rules/icons.md) — data-icon, icon sizing, passing icons as objects
|
||||
- [rules/styling.md](./rules/styling.md) — Semantic colors, variants, className, spacing, size, truncate, dark mode, cn(), z-index
|
||||
- [rules/base-vs-radix.md](./rules/base-vs-radix.md) — asChild vs render, Select, ToggleGroup, Slider, Accordion
|
||||
- [cli.md](./cli.md) — Commands, flags, presets, templates
|
||||
- [registry.md](./registry.md) — Authoring source registries, `include`, item definitions, dependencies, GitHub registry rules
|
||||
- [customization.md](./customization.md) — Theming, CSS variables, extending components
|
||||
@@ -0,0 +1,5 @@
|
||||
interface:
|
||||
display_name: "shadcn/ui"
|
||||
short_description: "Manages shadcn/ui components — adding, searching, fixing, debugging, styling, and composing UI."
|
||||
icon_small: "./assets/shadcn-small.png"
|
||||
icon_large: "./assets/shadcn.png"
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,290 @@
|
||||
# shadcn CLI Reference
|
||||
|
||||
Configuration is read from `components.json`.
|
||||
|
||||
> **IMPORTANT:** Always run commands using the project's package runner: `npx shadcn@latest`, `pnpm dlx shadcn@latest`, or `bunx --bun shadcn@latest`. Check `packageManager` from project context to choose the right one. Examples below use `npx shadcn@latest` but substitute the correct runner for the project.
|
||||
|
||||
> **IMPORTANT:** Only use the flags documented below. Do not invent or guess flags — if a flag isn't listed here, it doesn't exist. The CLI auto-detects the package manager from the project's lockfile; there is no `--package-manager` flag.
|
||||
|
||||
## Contents
|
||||
|
||||
- Commands: init, apply, add (dry-run, smart merge), search, view, docs, info, build
|
||||
- Templates: next, vite, start, react-router, astro
|
||||
- Presets: named, code, URL formats and fields
|
||||
- Switching presets
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
### `init` — Initialize or create a project
|
||||
|
||||
```bash
|
||||
npx shadcn@latest init [components...] [options]
|
||||
```
|
||||
|
||||
Initializes shadcn/ui in an existing project or creates a new project (when `--name` is provided). Optionally installs components in the same step.
|
||||
|
||||
| Flag | Short | Description | Default |
|
||||
| ----------------------- | ----- | --------------------------------------------------------- | ------- |
|
||||
| `--template <template>` | `-t` | Template (next, start, vite, next-monorepo, react-router) | — |
|
||||
| `--preset [name]` | `-p` | Preset configuration (named, code, or URL) | — |
|
||||
| `--yes` | `-y` | Skip confirmation prompt | `true` |
|
||||
| `--defaults` | `-d` | Use defaults (`--template=next --preset=base-nova`) | `false` |
|
||||
| `--force` | `-f` | Force overwrite existing configuration | `false` |
|
||||
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||
| `--name <name>` | `-n` | Name for new project | — |
|
||||
| `--silent` | `-s` | Mute output | `false` |
|
||||
| `--rtl` | | Enable RTL support | — |
|
||||
| `--reinstall` | | Re-install existing UI components | `false` |
|
||||
| `--monorepo` | | Scaffold a monorepo project | — |
|
||||
| `--no-monorepo` | | Skip the monorepo prompt | — |
|
||||
|
||||
`npx shadcn@latest create` is an alias for `npx shadcn@latest init`.
|
||||
|
||||
### `apply` — Apply a preset to an existing project
|
||||
|
||||
```bash
|
||||
npx shadcn@latest apply [preset] [options]
|
||||
```
|
||||
|
||||
Applies a preset to an existing project, overwriting preset-driven config, fonts, CSS variables, and detected UI components.
|
||||
|
||||
| Flag | Short | Description | Default |
|
||||
| ------------------- | ----- | ------------------------------------------ | ------- |
|
||||
| `--preset <preset>` | — | Preset configuration (named, code, or URL) | — |
|
||||
| `--yes` | `-y` | Skip confirmation prompt | `false` |
|
||||
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||
| `--silent` | `-s` | Mute output | `false` |
|
||||
|
||||
`[preset]` is a shorthand for `--preset <preset>`. If both are provided, they must match.
|
||||
If no preset is provided, the CLI offers to open the custom preset builder on `ui.shadcn.com/create`.
|
||||
|
||||
### `add` — Add components
|
||||
|
||||
> **IMPORTANT:** To compare local components against upstream or to preview changes, ALWAYS use `npx shadcn@latest add <component> --dry-run`, `--diff`, or `--view`. NEVER fetch raw files from GitHub or other sources manually. The CLI handles registry resolution, file paths, and CSS diffing automatically.
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add [components...] [options]
|
||||
```
|
||||
|
||||
Accepts component names, registry-prefixed names (`@magicui/shimmer-button`),
|
||||
GitHub item addresses (`owner/repo/item`), URLs, or local paths.
|
||||
|
||||
| Flag | Short | Description | Default |
|
||||
| --------------- | ----- | -------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| `--yes` | `-y` | Skip confirmation prompt | `false` |
|
||||
| `--overwrite` | `-o` | Overwrite existing files | `false` |
|
||||
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||
| `--all` | `-a` | Add all available components | `false` |
|
||||
| `--path <path>` | `-p` | Target path for the component | — |
|
||||
| `--silent` | `-s` | Mute output | `false` |
|
||||
| `--dry-run` | | Preview all changes without writing files | `false` |
|
||||
| `--diff [path]` | | Show diffs. Without a path, shows the first 5 files. With a path, shows that file only (implies `--dry-run`) | — |
|
||||
| `--view [path]` | | Show file contents. Without a path, shows the first 5 files. With a path, shows that file only (implies `--dry-run`) | — |
|
||||
|
||||
#### Dry-Run Mode
|
||||
|
||||
Use `--dry-run` to preview what `add` would do without writing any files. `--diff` and `--view` both imply `--dry-run`.
|
||||
|
||||
```bash
|
||||
# Preview all changes.
|
||||
npx shadcn@latest add button --dry-run
|
||||
|
||||
# Show diffs for all files (top 5).
|
||||
npx shadcn@latest add button --diff
|
||||
|
||||
# Show the diff for a specific file.
|
||||
npx shadcn@latest add button --diff button.tsx
|
||||
|
||||
# Show contents for all files (top 5).
|
||||
npx shadcn@latest add button --view
|
||||
|
||||
# Show the full content of a specific file.
|
||||
npx shadcn@latest add button --view button.tsx
|
||||
|
||||
# Works with URLs too.
|
||||
npx shadcn@latest add https://api.npoint.io/abc123 --dry-run
|
||||
|
||||
# Works with public GitHub registries too.
|
||||
npx shadcn@latest add owner/repo/item --dry-run
|
||||
|
||||
# CSS diffs.
|
||||
npx shadcn@latest add button --diff globals.css
|
||||
```
|
||||
|
||||
**When to use dry-run:**
|
||||
|
||||
- When the user asks "what files will this add?" or "what will this change?" — use `--dry-run`.
|
||||
- Before overwriting existing components — use `--diff` to preview the changes first.
|
||||
- When the user wants to inspect component source code without installing — use `--view`.
|
||||
- When checking what CSS changes would be made to `globals.css` — use `--diff globals.css`.
|
||||
- When the user asks to review or audit third-party registry code before installing — use `--view` to inspect the source.
|
||||
|
||||
> **`npx shadcn@latest add --dry-run` vs `npx shadcn@latest view`:** Prefer `npx shadcn@latest add --dry-run/--diff/--view` over `npx shadcn@latest view` when the user wants to preview changes to their project. `npx shadcn@latest view` only shows raw registry metadata. `npx shadcn@latest add --dry-run` shows exactly what would happen in the user's project: resolved file paths, diffs against existing files, and CSS updates. Use `npx shadcn@latest view` only when the user wants to browse registry info without a project context.
|
||||
|
||||
#### Smart Merge from Upstream
|
||||
|
||||
See [Updating Components in SKILL.md](./SKILL.md#updating-components) for the full workflow.
|
||||
|
||||
### `search` — Search registries
|
||||
|
||||
```bash
|
||||
npx shadcn@latest search [registries...] [options]
|
||||
```
|
||||
|
||||
Fuzzy search across registries. Also aliased as `npx shadcn@latest list`.
|
||||
Supports namespaces (`@acme`), public GitHub registry sources (`owner/repo`),
|
||||
and registry catalog URLs. Without `-q`, lists all items. When no registries are
|
||||
passed, searches every registry configured in `components.json`.
|
||||
|
||||
| Flag | Short | Description | Default |
|
||||
| ------------------- | ----- | ------------------------------------------------- | ------- |
|
||||
| `--query <query>` | `-q` | Search query | — |
|
||||
| `--type <type>` | `-t` | Filter by item type (e.g. `ui`, `block`, `hook`); comma-separated | — |
|
||||
| `--limit <number>` | `-l` | Max items to display | `100` |
|
||||
| `--offset <number>` | `-o` | Items to skip | `0` |
|
||||
| `--json` | | Output as JSON | `false` |
|
||||
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||
|
||||
### `view` — View item details
|
||||
|
||||
```bash
|
||||
npx shadcn@latest view <items...> [options]
|
||||
```
|
||||
|
||||
Displays item info including file contents. Examples:
|
||||
`npx shadcn@latest view @shadcn/button`,
|
||||
`npx shadcn@latest view owner/repo/item`.
|
||||
|
||||
### `docs` — Get component documentation URLs
|
||||
|
||||
```bash
|
||||
npx shadcn@latest docs <components...> [options]
|
||||
```
|
||||
|
||||
Outputs resolved URLs for component documentation, examples, and API references. Accepts one or more component names. Fetch the URLs to get the actual content.
|
||||
|
||||
Example output for `npx shadcn@latest docs input button`:
|
||||
|
||||
```
|
||||
base radix
|
||||
|
||||
input
|
||||
docs https://ui.shadcn.com/docs/components/radix/input
|
||||
examples https://raw.githubusercontent.com/.../examples/input-example.tsx
|
||||
|
||||
button
|
||||
docs https://ui.shadcn.com/docs/components/radix/button
|
||||
examples https://raw.githubusercontent.com/.../examples/button-example.tsx
|
||||
```
|
||||
|
||||
Some components include an `api` link to the underlying library (e.g. `cmdk` for the command component).
|
||||
|
||||
### `diff` — Check for updates
|
||||
|
||||
Do not use this command. Use `npx shadcn@latest add --diff` instead.
|
||||
|
||||
### `info` — Project information
|
||||
|
||||
```bash
|
||||
npx shadcn@latest info [options]
|
||||
```
|
||||
|
||||
Displays project info and `components.json` configuration. Run this first to discover the project's framework, aliases, Tailwind version, and resolved paths.
|
||||
|
||||
| Flag | Short | Description | Default |
|
||||
| ------------- | ----- | ----------------- | ------- |
|
||||
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||
|
||||
**Project Info fields:**
|
||||
|
||||
| Field | Type | Meaning |
|
||||
| -------------------- | --------- | ------------------------------------------------------------------ |
|
||||
| `framework` | `string` | Detected framework (`next`, `vite`, `react-router`, `start`, etc.) |
|
||||
| `frameworkVersion` | `string` | Framework version (e.g. `15.2.4`) |
|
||||
| `isSrcDir` | `boolean` | Whether the project uses a `src/` directory |
|
||||
| `isRSC` | `boolean` | Whether React Server Components are enabled |
|
||||
| `isTsx` | `boolean` | Whether the project uses TypeScript |
|
||||
| `tailwindVersion` | `string` | `"v3"` or `"v4"` |
|
||||
| `tailwindConfigFile` | `string` | Path to the Tailwind config file |
|
||||
| `tailwindCssFile` | `string` | Path to the global CSS file |
|
||||
| `aliasPrefix` | `string` | Import alias prefix (e.g. `@`, `~`, `@/`) |
|
||||
| `packageManager` | `string` | Detected package manager (`npm`, `pnpm`, `yarn`, `bun`) |
|
||||
|
||||
**Components.json fields:**
|
||||
|
||||
| Field | Type | Meaning |
|
||||
| -------------------- | --------- | ------------------------------------------------------------------------------------------ |
|
||||
| `base` | `string` | Primitive library (`radix` or `base`) — determines component APIs and available props |
|
||||
| `style` | `string` | Visual style (e.g. `nova`, `vega`) |
|
||||
| `rsc` | `boolean` | RSC flag from config |
|
||||
| `tsx` | `boolean` | TypeScript flag |
|
||||
| `tailwind.config` | `string` | Tailwind config path |
|
||||
| `tailwind.css` | `string` | Global CSS path — this is where custom CSS variables go |
|
||||
| `iconLibrary` | `string` | Icon library — determines icon import package (e.g. `lucide-react`, `@tabler/icons-react`) |
|
||||
| `aliases.components` | `string` | Component import alias (e.g. `@/components`) |
|
||||
| `aliases.utils` | `string` | Utils import alias (e.g. `@/lib/utils`) |
|
||||
| `aliases.ui` | `string` | UI component alias (e.g. `@/components/ui`) |
|
||||
| `aliases.lib` | `string` | Lib alias (e.g. `@/lib`) |
|
||||
| `aliases.hooks` | `string` | Hooks alias (e.g. `@/hooks`) |
|
||||
| `resolvedPaths` | `object` | Absolute file-system paths for each alias |
|
||||
| `registries` | `object` | Configured custom registries |
|
||||
|
||||
**Links fields:**
|
||||
|
||||
The `info` output includes a **Links** section with templated URLs for component docs, source, and examples. For resolved URLs, use `npx shadcn@latest docs <component>` instead.
|
||||
|
||||
### `build` — Build a custom registry
|
||||
|
||||
```bash
|
||||
npx shadcn@latest build [registry] [options]
|
||||
```
|
||||
|
||||
Builds `registry.json` into individual JSON files for distribution. Default input: `./registry.json`, default output: `./public/r`.
|
||||
|
||||
For authoring rules, `include`, item definitions, `registryDependencies`, and
|
||||
GitHub registry behavior, see [registry.md](./registry.md).
|
||||
|
||||
| Flag | Short | Description | Default |
|
||||
| ----------------- | ----- | ----------------- | ------------ |
|
||||
| `--output <path>` | `-o` | Output directory | `./public/r` |
|
||||
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||
|
||||
---
|
||||
|
||||
## Templates
|
||||
|
||||
| Value | Framework | Monorepo support |
|
||||
| -------------- | -------------- | ---------------- |
|
||||
| `next` | Next.js | Yes |
|
||||
| `vite` | Vite | Yes |
|
||||
| `start` | TanStack Start | Yes |
|
||||
| `react-router` | React Router | Yes |
|
||||
| `astro` | Astro | Yes |
|
||||
| `laravel` | Laravel | No |
|
||||
|
||||
All templates support monorepo scaffolding via the `--monorepo` flag. When passed, the CLI uses a monorepo-specific template directory (e.g. `next-monorepo`, `vite-monorepo`). When neither `--monorepo` nor `--no-monorepo` is passed, the CLI prompts interactively. Laravel does not support monorepo scaffolding.
|
||||
|
||||
---
|
||||
|
||||
## Presets
|
||||
|
||||
Three ways to specify a preset via `--preset`:
|
||||
|
||||
1. **Named:** `--preset nova` or `--preset lyra`
|
||||
2. **Code:** `--preset a2r6bw` (version-prefixed base62 string, e.g. `a2r6bw` or `b0`)
|
||||
3. **URL:** `--preset "https://ui.shadcn.com/init?base=radix&style=nova&..."`
|
||||
|
||||
> **IMPORTANT:** Never try to decode, fetch, or resolve preset codes manually. Preset codes are opaque — pass them directly to `npx shadcn@latest init --preset <code>` and let the CLI handle resolution.
|
||||
> Use `npx shadcn@latest apply --preset <code>` when overwriting an existing project's preset.
|
||||
|
||||
## Switching Presets
|
||||
|
||||
Ask the user first: **overwrite**, **merge**, or **skip** existing components?
|
||||
|
||||
- **Overwrite / Re-install** → `npx shadcn@latest apply --preset <code>`. Overwrites all detected component files with the new preset styles. Use when the user hasn't customized components.
|
||||
- **Merge** → `npx shadcn@latest init --preset <code> --force --no-reinstall`, then run `npx shadcn@latest info` to get the list of installed components and use the [smart merge workflow](./SKILL.md#updating-components) to update them one by one, preserving local changes. Use when the user has customized components.
|
||||
- **Skip** → `npx shadcn@latest init --preset <code> --force --no-reinstall`. Only updates config and CSS variables, leaves existing components as-is.
|
||||
|
||||
Always run preset commands inside the user's project directory. `apply` only works in an existing project with a `components.json` file. The CLI automatically preserves the current base (`base` vs `radix`) from `components.json`. If you must use a scratch/temp directory (e.g. for `--dry-run` comparisons), pass `--base <current-base>` explicitly — preset codes do not encode the base.
|
||||
@@ -0,0 +1,209 @@
|
||||
# Customization & Theming
|
||||
|
||||
Components reference semantic CSS variable tokens. Change the variables to change every component.
|
||||
|
||||
## Contents
|
||||
|
||||
- How it works (CSS variables → Tailwind utilities → components)
|
||||
- Color variables and OKLCH format
|
||||
- Dark mode setup
|
||||
- Changing the theme (presets, CSS variables)
|
||||
- Adding custom colors (Tailwind v3 and v4)
|
||||
- Border radius
|
||||
- Customizing components (variants, className, wrappers)
|
||||
- Checking for updates
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
1. CSS variables defined in `:root` (light) and `.dark` (dark mode).
|
||||
2. Tailwind maps them to utilities: `bg-primary`, `text-muted-foreground`, etc.
|
||||
3. Components use these utilities — changing a variable changes all components that reference it.
|
||||
|
||||
---
|
||||
|
||||
## Color Variables
|
||||
|
||||
Every color follows the `name` / `name-foreground` convention. The base variable is for backgrounds, `-foreground` is for text/icons on that background.
|
||||
|
||||
| Variable | Purpose |
|
||||
| -------------------------------------------- | -------------------------------- |
|
||||
| `--background` / `--foreground` | Page background and default text |
|
||||
| `--card` / `--card-foreground` | Card surfaces |
|
||||
| `--primary` / `--primary-foreground` | Primary buttons and actions |
|
||||
| `--secondary` / `--secondary-foreground` | Secondary actions |
|
||||
| `--muted` / `--muted-foreground` | Muted/disabled states |
|
||||
| `--accent` / `--accent-foreground` | Hover and accent states |
|
||||
| `--destructive` / `--destructive-foreground` | Error and destructive actions |
|
||||
| `--border` | Default border color |
|
||||
| `--input` | Form input borders |
|
||||
| `--ring` | Focus ring color |
|
||||
| `--chart-1` through `--chart-5` | Chart/data visualization |
|
||||
| `--sidebar-*` | Sidebar-specific colors |
|
||||
| `--surface` / `--surface-foreground` | Secondary surface |
|
||||
|
||||
Colors use OKLCH: `--primary: oklch(0.205 0 0)` where values are lightness (0–1), chroma (0 = gray), and hue (0–360).
|
||||
|
||||
---
|
||||
|
||||
## Dark Mode
|
||||
|
||||
Class-based toggle via `.dark` on the root element. In Next.js, use `next-themes`:
|
||||
|
||||
```tsx
|
||||
import { ThemeProvider } from "next-themes"
|
||||
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Changing the Theme
|
||||
|
||||
```bash
|
||||
# Apply a preset code from ui.shadcn.com.
|
||||
npx shadcn@latest apply --preset a2r6bw
|
||||
|
||||
# Positional shorthand also works.
|
||||
npx shadcn@latest apply a2r6bw
|
||||
|
||||
# Switch to a named preset and overwrite existing components.
|
||||
npx shadcn@latest apply --preset nova
|
||||
|
||||
# Preserve existing components instead.
|
||||
npx shadcn@latest init --preset nova --force --no-reinstall
|
||||
|
||||
# Use a custom theme URL.
|
||||
npx shadcn@latest apply --preset "https://ui.shadcn.com/init?base=radix&style=nova&theme=blue&..."
|
||||
```
|
||||
|
||||
Or edit CSS variables directly in `globals.css`.
|
||||
|
||||
---
|
||||
|
||||
## Adding Custom Colors
|
||||
|
||||
Add variables to the file at `tailwindCssFile` from `npx shadcn@latest info` (typically `globals.css`). Never create a new CSS file for this.
|
||||
|
||||
```css
|
||||
/* 1. Define in the global CSS file. */
|
||||
:root {
|
||||
--warning: oklch(0.84 0.16 84);
|
||||
--warning-foreground: oklch(0.28 0.07 46);
|
||||
}
|
||||
.dark {
|
||||
--warning: oklch(0.41 0.11 46);
|
||||
--warning-foreground: oklch(0.99 0.02 95);
|
||||
}
|
||||
```
|
||||
|
||||
```css
|
||||
/* 2a. Register with Tailwind v4 (@theme inline). */
|
||||
@theme inline {
|
||||
--color-warning: var(--warning);
|
||||
--color-warning-foreground: var(--warning-foreground);
|
||||
}
|
||||
```
|
||||
|
||||
When `tailwindVersion` is `"v3"` (check via `npx shadcn@latest info`), register in `tailwind.config.js` instead:
|
||||
|
||||
```js
|
||||
// 2b. Register with Tailwind v3 (tailwind.config.js).
|
||||
module.exports = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
warning: "oklch(var(--warning) / <alpha-value>)",
|
||||
"warning-foreground":
|
||||
"oklch(var(--warning-foreground) / <alpha-value>)",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// 3. Use in components.
|
||||
<div className="bg-warning text-warning-foreground">Warning</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Border Radius
|
||||
|
||||
`--radius` controls border radius globally. Components derive values from it (`rounded-lg` = `var(--radius)`, `rounded-md` = `calc(var(--radius) - 2px)`).
|
||||
|
||||
---
|
||||
|
||||
## Customizing Components
|
||||
|
||||
See also: [rules/styling.md](./rules/styling.md) for Incorrect/Correct examples.
|
||||
|
||||
Prefer these approaches in order:
|
||||
|
||||
### 1. Built-in variants
|
||||
|
||||
```tsx
|
||||
<Button variant="outline" size="sm">
|
||||
Click
|
||||
</Button>
|
||||
```
|
||||
|
||||
### 2. Tailwind classes via `className`
|
||||
|
||||
```tsx
|
||||
<Card className="mx-auto max-w-md">...</Card>
|
||||
```
|
||||
|
||||
### 3. Add a new variant
|
||||
|
||||
Edit the component source to add a variant via `cva`:
|
||||
|
||||
```tsx
|
||||
// components/ui/button.tsx
|
||||
warning: "bg-warning text-warning-foreground hover:bg-warning/90",
|
||||
```
|
||||
|
||||
### 4. Wrapper components
|
||||
|
||||
Compose shadcn/ui primitives into higher-level components:
|
||||
|
||||
```tsx
|
||||
export function ConfirmDialog({ title, description, onConfirm, children }) {
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onConfirm}>Confirm</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checking for Updates
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add button --diff
|
||||
```
|
||||
|
||||
To preview exactly what would change before updating, use `--dry-run` and `--diff`:
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add button --dry-run # see all affected files
|
||||
npx shadcn@latest add button --diff button.tsx # see the diff for a specific file
|
||||
```
|
||||
|
||||
See [Updating Components in SKILL.md](./SKILL.md#updating-components) for the full smart merge workflow.
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"skill_name": "shadcn",
|
||||
"evals": [
|
||||
{
|
||||
"id": 1,
|
||||
"prompt": "I'm building a Next.js app with shadcn/ui (base-nova preset, lucide icons). Create a settings form component with fields for: full name, email address, and notification preferences (email, SMS, push notifications as toggle options). Add validation states for required fields.",
|
||||
"expected_output": "A React component using FieldGroup, Field, ToggleGroup, data-invalid/aria-invalid validation, gap-* spacing, and semantic colors.",
|
||||
"files": [],
|
||||
"expectations": [
|
||||
"Uses FieldGroup and Field components for form layout instead of raw div with space-y",
|
||||
"Uses Switch for independent on/off notification toggles (not looping Button with manual active state)",
|
||||
"Uses data-invalid on Field and aria-invalid on the input control for validation states",
|
||||
"Uses gap-* (e.g. gap-4, gap-6) instead of space-y-* or space-x-* for spacing",
|
||||
"Uses semantic color tokens (e.g. bg-background, text-muted-foreground, text-destructive) instead of raw colors like bg-red-500",
|
||||
"No manual dark: color overrides"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"prompt": "Create a dialog component for editing a user profile. It should have the user's avatar at the top, input fields for name and bio, and Save/Cancel buttons with appropriate icons. Using shadcn/ui with radix-nova preset and tabler icons.",
|
||||
"expected_output": "A React component with DialogTitle, Avatar+AvatarFallback, data-icon on icon buttons, no icon sizing classes, tabler icon imports.",
|
||||
"files": [],
|
||||
"expectations": [
|
||||
"Includes DialogTitle for accessibility (visible or with sr-only class)",
|
||||
"Avatar component includes AvatarFallback",
|
||||
"Icons on buttons use the data-icon attribute (data-icon=\"inline-start\" or data-icon=\"inline-end\")",
|
||||
"No sizing classes on icons inside components (no size-4, w-4, h-4, etc.)",
|
||||
"Uses tabler icons (@tabler/icons-react) instead of lucide-react",
|
||||
"Uses asChild for custom triggers (radix preset)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"prompt": "Create a dashboard component that shows 4 stat cards in a grid. Each card has a title, large number, percentage change badge, and a loading skeleton state. Using shadcn/ui with base-nova preset and lucide icons.",
|
||||
"expected_output": "A React component with full Card composition, Skeleton for loading, Badge for changes, semantic colors, gap-* spacing.",
|
||||
"files": [],
|
||||
"expectations": [
|
||||
"Uses full Card composition with CardHeader, CardTitle, CardContent (not dumping everything into CardContent)",
|
||||
"Uses Skeleton component for loading placeholders instead of custom animate-pulse divs",
|
||||
"Uses Badge component for percentage change instead of custom styled spans",
|
||||
"Uses semantic color tokens instead of raw color values like bg-green-500 or text-red-600",
|
||||
"Uses gap-* instead of space-y-* or space-x-* for spacing",
|
||||
"Uses size-* when width and height are equal instead of separate w-* h-*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
# shadcn MCP Server
|
||||
|
||||
The CLI includes an MCP server that lets AI assistants search, browse, view, and install items from registries.
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
shadcn mcp # start the MCP server (stdio)
|
||||
shadcn mcp init # write config for your editor
|
||||
```
|
||||
|
||||
Editor config files:
|
||||
|
||||
| Editor | Config file |
|
||||
| ----------- | ------------------------------- |
|
||||
| Claude Code | `.mcp.json` |
|
||||
| Cursor | `.cursor/mcp.json` |
|
||||
| VS Code | `.vscode/mcp.json` |
|
||||
| OpenCode | `opencode.json` |
|
||||
| Codex | `~/.codex/config.toml` (manual) |
|
||||
|
||||
---
|
||||
|
||||
## Tools
|
||||
|
||||
> **Tip:** MCP tools handle registry operations (search, view, install). For project configuration (aliases, framework, Tailwind version), use `npx shadcn@latest info` — there is no MCP equivalent.
|
||||
|
||||
### `shadcn:get_project_registries`
|
||||
|
||||
Returns registry names from `components.json`. Errors if no `components.json` exists.
|
||||
|
||||
**Input:** none
|
||||
|
||||
### `shadcn:list_items_in_registries`
|
||||
|
||||
Lists all items from one or more registries. Registries can be configured
|
||||
namespaces such as `@acme`, public GitHub sources such as `owner/repo`, or
|
||||
registry catalog URLs. Omit `registries` to list from every registry configured
|
||||
in `components.json`.
|
||||
|
||||
**Input:** `registries` (string[], optional — omit for all configured), `types` (string[], optional — e.g. `["ui", "block"]`), `limit` (number, optional, defaults to 100), `offset` (number, optional)
|
||||
|
||||
### `shadcn:search_items_in_registries`
|
||||
|
||||
Fuzzy search across registries. Registries can be configured namespaces, public
|
||||
GitHub sources, or registry catalog URLs. Omit `registries` to search every
|
||||
registry configured in `components.json` — e.g. "find me a hero" across all
|
||||
configured registries.
|
||||
|
||||
**Input:** `registries` (string[], optional — omit for all configured), `query` (string), `types` (string[], optional — e.g. `["ui", "block"]`), `limit` (number, optional, defaults to 100), `offset` (number, optional)
|
||||
|
||||
### `shadcn:view_items_in_registries`
|
||||
|
||||
View item details including full file contents.
|
||||
|
||||
**Input:** `items` (string[]) — e.g.
|
||||
`["@shadcn/button", "@shadcn/card", "owner/repo/item"]`
|
||||
|
||||
### `shadcn:get_item_examples_from_registries`
|
||||
|
||||
Find usage examples and demos with source code. Omit `registries` to search
|
||||
every registry configured in `components.json`.
|
||||
|
||||
**Input:** `registries` (string[], optional — omit for all configured), `query` (string) — e.g. `"accordion-demo"`, `"button example"`
|
||||
|
||||
### `shadcn:get_add_command_for_items`
|
||||
|
||||
Returns the CLI install command.
|
||||
|
||||
**Input:** `items` (string[]) — e.g. `["@shadcn/button"]`
|
||||
|
||||
### `shadcn:get_audit_checklist`
|
||||
|
||||
Returns a checklist for verifying components (imports, deps, lint, TypeScript).
|
||||
|
||||
**Input:** none
|
||||
|
||||
---
|
||||
|
||||
## Configuring Registries
|
||||
|
||||
Namespaced and authenticated registries are set in `components.json`. The
|
||||
`@shadcn` registry is always built-in. Public GitHub registries can also be used
|
||||
directly as `owner/repo` registry sources when the repository has a root
|
||||
`registry.json`; they do not need `components.json` configuration.
|
||||
|
||||
```json
|
||||
{
|
||||
"registries": {
|
||||
"@acme": "https://acme.com/r/{name}.json",
|
||||
"@private": {
|
||||
"url": "https://private.com/r/{name}.json",
|
||||
"headers": { "Authorization": "Bearer ${MY_TOKEN}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- Names must start with `@`.
|
||||
- URLs must contain `{name}`.
|
||||
- `${VAR}` references are resolved from environment variables.
|
||||
|
||||
Community registry index: `https://ui.shadcn.com/r/registries.json`
|
||||
@@ -0,0 +1,277 @@
|
||||
# Registry Authoring and Addresses
|
||||
|
||||
Use this reference when the user wants to create, fix, publish, or reason about
|
||||
a shadcn registry.
|
||||
|
||||
## Mental Model
|
||||
|
||||
A registry has two forms:
|
||||
|
||||
- **Source registry**: an authored `registry.json` in a project or repository.
|
||||
It may use `include` and file paths that point at source files.
|
||||
- **Built registry**: generated JSON files served to CLI consumers, usually
|
||||
from `public/r`. Use `npx shadcn@latest build` to create this form.
|
||||
|
||||
The CLI installer consumes registry item payloads. A source registry is a way to
|
||||
author those payloads from real files.
|
||||
|
||||
Registry items are not limited to React components. They can distribute
|
||||
components, hooks, utilities, design tokens, pages, config files, docs, rules,
|
||||
workflows, templates, MCP files, and other project files.
|
||||
|
||||
## Root `registry.json`
|
||||
|
||||
The root registry file should define registry metadata and either `items` or
|
||||
`include`.
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry.json",
|
||||
"name": "acme",
|
||||
"homepage": "https://acme.com",
|
||||
"items": [
|
||||
{
|
||||
"name": "absolute-url",
|
||||
"type": "registry:lib",
|
||||
"title": "Absolute URL",
|
||||
"description": "A utility to turn any path into an absolute URL.",
|
||||
"files": [
|
||||
{
|
||||
"path": "lib/absolute-url.ts",
|
||||
"type": "registry:lib"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Root registry rules:
|
||||
|
||||
- Root `registry.json` must include `name` and `homepage`.
|
||||
- `items` is an array of registry item definitions.
|
||||
- `include` may be used to split the source registry into multiple files.
|
||||
- Included registry files may omit `name` and `homepage`.
|
||||
|
||||
## Include
|
||||
|
||||
Use `include` to keep large registries modular.
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry.json",
|
||||
"name": "acme",
|
||||
"homepage": "https://acme.com",
|
||||
"include": ["registry/ui/registry.json", "registry/blocks/registry.json"]
|
||||
}
|
||||
```
|
||||
|
||||
Include rules:
|
||||
|
||||
- Include paths are relative to the `registry.json` that declares them.
|
||||
- Include paths must explicitly point to a `registry.json` file.
|
||||
- Do not use remote URLs, absolute paths, or parent traversal (`..`).
|
||||
- Item file paths are relative to the registry file that declares the item.
|
||||
- Duplicate item names fail across the resolved registry.
|
||||
|
||||
Example included file:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"name": "button",
|
||||
"type": "registry:ui",
|
||||
"files": [
|
||||
{
|
||||
"path": "button.tsx",
|
||||
"type": "registry:ui"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
If this file is at `registry/ui/registry.json`, then `button.tsx` is read from
|
||||
`registry/ui/button.tsx`, and the built item path is emitted relative to the
|
||||
root registry.
|
||||
|
||||
## Item Definitions
|
||||
|
||||
Common item fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "login-form",
|
||||
"type": "registry:block",
|
||||
"title": "Login Form",
|
||||
"description": "A login form with email and password fields.",
|
||||
"dependencies": ["zod"],
|
||||
"registryDependencies": ["button", "input", "label"],
|
||||
"files": [
|
||||
{
|
||||
"path": "blocks/login-form.tsx",
|
||||
"type": "registry:block"
|
||||
}
|
||||
],
|
||||
"cssVars": {
|
||||
"light": {
|
||||
"brand": "oklch(0.62 0.18 250)"
|
||||
},
|
||||
"dark": {
|
||||
"brand": "oklch(0.72 0.16 250)"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Important fields:
|
||||
|
||||
- `name`: the installable item name. It is not necessarily a file path.
|
||||
- `type`: one of the registry item types, such as `registry:ui`,
|
||||
`registry:block`, `registry:lib`, `registry:hook`, `registry:file`,
|
||||
`registry:page`, `registry:theme`, `registry:style`, `registry:font`, or
|
||||
`registry:item`.
|
||||
- `files`: source files copied or generated by the item.
|
||||
- `dependencies`: npm runtime dependencies.
|
||||
- `devDependencies`: npm development dependencies.
|
||||
- `registryDependencies`: other registry items required by this item.
|
||||
- `cssVars`, `css`, `tailwind`, `envVars`, and `docs`: optional install-time
|
||||
additions.
|
||||
|
||||
File rules:
|
||||
|
||||
- File paths are relative to the declaring `registry.json`.
|
||||
- `registry:file` and `registry:page` files require a `target`.
|
||||
- Do not use remote file URLs in source registry file paths.
|
||||
- Keep source files copy-pasteable: no hidden app-only imports.
|
||||
|
||||
## Registry Dependencies
|
||||
|
||||
`registryDependencies` entries are item addresses, not file paths.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "login-form",
|
||||
"type": "registry:block",
|
||||
"registryDependencies": ["button", "@acme/input", "acme/ui/card#v1.2.0"],
|
||||
"files": [
|
||||
{
|
||||
"path": "blocks/login-form.tsx",
|
||||
"type": "registry:block"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Dependency rules:
|
||||
|
||||
- Bare names such as `"button"` mean official shadcn items.
|
||||
- Bare names never mean same-registry or same-repository items.
|
||||
- Namespaced dependencies use `@namespace/item-name`.
|
||||
- GitHub dependencies use `owner/repo/item-name`.
|
||||
- Pin GitHub dependencies with `owner/repo/item-name#ref` when needed.
|
||||
- Refs are not inherited. If `owner/repo/foo#v2` depends on `bar` from the same
|
||||
repo at `v2`, write `owner/repo/bar#v2`.
|
||||
- Do not use relative dependencies such as `"./bar"`.
|
||||
|
||||
## Address Schemes
|
||||
|
||||
When reasoning about a registry item string, classify it first.
|
||||
|
||||
| Address | Scheme | Meaning |
|
||||
| ----------------------------------- | --------- | ------------------------------------------------------------ |
|
||||
| `button` | shadcn | Official shadcn item named `button`. |
|
||||
| `@acme/button` | namespace | Item `button` from configured registry `@acme`. |
|
||||
| `@acme/ui/button` | namespace | Item `ui/button` from configured registry `@acme`. |
|
||||
| `https://example.com/r/button.json` | url | Built registry item JSON at that URL. |
|
||||
| `./button.json` | file | Built registry item JSON on disk. |
|
||||
| `acme/ui/button` | github | Item `button` from GitHub repo `acme/ui`. |
|
||||
| `acme/ui/forms/login#main` | github | Item `forms/login` from GitHub repo `acme/ui` at ref `main`. |
|
||||
|
||||
For namespace and GitHub addresses, slashful item names are allowed and are item
|
||||
names, not file paths. Addresses ending in `.json` keep file-address
|
||||
precedence, so `acme/ui/data/schema.json` is treated as a file path, not a
|
||||
GitHub item address.
|
||||
|
||||
## GitHub Registries
|
||||
|
||||
A public GitHub repository can act as a source registry when it has a root
|
||||
`registry.json`.
|
||||
|
||||
```txt
|
||||
owner/repo/item-name[#ref]
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- The first two path segments are GitHub owner and repo.
|
||||
- All remaining path segments are the registry item name.
|
||||
- The source entrypoint is always root `registry.json`.
|
||||
- GitHub registries are source registries consumed directly by the CLI. They do
|
||||
not require `shadcn build` or generated item JSON files.
|
||||
- `include` follows the same source-registry rules as local registries.
|
||||
- Currently, GitHub addresses support public `github.com` repositories only.
|
||||
- Private repos and GitHub Enterprise require explicit product decisions.
|
||||
|
||||
When implementing GitHub registry fetching, resolve refs to a commit SHA before
|
||||
reading source files. Do not read moving refs directly from
|
||||
`raw.githubusercontent.com`, because branch-like refs can be cached for several
|
||||
minutes.
|
||||
|
||||
Preferred flow:
|
||||
|
||||
```txt
|
||||
owner/repo[#ref]
|
||||
-> resolve ref with git ls-remote
|
||||
-> commit SHA
|
||||
-> read https://raw.githubusercontent.com/{owner}/{repo}/{sha}/registry.json
|
||||
-> read includes and item files from the same SHA
|
||||
```
|
||||
|
||||
This keeps a command on one consistent repository snapshot.
|
||||
|
||||
Full 40-character commit SHAs are already stable and can be used directly.
|
||||
Branches, tags, and short refs require Git so the CLI can resolve them to a
|
||||
commit SHA first.
|
||||
|
||||
## Build and Verify
|
||||
|
||||
Use the CLI to build source registries:
|
||||
|
||||
```bash
|
||||
npx shadcn@latest build
|
||||
npx shadcn@latest build registry.json --output public/r
|
||||
```
|
||||
|
||||
Use CLI commands to inspect the result:
|
||||
|
||||
```bash
|
||||
npx shadcn@latest list @acme
|
||||
npx shadcn@latest search @acme -q "login"
|
||||
npx shadcn@latest view @acme/login-form
|
||||
npx shadcn@latest add @acme/login-form --dry-run
|
||||
npx shadcn@latest registry validate ./registry.json
|
||||
```
|
||||
|
||||
Use GitHub addresses directly for public GitHub registries:
|
||||
|
||||
```bash
|
||||
npx shadcn@latest list owner/repo
|
||||
npx shadcn@latest search owner/repo -q "login"
|
||||
npx shadcn@latest view owner/repo/item
|
||||
npx shadcn@latest add owner/repo/item --dry-run
|
||||
npx shadcn@latest registry validate owner/repo
|
||||
```
|
||||
|
||||
When working on registry implementation in the shadcn/ui codebase:
|
||||
|
||||
- Keep address parsing pure and testable.
|
||||
- Do not add side effects to validators.
|
||||
- Preserve existing behavior for official shadcn, namespace, URL, and file
|
||||
schemes.
|
||||
- Add tests for address parsing, source loading, dependency resolution, list,
|
||||
search, view, and add paths.
|
||||
- Prefer small source-reader abstractions over a plugin system until there are
|
||||
multiple real providers.
|
||||
@@ -0,0 +1,306 @@
|
||||
# Base vs Radix
|
||||
|
||||
API differences between `base` and `radix`. Check the `base` field from `npx shadcn@latest info`.
|
||||
|
||||
## Contents
|
||||
|
||||
- Composition: asChild vs render
|
||||
- Button / trigger as non-button element
|
||||
- Select (items prop, placeholder, positioning, multiple, object values)
|
||||
- ToggleGroup (type vs multiple)
|
||||
- Slider (scalar vs array)
|
||||
- Accordion (type and defaultValue)
|
||||
|
||||
---
|
||||
|
||||
## Composition: asChild (radix) vs render (base)
|
||||
|
||||
Radix uses `asChild` to replace the default element. Base uses `render`. Don't wrap triggers in extra elements.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
<DialogTrigger>
|
||||
<div>
|
||||
<Button>Open</Button>
|
||||
</div>
|
||||
</DialogTrigger>
|
||||
```
|
||||
|
||||
**Correct (radix):**
|
||||
|
||||
```tsx
|
||||
<DialogTrigger asChild>
|
||||
<Button>Open</Button>
|
||||
</DialogTrigger>
|
||||
```
|
||||
|
||||
**Correct (base):**
|
||||
|
||||
```tsx
|
||||
<DialogTrigger render={<Button />}>Open</DialogTrigger>
|
||||
```
|
||||
|
||||
This applies to all trigger and close components: `DialogTrigger`, `SheetTrigger`, `AlertDialogTrigger`, `DropdownMenuTrigger`, `PopoverTrigger`, `TooltipTrigger`, `CollapsibleTrigger`, `DialogClose`, `SheetClose`, `NavigationMenuLink`, `BreadcrumbLink`, `SidebarMenuButton`, `Badge`, `Item`.
|
||||
|
||||
---
|
||||
|
||||
## Button / trigger as non-button element (base only)
|
||||
|
||||
When `render` changes an element to a non-button (`<a>`, `<span>`), add `nativeButton={false}`.
|
||||
|
||||
**Incorrect (base):** missing `nativeButton={false}`.
|
||||
|
||||
```tsx
|
||||
<Button render={<a href="/docs" />}>Read the docs</Button>
|
||||
```
|
||||
|
||||
**Correct (base):**
|
||||
|
||||
```tsx
|
||||
<Button render={<a href="/docs" />} nativeButton={false}>
|
||||
Read the docs
|
||||
</Button>
|
||||
```
|
||||
|
||||
**Correct (radix):**
|
||||
|
||||
```tsx
|
||||
<Button asChild>
|
||||
<a href="/docs">Read the docs</a>
|
||||
</Button>
|
||||
```
|
||||
|
||||
Same for triggers whose `render` is not a `Button`:
|
||||
|
||||
```tsx
|
||||
// base.
|
||||
<PopoverTrigger render={<InputGroupAddon />} nativeButton={false}>
|
||||
Pick date
|
||||
</PopoverTrigger>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Select
|
||||
|
||||
**items prop (base only).** Base requires an `items` prop on the root. Radix uses inline JSX only.
|
||||
|
||||
**Incorrect (base):**
|
||||
|
||||
```tsx
|
||||
<Select>
|
||||
<SelectTrigger><SelectValue placeholder="Select a fruit" /></SelectTrigger>
|
||||
</Select>
|
||||
```
|
||||
|
||||
**Correct (base):**
|
||||
|
||||
```tsx
|
||||
const items = [
|
||||
{ label: "Select a fruit", value: null },
|
||||
{ label: "Apple", value: "apple" },
|
||||
{ label: "Banana", value: "banana" },
|
||||
]
|
||||
|
||||
<Select items={items}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{items.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>{item.label}</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
```
|
||||
|
||||
**Correct (radix):**
|
||||
|
||||
```tsx
|
||||
<Select>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a fruit" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="apple">Apple</SelectItem>
|
||||
<SelectItem value="banana">Banana</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
```
|
||||
|
||||
**Placeholder.** Base uses a `{ value: null }` item in the items array. Radix uses `<SelectValue placeholder="...">`.
|
||||
|
||||
**Content positioning.** Base uses `alignItemWithTrigger`. Radix uses `position`.
|
||||
|
||||
```tsx
|
||||
// base.
|
||||
<SelectContent alignItemWithTrigger={false} side="bottom">
|
||||
|
||||
// radix.
|
||||
<SelectContent position="popper">
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Select — multiple selection and object values (base only)
|
||||
|
||||
Base supports `multiple`, render-function children on `SelectValue`, and object values with `itemToStringValue`. Radix is single-select with string values only.
|
||||
|
||||
**Correct (base — multiple selection):**
|
||||
|
||||
```tsx
|
||||
<Select items={items} multiple defaultValue={[]}>
|
||||
<SelectTrigger>
|
||||
<SelectValue>
|
||||
{(value: string[]) => value.length === 0 ? "Select fruits" : `${value.length} selected`}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
...
|
||||
</Select>
|
||||
```
|
||||
|
||||
**Correct (base — object values):**
|
||||
|
||||
```tsx
|
||||
<Select defaultValue={plans[0]} itemToStringValue={(plan) => plan.name}>
|
||||
<SelectTrigger>
|
||||
<SelectValue>{(value) => value.name}</SelectValue>
|
||||
</SelectTrigger>
|
||||
...
|
||||
</Select>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ToggleGroup
|
||||
|
||||
Base uses a `multiple` boolean prop. Radix uses `type="single"` or `type="multiple"`.
|
||||
|
||||
**Incorrect (base):**
|
||||
|
||||
```tsx
|
||||
<ToggleGroup type="single" defaultValue="daily">
|
||||
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
```
|
||||
|
||||
**Correct (base):**
|
||||
|
||||
```tsx
|
||||
// Single (no prop needed), defaultValue is always an array.
|
||||
<ToggleGroup defaultValue={["daily"]} spacing={2}>
|
||||
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
|
||||
<ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
|
||||
// Multi-selection.
|
||||
<ToggleGroup multiple>
|
||||
<ToggleGroupItem value="bold">Bold</ToggleGroupItem>
|
||||
<ToggleGroupItem value="italic">Italic</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
```
|
||||
|
||||
**Correct (radix):**
|
||||
|
||||
```tsx
|
||||
// Single, defaultValue is a string.
|
||||
<ToggleGroup type="single" defaultValue="daily" spacing={2}>
|
||||
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
|
||||
<ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
|
||||
// Multi-selection.
|
||||
<ToggleGroup type="multiple">
|
||||
<ToggleGroupItem value="bold">Bold</ToggleGroupItem>
|
||||
<ToggleGroupItem value="italic">Italic</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
```
|
||||
|
||||
**Controlled single value:**
|
||||
|
||||
```tsx
|
||||
// base — wrap/unwrap arrays.
|
||||
const [value, setValue] = React.useState("normal")
|
||||
<ToggleGroup value={[value]} onValueChange={(v) => setValue(v[0])}>
|
||||
|
||||
// radix — plain string.
|
||||
const [value, setValue] = React.useState("normal")
|
||||
<ToggleGroup type="single" value={value} onValueChange={setValue}>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Slider
|
||||
|
||||
Base accepts a plain number for a single thumb. Radix always requires an array.
|
||||
|
||||
**Incorrect (base):**
|
||||
|
||||
```tsx
|
||||
<Slider defaultValue={[50]} max={100} step={1} />
|
||||
```
|
||||
|
||||
**Correct (base):**
|
||||
|
||||
```tsx
|
||||
<Slider defaultValue={50} max={100} step={1} />
|
||||
```
|
||||
|
||||
**Correct (radix):**
|
||||
|
||||
```tsx
|
||||
<Slider defaultValue={[50]} max={100} step={1} />
|
||||
```
|
||||
|
||||
Both use arrays for range sliders. Controlled `onValueChange` in base may need a cast:
|
||||
|
||||
```tsx
|
||||
// base.
|
||||
const [value, setValue] = React.useState([0.3, 0.7])
|
||||
<Slider value={value} onValueChange={(v) => setValue(v as number[])} />
|
||||
|
||||
// radix.
|
||||
const [value, setValue] = React.useState([0.3, 0.7])
|
||||
<Slider value={value} onValueChange={setValue} />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Accordion
|
||||
|
||||
Radix requires `type="single"` or `type="multiple"` and supports `collapsible`. `defaultValue` is a string. Base uses no `type` prop, uses `multiple` boolean, and `defaultValue` is always an array.
|
||||
|
||||
**Incorrect (base):**
|
||||
|
||||
```tsx
|
||||
<Accordion type="single" collapsible defaultValue="item-1">
|
||||
<AccordionItem value="item-1">...</AccordionItem>
|
||||
</Accordion>
|
||||
```
|
||||
|
||||
**Correct (base):**
|
||||
|
||||
```tsx
|
||||
<Accordion defaultValue={["item-1"]}>
|
||||
<AccordionItem value="item-1">...</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
// Multi-select.
|
||||
<Accordion multiple defaultValue={["item-1", "item-2"]}>
|
||||
<AccordionItem value="item-1">...</AccordionItem>
|
||||
<AccordionItem value="item-2">...</AccordionItem>
|
||||
</Accordion>
|
||||
```
|
||||
|
||||
**Correct (radix):**
|
||||
|
||||
```tsx
|
||||
<Accordion type="single" collapsible defaultValue="item-1">
|
||||
<AccordionItem value="item-1">...</AccordionItem>
|
||||
</Accordion>
|
||||
```
|
||||
@@ -0,0 +1,195 @@
|
||||
# Component Composition
|
||||
|
||||
## Contents
|
||||
|
||||
- Items always inside their Group component
|
||||
- Callouts use Alert
|
||||
- Empty states use Empty component
|
||||
- Toast notifications use sonner
|
||||
- Choosing between overlay components
|
||||
- Dialog, Sheet, and Drawer always need a Title
|
||||
- Card structure
|
||||
- Button has no isPending or isLoading prop
|
||||
- TabsTrigger must be inside TabsList
|
||||
- Avatar always needs AvatarFallback
|
||||
- Use Separator instead of raw hr or border divs
|
||||
- Use Skeleton for loading placeholders
|
||||
- Use Badge instead of custom styled spans
|
||||
|
||||
---
|
||||
|
||||
## Items always inside their Group component
|
||||
|
||||
Never render items directly inside the content container.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
<SelectContent>
|
||||
<SelectItem value="apple">Apple</SelectItem>
|
||||
<SelectItem value="banana">Banana</SelectItem>
|
||||
</SelectContent>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="apple">Apple</SelectItem>
|
||||
<SelectItem value="banana">Banana</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
```
|
||||
|
||||
This applies to all group-based components:
|
||||
|
||||
| Item | Group |
|
||||
|------|-------|
|
||||
| `SelectItem`, `SelectLabel` | `SelectGroup` |
|
||||
| `DropdownMenuItem`, `DropdownMenuLabel`, `DropdownMenuSub` | `DropdownMenuGroup` |
|
||||
| `MenubarItem` | `MenubarGroup` |
|
||||
| `ContextMenuItem` | `ContextMenuGroup` |
|
||||
| `CommandItem` | `CommandGroup` |
|
||||
|
||||
---
|
||||
|
||||
## Callouts use Alert
|
||||
|
||||
```tsx
|
||||
<Alert>
|
||||
<AlertTitle>Warning</AlertTitle>
|
||||
<AlertDescription>Something needs attention.</AlertDescription>
|
||||
</Alert>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Empty states use Empty component
|
||||
|
||||
```tsx
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon"><FolderIcon /></EmptyMedia>
|
||||
<EmptyTitle>No projects yet</EmptyTitle>
|
||||
<EmptyDescription>Get started by creating a new project.</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button>Create Project</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Toast notifications use sonner
|
||||
|
||||
```tsx
|
||||
import { toast } from "sonner"
|
||||
|
||||
toast.success("Changes saved.")
|
||||
toast.error("Something went wrong.")
|
||||
toast("File deleted.", {
|
||||
action: { label: "Undo", onClick: () => undoDelete() },
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Choosing between overlay components
|
||||
|
||||
| Use case | Component |
|
||||
|----------|-----------|
|
||||
| Focused task that requires input | `Dialog` |
|
||||
| Destructive action confirmation | `AlertDialog` |
|
||||
| Side panel with details or filters | `Sheet` |
|
||||
| Mobile-first bottom panel | `Drawer` |
|
||||
| Quick info on hover | `HoverCard` |
|
||||
| Small contextual content on click | `Popover` |
|
||||
|
||||
---
|
||||
|
||||
## Dialog, Sheet, and Drawer always need a Title
|
||||
|
||||
`DialogTitle`, `SheetTitle`, `DrawerTitle` are required for accessibility. Use `className="sr-only"` if visually hidden.
|
||||
|
||||
```tsx
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Profile</DialogTitle>
|
||||
<DialogDescription>Update your profile.</DialogDescription>
|
||||
</DialogHeader>
|
||||
...
|
||||
</DialogContent>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Card structure
|
||||
|
||||
Use full composition — don't dump everything into `CardContent`:
|
||||
|
||||
```tsx
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Team Members</CardTitle>
|
||||
<CardDescription>Manage your team.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>...</CardContent>
|
||||
<CardFooter>
|
||||
<Button>Invite</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Button has no isPending or isLoading prop
|
||||
|
||||
Compose with `Spinner` + `data-icon` + `disabled`:
|
||||
|
||||
```tsx
|
||||
<Button disabled>
|
||||
<Spinner data-icon="inline-start" />
|
||||
Saving...
|
||||
</Button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TabsTrigger must be inside TabsList
|
||||
|
||||
Never render `TabsTrigger` directly inside `Tabs` — always wrap in `TabsList`:
|
||||
|
||||
```tsx
|
||||
<Tabs defaultValue="account">
|
||||
<TabsList>
|
||||
<TabsTrigger value="account">Account</TabsTrigger>
|
||||
<TabsTrigger value="password">Password</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="account">...</TabsContent>
|
||||
</Tabs>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Avatar always needs AvatarFallback
|
||||
|
||||
Always include `AvatarFallback` for when the image fails to load:
|
||||
|
||||
```tsx
|
||||
<Avatar>
|
||||
<AvatarImage src="/avatar.png" alt="User" />
|
||||
<AvatarFallback>JD</AvatarFallback>
|
||||
</Avatar>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Use existing components instead of custom markup
|
||||
|
||||
| Instead of | Use |
|
||||
|---|---|
|
||||
| `<hr>` or `<div className="border-t">` | `<Separator />` |
|
||||
| `<div className="animate-pulse">` with styled divs | `<Skeleton className="h-4 w-3/4" />` |
|
||||
| `<span className="rounded-full bg-green-100 ...">` | `<Badge variant="secondary">` |
|
||||
@@ -0,0 +1,192 @@
|
||||
# Forms & Inputs
|
||||
|
||||
## Contents
|
||||
|
||||
- Forms use FieldGroup + Field
|
||||
- InputGroup requires InputGroupInput/InputGroupTextarea
|
||||
- Buttons inside inputs use InputGroup + InputGroupAddon
|
||||
- Option sets (2–7 choices) use ToggleGroup
|
||||
- FieldSet + FieldLegend for grouping related fields
|
||||
- Field validation and disabled states
|
||||
|
||||
---
|
||||
|
||||
## Forms use FieldGroup + Field
|
||||
|
||||
Always use `FieldGroup` + `Field` — never raw `div` with `space-y-*`:
|
||||
|
||||
```tsx
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||
<Input id="email" type="email" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="password">Password</FieldLabel>
|
||||
<Input id="password" type="password" />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
```
|
||||
|
||||
Use `Field orientation="horizontal"` for settings pages. Use `FieldLabel className="sr-only"` for visually hidden labels.
|
||||
|
||||
**Choosing form controls:**
|
||||
|
||||
- Simple text input → `Input`
|
||||
- Dropdown with predefined options → `Select`
|
||||
- Searchable dropdown → `Combobox`
|
||||
- Native HTML select (no JS) → `native-select`
|
||||
- Boolean toggle → `Switch` (for settings) or `Checkbox` (for forms)
|
||||
- Single choice from few options → `RadioGroup`
|
||||
- Toggle between 2–5 options → `ToggleGroup` + `ToggleGroupItem`
|
||||
- OTP/verification code → `InputOTP`
|
||||
- Multi-line text → `Textarea`
|
||||
|
||||
---
|
||||
|
||||
## InputGroup requires InputGroupInput/InputGroupTextarea
|
||||
|
||||
Never use raw `Input` or `Textarea` inside an `InputGroup`.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
<InputGroup>
|
||||
<Input placeholder="Search..." />
|
||||
</InputGroup>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
import { InputGroup, InputGroupInput } from "@/components/ui/input-group"
|
||||
|
||||
<InputGroup>
|
||||
<InputGroupInput placeholder="Search..." />
|
||||
</InputGroup>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Buttons inside inputs use InputGroup + InputGroupAddon
|
||||
|
||||
Never place a `Button` directly inside or adjacent to an `Input` with custom positioning.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
<div className="relative">
|
||||
<Input placeholder="Search..." className="pr-10" />
|
||||
<Button className="absolute right-0 top-0" size="icon">
|
||||
<SearchIcon />
|
||||
</Button>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
import { InputGroup, InputGroupInput, InputGroupAddon } from "@/components/ui/input-group"
|
||||
|
||||
<InputGroup>
|
||||
<InputGroupInput placeholder="Search..." />
|
||||
<InputGroupAddon>
|
||||
<Button size="icon">
|
||||
<SearchIcon data-icon="inline-start" />
|
||||
</Button>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Option sets (2–7 choices) use ToggleGroup
|
||||
|
||||
Don't manually loop `Button` components with active state.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
const [selected, setSelected] = useState("daily")
|
||||
|
||||
<div className="flex gap-2">
|
||||
{["daily", "weekly", "monthly"].map((option) => (
|
||||
<Button
|
||||
key={option}
|
||||
variant={selected === option ? "default" : "outline"}
|
||||
onClick={() => setSelected(option)}
|
||||
>
|
||||
{option}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
||||
|
||||
<ToggleGroup spacing={2}>
|
||||
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
|
||||
<ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
|
||||
<ToggleGroupItem value="monthly">Monthly</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
```
|
||||
|
||||
Combine with `Field` for labelled toggle groups:
|
||||
|
||||
```tsx
|
||||
<Field orientation="horizontal">
|
||||
<FieldTitle id="theme-label">Theme</FieldTitle>
|
||||
<ToggleGroup aria-labelledby="theme-label" spacing={2}>
|
||||
<ToggleGroupItem value="light">Light</ToggleGroupItem>
|
||||
<ToggleGroupItem value="dark">Dark</ToggleGroupItem>
|
||||
<ToggleGroupItem value="system">System</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</Field>
|
||||
```
|
||||
|
||||
> **Note:** `defaultValue` and `type`/`multiple` props differ between base and radix. See [base-vs-radix.md](./base-vs-radix.md#togglegroup).
|
||||
|
||||
---
|
||||
|
||||
## FieldSet + FieldLegend for grouping related fields
|
||||
|
||||
Use `FieldSet` + `FieldLegend` for related checkboxes, radios, or switches — not `div` with a heading:
|
||||
|
||||
```tsx
|
||||
<FieldSet>
|
||||
<FieldLegend variant="label">Preferences</FieldLegend>
|
||||
<FieldDescription>Select all that apply.</FieldDescription>
|
||||
<FieldGroup className="gap-3">
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox id="dark" />
|
||||
<FieldLabel htmlFor="dark" className="font-normal">Dark mode</FieldLabel>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Field validation and disabled states
|
||||
|
||||
Both attributes are needed — `data-invalid`/`data-disabled` styles the field (label, description), while `aria-invalid`/`disabled` styles the control.
|
||||
|
||||
```tsx
|
||||
// Invalid.
|
||||
<Field data-invalid>
|
||||
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||
<Input id="email" aria-invalid />
|
||||
<FieldDescription>Invalid email address.</FieldDescription>
|
||||
</Field>
|
||||
|
||||
// Disabled.
|
||||
<Field data-disabled>
|
||||
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||
<Input id="email" disabled />
|
||||
</Field>
|
||||
```
|
||||
|
||||
Works for all controls: `Input`, `Textarea`, `Select`, `Checkbox`, `RadioGroupItem`, `Switch`, `Slider`, `NativeSelect`, `InputOTP`.
|
||||
@@ -0,0 +1,101 @@
|
||||
# Icons
|
||||
|
||||
**Always use the project's configured `iconLibrary` for imports.** Check the `iconLibrary` field from project context: `lucide` → `lucide-react`, `tabler` → `@tabler/icons-react`, etc. Never assume `lucide-react`.
|
||||
|
||||
---
|
||||
|
||||
## Icons in Button use data-icon attribute
|
||||
|
||||
Add `data-icon="inline-start"` (prefix) or `data-icon="inline-end"` (suffix) to the icon. No sizing classes on the icon.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
<Button>
|
||||
<SearchIcon className="mr-2 size-4" />
|
||||
Search
|
||||
</Button>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
<Button>
|
||||
<SearchIcon data-icon="inline-start"/>
|
||||
Search
|
||||
</Button>
|
||||
|
||||
<Button>
|
||||
Next
|
||||
<ArrowRightIcon data-icon="inline-end"/>
|
||||
</Button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## No sizing classes on icons inside components
|
||||
|
||||
Components handle icon sizing via CSS. Don't add `size-4`, `w-4 h-4`, or other sizing classes to icons inside `Button`, `DropdownMenuItem`, `Alert`, `Sidebar*`, or other shadcn components. Unless the user explicitly asks for custom icon sizes.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
<Button>
|
||||
<SearchIcon className="size-4" data-icon="inline-start" />
|
||||
Search
|
||||
</Button>
|
||||
|
||||
<DropdownMenuItem>
|
||||
<SettingsIcon className="mr-2 size-4" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
<Button>
|
||||
<SearchIcon data-icon="inline-start" />
|
||||
Search
|
||||
</Button>
|
||||
|
||||
<DropdownMenuItem>
|
||||
<SettingsIcon />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pass icons as component objects, not string keys
|
||||
|
||||
Use `icon={CheckIcon}`, not a string key to a lookup map.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
const iconMap = {
|
||||
check: CheckIcon,
|
||||
alert: AlertIcon,
|
||||
}
|
||||
|
||||
function StatusBadge({ icon }: { icon: string }) {
|
||||
const Icon = iconMap[icon]
|
||||
return <Icon />
|
||||
}
|
||||
|
||||
<StatusBadge icon="check" />
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
// Import from the project's configured iconLibrary (e.g. lucide-react, @tabler/icons-react).
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
function StatusBadge({ icon: Icon }: { icon: React.ComponentType }) {
|
||||
return <Icon />
|
||||
}
|
||||
|
||||
<StatusBadge icon={CheckIcon} />
|
||||
```
|
||||
@@ -0,0 +1,162 @@
|
||||
# Styling & Customization
|
||||
|
||||
See [customization.md](../customization.md) for theming, CSS variables, and adding custom colors.
|
||||
|
||||
## Contents
|
||||
|
||||
- Semantic colors
|
||||
- Built-in variants first
|
||||
- className for layout only
|
||||
- No space-x-* / space-y-*
|
||||
- Prefer size-* over w-* h-* when equal
|
||||
- Prefer truncate shorthand
|
||||
- No manual dark: color overrides
|
||||
- Use cn() for conditional classes
|
||||
- No manual z-index on overlay components
|
||||
|
||||
---
|
||||
|
||||
## Semantic colors
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
<div className="bg-blue-500 text-white">
|
||||
<p className="text-gray-600">Secondary text</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
<div className="bg-primary text-primary-foreground">
|
||||
<p className="text-muted-foreground">Secondary text</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## No raw color values for status/state indicators
|
||||
|
||||
For positive, negative, or status indicators, use Badge variants, semantic tokens like `text-destructive`, or define custom CSS variables — don't reach for raw Tailwind colors.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
<span className="text-emerald-600">+20.1%</span>
|
||||
<span className="text-green-500">Active</span>
|
||||
<span className="text-red-600">-3.2%</span>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
<Badge variant="secondary">+20.1%</Badge>
|
||||
<Badge>Active</Badge>
|
||||
<span className="text-destructive">-3.2%</span>
|
||||
```
|
||||
|
||||
If you need a success/positive color that doesn't exist as a semantic token, use a Badge variant or ask the user about adding a custom CSS variable to the theme (see [customization.md](../customization.md)).
|
||||
|
||||
---
|
||||
|
||||
## Built-in variants first
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
<Button className="border border-input bg-transparent hover:bg-accent">
|
||||
Click me
|
||||
</Button>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
<Button variant="outline">Click me</Button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## className for layout only
|
||||
|
||||
Use `className` for layout (e.g. `max-w-md`, `mx-auto`, `mt-4`), **not** for overriding component colors or typography. To change colors, use semantic tokens, built-in variants, or CSS variables.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
<Card className="bg-blue-100 text-blue-900 font-bold">
|
||||
<CardContent>Dashboard</CardContent>
|
||||
</Card>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
<Card className="max-w-md mx-auto">
|
||||
<CardContent>Dashboard</CardContent>
|
||||
</Card>
|
||||
```
|
||||
|
||||
To customize a component's appearance, prefer these approaches in order:
|
||||
1. **Built-in variants** — `variant="outline"`, `variant="destructive"`, etc.
|
||||
2. **Semantic color tokens** — `bg-primary`, `text-muted-foreground`.
|
||||
3. **CSS variables** — define custom colors in the global CSS file (see [customization.md](../customization.md)).
|
||||
|
||||
---
|
||||
|
||||
## No space-x-* / space-y-*
|
||||
|
||||
Use `gap-*` instead. `space-y-4` → `flex flex-col gap-4`. `space-x-2` → `flex gap-2`.
|
||||
|
||||
```tsx
|
||||
<div className="flex flex-col gap-4">
|
||||
<Input />
|
||||
<Input />
|
||||
<Button>Submit</Button>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Prefer size-* over w-* h-* when equal
|
||||
|
||||
`size-10` not `w-10 h-10`. Applies to icons, avatars, skeletons, etc.
|
||||
|
||||
---
|
||||
|
||||
## Prefer truncate shorthand
|
||||
|
||||
`truncate` not `overflow-hidden text-ellipsis whitespace-nowrap`.
|
||||
|
||||
---
|
||||
|
||||
## No manual dark: color overrides
|
||||
|
||||
Use semantic tokens — they handle light/dark via CSS variables. `bg-background text-foreground` not `bg-white dark:bg-gray-950`.
|
||||
|
||||
---
|
||||
|
||||
## Use cn() for conditional classes
|
||||
|
||||
Use the `cn()` utility from the project for conditional or merged class names. Don't write manual ternaries in className strings.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
<div className={`flex items-center ${isActive ? "bg-primary text-primary-foreground" : "bg-muted"}`}>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
<div className={cn("flex items-center", isActive ? "bg-primary text-primary-foreground" : "bg-muted")}>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## No manual z-index on overlay components
|
||||
|
||||
`Dialog`, `Sheet`, `Drawer`, `AlertDialog`, `DropdownMenu`, `Popover`, `Tooltip`, `HoverCard` handle their own stacking. Never add `z-50` or `z-[999]`.
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
# V5 Architecture Fix Plan
|
||||
|
||||
Source: /Users/heyandras/.claude/plans/what-do-you-think-soft-firefly.md
|
||||
|
||||
## Wave 1 (parallel) — DONE
|
||||
- [x] 1. Split DashboardController into domain controllers + Laravel policies (denyAsNotFound), dedupe cluster serializer
|
||||
- [x] 6. Frontend: extract Dashboard.tsx components, useCallback/memo, unified optimistic rollback, use-pending-ids reuse, mid-drag snap-back fix, types.ts drift, env-scoped merge
|
||||
- [x] 5. Hot-path index migration (wireguard_management_ip, node_address, host, runtime_container_id, last_seen_at)
|
||||
|
||||
## Wave 2 (parallel, after wave 1) — DONE
|
||||
- [x] 2. Status enums (ApplicationStatus/ServerStatus/IngressStatus/ContainerState) + observed_at ordered ingestion
|
||||
- [x] 3. Reconcile + prune scheduled jobs (V5ReconcileServersJob every 5m + per-server V5ReconcileServerStateJob, 24h container-status prune)
|
||||
- [x] 4. Job uniqueness (ShouldBeUnique deploy+bootstrap) + queued broadcasts (ShouldBroadcast, afterCommit, null-safe payloads)
|
||||
- [x] 7. Laravel↔coold verb handshake: UnsupportedCooldVerb detection (flux 501), graceful ingress degradation, coold_version persisted
|
||||
|
||||
## Wave 3 (everything else) — DONE
|
||||
- [x] Morph map (v5.application alias) + uuid collision retry + drop per-insert Schema::hasColumn + defaults dedup
|
||||
- [x] v5_servers.uuid non-null; capabilities → indexed has_coold/is_ingress booleans (wire format preserved)
|
||||
- [x] Firewall vs DB atomicity (DB=desired state, flux converge, compensating rollback; revoke-first destroy)
|
||||
- [x] Deploy failure compensation (stop+force-remove orphaned container, original error preserved)
|
||||
- [x] Caddyfile hostname/port validation + ValidHostname newline-bypass fix
|
||||
- [x] Ambiguous host_id resolution warning
|
||||
|
||||
## Wave 4 — DONE
|
||||
- [x] Full V5 suite: 262 passed (1901 assertions); tsc clean; npm build ok; pint clean
|
||||
|
||||
## Wave 5 (deep dives)
|
||||
- [ ] Clusters.tsx + remaining frontend audit
|
||||
- [ ] coold/flux Rust internals + security audit
|
||||
- [ ] V5 test quality/coverage audit
|
||||
|
||||
## Skipped (product decisions, documented)
|
||||
- Soft deletes on infra rows (changes cascade semantics — needs product call)
|
||||
- TLS in v5 ingress (feature, not fix)
|
||||
- config coold.php/flux.php merge (cosmetic)
|
||||
|
||||
## Wave 5 (deep dives) — DONE
|
||||
- [x] coold/flux Rust audit → findings reported (NOT fixed — separate repo, see session recap: no-TLS gRPC, wildcard cap profiles, lost status updates on outage, exec exit_code always 0, mount-allowlist gaps, unauthenticated Corrosion gossip)
|
||||
- [x] Frontend audit → all MUST/SHOULD-FIX applied (stale connections on env switch, deleteCluster shadow null-deref, persistSelection ok-guard, useTeamChannel extraction, apiRequest timeouts in Clusters, echo logging gated)
|
||||
- [x] Test-quality audit → all applied (shared V5TestSchema helper killed schema drift, DashboardTest 174-test monolith split into 12 files, substring tests quarantined in V5FrontendSourceContractTest, +20 new tests: policies, RemoveBootstrapMarker, broadcast payloads, channel auth)
|
||||
|
||||
## Wave 6 (audit fixes) — DONE
|
||||
- [x] v4/v5 currentTeam session cross-contamination (full Team model, write-on-change only)
|
||||
- [x] flux_url preflight 422 before bootstrap dispatch
|
||||
- [x] Bootstrap marker/coold_version ordering
|
||||
- [x] Enum literals sweep (jobs + StopCaddyIngress)
|
||||
- [x] ManagesConnectionFirewallRules + SerializesResourceConnections → app/Support/V5 classes
|
||||
|
||||
## Final state
|
||||
289 V5 tests passed (2005 assertions) + 333 v4 unit slice green; tsc clean; npm build ok; pint clean. Nothing committed.
|
||||
|
||||
## Wave 7 (security + JWT, cut off by session limit, then recovered) — DONE
|
||||
- [x] JWT: mint explicit 21-primitive caps (config flux.host_capabilities), NOT the host-agent:default wildcard that flux treats as authorize-all; escape-hatch profile config; jti claim + persisted agent_token_jti; kid header; TTL 24h→1h (config); RevokedAgentToken model + migration + isRevoked API; inbound bearer array (laravel_api_tokens) for rotation
|
||||
- [x] Authz: V5 policies role-gate mutations via isAdminOfTeam (403), keep denyAsNotFound (404) for cross-team; ClusterController::store authorize
|
||||
- [x] Input: ValidServerIp rejects private/reserved ranges behind config('coold.allow_private_server_ips'); error-detail leak → generic messages + Log::warning; throttle:v5 limiter (RouteServiceProvider)
|
||||
- [x] Stability: reconcile+refresh honor/advance status_observed_at (shared StatusObservation); Configured + full podman states in enums; deploy persists runtime_container_id after create; reconcile jobs on v5-reconcile queue; status_message churn fixed
|
||||
- [x] Team-delete teardown: Team::deleting → V5TeardownTeamJob (best-effort per-server container/ingress/marker teardown, self-contained payload)
|
||||
|
||||
## Wave 7 recovery fix (post-cutoff)
|
||||
- [x] FATAL: V5ReconcileServersJob + V5ReconcileServerStateJob redeclared `public $queue = 'v5-reconcile'` — incompatible with Queueable trait's `public $queue;` on PHP 8.5 → hard fatal crashing BOTH pest suite and `php artisan test` bootstrap (job discovery). Moved queue assignment to onQueue() in constructor.
|
||||
- [x] Stale test: ResourceConnectionControllerTest asserted old snapshot-fail detail; scenario hits the restore path → updated to "The previous rules were restored." (correct behavior)
|
||||
|
||||
## Final state (Wave 7)
|
||||
322 V5 tests passed (2124 assertions) via BOTH vendor/bin/pest AND php artisan test; v4 slice 308 passed; tsc clean; npm build ok; pint clean.
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/shadcn
|
||||
@@ -0,0 +1,32 @@
|
||||
vmType: "vz"
|
||||
arch: "default"
|
||||
cpus: 2
|
||||
memory: "2GiB"
|
||||
disk: "20GiB"
|
||||
containerd:
|
||||
system: false
|
||||
user: false
|
||||
ssh:
|
||||
localPort: 60003
|
||||
images:
|
||||
- location: "https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-amd64.img"
|
||||
arch: "x86_64"
|
||||
- location: "https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-arm64.img"
|
||||
arch: "aarch64"
|
||||
mounts: []
|
||||
provision:
|
||||
- mode: system
|
||||
script: |
|
||||
#!/usr/bin/env bash
|
||||
set -euxo pipefail
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
install -d -m 700 /root/.ssh
|
||||
cat >/root/.ssh/authorized_keys <<'KEYS'
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFuGmoeGq/pojrsyP1pszcNVuZx9iFkCELtxrh31QJ68 sail@76ff66d2e2dd
|
||||
KEYS
|
||||
chmod 600 /root/.ssh/authorized_keys
|
||||
sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin prohibit-password/' /etc/ssh/sshd_config
|
||||
sed -i 's/^#\?PubkeyAuthentication.*/PubkeyAuthentication yes/' /etc/ssh/sshd_config
|
||||
systemctl restart ssh || systemctl restart sshd
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends ca-certificates curl openssh-server sudo
|
||||
@@ -0,0 +1,7 @@
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
|
||||
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
|
||||
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
|
||||
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
@@ -0,0 +1 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFuGmoeGq/pojrsyP1pszcNVuZx9iFkCELtxrh31QJ68 sail@76ff66d2e2dd
|
||||
@@ -3,10 +3,12 @@ APP_ENV=local
|
||||
APP_NAME=Coolify
|
||||
APP_ID=development
|
||||
APP_KEY=
|
||||
COOLIFY_FLUX_LARAVEL_API_TOKEN=development-flux-token
|
||||
APP_URL=http://localhost
|
||||
APP_PORT=8000
|
||||
APP_DEBUG=true
|
||||
SSH_MUX_ENABLED=true
|
||||
COOLIFY_CONTAINER_ROLE=all
|
||||
|
||||
# PostgreSQL Database Configuration
|
||||
DB_DATABASE=coolify
|
||||
|
||||
@@ -2,6 +2,7 @@ APP_ENV=production
|
||||
APP_NAME="Coolify Staging"
|
||||
APP_ID=development
|
||||
APP_KEY=
|
||||
COOLIFY_FLUX_LARAVEL_API_TOKEN=test-flux-token
|
||||
APP_URL=http://localhost
|
||||
APP_PORT=8000
|
||||
SSH_MUX_ENABLED=true
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
APP_ENV=testing
|
||||
APP_KEY=base64:8VEfVNVkXQ9mH2L33WBWNMF4eQ0BWD5CTzB8mIxcl+k=
|
||||
COOLIFY_FLUX_LARAVEL_API_TOKEN=test-flux-token
|
||||
APP_DEBUG=true
|
||||
|
||||
DB_CONNECTION=testing
|
||||
|
||||
@@ -40,3 +40,10 @@ CHANGELOG.md
|
||||
/.workspaces
|
||||
tests/Browser/Screenshots
|
||||
tests/v4/Browser/Screenshots
|
||||
|
||||
# Local generated Lima configs
|
||||
.dev/bin/
|
||||
.dev/coold-assets/
|
||||
.dev/lima/ssh.config
|
||||
.dev/lima/ssh_key
|
||||
.dev/lima/hosts
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\V5\Application;
|
||||
|
||||
use App\Enums\V5\ApplicationStatus;
|
||||
use App\Enums\V5\ContainerState;
|
||||
use App\Enums\V5\ServerStatus;
|
||||
use App\Models\V5\Application;
|
||||
use App\Services\Flux\FluxClient;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class DeployNginxApplication
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public function __construct(private readonly FluxClient $fluxClient) {}
|
||||
|
||||
public function handle(Application $application): Application
|
||||
{
|
||||
$application->loadMissing('server');
|
||||
$server = $application->server;
|
||||
|
||||
if ($server === null) {
|
||||
return $this->markFailed($application, 'No server is attached to this application.');
|
||||
}
|
||||
|
||||
if ($server->status !== ServerStatus::Installed->value || $server->last_bootstrapped_at === null) {
|
||||
return $this->markFailed($application, "Bootstrap server {$server->name} before deploying to it.");
|
||||
}
|
||||
|
||||
$hostId = $server->fluxHostId();
|
||||
|
||||
if (! is_string($hostId) || $hostId === '') {
|
||||
return $this->markFailed($application, 'No Flux host ID is available for this server.');
|
||||
}
|
||||
|
||||
$containerId = null;
|
||||
|
||||
try {
|
||||
$this->fluxClient->pullImage($hostId, $application->image);
|
||||
$containerId = $this->fluxClient->createContainer($hostId, $this->containerSpec($application));
|
||||
|
||||
// Persist the runtime id the instant the container exists, before
|
||||
// start/inspect. A worker SIGKILL at the job timeout would otherwise
|
||||
// orphan a created container whose id only lived in this local var,
|
||||
// leaving failed()/reconcile unable to find and clean it by id.
|
||||
$application->update([
|
||||
'status' => ApplicationStatus::Created->value,
|
||||
'status_message' => 'Container created.',
|
||||
'runtime_container_id' => $containerId,
|
||||
]);
|
||||
|
||||
$this->fluxClient->startContainer($hostId, $containerId);
|
||||
$inspect = $this->fluxClient->inspectContainer($hostId, $containerId);
|
||||
|
||||
if (! $this->isContainerRunning($inspect)) {
|
||||
$this->cleanUpContainer($application, $hostId, $containerId);
|
||||
|
||||
return $this->markFailed($application, 'Container did not stay running.');
|
||||
}
|
||||
|
||||
$application->update([
|
||||
'status' => ApplicationStatus::Running->value,
|
||||
'status_message' => 'Container started.',
|
||||
'runtime_container_id' => $containerId,
|
||||
]);
|
||||
|
||||
return $application->refresh()->load('server');
|
||||
} catch (\Throwable $e) {
|
||||
if (is_string($containerId) && $containerId !== '') {
|
||||
$this->cleanUpContainer($application, $hostId, $containerId);
|
||||
}
|
||||
|
||||
return $this->markFailed($application, $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort compensation for a failed deploy: stop and force-remove the
|
||||
* container this run created so it is never left orphaned on the node, then
|
||||
* null the runtime id we persisted right after create so a cleaned-up
|
||||
* failure never leaves a dangling id that reconcile would try to reap.
|
||||
* Cleanup failures only log a warning and never mask the original error.
|
||||
*/
|
||||
private function cleanUpContainer(Application $application, string $hostId, string $containerId): void
|
||||
{
|
||||
try {
|
||||
$this->fluxClient->stopContainer($hostId, $containerId);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Could not stop the container created by a failed v5 deploy.', [
|
||||
'application_id' => $application->getKey(),
|
||||
'container_id' => $containerId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->fluxClient->removeContainer($hostId, $containerId, force: true);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Could not remove the container created by a failed v5 deploy.', [
|
||||
'application_id' => $application->getKey(),
|
||||
'container_id' => $containerId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($application->runtime_container_id === $containerId) {
|
||||
$application->update(['runtime_container_id' => null]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function containerSpec(Application $application): array
|
||||
{
|
||||
$network = $this->meshNetwork($application);
|
||||
$containerName = $application->container_name;
|
||||
|
||||
return [
|
||||
'name' => $containerName,
|
||||
'image' => $application->image,
|
||||
'networks' => [$network],
|
||||
'network_aliases' => [$containerName],
|
||||
'dns_search' => [$this->meshDnsSearchDomain($application)],
|
||||
'restart_policy' => 'unless-stopped',
|
||||
];
|
||||
}
|
||||
|
||||
private function meshNetwork(Application $application): string
|
||||
{
|
||||
$namespace = $application->mesh_namespace ?: 'default';
|
||||
|
||||
return "coolify-{$namespace}-mesh";
|
||||
}
|
||||
|
||||
private function meshDnsSearchDomain(Application $application): string
|
||||
{
|
||||
$namespace = $application->mesh_namespace ?: 'default';
|
||||
|
||||
return "{$namespace}.coolify.internal";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $inspect
|
||||
*/
|
||||
private function isContainerRunning(array $inspect): bool
|
||||
{
|
||||
$state = $inspect['State'] ?? [];
|
||||
|
||||
if (is_array($state) && ($state['Running'] ?? null) === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return is_string($inspect['state'] ?? null) && $inspect['state'] === ContainerState::Running->value;
|
||||
}
|
||||
|
||||
private function markFailed(Application $application, string $message): Application
|
||||
{
|
||||
$application->update([
|
||||
'status' => ApplicationStatus::Failed->value,
|
||||
'status_message' => str($message)->limit(10000)->toString(),
|
||||
]);
|
||||
|
||||
return $application->refresh()->load('server');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\V5\Application;
|
||||
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\V5\Application;
|
||||
use Illuminate\Contracts\Process\ProcessResult;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class DestroyNginxApplication
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public function handle(Application $application): ?string
|
||||
{
|
||||
$application->loadMissing('server.privateKey');
|
||||
$server = $application->server;
|
||||
|
||||
if ($server === null || ! $server->privateKey instanceof PrivateKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$keyLocation = $this->writeTemporaryPrivateKey($server->privateKey);
|
||||
|
||||
try {
|
||||
$result = Process::timeout(120)->run([
|
||||
'ssh',
|
||||
'-o',
|
||||
'BatchMode=yes',
|
||||
'-o',
|
||||
'LogLevel=ERROR',
|
||||
'-o',
|
||||
'StrictHostKeyChecking=no',
|
||||
'-o',
|
||||
'UserKnownHostsFile=/dev/null',
|
||||
'-o',
|
||||
'ConnectTimeout=10',
|
||||
'-o',
|
||||
'IdentitiesOnly=yes',
|
||||
'-i',
|
||||
$keyLocation,
|
||||
'-p',
|
||||
(string) $server->ssh_port,
|
||||
"{$server->ssh_user}@{$server->host}",
|
||||
$this->remoteCommand($application),
|
||||
]);
|
||||
|
||||
if (! $result->successful()) {
|
||||
return $this->processOutput($result);
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (\Throwable $e) {
|
||||
return $e->getMessage();
|
||||
} finally {
|
||||
@unlink($keyLocation);
|
||||
}
|
||||
}
|
||||
|
||||
private function remoteCommand(Application $application): string
|
||||
{
|
||||
$containerName = escapeshellarg($application->container_name);
|
||||
|
||||
return implode(PHP_EOL, [
|
||||
'set -e',
|
||||
'if [ "$(id -u)" = "0" ]; then podman=podman; else podman="sudo -n podman"; fi',
|
||||
"\$podman rm -f {$containerName} >/dev/null 2>&1 || true",
|
||||
]);
|
||||
}
|
||||
|
||||
private function processOutput(ProcessResult $result): string
|
||||
{
|
||||
$output = trim($result->output()."\n".$result->errorOutput());
|
||||
|
||||
return $output !== '' ? $output : 'Could not delete nginx container.';
|
||||
}
|
||||
|
||||
private function writeTemporaryPrivateKey(PrivateKey $privateKey): string
|
||||
{
|
||||
$keyDirectory = storage_path('app/ssh/keys');
|
||||
if (! is_dir($keyDirectory)) {
|
||||
mkdir($keyDirectory, 0700, true);
|
||||
}
|
||||
|
||||
$keyLocation = tempnam($keyDirectory, 'v5_nginx_destroy_key_');
|
||||
if ($keyLocation === false) {
|
||||
throw new \RuntimeException('Could not create a temporary SSH key file.');
|
||||
}
|
||||
|
||||
file_put_contents($keyLocation, $privateKey->private_key);
|
||||
chmod($keyLocation, 0600);
|
||||
|
||||
return $keyLocation;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\V5\Flux;
|
||||
|
||||
use App\Enums\V5\ApplicationStatus;
|
||||
use App\Enums\V5\ContainerState;
|
||||
use App\Enums\V5\IngressStatus;
|
||||
use App\Enums\V5\ServerStatus;
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\ContainerStatus;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Support\V5\StatusObservation;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class ApplyFluxResourceStatusUpdate
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public function handle(array $payload): ?Model
|
||||
{
|
||||
$resourceType = strtolower((string) data_get($payload, 'resource_type', data_get($payload, 'type', '')));
|
||||
|
||||
$containerStatus = $resourceType === 'container' ? $this->upsertContainerStatus($payload) : null;
|
||||
|
||||
if ($this->isCaddyIngressStatusUpdate($payload, $resourceType)) {
|
||||
return $this->updateCaddyIngress($payload) ?? $containerStatus;
|
||||
}
|
||||
|
||||
if (in_array($resourceType, ['server', 'node', 'host'], true)) {
|
||||
return $this->updateServer($payload);
|
||||
}
|
||||
|
||||
return $this->updateApplication($payload) ?? $containerStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function upsertContainerStatus(array $payload): ?ContainerStatus
|
||||
{
|
||||
$status = $this->status($payload, ContainerState::class);
|
||||
$containerId = $this->stringValue($payload, 'container_id') ?? $this->stringValue($payload, 'runtime_container_id');
|
||||
$server = $this->findServer($payload);
|
||||
|
||||
if ($status === null || $containerId === null || ! $server instanceof V5Server) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$observedAt = $this->observedAt($payload);
|
||||
$existing = ContainerStatus::query()
|
||||
->where('server_id', $server->id)
|
||||
->where('container_id', $containerId)
|
||||
->first();
|
||||
|
||||
if ($this->isStaleObservation($observedAt, $existing?->status_observed_at, 'container status', [
|
||||
'server_id' => $server->id,
|
||||
'container_id' => $containerId,
|
||||
])) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$attributes = [
|
||||
'team_id' => $server->team_id,
|
||||
'container_name' => $this->stringValue($payload, 'container_name') ?? $this->stringValue($payload, 'name'),
|
||||
'image' => $this->stringValue($payload, 'image'),
|
||||
'status' => $status,
|
||||
'status_message' => $this->statusMessage($payload, 'Container state received from coold.'),
|
||||
'last_seen_at' => now(),
|
||||
];
|
||||
|
||||
if ($observedAt !== null) {
|
||||
$attributes['status_observed_at'] = $observedAt;
|
||||
}
|
||||
|
||||
ContainerStatus::query()->updateOrCreate([
|
||||
'server_id' => $server->id,
|
||||
'container_id' => $containerId,
|
||||
], $attributes);
|
||||
|
||||
return ContainerStatus::query()
|
||||
->where('server_id', $server->id)
|
||||
->where('container_id', $containerId)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function updateApplication(array $payload): ?V5Application
|
||||
{
|
||||
$status = $this->status($payload, ApplicationStatus::class);
|
||||
|
||||
if ($status === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$application = $this->findApplication($payload);
|
||||
|
||||
if (! $application instanceof V5Application) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$observedAt = $this->observedAt($payload);
|
||||
|
||||
if ($this->isStaleObservation($observedAt, $application->status_observed_at, 'application status', [
|
||||
'application_id' => $application->id,
|
||||
])) {
|
||||
return $application;
|
||||
}
|
||||
|
||||
$payloadContainerId = $this->stringValue($payload, 'runtime_container_id')
|
||||
?? $this->stringValue($payload, 'container_id');
|
||||
|
||||
// Payloads may carry no timestamp, so the container id remains an
|
||||
// ordering signal as a second layer: an update for a superseded
|
||||
// container is stale and must not overwrite the current one's state.
|
||||
if (
|
||||
$payloadContainerId !== null
|
||||
&& $application->runtime_container_id !== null
|
||||
&& $payloadContainerId !== $application->runtime_container_id
|
||||
) {
|
||||
return $application;
|
||||
}
|
||||
|
||||
$attributes = [
|
||||
'status' => $status,
|
||||
'status_message' => $this->statusMessage($payload, 'Status updated by flux.'),
|
||||
'runtime_container_id' => $payloadContainerId ?? $application->runtime_container_id,
|
||||
];
|
||||
|
||||
if ($observedAt !== null) {
|
||||
$attributes['status_observed_at'] = $observedAt;
|
||||
}
|
||||
|
||||
$application->update($attributes);
|
||||
|
||||
return $application->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function updateServer(array $payload): ?V5Server
|
||||
{
|
||||
$status = $this->status($payload, ServerStatus::class);
|
||||
|
||||
if ($status === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$server = $this->findServer($payload);
|
||||
|
||||
if (! $server instanceof V5Server) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$observedAt = $this->observedAt($payload);
|
||||
|
||||
if ($this->isStaleObservation($observedAt, $server->status_observed_at, 'server status', [
|
||||
'server_id' => $server->id,
|
||||
])) {
|
||||
return $server;
|
||||
}
|
||||
|
||||
$attributes = [
|
||||
'status' => $status,
|
||||
'last_status_check' => 'flux',
|
||||
'last_status_output' => $this->statusMessage($payload, 'Status updated by flux.'),
|
||||
'last_status_checked_at' => now(),
|
||||
];
|
||||
|
||||
if ($observedAt !== null) {
|
||||
$attributes['status_observed_at'] = $observedAt;
|
||||
}
|
||||
|
||||
$server->update($attributes);
|
||||
|
||||
return $server->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* The ingress state shares the server row but describes a different
|
||||
* resource, so it deliberately does not read or write the server's
|
||||
* `status_observed_at` watermark.
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function updateCaddyIngress(array $payload): ?V5Server
|
||||
{
|
||||
$status = $this->status($payload, IngressStatus::class);
|
||||
|
||||
if ($status === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$server = $this->findServer($payload);
|
||||
|
||||
if (! $server instanceof V5Server || ! $server->isIngress()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$server->update([
|
||||
'ingress_type' => 'caddy',
|
||||
'ingress_status' => $status,
|
||||
'last_status_check' => 'flux',
|
||||
'last_status_output' => $this->statusMessage($payload, 'Status updated by flux.'),
|
||||
'last_status_checked_at' => now(),
|
||||
]);
|
||||
|
||||
return $server->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function findApplication(array $payload): ?V5Application
|
||||
{
|
||||
$server = $this->findServer($payload);
|
||||
|
||||
if (! $server instanceof V5Server) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$query = V5Application::query()
|
||||
->with('server')
|
||||
->where('server_id', $server->id)
|
||||
->where('team_id', $server->team_id);
|
||||
|
||||
$applicationUuid = $this->stringValue($payload, 'application_uuid') ?? $this->stringValue($payload, 'resource_uuid');
|
||||
|
||||
if ($applicationUuid !== null) {
|
||||
return $query->where('uuid', $applicationUuid)->first();
|
||||
}
|
||||
|
||||
$containerName = $this->stringValue($payload, 'container_name') ?? $this->stringValue($payload, 'name');
|
||||
|
||||
if ($containerName !== null) {
|
||||
return $query->where('container_name', $containerName)->first();
|
||||
}
|
||||
|
||||
$containerId = $this->stringValue($payload, 'runtime_container_id') ?? $this->stringValue($payload, 'container_id');
|
||||
|
||||
if ($containerId !== null) {
|
||||
return $query->where('runtime_container_id', $containerId)->first();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function isCaddyIngressStatusUpdate(array $payload, string $resourceType): bool
|
||||
{
|
||||
if (in_array($resourceType, ['caddy_ingress', 'caddy-ingress'], true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->stringValue($payload, 'container_name') === 'coolify-v5-caddy'
|
||||
|| $this->stringValue($payload, 'name') === 'coolify-v5-caddy';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function findServer(array $payload): ?V5Server
|
||||
{
|
||||
$serverUuid = $this->stringValue($payload, 'server_uuid') ?? $this->stringValue($payload, 'host_server_uuid');
|
||||
|
||||
if ($serverUuid !== null) {
|
||||
return V5Server::query()->where('uuid', $serverUuid)->first();
|
||||
}
|
||||
|
||||
$hostId = $this->stringValue($payload, 'host_id')
|
||||
?? $this->stringValue($payload, 'node_id')
|
||||
?? $this->stringValue($payload, 'server_host');
|
||||
|
||||
if ($hostId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$matches = V5Server::query()
|
||||
->where('uuid', $hostId)
|
||||
->limit(2)
|
||||
->get();
|
||||
|
||||
if ($matches->count() > 1) {
|
||||
Log::warning('Dropping flux resource status update: host id matches multiple v5 servers.', [
|
||||
'host_id' => $hostId,
|
||||
'server_ids' => $matches->pluck('id')->all(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $matches->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the raw payload status onto the given status enum. Unknown values
|
||||
* are never written to the database: they fall back to the enum's
|
||||
* Unknown case and are logged.
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
* @param class-string<ApplicationStatus|ContainerState|IngressStatus|ServerStatus> $enumClass
|
||||
*/
|
||||
private function status(array $payload, string $enumClass): ?string
|
||||
{
|
||||
$raw = $this->stringValue($payload, 'status') ?? $this->stringValue($payload, 'state');
|
||||
|
||||
return StatusObservation::normalize($raw, $enumClass);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function observedAt(array $payload): ?CarbonInterface
|
||||
{
|
||||
$observedAt = $this->stringValue($payload, 'observed_at');
|
||||
|
||||
if ($observedAt === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return rescue(fn (): CarbonImmutable => CarbonImmutable::parse($observedAt), null, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* A payload that carries an observation timestamp older than the one
|
||||
* already persisted is stale (delivered out of order) and must not
|
||||
* clobber the newer state.
|
||||
*
|
||||
* @param array<string, mixed> $logContext
|
||||
*/
|
||||
private function isStaleObservation(?CarbonInterface $observedAt, ?CarbonInterface $currentObservedAt, string $context, array $logContext): bool
|
||||
{
|
||||
return StatusObservation::isStale($observedAt, $currentObservedAt, $context, $logContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function statusMessage(array $payload, string $fallback): string
|
||||
{
|
||||
return $this->stringValue($payload, 'status_message')
|
||||
?? $this->stringValue($payload, 'message')
|
||||
?? $fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function stringValue(array $payload, string $key): ?string
|
||||
{
|
||||
$value = data_get($payload, $key);
|
||||
|
||||
return is_string($value) && $value !== '' ? $value : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\V5\Proxy;
|
||||
|
||||
use App\Models\V5\Application;
|
||||
use App\Models\V5\ApplicationDomain;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class GenerateCaddyIngressConfiguration
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
/**
|
||||
* Strict RFC 1123 hostname: dot-separated alphanumeric labels with inner
|
||||
* hyphens, max 253 characters. Anchored with \A/\z (never $) so values
|
||||
* containing newlines, braces, quotes, whitespace, or control characters
|
||||
* can never inject extra directives into the generated Caddyfile.
|
||||
*/
|
||||
private const HOSTNAME_PATTERN = '/\A(?=.{1,253}\z)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*\z/i';
|
||||
|
||||
/**
|
||||
* @param Collection<int, Application>|null $applications
|
||||
* @return array{compose: string, caddyfile: string, apps: array<int, array{name: string, caddyfile: string}>}
|
||||
*/
|
||||
public function handle(?Collection $applications = null): array
|
||||
{
|
||||
return [
|
||||
'compose' => $this->compose(),
|
||||
'caddyfile' => $this->rootCaddyfile(),
|
||||
'apps' => $this->appCaddyfiles($applications ?? collect()),
|
||||
];
|
||||
}
|
||||
|
||||
private function compose(): string
|
||||
{
|
||||
return Yaml::dump([
|
||||
'services' => [
|
||||
'caddy' => [
|
||||
'image' => 'docker.io/library/caddy:2-alpine',
|
||||
'container_name' => 'coolify-v5-caddy',
|
||||
'restart' => 'unless-stopped',
|
||||
'ports' => [
|
||||
'80:80',
|
||||
],
|
||||
'volumes' => [
|
||||
'./Caddyfile:/etc/caddy/Caddyfile:ro',
|
||||
'./apps:/etc/caddy/apps:ro',
|
||||
'./data:/data',
|
||||
'./config:/config',
|
||||
],
|
||||
],
|
||||
],
|
||||
], 8, 2);
|
||||
}
|
||||
|
||||
private function rootCaddyfile(): string
|
||||
{
|
||||
return <<<'CADDY'
|
||||
:80 {
|
||||
respond /coolify-health 200
|
||||
respond 404
|
||||
}
|
||||
|
||||
import apps/*.caddy
|
||||
CADDY;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Application> $applications
|
||||
* @return array<int, array{name: string, caddyfile: string}>
|
||||
*/
|
||||
private function appCaddyfiles(Collection $applications): array
|
||||
{
|
||||
return $applications
|
||||
->each(fn (Application $application) => $application->loadMissing('domains'))
|
||||
->map(fn (Application $application) => [
|
||||
'name' => $this->appFileName($application),
|
||||
'caddyfile' => $this->applicationCaddyfile($application),
|
||||
])
|
||||
->filter(fn (array $file) => $file['caddyfile'] !== '')
|
||||
->sortBy('name')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function applicationCaddyfile(Application $application): string
|
||||
{
|
||||
if (! $application->ingress_enabled || ! $application->internal_port) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $application->domains
|
||||
->map(fn (ApplicationDomain $domain) => $this->applicationRoute($application, $domain))
|
||||
->filter()
|
||||
->sort()
|
||||
->implode("\n\n");
|
||||
}
|
||||
|
||||
private function applicationRoute(Application $application, ApplicationDomain $domain): ?string
|
||||
{
|
||||
if ($domain->domain === null || $domain->domain === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$namespace = $application->mesh_namespace ?: 'default';
|
||||
$internalPort = (int) $application->internal_port;
|
||||
|
||||
if (! $this->isSafeHostname($domain->domain)) {
|
||||
Log::warning('Skipping a caddy ingress route with an unsafe domain.', [
|
||||
'application_id' => $application->getKey(),
|
||||
'domain' => $domain->domain,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $this->isSafeHostname($application->container_name) || ! $this->isSafeHostname($namespace)) {
|
||||
Log::warning('Skipping a caddy ingress route with an unsafe container name or namespace.', [
|
||||
'application_id' => $application->getKey(),
|
||||
'container_name' => $application->container_name,
|
||||
'namespace' => $namespace,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($internalPort < 1 || $internalPort > 65535) {
|
||||
Log::warning('Skipping a caddy ingress route with an out-of-range internal port.', [
|
||||
'application_id' => $application->getKey(),
|
||||
'internal_port' => $application->internal_port,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$upstream = "{$application->container_name}.{$namespace}.coolify.internal:{$internalPort}";
|
||||
|
||||
return implode("\n", [
|
||||
"http://{$domain->domain} {",
|
||||
" reverse_proxy {$upstream}",
|
||||
'}',
|
||||
]);
|
||||
}
|
||||
|
||||
private function isSafeHostname(mixed $value): bool
|
||||
{
|
||||
return is_string($value) && preg_match(self::HOSTNAME_PATTERN, $value) === 1;
|
||||
}
|
||||
|
||||
private function appFileName(Application $application): string
|
||||
{
|
||||
return 'app_'.$application->getKey();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\V5\Proxy;
|
||||
|
||||
use App\Exceptions\V5\UnsupportedCooldVerb;
|
||||
use App\Models\V5\Application;
|
||||
use App\Models\V5\Server;
|
||||
use App\Services\Flux\FluxClient;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class StartCaddyIngress
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
private const FIREWALL_PORTS = [80];
|
||||
|
||||
public function __construct(private readonly FluxClient $fluxClient) {}
|
||||
|
||||
public function handle(Server $server): string
|
||||
{
|
||||
if (! $server->isIngress()) {
|
||||
return 'Server is not an ingress server.';
|
||||
}
|
||||
|
||||
$hostId = $server->fluxHostId();
|
||||
|
||||
if (! is_string($hostId) || $hostId === '') {
|
||||
throw new \RuntimeException('Server is missing its Flux host id.');
|
||||
}
|
||||
|
||||
$configuration = GenerateCaddyIngressConfiguration::run($this->applications($server));
|
||||
$output = $this->fluxClient->applyIngress($hostId, 'caddy', $configuration['caddyfile'], $this->ingressApps($configuration['apps']));
|
||||
|
||||
$firewallWarning = null;
|
||||
|
||||
foreach (self::FIREWALL_PORTS as $port) {
|
||||
try {
|
||||
$this->fluxClient->applyFirewallRule($hostId, [
|
||||
'id' => "v5-caddy-ingress:{$port}",
|
||||
'namespace' => 'default',
|
||||
'src' => '0.0.0.0/0',
|
||||
'dst' => 'coolify-v5-caddy',
|
||||
'proto' => 'tcp',
|
||||
'port' => $port,
|
||||
]);
|
||||
} catch (UnsupportedCooldVerb $exception) {
|
||||
$firewallWarning = "Caddy ingress is running, but this node's coold does not support {$exception->verb}, so the managed firewall was not updated for port {$port}.";
|
||||
Log::warning('V5 caddy ingress firewall rule skipped: coold verb unsupported', [
|
||||
'server_id' => $server->id,
|
||||
'port' => $port,
|
||||
'verb' => $exception->verb,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($server->exists) {
|
||||
$server->update([
|
||||
'ingress_type' => 'caddy',
|
||||
'ingress_status' => 'running',
|
||||
...($firewallWarning === null ? [] : [
|
||||
'last_status_check' => 'flux',
|
||||
'last_status_output' => $firewallWarning,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
return $output;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{name: string, caddyfile: string}> $apps
|
||||
* @return array<int, array{name: string, config: string}>
|
||||
*/
|
||||
private function ingressApps(array $apps): array
|
||||
{
|
||||
return array_map(
|
||||
fn (array $app): array => [
|
||||
'name' => $app['name'],
|
||||
'config' => $app['caddyfile'],
|
||||
],
|
||||
$apps
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Application>
|
||||
*/
|
||||
private function applications(Server $server): Collection
|
||||
{
|
||||
if (! $server->exists) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return Application::query()
|
||||
->where('team_id', $server->team_id)
|
||||
->where('server_id', $server->id)
|
||||
->with('domains')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\V5\Proxy;
|
||||
|
||||
use App\Enums\V5\IngressStatus;
|
||||
use App\Exceptions\V5\UnsupportedCooldVerb;
|
||||
use App\Models\V5\Server;
|
||||
use App\Services\Flux\FluxClient;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class StopCaddyIngress
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
private const FIREWALL_PORTS = [80];
|
||||
|
||||
public function __construct(private readonly FluxClient $fluxClient) {}
|
||||
|
||||
public function handle(Server $server): string
|
||||
{
|
||||
if (! $server->isIngress() && $server->ingress_type === null) {
|
||||
return 'Server is not an ingress server.';
|
||||
}
|
||||
|
||||
$hostId = $server->fluxHostId();
|
||||
|
||||
if (! is_string($hostId) || $hostId === '') {
|
||||
throw new \RuntimeException('Server is missing its Flux host id.');
|
||||
}
|
||||
|
||||
// Revoke first: if stopping the container fails the allow rules must not
|
||||
// stay orphaned on the host.
|
||||
foreach (self::FIREWALL_PORTS as $port) {
|
||||
$this->revokeFirewallRuleIfPresent($hostId, "v5-caddy-ingress:{$port}");
|
||||
}
|
||||
|
||||
$output = $this->fluxClient->stopIngress($hostId, 'caddy');
|
||||
|
||||
if ($server->exists) {
|
||||
$server->update(['ingress_status' => IngressStatus::Exited->value]);
|
||||
}
|
||||
|
||||
return $output;
|
||||
}
|
||||
|
||||
private function revokeFirewallRuleIfPresent(string $hostId, string $ruleId): void
|
||||
{
|
||||
try {
|
||||
$this->fluxClient->revokeFirewallRule($hostId, $ruleId);
|
||||
} catch (UnsupportedCooldVerb $exception) {
|
||||
Log::warning('V5 caddy ingress firewall revoke skipped: coold verb unsupported', [
|
||||
'host_id' => $hostId,
|
||||
'rule_id' => $ruleId,
|
||||
'verb' => $exception->verb,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
} catch (\RuntimeException $exception) {
|
||||
if (! str_contains(Str::lower($exception->getMessage()), 'not found')) {
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\V5\Server;
|
||||
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\V5\Server;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class PushHostAgentToken
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
/**
|
||||
* Best-effort SSH push of a freshly minted host JWT to the on-host jwt path.
|
||||
*
|
||||
* coold re-reads the JWT file on every reconnect and flux drops the stream
|
||||
* at the token's exp, so overwriting the file in place is enough for the
|
||||
* next reconnect to pick up the new token — coold is intentionally NOT
|
||||
* restarted here (a restart would force an unnecessary disconnect of a
|
||||
* stream that is still valid on the current token).
|
||||
*
|
||||
* Mirrors V5BootstrapServerJob::enrollCooldIntoFlux for the write mechanics
|
||||
* (printf %s <token> | sudo tee <path>; chmod 600) and RemoveBootstrapMarker
|
||||
* for the SSH/temp-key mechanics. Returns whether the write succeeded;
|
||||
* every failure path (missing key, SSH error, exception) resolves to false
|
||||
* and always cleans up the temporary key file.
|
||||
*/
|
||||
public function handle(Server $server, string $token): bool
|
||||
{
|
||||
$server->loadMissing('privateKey');
|
||||
|
||||
if (! $server->privateKey instanceof PrivateKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$jwtPath = trim((string) config('coold.flux_host_jwt_path', '/etc/coolify/host-jwt'));
|
||||
|
||||
if ($jwtPath === '') {
|
||||
$jwtPath = '/etc/coolify/host-jwt';
|
||||
}
|
||||
|
||||
$jwtPath = str_replace(["\r", "\n"], '', $jwtPath);
|
||||
$token = str_replace(["\r", "\n"], '', $token);
|
||||
|
||||
$keyDirectory = storage_path('app/ssh/keys');
|
||||
if (! is_dir($keyDirectory)) {
|
||||
mkdir($keyDirectory, 0700, true);
|
||||
}
|
||||
|
||||
$keyLocation = tempnam($keyDirectory, 'v5_ssh_key_');
|
||||
if ($keyLocation === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
file_put_contents($keyLocation, $server->privateKey->private_key);
|
||||
chmod($keyLocation, 0600);
|
||||
|
||||
$tokenArgument = escapeshellarg($token);
|
||||
$jwtPathArgument = $this->shellPathArg($jwtPath);
|
||||
$script = <<<SH
|
||||
set -e
|
||||
SUDO=''
|
||||
if [ "\$(id -u)" != "0" ]; then SUDO='sudo'; fi
|
||||
\$SUDO mkdir -p /etc/coolify
|
||||
printf %s {$tokenArgument} | \$SUDO tee {$jwtPathArgument} >/dev/null
|
||||
\$SUDO chmod 600 {$jwtPathArgument}
|
||||
SH;
|
||||
|
||||
try {
|
||||
$result = Process::timeout(30)->run([
|
||||
'ssh',
|
||||
'-o', 'BatchMode=yes',
|
||||
'-o', 'LogLevel=ERROR',
|
||||
'-o', 'StrictHostKeyChecking=no',
|
||||
'-o', 'UserKnownHostsFile=/dev/null',
|
||||
'-o', 'ConnectTimeout=10',
|
||||
'-o', 'IdentitiesOnly=yes',
|
||||
'-i', $keyLocation,
|
||||
'-p', (string) $server->ssh_port,
|
||||
"{$server->ssh_user}@{$server->host}",
|
||||
$script,
|
||||
]);
|
||||
|
||||
return $result->successful();
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
} finally {
|
||||
@unlink($keyLocation);
|
||||
}
|
||||
}
|
||||
|
||||
private function shellPathArg(string $value): string
|
||||
{
|
||||
if (preg_match('/^[A-Za-z0-9_\/:.,@%+=-]+$/', $value) === 1) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return escapeshellarg($value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\V5\Server;
|
||||
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\V5\Server;
|
||||
use App\Services\Flux\AgentTokenIssuer;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class RemoveBootstrapMarker
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
/**
|
||||
* Best-effort removal of the on-host bootstrap identity (marker, host JWT and
|
||||
* Flux drop-in) so a re-added server can never silently adopt stale state.
|
||||
*
|
||||
* The host token jti is revoked first (a local DB write plus a best-effort
|
||||
* push to the flux revocation store) so a captured or pre-copied token is
|
||||
* recorded revoked even when the host is unreachable — see
|
||||
* AgentTokenIssuer::revoke.
|
||||
*/
|
||||
public function handle(Server $server): bool
|
||||
{
|
||||
app(AgentTokenIssuer::class)->revoke($server);
|
||||
|
||||
$server->loadMissing('privateKey');
|
||||
|
||||
if (! $server->privateKey instanceof PrivateKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$keyDirectory = storage_path('app/ssh/keys');
|
||||
if (! is_dir($keyDirectory)) {
|
||||
mkdir($keyDirectory, 0700, true);
|
||||
}
|
||||
|
||||
$keyLocation = tempnam($keyDirectory, 'v5_ssh_key_');
|
||||
if ($keyLocation === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
file_put_contents($keyLocation, $server->privateKey->private_key);
|
||||
chmod($keyLocation, 0600);
|
||||
|
||||
$script = implode("\n", [
|
||||
"SUDO=''",
|
||||
'if [ "$(id -u)" != "0" ]; then SUDO=\'sudo\'; fi',
|
||||
'$SUDO rm -f /etc/coolify/v5-node.json /etc/coolify/host-jwt /etc/systemd/system/coold.service.d/10-flux.conf',
|
||||
]);
|
||||
|
||||
try {
|
||||
$result = Process::timeout(15)->run([
|
||||
'ssh',
|
||||
'-o', 'BatchMode=yes',
|
||||
'-o', 'LogLevel=ERROR',
|
||||
'-o', 'StrictHostKeyChecking=no',
|
||||
'-o', 'UserKnownHostsFile=/dev/null',
|
||||
'-o', 'ConnectTimeout=10',
|
||||
'-o', 'IdentitiesOnly=yes',
|
||||
'-i', $keyLocation,
|
||||
'-p', (string) $server->ssh_port,
|
||||
"{$server->ssh_user}@{$server->host}",
|
||||
$script,
|
||||
]);
|
||||
|
||||
return $result->successful();
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
} finally {
|
||||
@unlink($keyLocation);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\V5\Server;
|
||||
|
||||
use App\Enums\V5\ServerStatus;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Models\V5\Cluster;
|
||||
use App\Models\V5\Server;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
/**
|
||||
* Registers local Lima development VMs (provisioned by scripts/dev.sh) as
|
||||
* cluster servers. They are intentionally seeded as Installed with
|
||||
* last_bootstrapped_at already set but has_coold=false, so they skip the real
|
||||
* bootstrap flow by design: V5BootstrapServerJob early-returns on a non-null
|
||||
* last_bootstrapped_at, and V5ReconcileServersJob ignores them until
|
||||
* something marks has_coold=true.
|
||||
*/
|
||||
class SyncDevLimaServers
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
/**
|
||||
* @param array<int, array{
|
||||
* name: string,
|
||||
* host: string,
|
||||
* ssh_user: string,
|
||||
* ssh_port: int,
|
||||
* wireguard_management_ip?: ?string,
|
||||
* wireguard_listen_port_override?: ?int,
|
||||
* wireguard_endpoint_override?: ?string
|
||||
* }> $servers
|
||||
*/
|
||||
public function handle(
|
||||
Team $team,
|
||||
User $user,
|
||||
?PrivateKey $privateKey,
|
||||
string $clusterName,
|
||||
array $servers,
|
||||
): Cluster {
|
||||
$cluster = Cluster::query()->updateOrCreate([
|
||||
'team_id' => $team->id,
|
||||
'name' => $clusterName,
|
||||
], [
|
||||
'created_by_user_id' => $user->id,
|
||||
'description' => 'Local Lima development cluster managed by scripts/dev.sh.',
|
||||
]);
|
||||
|
||||
foreach ($servers as $server) {
|
||||
$wireguardManagementIp = $server['wireguard_management_ip'] ?? null;
|
||||
$values = [
|
||||
'created_by_user_id' => $user->id,
|
||||
'private_key_id' => $privateKey?->id,
|
||||
'host' => $server['host'],
|
||||
'ssh_user' => $server['ssh_user'],
|
||||
'ssh_port' => $server['ssh_port'],
|
||||
'status' => ServerStatus::Installed->value,
|
||||
'has_coold' => false,
|
||||
'is_ingress' => false,
|
||||
'builder_enabled' => false,
|
||||
'builder_capacity' => 0,
|
||||
'node_address' => $wireguardManagementIp ?: $server['host'],
|
||||
'wireguard_management_ip' => $wireguardManagementIp,
|
||||
'last_bootstrapped_at' => now(),
|
||||
];
|
||||
|
||||
if (array_key_exists('wireguard_listen_port_override', $server)) {
|
||||
$values['wireguard_listen_port_override'] = $server['wireguard_listen_port_override'];
|
||||
}
|
||||
|
||||
if (array_key_exists('wireguard_endpoint_override', $server)) {
|
||||
$values['wireguard_endpoint_override'] = $server['wireguard_endpoint_override'];
|
||||
}
|
||||
|
||||
Server::query()->updateOrCreate([
|
||||
'team_id' => $team->id,
|
||||
'cluster_id' => $cluster->id,
|
||||
'name' => $server['name'],
|
||||
], $values);
|
||||
}
|
||||
|
||||
return $cluster->refresh();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Flux\AgentTokenIssuer;
|
||||
use App\Support\V5\V5Feature;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class FluxDev extends Command
|
||||
{
|
||||
protected $signature = 'flux:dev
|
||||
{host_id=coold-dev : Stable coold host id}
|
||||
{--caps= : Comma-separated host capabilities}
|
||||
{--ttl=3600 : Token lifetime in seconds}
|
||||
{--output= : Optional path to write the token with 0600 permissions}';
|
||||
|
||||
protected $description = 'Run Flux development helpers.';
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function defaultCapabilities(): array
|
||||
{
|
||||
return [
|
||||
'host-agent:dev',
|
||||
];
|
||||
}
|
||||
|
||||
public function handle(AgentTokenIssuer $agentTokenIssuer): int
|
||||
{
|
||||
if (! V5Feature::enabled()) {
|
||||
$this->error('V5 is only available in development environments.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$hostId = (string) $this->argument('host_id');
|
||||
$ttl = max(60, (int) $this->option('ttl'));
|
||||
$caps = collect(explode(',', (string) $this->option('caps')))
|
||||
->map(fn (string $cap) => trim($cap))
|
||||
->filter()
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($caps === []) {
|
||||
$caps = $this->defaultCapabilities();
|
||||
}
|
||||
|
||||
try {
|
||||
$token = $agentTokenIssuer->issue($hostId, $caps, $ttl);
|
||||
} catch (\RuntimeException $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$output = $this->option('output');
|
||||
|
||||
if (is_string($output) && $output !== '') {
|
||||
$outputPath = Str::startsWith($output, '/') ? $output : base_path($output);
|
||||
File::ensureDirectoryExists(dirname($outputPath));
|
||||
File::put($outputPath, $token.PHP_EOL);
|
||||
chmod($outputPath, 0600);
|
||||
$this->info("Host JWT written to {$outputPath}.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->line($token);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Flux\AgentTokenIssuer;
|
||||
use App\Support\V5\V5Feature;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* Generates the ES256 (EC P-256) keypair used to authorize coold host agents
|
||||
* against flux. Laravel signs the per-host JWT with the private key
|
||||
* (config('flux.jwt_private_key_path')); flux verifies it with the matching
|
||||
* public key (config('flux.jwt_public_key_path')). Without this keypair a fresh
|
||||
* install cannot mint host tokens, so this command is a bootstrap prerequisite.
|
||||
*
|
||||
* @see AgentTokenIssuer
|
||||
*/
|
||||
class V5FluxGenerateKeys extends Command
|
||||
{
|
||||
protected $signature = 'v5:flux-generate-keys
|
||||
{--force : Overwrite an existing private key instead of refusing}
|
||||
{--show-public : Print the generated public key PEM so it can be provisioned to flux}';
|
||||
|
||||
protected $description = 'Generate the ES256 keypair Flux uses to sign and verify coold host agent JWTs.';
|
||||
|
||||
public function handle(AgentTokenIssuer $agentTokenIssuer): int
|
||||
{
|
||||
if (! V5Feature::enabled()) {
|
||||
$this->error('V5 is only available in development environments.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$privateKeyPath = (string) config('flux.jwt_private_key_path');
|
||||
$publicKeyPath = (string) config('flux.jwt_public_key_path');
|
||||
|
||||
if ($privateKeyPath === '' || $publicKeyPath === '') {
|
||||
$this->error('Flux JWT key paths are not configured (flux.jwt_private_key_path / flux.jwt_public_key_path).');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// Idempotent by default: re-running during provisioning must not clobber
|
||||
// a live key (which would instantly invalidate every host token on
|
||||
// disk). Refuse unless --force is passed, and exit SUCCESS so a
|
||||
// provisioning script can call this unconditionally on every deploy.
|
||||
if (File::exists($privateKeyPath) && ! $this->option('force')) {
|
||||
$this->warn("A Flux JWT private key already exists at {$privateKeyPath}.");
|
||||
$this->line('Refusing to overwrite it. Re-run with --force to replace it (this invalidates every host token currently on disk).');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// curve_name drives the actual EC key (P-256). private_key_bits is
|
||||
// still validated by PHP's generic length check (>= 384) even though it
|
||||
// is irrelevant to EC, so it must be present or openssl_pkey_new fails
|
||||
// with "Private key length must be at least 384 bits, configured to 0".
|
||||
$keyPair = openssl_pkey_new([
|
||||
'private_key_type' => OPENSSL_KEYTYPE_EC,
|
||||
'curve_name' => 'prime256v1',
|
||||
'private_key_bits' => 384,
|
||||
]);
|
||||
|
||||
if ($keyPair === false) {
|
||||
$this->error('Failed to generate an EC P-256 keypair: '.openssl_error_string());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$privatePem = '';
|
||||
|
||||
if (! openssl_pkey_export($keyPair, $privatePem)) {
|
||||
$this->error('Failed to export the private key PEM: '.openssl_error_string());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$details = openssl_pkey_get_details($keyPair);
|
||||
|
||||
if ($details === false || ! isset($details['key'])) {
|
||||
$this->error('Failed to read the generated public key PEM.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$publicPem = (string) $details['key'];
|
||||
|
||||
$this->writeKeyFile($privateKeyPath, $privatePem, 0600);
|
||||
$this->writeKeyFile($publicKeyPath, $publicPem, 0644);
|
||||
|
||||
// Self-check: the whole point of this command is that AgentTokenIssuer
|
||||
// can mint with the key we just wrote. If the format were wrong (e.g.
|
||||
// not a PEM EC private key Firebase\JWT accepts for ES256) this fails
|
||||
// loudly here instead of silently at the first real host bootstrap.
|
||||
try {
|
||||
$token = $agentTokenIssuer->issue('flux-keygen-selfcheck');
|
||||
} catch (\Throwable $exception) {
|
||||
$this->error('Generated a keypair but AgentTokenIssuer could not mint a token with it: '.$exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (substr_count($token, '.') !== 2) {
|
||||
$this->error('Generated key produced a malformed JWT (expected 3 segments).');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info('Generated a fresh ES256 (EC P-256) Flux keypair.');
|
||||
$this->line(" Private key (0600): {$privateKeyPath}");
|
||||
$this->line(" Public key (0644): {$publicKeyPath}");
|
||||
$this->newLine();
|
||||
$this->line('Provision the PUBLIC key to flux — flux verifies every host JWT with it.');
|
||||
$this->line('Keep the PRIVATE key secret and on the Laravel host only.');
|
||||
|
||||
if ($this->option('show-public')) {
|
||||
$this->newLine();
|
||||
$this->line(rtrim($publicPem));
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a key file with exact permissions, creating the parent directory at
|
||||
* 0700 if missing. chmod is applied after the write because umask can
|
||||
* loosen both the mkdir mode and the created file mode.
|
||||
*/
|
||||
private function writeKeyFile(string $path, string $contents, int $mode): void
|
||||
{
|
||||
$directory = dirname($path);
|
||||
|
||||
if (! is_dir($directory)) {
|
||||
File::makeDirectory($directory, 0700, true);
|
||||
@chmod($directory, 0700);
|
||||
}
|
||||
|
||||
File::put($path, $contents);
|
||||
@chmod($path, $mode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Actions\V5\Server\SyncDevLimaServers;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Support\V5\V5Feature;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class V5SyncDevLimaServers extends Command
|
||||
{
|
||||
protected $signature = 'v5:sync-dev-lima-servers
|
||||
{--team-id=0 : Team that owns the dev servers}
|
||||
{--user-id=0 : User recorded as creator}
|
||||
{--private-key-id= : Optional private key used by the dev servers}
|
||||
{--cluster=Development-Lima : Cluster name for the dev Lima servers}
|
||||
{--server=* : Server as name|host|ssh_user|ssh_port|wireguard_management_ip}';
|
||||
|
||||
protected $description = 'Sync development Lima VMs into the v5 server/cluster tables.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (! V5Feature::enabled()) {
|
||||
$this->error('V5 is only available in development environments.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$team = Team::query()->find((int) $this->option('team-id')) ?? Team::query()->orderBy('id')->first();
|
||||
$user = User::query()->find((int) $this->option('user-id')) ?? User::query()->orderBy('id')->first();
|
||||
$privateKeyId = $this->option('private-key-id');
|
||||
$privateKey = is_numeric($privateKeyId)
|
||||
? PrivateKey::query()->find((int) $privateKeyId)
|
||||
: PrivateKey::query()
|
||||
->where('team_id', $team?->id)
|
||||
->where('is_git_related', false)
|
||||
->orderBy('id')
|
||||
->first();
|
||||
|
||||
if (! $team instanceof Team || ! $user instanceof User) {
|
||||
$this->warn('Cannot sync dev Lima servers without an existing team and user.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$servers = $this->option('server');
|
||||
|
||||
if (! is_array($servers) || $servers === []) {
|
||||
$this->warn('No dev Lima servers were provided.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$parsedServers = [];
|
||||
|
||||
foreach ($servers as $server) {
|
||||
$parts = explode('|', (string) $server);
|
||||
|
||||
if (! in_array(count($parts), [4, 5], true)) {
|
||||
$this->error("Invalid server '{$server}'. Expected name|host|ssh_user|ssh_port|wireguard_management_ip.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
[$name, $host, $sshUser, $sshPort] = array_slice($parts, 0, 4);
|
||||
$wireguardManagementIp = ($parts[4] ?? null) ?: null;
|
||||
|
||||
$parsedServers[] = [
|
||||
'name' => $name,
|
||||
'host' => $host,
|
||||
'ssh_user' => $sshUser,
|
||||
'ssh_port' => (int) $sshPort,
|
||||
'wireguard_management_ip' => $wireguardManagementIp,
|
||||
];
|
||||
}
|
||||
|
||||
SyncDevLimaServers::run(
|
||||
team: $team,
|
||||
user: $user,
|
||||
privateKey: $privateKey,
|
||||
clusterName: (string) $this->option('cluster'),
|
||||
servers: $parsedServers,
|
||||
);
|
||||
|
||||
foreach ($parsedServers as $server) {
|
||||
$this->info("Synced {$server['name']} ({$server['host']}:{$server['ssh_port']}).");
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,10 @@ use App\Jobs\RegenerateSslCertJob;
|
||||
use App\Jobs\ScheduledJobManager;
|
||||
use App\Jobs\ServerManagerJob;
|
||||
use App\Jobs\UpdateCoolifyJob;
|
||||
use App\Jobs\V5ReconcileServersJob;
|
||||
use App\Jobs\V5RotateAgentTokensJob;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Support\V5\V5Feature;
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
|
||||
|
||||
@@ -49,6 +52,11 @@ class Kernel extends ConsoleKernel
|
||||
$this->scheduleInstance->command('sanctum:prune-expired --hours=1')->hourly()->onOneServer();
|
||||
$this->scheduleInstance->job(new ApiTokenExpirationWarningJob)->hourly()->onOneServer();
|
||||
|
||||
if (V5Feature::enabled()) {
|
||||
$this->scheduleInstance->job(new V5ReconcileServersJob)->everyFiveMinutes()->withoutOverlapping()->onOneServer();
|
||||
$this->scheduleInstance->job(new V5RotateAgentTokensJob)->everyFifteenMinutes()->withoutOverlapping()->onOneServer();
|
||||
}
|
||||
|
||||
if (isDev()) {
|
||||
// Instance Jobs
|
||||
$this->scheduleInstance->command('horizon:snapshot')->everyMinute();
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums\V5;
|
||||
|
||||
/**
|
||||
* Lifecycle states persisted on `v5_applications.status`.
|
||||
*
|
||||
* Besides Coolify's own states (creating, failed, unknown), the column also
|
||||
* receives raw container runtime states reported by coold, so the Docker and
|
||||
* Podman container states are part of the catalog.
|
||||
*/
|
||||
enum ApplicationStatus: string
|
||||
{
|
||||
case Creating = 'creating';
|
||||
case Configured = 'configured';
|
||||
case Created = 'created';
|
||||
case Starting = 'starting';
|
||||
case Running = 'running';
|
||||
case Restarting = 'restarting';
|
||||
case Paused = 'paused';
|
||||
case Removing = 'removing';
|
||||
case Stopping = 'stopping';
|
||||
case Stopped = 'stopped';
|
||||
case Exited = 'exited';
|
||||
case Dead = 'dead';
|
||||
case Failed = 'failed';
|
||||
case Unknown = 'unknown';
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums\V5;
|
||||
|
||||
/**
|
||||
* Runtime states persisted on `v5_container_statuses.status`.
|
||||
*
|
||||
* Named ContainerState (not ContainerStatus) to avoid clashing with the
|
||||
* App\Models\V5\ContainerStatus Eloquent model. Covers the Docker and Podman
|
||||
* container states reported by coold.
|
||||
*/
|
||||
enum ContainerState: string
|
||||
{
|
||||
case Configured = 'configured';
|
||||
case Created = 'created';
|
||||
case Starting = 'starting';
|
||||
case Running = 'running';
|
||||
case Restarting = 'restarting';
|
||||
case Paused = 'paused';
|
||||
case Removing = 'removing';
|
||||
case Stopping = 'stopping';
|
||||
case Stopped = 'stopped';
|
||||
case Exited = 'exited';
|
||||
case Dead = 'dead';
|
||||
case Unknown = 'unknown';
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums\V5;
|
||||
|
||||
/**
|
||||
* States persisted on `v5_servers.ingress_status`.
|
||||
*
|
||||
* The value mirrors the ingress proxy container's runtime state as reported
|
||||
* by coold, so the Docker and Podman container states are part of the catalog.
|
||||
*/
|
||||
enum IngressStatus: string
|
||||
{
|
||||
case Configured = 'configured';
|
||||
case Created = 'created';
|
||||
case Starting = 'starting';
|
||||
case Running = 'running';
|
||||
case Restarting = 'restarting';
|
||||
case Paused = 'paused';
|
||||
case Removing = 'removing';
|
||||
case Stopping = 'stopping';
|
||||
case Stopped = 'stopped';
|
||||
case Exited = 'exited';
|
||||
case Dead = 'dead';
|
||||
case Unknown = 'unknown';
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums\V5;
|
||||
|
||||
/**
|
||||
* Lifecycle states persisted on `v5_servers.status`.
|
||||
*/
|
||||
enum ServerStatus: string
|
||||
{
|
||||
case Added = 'added';
|
||||
case Installed = 'installed';
|
||||
case Failed = 'failed';
|
||||
case Unreachable = 'unreachable';
|
||||
case Unknown = 'unknown';
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Support\V5\CanvasResourceSerializer;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class V5CanvasResourceUpdated implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* Push the queued broadcast job only after the dispatching database
|
||||
* transaction commits, so workers never serialize pre-commit state.
|
||||
*/
|
||||
public bool $afterCommit = true;
|
||||
|
||||
public function __construct(
|
||||
public int $teamId,
|
||||
public ?int $applicationId = null,
|
||||
public ?int $caddyIngressServerId = null,
|
||||
public ?int $serverId = null,
|
||||
) {}
|
||||
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [
|
||||
new PrivateChannel("team.{$this->teamId}"),
|
||||
];
|
||||
}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'v5.canvas.resource.updated';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{application: array<string, mixed>|null, applications: array<int, array<string, mixed>>, caddyIngress: array<string, mixed>|null}
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
$serializer = app(CanvasResourceSerializer::class);
|
||||
$application = $this->applicationId !== null
|
||||
? V5Application::query()->with(['server', 'domains'])->find($this->applicationId)
|
||||
: null;
|
||||
$applications = $this->serverId !== null
|
||||
? V5Application::query()
|
||||
->where('server_id', $this->serverId)
|
||||
->with(['server', 'domains'])
|
||||
->get()
|
||||
: collect();
|
||||
$caddyIngress = $this->caddyIngressServerId !== null
|
||||
? V5Server::query()->find($this->caddyIngressServerId)
|
||||
: null;
|
||||
|
||||
return [
|
||||
'application' => $application instanceof V5Application ? $serializer->serializeApplication($application) : null,
|
||||
'applications' => $applications
|
||||
->map(fn (V5Application $application) => $serializer->serializeApplication($application))
|
||||
->values()
|
||||
->all(),
|
||||
'caddyIngress' => $caddyIngress instanceof V5Server && $caddyIngress->isIngress()
|
||||
? $serializer->serializeCaddyIngress($caddyIngress)
|
||||
: null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\V5\Cluster as V5Cluster;
|
||||
use App\Support\V5\ClusterSerializer;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class V5ClusterUpdated implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* Push the queued broadcast job only after the dispatching database
|
||||
* transaction commits, so workers never serialize pre-commit state.
|
||||
*/
|
||||
public bool $afterCommit = true;
|
||||
|
||||
public function __construct(public int $teamId, public int $clusterId) {}
|
||||
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [
|
||||
new PrivateChannel("team.{$this->teamId}"),
|
||||
];
|
||||
}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'v5.cluster.updated';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{cluster: array<string, mixed>|null}
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
$cluster = V5Cluster::query()
|
||||
->where('team_id', $this->teamId)
|
||||
->with(['servers' => fn ($query) => $query
|
||||
->with('privateKey')
|
||||
->orderBy('name')])
|
||||
->withCount('servers')
|
||||
->find($this->clusterId);
|
||||
|
||||
return [
|
||||
'cluster' => $cluster instanceof V5Cluster ? app(ClusterSerializer::class)->serialize($cluster) : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class V5RealtimeTestEvent implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public string $sentAt;
|
||||
|
||||
public function __construct(public int $teamId, public string $message)
|
||||
{
|
||||
$this->sentAt = now()->toJSON();
|
||||
}
|
||||
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [
|
||||
new PrivateChannel("team.{$this->teamId}"),
|
||||
];
|
||||
}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'v5.realtime.test';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{message: string, teamId: int, sentAt: string}
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'message' => $this->message,
|
||||
'teamId' => $this->teamId,
|
||||
'sentAt' => $this->sentAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -69,8 +69,9 @@ class Handler extends ExceptionHandler
|
||||
*/
|
||||
public function render($request, Throwable $e)
|
||||
{
|
||||
// Handle authorization exceptions for API routes
|
||||
if ($e instanceof AuthorizationException) {
|
||||
// Handle authorization exceptions for API routes. Exceptions carrying
|
||||
// an explicit status (e.g. denyAsNotFound) keep it via parent::render.
|
||||
if ($e instanceof AuthorizationException && ! $e->hasStatus()) {
|
||||
if ($request->is('api/*') || $request->expectsJson()) {
|
||||
if ($request->is('api/*')) {
|
||||
auditLog('api.auth.policy_denied', [
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions\V5;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* The per-node coold agent does not implement the dispatched verb. Flux
|
||||
* rejects these before they reach the node, so callers can degrade
|
||||
* gracefully instead of treating the miss as an operational failure.
|
||||
*/
|
||||
class UnsupportedCooldVerb extends RuntimeException
|
||||
{
|
||||
public function __construct(public readonly string $verb, string $message = '')
|
||||
{
|
||||
parent::__construct($message !== '' ? $message : "The node's coold agent does not support the {$verb} verb.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Internal;
|
||||
|
||||
use App\Actions\V5\Flux\ApplyFluxResourceStatusUpdate;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
class FluxResourceStatusController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
if (! $this->authorizedBearer($request)) {
|
||||
abort(401);
|
||||
}
|
||||
|
||||
$validated = Validator::make($request->all(), [
|
||||
'resource_type' => ['required', 'string', 'max:64'],
|
||||
'team_id' => ['prohibited'],
|
||||
'application_id' => ['prohibited'],
|
||||
'resource_id' => ['prohibited'],
|
||||
'server_id' => ['prohibited'],
|
||||
'host_server_id' => ['prohibited'],
|
||||
'application_uuid' => ['nullable', 'string', 'max:255'],
|
||||
'resource_uuid' => ['nullable', 'string', 'max:255'],
|
||||
'server_uuid' => ['nullable', 'string', 'max:255'],
|
||||
'host_server_uuid' => ['nullable', 'string', 'max:255'],
|
||||
'host_id' => ['nullable', 'string', 'max:255'],
|
||||
'node_id' => ['nullable', 'string', 'max:255'],
|
||||
'server_host' => ['nullable', 'string', 'max:255'],
|
||||
'container_id' => ['nullable', 'string', 'max:255'],
|
||||
'runtime_container_id' => ['nullable', 'string', 'max:255'],
|
||||
'container_name' => ['nullable', 'string', 'max:255'],
|
||||
'name' => ['nullable', 'string', 'max:255'],
|
||||
'status' => ['required_without:state', 'string', 'max:64'],
|
||||
'state' => ['required_without:status', 'string', 'max:64'],
|
||||
'status_message' => ['nullable', 'string', 'max:1000'],
|
||||
'message' => ['nullable', 'string', 'max:1000'],
|
||||
'observed_at' => ['nullable', 'string', 'date'],
|
||||
])->validate();
|
||||
|
||||
$resource = ApplyFluxResourceStatusUpdate::run($validated);
|
||||
|
||||
if ($resource === null) {
|
||||
if (($validated['resource_type'] ?? null) === 'container') {
|
||||
return response()->json([
|
||||
'message' => 'Container status accepted.',
|
||||
], 202);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'No matching v5 resource was found.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Resource status updated.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant-time match the presented bearer token against every accepted
|
||||
* inbound token. Accepting an array (config('flux.laravel_api_tokens'),
|
||||
* falling back to the single config('flux.laravel_api_token')) lets an
|
||||
* operator rotate by serving old+new tokens simultaneously.
|
||||
*
|
||||
* SECURITY: this is still a shared global secret — every flux instance
|
||||
* presents the same token, so it cannot be scoped or revoked per-flux, and
|
||||
* a leak forces a fleet-wide rotation. The target design is per-flux,
|
||||
* individually rotatable tokens; until then the array support above is the
|
||||
* mitigation that makes rotation possible without downtime.
|
||||
*/
|
||||
private function authorizedBearer(Request $request): bool
|
||||
{
|
||||
$presented = (string) $request->bearerToken();
|
||||
|
||||
if ($presented === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($this->acceptedTokens() as $token) {
|
||||
if (hash_equals($token, $presented)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function acceptedTokens(): array
|
||||
{
|
||||
$tokens = config('flux.laravel_api_tokens', []);
|
||||
$tokens = is_array($tokens) ? $tokens : [];
|
||||
|
||||
$single = config('flux.laravel_api_token');
|
||||
|
||||
if (is_string($single) && $single !== '') {
|
||||
$tokens[] = $single;
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
array_map(fn ($token): string => is_string($token) ? $token : '', $tokens),
|
||||
fn (string $token): bool => $token !== ''
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,636 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V5;
|
||||
|
||||
use App\Actions\V5\Application\DestroyNginxApplication;
|
||||
use App\Actions\V5\Proxy\StartCaddyIngress;
|
||||
use App\Enums\V5\ApplicationStatus;
|
||||
use App\Enums\V5\IngressStatus;
|
||||
use App\Enums\V5\ServerStatus;
|
||||
use App\Exceptions\V5\UnsupportedCooldVerb;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\V5\Concerns\HandlesIngressSyncErrors;
|
||||
use App\Http\Controllers\V5\Concerns\ResolvesCurrentTeam;
|
||||
use App\Http\Controllers\V5\Concerns\ResolvesProjectSelection;
|
||||
use App\Http\Controllers\V5\Concerns\SerializesCanvasResources;
|
||||
use App\Jobs\V5DeployApplicationJob;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\ApplicationDomain as V5ApplicationDomain;
|
||||
use App\Models\V5\ResourceConnection;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Rules\ValidHostname;
|
||||
use App\Services\Flux\FluxClient;
|
||||
use App\Support\V5\CanvasResourceSerializer;
|
||||
use App\Support\V5\ConnectionFirewallSync;
|
||||
use App\Support\V5\StatusObservation;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ApplicationController extends Controller
|
||||
{
|
||||
use HandlesIngressSyncErrors;
|
||||
use ResolvesCurrentTeam;
|
||||
use ResolvesProjectSelection;
|
||||
use SerializesCanvasResources;
|
||||
|
||||
private const DEFAULT_NGINX_IMAGE = 'docker.io/library/nginx:alpine';
|
||||
|
||||
public function __construct(private readonly ConnectionFirewallSync $firewallSync) {}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$this->authorize('create', [V5Application::class, $currentTeam]);
|
||||
$projects = $this->projects($currentTeam);
|
||||
[$selectedProject, $selectedEnvironment] = $this->selectedProjectAndEnvironment($request, $projects);
|
||||
|
||||
if ($selectedProject === null || $selectedEnvironment === null) {
|
||||
return response()->json([
|
||||
'message' => 'Select a project and environment before deploying nginx.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$project = $this->projectQuery($currentTeam)
|
||||
->where('uuid', $selectedProject['uuid'])
|
||||
->first();
|
||||
|
||||
if (! $project instanceof Project) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$environment = $this->selectedEnvironment($project, $selectedEnvironment['uuid']);
|
||||
|
||||
if (! $environment instanceof Environment) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'server_uuid' => ['nullable', 'string', 'max:255'],
|
||||
'image' => ['nullable', 'string', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\/:@-]*$/'],
|
||||
]);
|
||||
$image = trim($validated['image'] ?? '') ?: self::DEFAULT_NGINX_IMAGE;
|
||||
|
||||
$server = V5Server::query()
|
||||
->where('team_id', $currentTeam->id)
|
||||
->when(
|
||||
isset($validated['server_uuid']),
|
||||
fn (Builder $query) => $query->where('uuid', $validated['server_uuid']),
|
||||
fn (Builder $query) => $query
|
||||
->orderByRaw('last_bootstrapped_at is null')
|
||||
->orderBy('name')
|
||||
)
|
||||
->first();
|
||||
|
||||
if (! $server instanceof V5Server) {
|
||||
return response()->json([
|
||||
'message' => 'Add a v5 server before deploying nginx.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($server->status !== ServerStatus::Installed->value || $server->last_bootstrapped_at === null) {
|
||||
return response()->json([
|
||||
'message' => "Bootstrap server {$server->name} before deploying to it.",
|
||||
], 422);
|
||||
}
|
||||
|
||||
$canvasPosition = $this->nextApplicationCanvasPosition($currentTeam, $project, $environment);
|
||||
|
||||
$application = V5Application::query()->create([
|
||||
'team_id' => $currentTeam->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => $request->user()->id,
|
||||
'name' => 'nginx-test',
|
||||
'image' => $image,
|
||||
'container_name' => 'coolify-v5-nginx-'.strtolower((string) Str::ulid()),
|
||||
'status' => ApplicationStatus::Creating->value,
|
||||
'status_message' => 'Starting nginx container.',
|
||||
'mesh_namespace' => 'default',
|
||||
'canvas_x' => $canvasPosition['canvas_x'],
|
||||
'canvas_y' => $canvasPosition['canvas_y'],
|
||||
]);
|
||||
|
||||
V5DeployApplicationJob::dispatch($application->id);
|
||||
|
||||
return response()->json([
|
||||
'application' => $this->serializeApplication($application),
|
||||
], 202);
|
||||
}
|
||||
|
||||
public function refresh(Request $request, FluxClient $fluxClient): JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$projects = $this->projects($currentTeam);
|
||||
[$selectedProject, $selectedEnvironment] = $this->selectedProjectAndEnvironment($request, $projects);
|
||||
|
||||
if ($selectedProject === null || $selectedEnvironment === null) {
|
||||
return response()->json([
|
||||
'message' => 'Select a project and environment before refreshing applications.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$applications = $this->applicationQuery($currentTeam, $selectedProject, $selectedEnvironment)
|
||||
->with('server')
|
||||
->get();
|
||||
$errors = [];
|
||||
|
||||
$applications
|
||||
->groupBy('server_id')
|
||||
->each(function (Collection $serverApplications) use ($fluxClient, &$errors): void {
|
||||
/** @var V5Application|null $firstApplication */
|
||||
$firstApplication = $serverApplications->first();
|
||||
$server = $firstApplication?->server;
|
||||
$hostId = $server?->fluxHostId();
|
||||
|
||||
if (! $server instanceof V5Server || ! is_string($hostId) || $hostId === '') {
|
||||
$errors[] = 'A server is missing its Flux host id.';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// The moment we query coold is the observation time for the rows
|
||||
// this refresh writes, so a fresher webhook always wins the
|
||||
// status_observed_at watermark and is never clobbered.
|
||||
$observedAt = CarbonImmutable::now();
|
||||
|
||||
try {
|
||||
$containers = collect($fluxClient->listContainers($hostId));
|
||||
} catch (\Throwable $e) {
|
||||
$errors[] = $e->getMessage();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$serverApplications->each(function (V5Application $application) use ($containers, $observedAt): void {
|
||||
$container = $containers->first(function (array $container) use ($application): bool {
|
||||
return ($application->runtime_container_id !== null && ($container['id'] ?? null) === $application->runtime_container_id)
|
||||
|| ($container['name'] ?? null) === $application->container_name;
|
||||
});
|
||||
|
||||
if (! is_array($container)) {
|
||||
// A creating application without a container id simply has
|
||||
// not materialized yet; the deploy job will settle it.
|
||||
if ($application->status === ApplicationStatus::Creating->value && $application->runtime_container_id === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (StatusObservation::isStale($observedAt, $application->status_observed_at, 'application status', ['application_id' => $application->id])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$application->update([
|
||||
'status' => ApplicationStatus::Exited->value,
|
||||
'status_message' => 'Container not found on server.',
|
||||
'status_observed_at' => $observedAt,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (StatusObservation::isStale($observedAt, $application->status_observed_at, 'application status', ['application_id' => $application->id])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rawState = is_string($container['state'] ?? null) && $container['state'] !== '' ? $container['state'] : null;
|
||||
|
||||
$application->update([
|
||||
'status' => StatusObservation::normalize($rawState, ApplicationStatus::class) ?? ApplicationStatus::Unknown->value,
|
||||
'status_message' => 'Container state refreshed from coold.',
|
||||
'status_observed_at' => $observedAt,
|
||||
'runtime_container_id' => is_string($container['id'] ?? null) ? $container['id'] : $application->runtime_container_id,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
V5Server::query()
|
||||
->where('team_id', $currentTeam->id)
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->filter(fn (V5Server $server) => $server->isIngress())
|
||||
->each(function (V5Server $server) use ($fluxClient, &$errors): void {
|
||||
$hostId = $server->fluxHostId();
|
||||
|
||||
if (! is_string($hostId) || $hostId === '') {
|
||||
$errors[] = "Caddy ingress server {$server->name} is missing its Flux host id.";
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$containers = collect($fluxClient->listContainers($hostId));
|
||||
} catch (\Throwable $e) {
|
||||
$errors[] = $e->getMessage();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$container = $containers->first(fn (array $container) => ($container['name'] ?? null) === 'coolify-v5-caddy');
|
||||
$rawState = is_array($container) && is_string($container['state'] ?? null) && $container['state'] !== '' ? $container['state'] : null;
|
||||
$state = $rawState !== null
|
||||
? (StatusObservation::normalize($rawState, IngressStatus::class) ?? IngressStatus::Unknown->value)
|
||||
: IngressStatus::Exited->value;
|
||||
|
||||
$server->update([
|
||||
'ingress_type' => 'caddy',
|
||||
'ingress_status' => $state,
|
||||
'last_status_check' => 'flux',
|
||||
'last_status_output' => 'Caddy ingress state refreshed from coold.',
|
||||
'last_status_checked_at' => now(),
|
||||
]);
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'applications' => $this->applicationQuery($currentTeam, $selectedProject, $selectedEnvironment)
|
||||
->with('server')
|
||||
->orderBy('created_at')
|
||||
->get()
|
||||
->map(fn (V5Application $application) => $this->serializeApplication($application))
|
||||
->all(),
|
||||
'caddyIngresses' => $this->caddyIngresses($currentTeam),
|
||||
'errors' => $errors,
|
||||
]);
|
||||
}
|
||||
|
||||
public function logs(Request $request, V5Application $application): JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$this->authorize('view', [$application, $currentTeam]);
|
||||
|
||||
$application->loadMissing('server');
|
||||
$server = $application->server;
|
||||
$hostId = $server?->fluxHostId();
|
||||
$containerId = $application->runtime_container_id;
|
||||
|
||||
$logs = null;
|
||||
$logsError = null;
|
||||
|
||||
// A container id only appears once the deploy actually created one; a
|
||||
// deploy that failed before that (e.g. host not connected) has none, so
|
||||
// there is nothing to fetch and the frontend just shows the status.
|
||||
if (is_string($containerId) && $containerId !== '' && $server instanceof V5Server && $server->status !== ServerStatus::Unreachable->value && is_string($hostId) && $hostId !== '') {
|
||||
try {
|
||||
$logs = app(FluxClient::class)->containerLogs($hostId, $containerId);
|
||||
} catch (UnsupportedCooldVerb $exception) {
|
||||
$logsError = "This node's coold does not support container logs.";
|
||||
} catch (\RuntimeException $exception) {
|
||||
Log::warning('V5 application container logs request failed', [
|
||||
'application_id' => $application->id,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
$logsError = 'Could not fetch container logs through Flux. Check the Flux and coold status, then try again.';
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => $application->status,
|
||||
'statusMessage' => $application->status_message,
|
||||
'containerId' => $containerId,
|
||||
'logs' => $logs,
|
||||
'logsError' => $logsError,
|
||||
]);
|
||||
}
|
||||
|
||||
public function updatePosition(Request $request, V5Application $application): JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$this->authorize('update', [$application, $currentTeam]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'canvas_x' => ['required', 'integer', 'min:-100000', 'max:100000'],
|
||||
'canvas_y' => ['required', 'integer', 'min:-100000', 'max:100000'],
|
||||
]);
|
||||
|
||||
$application->update([
|
||||
'canvas_x' => $validated['canvas_x'],
|
||||
'canvas_y' => $validated['canvas_y'],
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'application' => $this->serializeApplication($application->refresh()->load('server')),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateIngress(Request $request, V5Application $application): JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$this->authorize('updateIngress', [$application, $currentTeam]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'ingress_enabled' => ['required', 'boolean'],
|
||||
'internal_port' => ['nullable', 'integer', 'min:1', 'max:65535'],
|
||||
'domains' => [Rule::requiredIf(fn () => $request->boolean('ingress_enabled')), 'array', 'min:1'],
|
||||
'domains.*' => ['required', 'string', 'max:255', 'distinct:ignore_case', new ValidHostname],
|
||||
]);
|
||||
|
||||
$application->loadMissing('server');
|
||||
|
||||
if ($validated['ingress_enabled'] && ! $application->server?->isIngress()) {
|
||||
return response()->json([
|
||||
'message' => 'Enable ingress on the server before enabling app ingress.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($validated['ingress_enabled'] && array_key_exists('domains', $validated)) {
|
||||
$conflict = $this->conflictingApplicationDomain($application, $validated['domains']);
|
||||
|
||||
if ($conflict instanceof V5ApplicationDomain) {
|
||||
return response()->json([
|
||||
'message' => "The domain {$conflict->domain} is already used by application \"{$conflict->application?->name}\" on this server.",
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
$originalAttributes = $application->only(['ingress_enabled', 'internal_port']);
|
||||
$originalDomains = $application->domains()->pluck('domain')->all();
|
||||
|
||||
DB::transaction(function () use ($application, $validated): void {
|
||||
$application->update([
|
||||
'ingress_enabled' => $validated['ingress_enabled'],
|
||||
'internal_port' => $validated['internal_port'] ?? null,
|
||||
]);
|
||||
|
||||
if (array_key_exists('domains', $validated)) {
|
||||
$application->domains()->delete();
|
||||
|
||||
collect($validated['domains'])
|
||||
->map(fn (string $domain) => trim($domain))
|
||||
->filter()
|
||||
->unique()
|
||||
->each(fn (string $domain) => V5ApplicationDomain::query()->create([
|
||||
'application_id' => $application->id,
|
||||
'domain' => $domain,
|
||||
]));
|
||||
}
|
||||
});
|
||||
|
||||
$application->refresh()->load(['server', 'domains']);
|
||||
|
||||
if ($application->server?->isIngress() && $application->server->status === ServerStatus::Installed->value) {
|
||||
try {
|
||||
StartCaddyIngress::run($application->server);
|
||||
} catch (\RuntimeException $exception) {
|
||||
$this->restoreApplicationIngress($application, $originalAttributes, $originalDomains);
|
||||
|
||||
return $this->ingressSyncErrorResponse($exception);
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'application' => $this->serializeApplication($application),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateCaddyIngressPosition(Request $request, V5Server $server): JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$this->authorize('updateCanvasPosition', [$server, $currentTeam]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'canvas_x' => ['required', 'integer', 'min:-100000', 'max:100000'],
|
||||
'canvas_y' => ['required', 'integer', 'min:-100000', 'max:100000'],
|
||||
]);
|
||||
|
||||
$server->update([
|
||||
'canvas_x' => $validated['canvas_x'],
|
||||
'canvas_y' => $validated['canvas_y'],
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'caddyIngress' => $this->serializeCaddyIngress($server->refresh()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, V5Application $application, FluxClient $fluxClient): Response|JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$this->authorize('delete', [$application, $currentTeam]);
|
||||
|
||||
$application->loadMissing(['server', 'domains']);
|
||||
$server = $application->server;
|
||||
$connections = $this->applicationResourceConnections($application);
|
||||
|
||||
if ($request->boolean('delete_locally')) {
|
||||
$this->deleteApplicationLocally($application, $connections);
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
$oldFirewallRules = $connections
|
||||
->flatMap(function (ResourceConnection $connection): Collection {
|
||||
// Deletion must never be blocked by an endpoint that already lost
|
||||
// its server; those rules can no longer be revoked anyway.
|
||||
try {
|
||||
return $this->firewallSync->rulesFor($connection->load('rules'));
|
||||
} catch (\RuntimeException $exception) {
|
||||
report($exception);
|
||||
|
||||
return collect();
|
||||
}
|
||||
});
|
||||
$originalIngressAttributes = null;
|
||||
$originalIngressDomains = [];
|
||||
$ingressConfigurationChanged = false;
|
||||
|
||||
try {
|
||||
$this->firewallSync->sync($fluxClient, $oldFirewallRules, collect());
|
||||
} catch (\RuntimeException $exception) {
|
||||
report($exception);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Could not sync firewall rules through Flux.',
|
||||
'detail' => $exception->getMessage(),
|
||||
], 502);
|
||||
}
|
||||
|
||||
if ($server instanceof V5Server && $server->isIngress() && $server->status === ServerStatus::Installed->value && $application->ingress_enabled) {
|
||||
$originalIngressAttributes = $application->only(['ingress_enabled', 'internal_port']);
|
||||
$originalIngressDomains = $application->domains()->pluck('domain')->all();
|
||||
|
||||
DB::transaction(function () use ($application): void {
|
||||
$application->update([
|
||||
'ingress_enabled' => false,
|
||||
'internal_port' => null,
|
||||
]);
|
||||
$application->domains()->delete();
|
||||
});
|
||||
|
||||
try {
|
||||
StartCaddyIngress::run($server);
|
||||
$ingressConfigurationChanged = true;
|
||||
} catch (\RuntimeException $exception) {
|
||||
$this->restoreApplicationIngress($application, $originalIngressAttributes, $originalIngressDomains);
|
||||
|
||||
return $this->ingressSyncErrorResponse($exception);
|
||||
}
|
||||
}
|
||||
|
||||
$error = DestroyNginxApplication::run($application);
|
||||
|
||||
if ($error !== null) {
|
||||
if ($originalIngressAttributes !== null) {
|
||||
$this->restoreApplicationIngress($application, $originalIngressAttributes, $originalIngressDomains);
|
||||
|
||||
if ($ingressConfigurationChanged && $server instanceof V5Server) {
|
||||
try {
|
||||
StartCaddyIngress::run($server);
|
||||
} catch (\RuntimeException $exception) {
|
||||
report($exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$this->firewallSync->sync($fluxClient, collect(), $oldFirewallRules);
|
||||
} catch (\RuntimeException $exception) {
|
||||
report($exception);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => $error,
|
||||
'can_delete_locally' => true,
|
||||
], 422);
|
||||
}
|
||||
|
||||
$this->deleteApplicationLocally($application, $connections);
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, ResourceConnection> $connections
|
||||
*/
|
||||
private function deleteApplicationLocally(V5Application $application, Collection $connections): void
|
||||
{
|
||||
DB::transaction(function () use ($application, $connections): void {
|
||||
$connections->each(function (ResourceConnection $connection): void {
|
||||
$connection->rules()->delete();
|
||||
$connection->delete();
|
||||
});
|
||||
|
||||
$application->delete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, ResourceConnection>
|
||||
*/
|
||||
private function applicationResourceConnections(V5Application $application): Collection
|
||||
{
|
||||
return ResourceConnection::query()
|
||||
->where('team_id', $application->team_id)
|
||||
->where(function (Builder $query) use ($application): void {
|
||||
$query
|
||||
->where(function (Builder $query) use ($application): void {
|
||||
$query
|
||||
->where('resource_one_type', $application->getMorphClass())
|
||||
->where('resource_one_id', $application->id);
|
||||
})
|
||||
->orWhere(function (Builder $query) use ($application): void {
|
||||
$query
|
||||
->where('resource_two_type', $application->getMorphClass())
|
||||
->where('resource_two_id', $application->id);
|
||||
});
|
||||
})
|
||||
->with('rules')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{canvas_x: int, canvas_y: int}
|
||||
*/
|
||||
private function nextApplicationCanvasPosition(Team $currentTeam, Project $project, Environment $environment): array
|
||||
{
|
||||
$existingApplications = V5Application::query()
|
||||
->where('team_id', $currentTeam->id)
|
||||
->where('project_id', $project->id)
|
||||
->where('environment_id', $environment->id)
|
||||
->get(['canvas_x', 'canvas_y']);
|
||||
|
||||
$horizontalStep = CanvasResourceSerializer::CARD_WIDTH + CanvasResourceSerializer::CARD_GAP;
|
||||
$verticalStep = CanvasResourceSerializer::CARD_HEIGHT + CanvasResourceSerializer::CARD_GAP;
|
||||
|
||||
for ($row = 0; $row < 100; $row++) {
|
||||
for ($column = 0; $column < 100; $column++) {
|
||||
$candidate = [
|
||||
'canvas_x' => $column * $horizontalStep,
|
||||
'canvas_y' => $row * $verticalStep,
|
||||
];
|
||||
|
||||
if (! $this->canvasPositionCollides($candidate, $existingApplications)) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'canvas_x' => $existingApplications->max('canvas_x') + $horizontalStep,
|
||||
'canvas_y' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{canvas_x: int, canvas_y: int} $candidate
|
||||
* @param Collection<int, V5Application> $existingApplications
|
||||
*/
|
||||
private function canvasPositionCollides(array $candidate, Collection $existingApplications): bool
|
||||
{
|
||||
return $existingApplications->contains(function (V5Application $application) use ($candidate) {
|
||||
return abs($candidate['canvas_x'] - $application->canvas_x) < CanvasResourceSerializer::CARD_WIDTH + CanvasResourceSerializer::CARD_GAP
|
||||
&& abs($candidate['canvas_y'] - $application->canvas_y) < CanvasResourceSerializer::CARD_HEIGHT + CanvasResourceSerializer::CARD_GAP;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $domains
|
||||
*/
|
||||
private function conflictingApplicationDomain(V5Application $application, array $domains): ?V5ApplicationDomain
|
||||
{
|
||||
$normalizedDomains = collect($domains)
|
||||
->map(fn (string $domain) => Str::lower(trim($domain)))
|
||||
->filter()
|
||||
->values();
|
||||
|
||||
if ($normalizedDomains->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return V5ApplicationDomain::query()
|
||||
->whereIn(DB::raw('LOWER(domain)'), $normalizedDomains->all())
|
||||
->whereHas('application', fn (Builder $query) => $query
|
||||
->where('server_id', $application->server_id)
|
||||
->whereKeyNot($application->id)
|
||||
->where('ingress_enabled', true))
|
||||
->with('application:id,name')
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
* @param array<int, string> $domains
|
||||
*/
|
||||
private function restoreApplicationIngress(V5Application $application, array $attributes, array $domains): void
|
||||
{
|
||||
DB::transaction(function () use ($application, $attributes, $domains): void {
|
||||
$application->update($attributes);
|
||||
$application->domains()->delete();
|
||||
|
||||
foreach ($domains as $domain) {
|
||||
V5ApplicationDomain::query()->create([
|
||||
'application_id' => $application->id,
|
||||
'domain' => $domain,
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V5;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\V5\Concerns\ResolvesCurrentTeam;
|
||||
use App\Http\Controllers\V5\Concerns\ResolvesProjectSelection;
|
||||
use App\Http\Controllers\V5\Concerns\ValidatesBuilderConfiguration;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Team;
|
||||
use App\Models\V5\Cluster as V5Cluster;
|
||||
use App\Services\Flux\FluxHealth;
|
||||
use App\Support\V5\ClusterSerializer;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class ClusterController extends Controller
|
||||
{
|
||||
use ResolvesCurrentTeam;
|
||||
use ResolvesProjectSelection;
|
||||
use ValidatesBuilderConfiguration;
|
||||
|
||||
public function index(Request $request, FluxHealth $fluxHealth): Response
|
||||
{
|
||||
$currentTeam = $request->attributes->get('v5.currentTeam');
|
||||
$projects = $this->projects($currentTeam);
|
||||
[$selectedProject, $selectedEnvironment] = $this->selectedProjectAndEnvironment($request, $projects);
|
||||
|
||||
return Inertia::render('Clusters', [
|
||||
'currentTeam' => $this->serializeCurrentTeam($currentTeam),
|
||||
'flux' => $fluxHealth->check(),
|
||||
'clusters' => $this->clusters($currentTeam),
|
||||
'privateKeys' => $this->privateKeys($currentTeam),
|
||||
'projects' => $projects,
|
||||
'selectedProjectUuid' => $selectedProject['uuid'] ?? null,
|
||||
'selectedEnvironmentUuid' => $selectedEnvironment['uuid'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Request $request, V5Cluster $cluster): JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$this->authorize('view', [$cluster, $currentTeam]);
|
||||
|
||||
return response()->json([
|
||||
'cluster' => app(ClusterSerializer::class)->serializeFresh($cluster),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$this->authorize('create', [V5Cluster::class, $currentTeam]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::unique('v5_clusters', 'name')->where('team_id', $currentTeam->id),
|
||||
],
|
||||
'description' => ['nullable', 'string', 'max:1000'],
|
||||
'wireguard_interface' => ['sometimes', 'string', 'max:32', 'regex:/^[a-zA-Z0-9_.-]+$/'],
|
||||
'wireguard_management_pool' => ['sometimes', 'string', 'max:64', $this->ipv4CidrRule()],
|
||||
'wireguard_listen_port' => ['sometimes', 'integer', 'min:1', 'max:65535'],
|
||||
'container_network_pool' => ['sometimes', 'string', 'max:64', $this->ipv4CidrRule()],
|
||||
'container_network_prefix' => ['sometimes', 'integer', 'min:1', 'max:32'],
|
||||
'namespaces' => ['sometimes', 'array', 'min:1'],
|
||||
'namespaces.*' => ['string', 'distinct', 'regex:/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/'],
|
||||
'default_deny_containers' => ['sometimes', 'boolean'],
|
||||
'coold_version' => ['sometimes', 'string', 'max:64'],
|
||||
'corrosion_version' => ['sometimes', 'string', 'max:64'],
|
||||
'corrosion_gossip_port' => ['sometimes', 'integer', 'min:1', 'max:65535'],
|
||||
'corrosion_api_port' => ['sometimes', 'integer', 'min:1', 'max:65535'],
|
||||
'builder_enabled' => ['sometimes', 'boolean'],
|
||||
'builder_capacity' => $this->builderCapacityRules(
|
||||
$this->requestedBuilderEnabled($request, true)
|
||||
),
|
||||
'builder_cpu_quota' => ['sometimes', 'string', 'max:32'],
|
||||
'builder_memory_max' => ['sometimes', 'string', 'max:32'],
|
||||
'builder_timeout_secs' => ['sometimes', 'integer', 'min:1', 'max:86400'],
|
||||
]);
|
||||
|
||||
$cluster = V5Cluster::query()->create([
|
||||
...$this->defaultClusterConfiguration(),
|
||||
...collect($validated)->except(['name', 'description'])->all(),
|
||||
'team_id' => $currentTeam->id,
|
||||
'created_by_user_id' => $request->user()->id,
|
||||
'name' => $validated['name'],
|
||||
'description' => $validated['description'] ?? null,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'cluster' => app(ClusterSerializer::class)->serializeFresh($cluster),
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, V5Cluster $cluster): \Illuminate\Http\Response|JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$this->authorize('delete', [$cluster, $currentTeam]);
|
||||
|
||||
if ($cluster->servers()->exists()) {
|
||||
return response()->json([
|
||||
'message' => 'Only empty clusters can be deleted.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$cluster->delete();
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function clusters(mixed $currentTeam): array
|
||||
{
|
||||
if (! $currentTeam instanceof Team) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$serializer = app(ClusterSerializer::class);
|
||||
|
||||
return V5Cluster::query()
|
||||
->where('team_id', $currentTeam->id)
|
||||
->with(['servers' => fn ($query) => $query
|
||||
->with('privateKey')
|
||||
->orderBy('name')])
|
||||
->withCount('servers')
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(fn (V5Cluster $cluster) => $serializer->serialize($cluster))
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{id: string, name: string}>
|
||||
*/
|
||||
private function privateKeys(mixed $currentTeam): array
|
||||
{
|
||||
if (! $currentTeam instanceof Team) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return PrivateKey::query()
|
||||
->where('team_id', $currentTeam->id)
|
||||
->where('is_git_related', false)
|
||||
->orderBy('name')
|
||||
->get(['id', 'uuid', 'name'])
|
||||
->map(fn (PrivateKey $privateKey) => [
|
||||
'id' => $privateKey->uuid,
|
||||
'name' => $privateKey->name,
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function defaultClusterConfiguration(): array
|
||||
{
|
||||
return [
|
||||
'wireguard_interface' => V5Cluster::DEFAULT_WIREGUARD_INTERFACE,
|
||||
'wireguard_management_pool' => V5Cluster::DEFAULT_WIREGUARD_MANAGEMENT_POOL,
|
||||
'wireguard_listen_port' => V5Cluster::DEFAULT_WIREGUARD_LISTEN_PORT,
|
||||
'container_network_pool' => V5Cluster::DEFAULT_CONTAINER_NETWORK_POOL,
|
||||
'container_network_prefix' => V5Cluster::DEFAULT_CONTAINER_NETWORK_PREFIX,
|
||||
'namespaces' => V5Cluster::DEFAULT_NAMESPACES,
|
||||
'default_deny_containers' => true,
|
||||
'coold_version' => V5Cluster::DEFAULT_COOLD_VERSION,
|
||||
'corrosion_version' => V5Cluster::DEFAULT_CORROSION_VERSION,
|
||||
'corrosion_gossip_port' => V5Cluster::DEFAULT_CORROSION_GOSSIP_PORT,
|
||||
'corrosion_api_port' => V5Cluster::DEFAULT_CORROSION_API_PORT,
|
||||
'builder_enabled' => true,
|
||||
'builder_capacity' => V5Cluster::DEFAULT_BUILDER_CAPACITY,
|
||||
'builder_cpu_quota' => V5Cluster::DEFAULT_BUILDER_CPU_QUOTA,
|
||||
'builder_memory_max' => V5Cluster::DEFAULT_BUILDER_MEMORY_MAX,
|
||||
'builder_timeout_secs' => V5Cluster::DEFAULT_BUILDER_TIMEOUT_SECS,
|
||||
];
|
||||
}
|
||||
|
||||
private function ipv4CidrRule(): \Closure
|
||||
{
|
||||
return function (string $attribute, mixed $value, \Closure $fail): void {
|
||||
if (! is_string($value) || ! str_contains($value, '/')) {
|
||||
$fail('The :attribute must be a valid IPv4 CIDR range.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
[$ip, $prefix] = explode('/', $value, 2);
|
||||
|
||||
if (
|
||||
filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) === false
|
||||
|| ! ctype_digit($prefix)
|
||||
|| (int) $prefix < 0
|
||||
|| (int) $prefix > 32
|
||||
) {
|
||||
$fail('The :attribute must be a valid IPv4 CIDR range.');
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V5\Concerns;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
trait HandlesIngressSyncErrors
|
||||
{
|
||||
protected function ingressSyncErrorResponse(\RuntimeException $exception): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'message' => $this->friendlyIngressSyncError($exception->getMessage()),
|
||||
'detail' => $exception->getMessage(),
|
||||
], 502);
|
||||
}
|
||||
|
||||
protected function friendlyIngressSyncError(string $message): string
|
||||
{
|
||||
$normalized = Str::lower($message);
|
||||
|
||||
if (str_contains($normalized, 'invalid http response') || str_contains($normalized, 'could not talk to flux')) {
|
||||
return 'Could not reach Flux. Check that Flux is running in the Coolify container and try again.';
|
||||
}
|
||||
|
||||
if (str_contains($normalized, 'dispatch timeout') || str_contains($normalized, 'timed out')) {
|
||||
return 'coold did not respond in time. Check that the server agent is running and connected to Flux.';
|
||||
}
|
||||
|
||||
if (str_contains($normalized, 'validate caddyfile')) {
|
||||
return 'Caddy rejected the generated ingress configuration. Check the domains and internal port, then try again.';
|
||||
}
|
||||
|
||||
if (str_contains($normalized, 'start caddy ingress') || str_contains($normalized, 'reload caddy ingress')) {
|
||||
return 'Could not start Caddy ingress on the server. Check that Podman is running and port 80 is available.';
|
||||
}
|
||||
|
||||
return 'Could not update ingress. Check Flux and coold logs, then try again.';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V5\Concerns;
|
||||
|
||||
use App\Models\Team;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
trait ResolvesCurrentTeam
|
||||
{
|
||||
/**
|
||||
* Resolve the current team set by the EnsureCurrentTeam middleware, or
|
||||
* abort with a 404 so resources outside the team stay invisible.
|
||||
*/
|
||||
protected function currentTeamOrFail(Request $request): Team
|
||||
{
|
||||
$currentTeam = $request->attributes->get('v5.currentTeam');
|
||||
|
||||
abort_unless($currentTeam instanceof Team, 404);
|
||||
|
||||
return $currentTeam;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V5\Concerns;
|
||||
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
trait ResolvesProjectSelection
|
||||
{
|
||||
protected const SELECTED_PROJECT_SESSION_KEY = 'v5.selectedProjectUuid';
|
||||
|
||||
protected const SELECTED_ENVIRONMENT_SESSION_KEY = 'v5.selectedEnvironmentUuid';
|
||||
|
||||
/**
|
||||
* @return array{id: int}|null
|
||||
*/
|
||||
protected function serializeCurrentTeam(mixed $currentTeam): ?array
|
||||
{
|
||||
if (! $currentTeam instanceof Team) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $currentTeam->id,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{uuid: string, name: string, environments: array<int, array{uuid: string, name: string}>}> $projects
|
||||
* @return array{0: array{uuid: string, name: string, environments: array<int, array{uuid: string, name: string}>}|null, 1: array{uuid: string, name: string}|null}
|
||||
*/
|
||||
protected function selectedProjectAndEnvironment(Request $request, array $projects): array
|
||||
{
|
||||
$selectedProjectUuid = $request->query('project', $request->session()->get(self::SELECTED_PROJECT_SESSION_KEY));
|
||||
$selectedEnvironmentUuid = $request->query('environment', $request->session()->get(self::SELECTED_ENVIRONMENT_SESSION_KEY));
|
||||
$selectedProject = null;
|
||||
|
||||
foreach ($projects as $project) {
|
||||
if ($project['uuid'] === $selectedProjectUuid) {
|
||||
$selectedProject = $project;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$selectedProject ??= $projects[0] ?? null;
|
||||
$selectedEnvironment = null;
|
||||
|
||||
foreach ($selectedProject['environments'] ?? [] as $environment) {
|
||||
if ($environment['uuid'] === $selectedEnvironmentUuid) {
|
||||
$selectedEnvironment = $environment;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$selectedEnvironment ??= $selectedProject['environments'][0] ?? null;
|
||||
|
||||
if ($request->query->has('project') || $request->query->has('environment')) {
|
||||
$request->session()->put([
|
||||
self::SELECTED_PROJECT_SESSION_KEY => $selectedProject['uuid'] ?? null,
|
||||
self::SELECTED_ENVIRONMENT_SESSION_KEY => $selectedEnvironment['uuid'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
return [$selectedProject, $selectedEnvironment];
|
||||
}
|
||||
|
||||
protected function selectedEnvironment(Project $project, ?string $environmentUuid): ?Environment
|
||||
{
|
||||
if ($environmentUuid === null) {
|
||||
return $project->environments->first();
|
||||
}
|
||||
|
||||
$environment = $project->environments->firstWhere('uuid', $environmentUuid);
|
||||
|
||||
if (! $environment instanceof Environment) {
|
||||
abort(422, 'The selected environment is not available for the selected project.');
|
||||
}
|
||||
|
||||
return $environment;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{uuid: string, name: string, environments: array<int, array{uuid: string, name: string}>}>
|
||||
*/
|
||||
protected function projects(mixed $currentTeam): array
|
||||
{
|
||||
if (! $currentTeam instanceof Team) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->projectQuery($currentTeam)
|
||||
->get()
|
||||
->map(fn (Project $project) => [
|
||||
'uuid' => $project->uuid,
|
||||
'name' => $project->name,
|
||||
'environments' => $project->environments
|
||||
->map(fn ($environment) => [
|
||||
'uuid' => $environment->uuid,
|
||||
'name' => $environment->name,
|
||||
])
|
||||
->all(),
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
protected function projectQuery(Team $currentTeam): Builder
|
||||
{
|
||||
return Project::query()
|
||||
->select(['id', 'uuid', 'name', 'team_id'])
|
||||
->where('team_id', $currentTeam->id)
|
||||
->with(['environments' => fn ($query) => $query
|
||||
->select(['id', 'uuid', 'name', 'project_id'])
|
||||
->orderByRaw("CASE WHEN LOWER(name) = 'production' THEN 0 ELSE 1 END")
|
||||
->orderByRaw('LOWER(name)')])
|
||||
->orderByRaw('LOWER(name)');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V5\Concerns;
|
||||
|
||||
use App\Models\Team;
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Support\V5\CanvasResourceSerializer;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
trait SerializesCanvasResources
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function serializeApplication(V5Application $application): array
|
||||
{
|
||||
return app(CanvasResourceSerializer::class)->serializeApplication($application);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function serializeCaddyIngress(V5Server $server, int $index = 0): array
|
||||
{
|
||||
return app(CanvasResourceSerializer::class)->serializeCaddyIngress($server, $index);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
protected function caddyIngresses(mixed $currentTeam): array
|
||||
{
|
||||
if (! $currentTeam instanceof Team) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return V5Server::query()
|
||||
->where('team_id', $currentTeam->id)
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->filter(fn (V5Server $server) => $server->isIngress())
|
||||
->values()
|
||||
->map(fn (V5Server $server, int $index) => $this->serializeCaddyIngress($server, $index))
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{uuid: string} $selectedProject
|
||||
* @param array{uuid: string} $selectedEnvironment
|
||||
* @return Builder<V5Application>
|
||||
*/
|
||||
protected function applicationQuery(Team $currentTeam, array $selectedProject, array $selectedEnvironment): Builder
|
||||
{
|
||||
return V5Application::query()
|
||||
->where('team_id', $currentTeam->id)
|
||||
->whereHas('project', fn (Builder $query) => $query
|
||||
->where('team_id', $currentTeam->id)
|
||||
->where('uuid', $selectedProject['uuid']))
|
||||
->whereHas('environment', fn (Builder $query) => $query
|
||||
->where('uuid', $selectedEnvironment['uuid']));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V5\Concerns;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
trait ValidatesBuilderConfiguration
|
||||
{
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
protected function builderCapacityRules(bool $builderEnabled, bool $required = false): array
|
||||
{
|
||||
return [
|
||||
$required ? 'required' : 'sometimes',
|
||||
'integer',
|
||||
$builderEnabled ? 'min:1' : 'min:0',
|
||||
'max:1000',
|
||||
];
|
||||
}
|
||||
|
||||
protected function requestedBuilderEnabled(Request $request, bool $default): bool
|
||||
{
|
||||
if (! $request->has('builder_enabled')) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return $request->boolean('builder_enabled');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V5;
|
||||
|
||||
use App\Events\V5RealtimeTestEvent;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\V5\Concerns\ResolvesCurrentTeam;
|
||||
use App\Http\Controllers\V5\Concerns\ResolvesProjectSelection;
|
||||
use App\Http\Controllers\V5\Concerns\SerializesCanvasResources;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\ResourceConnection;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Services\Flux\FluxHealth;
|
||||
use App\Support\V5\ResourceConnectionSerializer;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
use ResolvesCurrentTeam;
|
||||
use ResolvesProjectSelection;
|
||||
use SerializesCanvasResources;
|
||||
|
||||
public function __construct(private readonly ResourceConnectionSerializer $connectionSerializer) {}
|
||||
|
||||
public function __invoke(Request $request, FluxHealth $fluxHealth): Response
|
||||
{
|
||||
$currentTeam = $request->attributes->get('v5.currentTeam');
|
||||
$projects = $this->projects($currentTeam);
|
||||
[$selectedProject, $selectedEnvironment] = $this->selectedProjectAndEnvironment($request, $projects);
|
||||
$applications = $this->applications($currentTeam, $selectedProject, $selectedEnvironment);
|
||||
$requestedApplicationUuid = $request->query('application');
|
||||
$selectedApplicationUuid = collect($applications)->contains(
|
||||
fn (array $application): bool => $application['id'] === $requestedApplicationUuid
|
||||
) ? $requestedApplicationUuid : null;
|
||||
|
||||
return Inertia::render('Dashboard', [
|
||||
'currentTeam' => $this->serializeCurrentTeam($currentTeam),
|
||||
'flux' => $fluxHealth->check(),
|
||||
'applications' => $applications,
|
||||
'caddyIngresses' => $this->caddyIngresses($currentTeam),
|
||||
'resourceConnections' => $this->resourceConnections($currentTeam, $selectedProject, $selectedEnvironment),
|
||||
'nginxServers' => $this->nginxServers($currentTeam),
|
||||
'projects' => $projects,
|
||||
'selectedProjectUuid' => $selectedProject['uuid'] ?? null,
|
||||
'selectedEnvironmentUuid' => $selectedEnvironment['uuid'] ?? null,
|
||||
'selectedApplicationUuid' => $selectedApplicationUuid,
|
||||
]);
|
||||
}
|
||||
|
||||
public function realtimeTest(Request $request): Response
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
|
||||
return Inertia::render('RealtimeTest', [
|
||||
'currentTeam' => [
|
||||
'id' => $currentTeam->id,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function broadcastRealtimeTest(Request $request): JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
|
||||
$validated = $request->validate([
|
||||
'message' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
V5RealtimeTestEvent::dispatch(
|
||||
$currentTeam->id,
|
||||
$validated['message'] ?? 'Manual v5 realtime test'
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Realtime test event broadcasted.',
|
||||
], 202);
|
||||
}
|
||||
|
||||
public function updateSelection(Request $request): \Illuminate\Http\Response
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
|
||||
$validated = $request->validate([
|
||||
'project_uuid' => ['required', 'string'],
|
||||
'environment_uuid' => ['nullable', 'string'],
|
||||
]);
|
||||
|
||||
$project = $this->projectQuery($currentTeam)
|
||||
->where('uuid', $validated['project_uuid'])
|
||||
->first();
|
||||
|
||||
if (! $project instanceof Project) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$environment = $this->selectedEnvironment($project, $validated['environment_uuid'] ?? null);
|
||||
|
||||
$request->session()->put([
|
||||
self::SELECTED_PROJECT_SESSION_KEY => $project->uuid,
|
||||
self::SELECTED_ENVIRONMENT_SESSION_KEY => $environment?->uuid,
|
||||
]);
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function applications(mixed $currentTeam, ?array $selectedProject, ?array $selectedEnvironment): array
|
||||
{
|
||||
if (! $currentTeam instanceof Team || $selectedProject === null || $selectedEnvironment === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->applicationQuery($currentTeam, $selectedProject, $selectedEnvironment)
|
||||
->with('server')
|
||||
->orderBy('created_at')
|
||||
->get()
|
||||
->map(fn (V5Application $application) => $this->serializeApplication($application))
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function resourceConnections(mixed $currentTeam, ?array $selectedProject, ?array $selectedEnvironment): array
|
||||
{
|
||||
if (! $currentTeam instanceof Team || $selectedProject === null || $selectedEnvironment === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return ResourceConnection::query()
|
||||
->where('team_id', $currentTeam->id)
|
||||
->whereHas('project', fn (Builder $query) => $query
|
||||
->where('team_id', $currentTeam->id)
|
||||
->where('uuid', $selectedProject['uuid']))
|
||||
->whereHas('environment', fn (Builder $query) => $query
|
||||
->where('uuid', $selectedEnvironment['uuid']))
|
||||
->with('rules')
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->map(fn (ResourceConnection $connection) => $this->connectionSerializer->serialize($connection))
|
||||
->all();
|
||||
}
|
||||
|
||||
private function nginxServers(mixed $currentTeam): array
|
||||
{
|
||||
if (! $currentTeam instanceof Team) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return V5Server::query()
|
||||
->where('team_id', $currentTeam->id)
|
||||
->orderByRaw('last_bootstrapped_at is null')
|
||||
->orderBy('name')
|
||||
->get(['id', 'uuid', 'name', 'host', 'status'])
|
||||
->map(fn (V5Server $server) => [
|
||||
'id' => $server->uuid,
|
||||
'name' => $server->name,
|
||||
'host' => $server->host,
|
||||
'status' => $server->status,
|
||||
])
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V5;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\V5\Concerns\ResolvesCurrentTeam;
|
||||
use App\Http\Controllers\V5\Concerns\ResolvesProjectSelection;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\ResourceConnection;
|
||||
use App\Services\Flux\FluxClient;
|
||||
use App\Support\V5\ConnectionFirewallSync;
|
||||
use App\Support\V5\ResourceConnectionSerializer;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ResourceConnectionController extends Controller
|
||||
{
|
||||
use ResolvesCurrentTeam;
|
||||
use ResolvesProjectSelection;
|
||||
|
||||
public function __construct(
|
||||
private readonly ConnectionFirewallSync $firewallSync,
|
||||
private readonly ResourceConnectionSerializer $connectionSerializer,
|
||||
) {}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$this->authorize('create', [ResourceConnection::class, $currentTeam]);
|
||||
$projects = $this->projects($currentTeam);
|
||||
[$selectedProject, $selectedEnvironment] = $this->selectedProjectAndEnvironment($request, $projects);
|
||||
|
||||
if ($selectedProject === null || $selectedEnvironment === null) {
|
||||
return response()->json([
|
||||
'message' => 'Select a project and environment before connecting resources.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$project = $this->projectQuery($currentTeam)
|
||||
->where('uuid', $selectedProject['uuid'])
|
||||
->first();
|
||||
|
||||
if (! $project instanceof Project) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$environment = $this->selectedEnvironment($project, $selectedEnvironment['uuid']);
|
||||
|
||||
if (! $environment instanceof Environment) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'resource_one' => ['required', 'array'],
|
||||
'resource_one.type' => ['required', 'string', Rule::in(['application'])],
|
||||
'resource_one.uuid' => ['required', 'string', 'max:255'],
|
||||
'resource_two' => ['required', 'array'],
|
||||
'resource_two.type' => ['required', 'string', Rule::in(['application'])],
|
||||
'resource_two.uuid' => ['required', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
$resourceOne = $this->resolveConnectableResource($currentTeam, $project, $environment, $validated['resource_one']);
|
||||
$resourceTwo = $this->resolveConnectableResource($currentTeam, $project, $environment, $validated['resource_two']);
|
||||
|
||||
if ($this->resourceIdentity($resourceOne) === $this->resourceIdentity($resourceTwo)) {
|
||||
return response()->json([
|
||||
'message' => 'A resource cannot connect to itself.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$connection = ResourceConnection::query()->firstOrCreate(
|
||||
[
|
||||
'team_id' => $currentTeam->id,
|
||||
'resource_pair_key' => $this->resourcePairKey($resourceOne, $resourceTwo),
|
||||
],
|
||||
[
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'resource_one_type' => $resourceOne->getMorphClass(),
|
||||
'resource_one_id' => $resourceOne->getKey(),
|
||||
'resource_two_type' => $resourceTwo->getMorphClass(),
|
||||
'resource_two_id' => $resourceTwo->getKey(),
|
||||
'created_by_user_id' => $request->user()->id,
|
||||
],
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'connection' => $this->connectionSerializer->serialize($connection->load('rules')),
|
||||
], $connection->wasRecentlyCreated ? 201 : 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the connection's rules, then converge the node firewalls.
|
||||
*
|
||||
* Ordering & failure semantics:
|
||||
* 1. Snapshot the current DB rules and their firewall representation; abort
|
||||
* with 502 before mutating anything when the snapshot cannot be built.
|
||||
* 2. Commit the requested rules in a DB transaction — the DB always holds
|
||||
* the desired state.
|
||||
* 3. Converge the node firewalls through Flux. Nodes whose coold lacks the
|
||||
* firewall verbs (UnsupportedCooldVerb) are tolerated: the committed
|
||||
* rules are kept and the request succeeds.
|
||||
* 4. On a real Flux failure the previous rules are restored in a second DB
|
||||
* transaction, the node firewalls are rolled back to the restored rules
|
||||
* best-effort (warning-logged when that also fails — deterministic rule
|
||||
* ids keep a later re-sync idempotent), and the original error surfaces
|
||||
* to the caller as a 502 {message, detail} response.
|
||||
*/
|
||||
public function update(Request $request, ResourceConnection $connection, FluxClient $fluxClient): JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$this->authorize('update', [$connection, $currentTeam]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'ports_by_direction' => ['present', 'array'],
|
||||
'ports_by_direction.*' => ['array'],
|
||||
'ports_by_direction.*.*' => ['integer', 'min:1', 'max:65535', 'distinct'],
|
||||
]);
|
||||
|
||||
$connection->load('rules');
|
||||
$oldRulePayloads = $connection->rules
|
||||
->map(fn ($rule): array => [
|
||||
'source_resource_type' => $rule->source_resource_type,
|
||||
'source_resource_id' => $rule->source_resource_id,
|
||||
'target_resource_type' => $rule->target_resource_type,
|
||||
'target_resource_id' => $rule->target_resource_id,
|
||||
'protocol' => $rule->protocol,
|
||||
'port' => $rule->port,
|
||||
])
|
||||
->all();
|
||||
|
||||
try {
|
||||
$oldFirewallRules = $this->firewallSync->rulesFor($connection);
|
||||
} catch (\RuntimeException $exception) {
|
||||
report($exception);
|
||||
Log::warning('V5 resource connection firewall snapshot failed', [
|
||||
'connection_id' => $connection->id,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Could not sync firewall rules through Flux.',
|
||||
'detail' => 'The connection was left unchanged. Check the server diagnostics and try again.',
|
||||
], 502);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($connection, $validated): void {
|
||||
$connection->rules()->delete();
|
||||
$resourcesByUuid = $this->connectionSerializer->applicationsByUuid($connection);
|
||||
|
||||
foreach ($validated['ports_by_direction'] as $direction => $ports) {
|
||||
[$sourceResourceUuid, $targetResourceUuid] = array_pad(explode('->', (string) $direction, 2), 2, null);
|
||||
$sourceResource = is_string($sourceResourceUuid) ? $resourcesByUuid->get($sourceResourceUuid) : null;
|
||||
$targetResource = is_string($targetResourceUuid) ? $resourcesByUuid->get($targetResourceUuid) : null;
|
||||
|
||||
if (! $sourceResource instanceof V5Application || ! $targetResource instanceof V5Application) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (array_unique($ports) as $port) {
|
||||
$connection->rules()->create([
|
||||
'source_resource_type' => $this->resourceTypeForConnectionUuid($connection, $sourceResource->uuid),
|
||||
'source_resource_id' => $sourceResource->id,
|
||||
'target_resource_type' => $this->resourceTypeForConnectionUuid($connection, $targetResource->uuid),
|
||||
'target_resource_id' => $targetResource->id,
|
||||
'protocol' => 'tcp',
|
||||
'port' => (int) $port,
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$connection->refresh()->load('rules');
|
||||
|
||||
$newFirewallRules = null;
|
||||
|
||||
try {
|
||||
$newFirewallRules = $this->firewallSync->rulesFor($connection);
|
||||
$this->firewallSync->sync($fluxClient, $oldFirewallRules, $newFirewallRules);
|
||||
} catch (\RuntimeException $exception) {
|
||||
$this->restoreConnectionRules($connection, $oldRulePayloads);
|
||||
$this->rollBackFirewallRules($fluxClient, $connection, $newFirewallRules, $oldFirewallRules);
|
||||
report($exception);
|
||||
Log::warning('V5 resource connection firewall sync failed', [
|
||||
'connection_id' => $connection->id,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Could not sync firewall rules through Flux.',
|
||||
'detail' => 'The previous rules were restored. Check the server diagnostics and try again.',
|
||||
], 502);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'connection' => $this->connectionSerializer->serialize($connection),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the connection using revoke-first ordering.
|
||||
*
|
||||
* The node firewall rules are revoked before any DB rows are removed. When
|
||||
* a revoke fails with a real error the delete is aborted with a 502
|
||||
* {message, detail} response so the DB never loses track of rules that may
|
||||
* still be open on a reachable node; UnsupportedCooldVerb and
|
||||
* already-missing rules are tolerated. When the firewall snapshot cannot
|
||||
* be built (an endpoint lost its server host id) the node cannot be
|
||||
* addressed at all, so the failure is reported and the delete proceeds.
|
||||
* Deterministic rule ids make a retried delete revoke the same node-side
|
||||
* rules idempotently.
|
||||
*/
|
||||
public function destroy(Request $request, ResourceConnection $connection, FluxClient $fluxClient): Response|JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$this->authorize('delete', [$connection, $currentTeam]);
|
||||
|
||||
try {
|
||||
$oldFirewallRules = $this->firewallSync->rulesFor($connection->load('rules'));
|
||||
} catch (\RuntimeException $exception) {
|
||||
report($exception);
|
||||
$oldFirewallRules = collect();
|
||||
}
|
||||
|
||||
try {
|
||||
$this->firewallSync->sync($fluxClient, $oldFirewallRules, collect());
|
||||
} catch (\RuntimeException $exception) {
|
||||
report($exception);
|
||||
Log::warning('V5 resource connection firewall revoke failed', [
|
||||
'connection_id' => $connection->id,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Could not sync firewall rules through Flux.',
|
||||
'detail' => 'The connection was not deleted. Check the server diagnostics and try again.',
|
||||
], 502);
|
||||
}
|
||||
|
||||
$connection->delete();
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{type: string, uuid: string} $resource
|
||||
*/
|
||||
private function resolveConnectableResource(Team $team, Project $project, Environment $environment, array $resource): Model
|
||||
{
|
||||
return match ($resource['type']) {
|
||||
'application' => V5Application::query()
|
||||
->where('team_id', $team->id)
|
||||
->where('project_id', $project->id)
|
||||
->where('environment_id', $environment->id)
|
||||
->where('uuid', $resource['uuid'])
|
||||
->firstOrFail(),
|
||||
};
|
||||
}
|
||||
|
||||
private function resourcePairKey(Model $resourceOne, Model $resourceTwo): string
|
||||
{
|
||||
return collect([
|
||||
$this->resourceIdentity($resourceOne),
|
||||
$this->resourceIdentity($resourceTwo),
|
||||
])->sort()->implode('|');
|
||||
}
|
||||
|
||||
private function resourceIdentity(Model $resource): string
|
||||
{
|
||||
return $resource->getMorphClass().':'.$resource->getKey();
|
||||
}
|
||||
|
||||
private function resourceTypeForConnectionUuid(ResourceConnection $connection, string $resourceUuid): string
|
||||
{
|
||||
$resourcesByUuid = $this->connectionSerializer->applicationsByUuid($connection);
|
||||
$resource = $resourcesByUuid->get($resourceUuid);
|
||||
|
||||
return $resource instanceof V5Application && (int) $connection->resource_one_id === $resource->id
|
||||
? $connection->resource_one_type
|
||||
: $connection->resource_two_type;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $rulePayloads
|
||||
*/
|
||||
private function restoreConnectionRules(ResourceConnection $connection, array $rulePayloads): void
|
||||
{
|
||||
DB::transaction(function () use ($connection, $rulePayloads): void {
|
||||
$connection->rules()->delete();
|
||||
|
||||
foreach ($rulePayloads as $rulePayload) {
|
||||
$connection->rules()->create($rulePayload);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort roll back of a partially converged node firewall to the
|
||||
* restored rules after a failed forward sync. Skipped when the forward
|
||||
* sync never started (the node was not touched). Failures are only logged
|
||||
* because the DB already holds the restored, authoritative rules and the
|
||||
* deterministic rule ids keep a later re-sync idempotent.
|
||||
*
|
||||
* @param Collection<int, array{id: string, hostId: string, rule: array{id: string, namespace: string, src: string, dst: string, proto: string, port: int}}>|null $attemptedFirewallRules
|
||||
* @param Collection<int, array{id: string, hostId: string, rule: array{id: string, namespace: string, src: string, dst: string, proto: string, port: int}}> $restoredFirewallRules
|
||||
*/
|
||||
private function rollBackFirewallRules(FluxClient $fluxClient, ResourceConnection $connection, ?Collection $attemptedFirewallRules, Collection $restoredFirewallRules): void
|
||||
{
|
||||
if (! $attemptedFirewallRules instanceof Collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->firewallSync->sync($fluxClient, $attemptedFirewallRules, $restoredFirewallRules);
|
||||
} catch (\RuntimeException $exception) {
|
||||
Log::warning('V5 resource connection firewall rollback failed; node firewall may diverge from the restored rules until the next sync', [
|
||||
'connection_id' => $connection->id,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,901 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V5;
|
||||
|
||||
use App\Actions\V5\Proxy\StartCaddyIngress;
|
||||
use App\Actions\V5\Proxy\StopCaddyIngress;
|
||||
use App\Actions\V5\Server\RemoveBootstrapMarker;
|
||||
use App\Enums\V5\ServerStatus;
|
||||
use App\Events\V5ClusterUpdated;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\V5\Concerns\HandlesIngressSyncErrors;
|
||||
use App\Http\Controllers\V5\Concerns\ResolvesCurrentTeam;
|
||||
use App\Http\Controllers\V5\Concerns\ValidatesBuilderConfiguration;
|
||||
use App\Jobs\V5BootstrapServerJob;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\Cluster as V5Cluster;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Rules\ValidServerIp;
|
||||
use App\Services\Flux\AgentTokenIssuer;
|
||||
use App\Services\Flux\FluxClient;
|
||||
use App\Support\V5\ClusterSerializer;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ServerController extends Controller
|
||||
{
|
||||
use HandlesIngressSyncErrors;
|
||||
use ResolvesCurrentTeam;
|
||||
use ValidatesBuilderConfiguration;
|
||||
|
||||
public function store(Request $request, V5Cluster $cluster): JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$this->authorize('create', [V5Server::class, $currentTeam, $cluster]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'host' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
$this->noControlCharactersRule(),
|
||||
new ValidServerIp,
|
||||
Rule::unique('v5_servers', 'host')
|
||||
->where('team_id', $currentTeam->id)
|
||||
->where('ssh_port', (int) $request->input('ssh_port', 22)),
|
||||
],
|
||||
'ssh_user' => ['required', 'string', 'max:255', 'regex:/^[A-Za-z0-9._-]+$/', $this->noControlCharactersRule()],
|
||||
'ssh_port' => ['required', 'integer', 'min:1', 'max:65535'],
|
||||
'private_key_uuid' => [
|
||||
'required',
|
||||
'string',
|
||||
Rule::exists('private_keys', 'uuid')->where('team_id', $currentTeam->id),
|
||||
],
|
||||
'node_address' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:255',
|
||||
$this->noControlCharactersRule(),
|
||||
new ValidServerIp,
|
||||
Rule::unique('v5_servers', 'node_address')->where('team_id', $currentTeam->id),
|
||||
],
|
||||
'builder_enabled' => ['sometimes', 'boolean'],
|
||||
'builder_capacity' => $this->builderCapacityRules(
|
||||
$this->requestedBuilderEnabled($request, $cluster->builder_enabled)
|
||||
),
|
||||
'builder_cpu_quota' => ['sometimes', 'string', 'max:32'],
|
||||
'wireguard_listen_port_override' => ['nullable', 'integer', 'min:1', 'max:65535'],
|
||||
'wireguard_endpoint_override' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:255',
|
||||
$this->noControlCharactersRule(),
|
||||
$this->hostPortRule(),
|
||||
Rule::unique('v5_servers', 'wireguard_endpoint_override')->where('cluster_id', $cluster->id),
|
||||
],
|
||||
'ingress_enabled' => ['sometimes', 'boolean'],
|
||||
'ingress_type' => [
|
||||
Rule::requiredIf(fn () => $request->boolean('ingress_enabled')),
|
||||
'nullable',
|
||||
'string',
|
||||
Rule::in(['caddy']),
|
||||
],
|
||||
]);
|
||||
|
||||
$capacity = $this->clusterServerCapacity($cluster);
|
||||
|
||||
if ($capacity !== null && $cluster->servers()->count() >= $capacity) {
|
||||
return response()->json([
|
||||
'message' => "This cluster's network pools are full ({$capacity} server(s) max). Grow the pools or remove a server first.",
|
||||
], 422);
|
||||
}
|
||||
|
||||
$builderEnabled = (bool) ($validated['builder_enabled'] ?? $cluster->builder_enabled);
|
||||
$ingressEnabled = (bool) ($validated['ingress_enabled'] ?? false);
|
||||
$ingressType = $ingressEnabled ? $validated['ingress_type'] : null;
|
||||
$builderCapacity = (int) ($validated['builder_capacity'] ?? $cluster->builder_capacity);
|
||||
$builderCpuQuota = $validated['builder_cpu_quota'] ?? $cluster->builder_cpu_quota;
|
||||
$devWireguardOverrides = $this->devLimaWireguardOverrides($validated['host'], (int) $validated['ssh_port']);
|
||||
$privateKey = PrivateKey::query()
|
||||
->where('team_id', $currentTeam->id)
|
||||
->where('uuid', $validated['private_key_uuid'])
|
||||
->firstOrFail();
|
||||
|
||||
V5Server::query()->create([
|
||||
'team_id' => $currentTeam->id,
|
||||
'cluster_id' => $cluster->id,
|
||||
'created_by_user_id' => $request->user()->id,
|
||||
'name' => $validated['name'],
|
||||
'host' => $validated['host'],
|
||||
'ssh_user' => $validated['ssh_user'],
|
||||
'ssh_port' => $validated['ssh_port'],
|
||||
'private_key_id' => $privateKey->id,
|
||||
'status' => ServerStatus::Added->value,
|
||||
'ingress_type' => $ingressType,
|
||||
'is_ingress' => $ingressEnabled,
|
||||
'builder_enabled' => $builderEnabled,
|
||||
'builder_capacity' => $builderCapacity,
|
||||
'builder_cpu_quota' => $builderCpuQuota,
|
||||
'node_address' => $validated['node_address'] ?? $validated['host'],
|
||||
'wireguard_listen_port_override' => $validated['wireguard_listen_port_override'] ?? $devWireguardOverrides['listen_port'],
|
||||
'wireguard_endpoint_override' => $validated['wireguard_endpoint_override'] ?? $devWireguardOverrides['endpoint'],
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'cluster' => app(ClusterSerializer::class)->serializeFresh($cluster),
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function update(Request $request, V5Cluster $cluster, V5Server $server): JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$this->authorize('update', [$server, $currentTeam, $cluster]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'builder_enabled' => ['required', 'boolean'],
|
||||
'builder_capacity' => $this->builderCapacityRules(
|
||||
$request->boolean('builder_enabled'),
|
||||
required: true
|
||||
),
|
||||
'builder_cpu_quota' => ['required', 'string', 'max:32'],
|
||||
'ingress_enabled' => ['sometimes', 'boolean'],
|
||||
'ingress_type' => [
|
||||
Rule::requiredIf(fn () => $request->boolean('ingress_enabled')),
|
||||
'nullable',
|
||||
'string',
|
||||
Rule::in(['caddy']),
|
||||
],
|
||||
]);
|
||||
|
||||
$wasIngress = $server->isIngress();
|
||||
$builderEnabled = (bool) $validated['builder_enabled'];
|
||||
$ingressEnabled = (bool) ($validated['ingress_enabled'] ?? $wasIngress);
|
||||
$ingressType = $ingressEnabled ? ($validated['ingress_type'] ?? $server->ingress_type ?? 'caddy') : null;
|
||||
$originalServerAttributes = $server->only([
|
||||
'is_ingress',
|
||||
'ingress_type',
|
||||
'ingress_status',
|
||||
'builder_enabled',
|
||||
'builder_capacity',
|
||||
'builder_cpu_quota',
|
||||
]);
|
||||
|
||||
// Stop the ingress before persisting the change: StopCaddyIngress needs
|
||||
// the server's current ingress state, and a failed stop must leave the
|
||||
// capability untouched.
|
||||
if ($wasIngress && ! $ingressEnabled && $server->status === ServerStatus::Installed->value) {
|
||||
try {
|
||||
StopCaddyIngress::run($server);
|
||||
} catch (\RuntimeException $exception) {
|
||||
return $this->ingressSyncErrorResponse($exception);
|
||||
}
|
||||
}
|
||||
|
||||
$server->update([
|
||||
'is_ingress' => $ingressEnabled,
|
||||
'ingress_type' => $ingressType,
|
||||
'builder_enabled' => $builderEnabled,
|
||||
'builder_capacity' => (int) $validated['builder_capacity'],
|
||||
'builder_cpu_quota' => $validated['builder_cpu_quota'],
|
||||
]);
|
||||
|
||||
$server->refresh();
|
||||
|
||||
if (! $wasIngress && $ingressEnabled && $server->status === ServerStatus::Installed->value) {
|
||||
try {
|
||||
StartCaddyIngress::run($server);
|
||||
} catch (\RuntimeException $exception) {
|
||||
$server->update($originalServerAttributes);
|
||||
|
||||
return $this->ingressSyncErrorResponse($exception);
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'cluster' => app(ClusterSerializer::class)->serializeFresh($cluster),
|
||||
]);
|
||||
}
|
||||
|
||||
public function check(Request $request, V5Cluster $cluster, V5Server $server): JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$this->authorize('check', [$server, $currentTeam, $cluster]);
|
||||
|
||||
if (! $server->privateKey instanceof PrivateKey) {
|
||||
return response()->json([
|
||||
'status' => 'failed',
|
||||
'output' => 'No private key is attached to this server.',
|
||||
'checkedAt' => now()->toJSON(),
|
||||
]);
|
||||
}
|
||||
|
||||
$keyDirectory = storage_path('app/ssh/keys');
|
||||
if (! is_dir($keyDirectory)) {
|
||||
mkdir($keyDirectory, 0700, true);
|
||||
}
|
||||
|
||||
$keyLocation = tempnam($keyDirectory, 'v5_ssh_key_');
|
||||
if ($keyLocation === false) {
|
||||
return response()->json([
|
||||
'status' => 'failed',
|
||||
'output' => 'Could not create a temporary SSH key file.',
|
||||
'checkedAt' => now()->toJSON(),
|
||||
]);
|
||||
}
|
||||
|
||||
file_put_contents($keyLocation, $server->privateKey->private_key);
|
||||
chmod($keyLocation, 0600);
|
||||
|
||||
$target = "{$server->ssh_user}@{$server->host}";
|
||||
$command = [
|
||||
'ssh',
|
||||
'-o',
|
||||
'BatchMode=yes',
|
||||
'-o',
|
||||
'LogLevel=ERROR',
|
||||
'-o',
|
||||
'StrictHostKeyChecking=no',
|
||||
'-o',
|
||||
'UserKnownHostsFile=/dev/null',
|
||||
'-o',
|
||||
'ConnectTimeout=10',
|
||||
'-o',
|
||||
'IdentitiesOnly=yes',
|
||||
'-i',
|
||||
$keyLocation,
|
||||
'-p',
|
||||
(string) $server->ssh_port,
|
||||
$target,
|
||||
"printf 'SSH connection OK\n'; hostname; uname -srm; command -v docker || true; command -v podman || true",
|
||||
];
|
||||
|
||||
try {
|
||||
$result = Process::timeout(15)->run($command);
|
||||
$output = trim($result->output()."\n".$result->errorOutput());
|
||||
$status = $result->successful() ? 'reachable' : 'failed';
|
||||
} catch (\Throwable $e) {
|
||||
$output = $e->getMessage();
|
||||
$status = 'failed';
|
||||
} finally {
|
||||
@unlink($keyLocation);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => $status,
|
||||
'output' => str($output !== '' ? $output : 'No output returned.')->limit(10000)->toString(),
|
||||
'checkedAt' => now()->toJSON(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function restartCoold(Request $request, V5Cluster $cluster, V5Server $server, AgentTokenIssuer $agentTokenIssuer, FluxClient $fluxClient): JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$this->authorize('restartCoold', [$server, $currentTeam, $cluster]);
|
||||
|
||||
$server->loadMissing('privateKey');
|
||||
|
||||
if (! $server->privateKey instanceof PrivateKey) {
|
||||
return response()->json([
|
||||
'message' => 'No private key is attached to this server.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$token = $agentTokenIssuer->issueForServer($server);
|
||||
$output = $this->restartCooldOverSsh($server, $token);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('V5 coold restart over SSH failed', [
|
||||
'server_id' => $server->id,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => str($e->getMessage() !== '' ? $e->getMessage() : 'Could not restart coold over SSH.')->limit(10000)->toString(),
|
||||
], 502);
|
||||
}
|
||||
|
||||
$connected = false;
|
||||
|
||||
try {
|
||||
usleep(500_000);
|
||||
$fluxClient->cooldLogs($server->fluxHostId(), 1);
|
||||
$connected = true;
|
||||
$server->forceFill([
|
||||
'status' => ServerStatus::Installed->value,
|
||||
'last_status_check' => 'flux',
|
||||
'last_status_output' => 'coold restarted over SSH and reconnected to Flux.',
|
||||
])->save();
|
||||
} catch (\Throwable $e) {
|
||||
Log::info('V5 coold restart succeeded but Flux reconnect is not confirmed yet', [
|
||||
'server_id' => $server->id,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'cluster' => app(ClusterSerializer::class)->serializeFresh($cluster),
|
||||
'output' => $output,
|
||||
'connected' => $connected,
|
||||
'restartedAt' => now()->toJSON(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function restartCooldOverSsh(V5Server $server, string $token): string
|
||||
{
|
||||
$encodedToken = base64_encode($token);
|
||||
$script = implode(PHP_EOL, [
|
||||
'set -e',
|
||||
"SUDO=''",
|
||||
'if [ "$(id -u)" != "0" ]; then SUDO="sudo -n"; fi',
|
||||
'$SUDO mkdir -p /etc/coolify',
|
||||
'printf %s '.escapeshellarg($encodedToken).' | base64 -d | $SUDO tee /etc/coolify/host-jwt >/dev/null',
|
||||
'$SUDO chmod 600 /etc/coolify/host-jwt',
|
||||
'$SUDO systemctl reset-failed coold.service || true',
|
||||
'$SUDO systemctl restart coold.service',
|
||||
'$SUDO systemctl is-active coold.service',
|
||||
'$SUDO systemctl status coold.service --no-pager -l | sed -n "1,18p"',
|
||||
]);
|
||||
|
||||
return $this->runServerSshCommand($server, $script, 'SSH coold restart command failed.', 45);
|
||||
}
|
||||
|
||||
public function cooldLogs(Request $request, V5Cluster $cluster, V5Server $server, FluxClient $fluxClient): JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$this->authorize('viewDiagnostics', [$server, $currentTeam, $cluster]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'tail' => ['sometimes', 'integer', 'min:1', 'max:1000'],
|
||||
]);
|
||||
|
||||
$hostId = $server->fluxHostId();
|
||||
|
||||
if (! is_string($hostId) || $hostId === '') {
|
||||
return response()->json([
|
||||
'message' => 'This server is missing its Flux host id.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$output = $fluxClient->cooldLogs($hostId, (int) ($validated['tail'] ?? 200));
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('V5 coold logs request failed', [
|
||||
'server_id' => $server->id,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
if ($server->privateKey instanceof PrivateKey) {
|
||||
try {
|
||||
return response()->json([
|
||||
'output' => $this->cooldLogsOverSsh($server, (int) ($validated['tail'] ?? 200)),
|
||||
'source' => 'ssh',
|
||||
'fetchedAt' => now()->toJSON(),
|
||||
]);
|
||||
} catch (\Throwable $sshException) {
|
||||
Log::warning('V5 coold logs SSH fallback failed', [
|
||||
'server_id' => $server->id,
|
||||
'message' => $sshException->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Could not fetch coold logs through Flux. Check the Flux and coold status, then try again.',
|
||||
], 502);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'output' => $output,
|
||||
'source' => 'flux',
|
||||
'fetchedAt' => now()->toJSON(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function cooldLogsOverSsh(V5Server $server, int $tail): string
|
||||
{
|
||||
return $this->runServerSshCommand(
|
||||
$server,
|
||||
'sudo -n journalctl -u coold -n '.max(1, min($tail, 1000)).' --no-pager -q || journalctl -u coold -n '.max(1, min($tail, 1000)).' --no-pager -q',
|
||||
'SSH coold log command failed.',
|
||||
);
|
||||
}
|
||||
|
||||
private function runServerSshCommand(V5Server $server, string $remoteCommand, string $failureMessage, int $timeout = 15): string
|
||||
{
|
||||
$keyDirectory = storage_path('app/ssh/keys');
|
||||
if (! is_dir($keyDirectory)) {
|
||||
mkdir($keyDirectory, 0700, true);
|
||||
}
|
||||
|
||||
$keyLocation = tempnam($keyDirectory, 'v5_ssh_key_');
|
||||
if ($keyLocation === false) {
|
||||
throw new \RuntimeException('Could not create a temporary SSH key file.');
|
||||
}
|
||||
|
||||
file_put_contents($keyLocation, $server->privateKey->private_key);
|
||||
chmod($keyLocation, 0600);
|
||||
|
||||
try {
|
||||
$result = Process::timeout($timeout)->run([
|
||||
'ssh',
|
||||
'-o',
|
||||
'BatchMode=yes',
|
||||
'-o',
|
||||
'LogLevel=ERROR',
|
||||
'-o',
|
||||
'StrictHostKeyChecking=no',
|
||||
'-o',
|
||||
'UserKnownHostsFile=/dev/null',
|
||||
'-o',
|
||||
'ConnectTimeout=10',
|
||||
'-o',
|
||||
'IdentitiesOnly=yes',
|
||||
'-i',
|
||||
$keyLocation,
|
||||
'-p',
|
||||
(string) $server->ssh_port,
|
||||
"{$server->ssh_user}@{$server->host}",
|
||||
$remoteCommand,
|
||||
]);
|
||||
$output = trim($result->output()."\n".$result->errorOutput());
|
||||
|
||||
if (! $result->successful()) {
|
||||
throw new \RuntimeException($output !== '' ? $output : $failureMessage);
|
||||
}
|
||||
|
||||
return str($output)->limit(10000)->toString();
|
||||
} finally {
|
||||
@unlink($keyLocation);
|
||||
}
|
||||
}
|
||||
|
||||
public function corrosionTables(Request $request, V5Cluster $cluster, V5Server $server, FluxClient $fluxClient): JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$this->authorize('viewDiagnostics', [$server, $currentTeam, $cluster]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'limit' => ['sometimes', 'integer', 'min:1', 'max:1000'],
|
||||
]);
|
||||
|
||||
$hostId = $server->fluxHostId();
|
||||
|
||||
if (! is_string($hostId) || $hostId === '') {
|
||||
return response()->json([
|
||||
'message' => 'This server is missing its Flux host id.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$output = $fluxClient->corrosionTables($hostId, (int) ($validated['limit'] ?? 200));
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('V5 corrosion tables request failed', [
|
||||
'server_id' => $server->id,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
if ($server->privateKey instanceof PrivateKey) {
|
||||
try {
|
||||
return response()->json([
|
||||
'output' => $this->corrosionTablesOverSsh($server, $cluster, (int) ($validated['limit'] ?? 200)),
|
||||
'source' => 'ssh',
|
||||
'fetchedAt' => now()->toJSON(),
|
||||
]);
|
||||
} catch (\Throwable $sshException) {
|
||||
Log::warning('V5 corrosion tables SSH fallback failed', [
|
||||
'server_id' => $server->id,
|
||||
'message' => $sshException->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Could not fetch corrosion tables through Flux. Check the Flux and coold status, then try again.',
|
||||
], 502);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'output' => $output,
|
||||
'source' => 'flux',
|
||||
'fetchedAt' => now()->toJSON(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function corrosionTablesOverSsh(V5Server $server, V5Cluster $cluster, int $limit): string
|
||||
{
|
||||
$limit = max(1, min($limit, 1000));
|
||||
$script = <<<'PYTHON'
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import urllib.request
|
||||
|
||||
limit = __LIMIT__
|
||||
url = "http://127.0.0.1:__PORT__/v1/queries"
|
||||
|
||||
def query(sql):
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
data=json.dumps([sql, []]).encode(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=10) as response:
|
||||
return json.loads(response.read().decode())
|
||||
|
||||
def quote_identifier(value):
|
||||
return '"' + value.replace('"', '""') + '"'
|
||||
|
||||
tables = []
|
||||
for row in query("SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name"):
|
||||
name = row[0] if row else None
|
||||
if not isinstance(name, str):
|
||||
continue
|
||||
|
||||
identifier = quote_identifier(name)
|
||||
columns = [column[1] for column in query(f"PRAGMA table_info({identifier})") if len(column) > 1]
|
||||
rows = query(f"SELECT * FROM {identifier} LIMIT {limit}")
|
||||
tables.append({"name": name, "columns": columns, "rows": rows})
|
||||
|
||||
print(json.dumps({"limit": limit, "tables": tables}, separators=(",", ":")))
|
||||
PY
|
||||
PYTHON;
|
||||
|
||||
return $this->runServerSshCommand($server, str_replace(
|
||||
['__LIMIT__', '__PORT__'],
|
||||
[(string) $limit, (string) $cluster->corrosion_api_port],
|
||||
$script,
|
||||
), 'SSH corrosion table command failed.');
|
||||
}
|
||||
|
||||
public function firewallRules(Request $request, V5Cluster $cluster, V5Server $server, FluxClient $fluxClient): JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$this->authorize('viewDiagnostics', [$server, $currentTeam, $cluster]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'namespace' => ['sometimes', 'string', 'max:63'],
|
||||
]);
|
||||
|
||||
$hostId = $server->fluxHostId();
|
||||
|
||||
if (! is_string($hostId) || $hostId === '') {
|
||||
return response()->json([
|
||||
'message' => 'This server is missing its Flux host id.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$rules = $fluxClient->listFirewallRules($hostId, (string) ($validated['namespace'] ?? ''));
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('V5 firewall rules request failed', [
|
||||
'server_id' => $server->id,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
if ($server->privateKey instanceof PrivateKey) {
|
||||
try {
|
||||
return response()->json([
|
||||
'rules' => $this->firewallRulesOverSsh($server, (string) ($validated['namespace'] ?? '')),
|
||||
'source' => 'ssh',
|
||||
'fetchedAt' => now()->toJSON(),
|
||||
]);
|
||||
} catch (\Throwable $sshException) {
|
||||
Log::warning('V5 firewall rules SSH fallback failed', [
|
||||
'server_id' => $server->id,
|
||||
'message' => $sshException->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Could not fetch firewall rules through Flux. Check the Flux and coold status, then try again.',
|
||||
], 502);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'rules' => $rules,
|
||||
'source' => 'flux',
|
||||
'fetchedAt' => now()->toJSON(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{id: string, namespace: string, src: string, dst: string, proto: string, port: int}>
|
||||
*/
|
||||
private function firewallRulesOverSsh(V5Server $server, string $namespace): array
|
||||
{
|
||||
$script = str_replace('__NAMESPACE__', json_encode($namespace, JSON_THROW_ON_ERROR), <<<'PYTHON'
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
namespace = __NAMESPACE__
|
||||
path = Path("/etc/coolify/firewall-rules.tsv")
|
||||
rules = []
|
||||
|
||||
if path.exists():
|
||||
for line in path.read_text().splitlines():
|
||||
parts = line.split("\t")
|
||||
if len(parts) == 6:
|
||||
rule_id, rule_namespace, src, dst, proto, port = parts
|
||||
elif len(parts) == 5:
|
||||
rule_id = ""
|
||||
rule_namespace, src, dst, proto, port = parts
|
||||
else:
|
||||
continue
|
||||
|
||||
if namespace and rule_namespace != namespace:
|
||||
continue
|
||||
|
||||
try:
|
||||
port = int(port)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
rules.append({
|
||||
"id": rule_id,
|
||||
"namespace": rule_namespace,
|
||||
"src": src,
|
||||
"dst": dst,
|
||||
"proto": proto,
|
||||
"port": port,
|
||||
})
|
||||
|
||||
print(json.dumps(rules, separators=(",", ":")))
|
||||
PY
|
||||
PYTHON);
|
||||
|
||||
$output = $this->runServerSshCommand($server, $script, 'SSH firewall rules command failed.');
|
||||
$rules = json_decode($output, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
return is_array($rules) ? $rules : [];
|
||||
}
|
||||
|
||||
public function bootstrap(Request $request, V5Cluster $cluster, V5Server $server): JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$this->authorize('bootstrap', [$server, $currentTeam, $cluster]);
|
||||
|
||||
// Fail fast: the bootstrap job hard-fails on Flux enrollment (after
|
||||
// the WireGuard mesh is already built) when no Flux URL is configured.
|
||||
if (trim((string) config('coold.flux_url', '')) === '') {
|
||||
return response()->json([
|
||||
'message' => 'COOLIFY_COOLD_FLUX_URL is not configured, so bootstrapped servers cannot be enrolled into Flux. Set it and retry the bootstrap.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($server->last_bootstrapped_at !== null) {
|
||||
return response()->json([
|
||||
'message' => 'This server is already bootstrapped.',
|
||||
], 409);
|
||||
}
|
||||
|
||||
$installedServers = $cluster->servers()
|
||||
->with('privateKey')
|
||||
->whereNotNull('last_bootstrapped_at')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
$server->load('privateKey');
|
||||
$servers = $installedServers->toBase()
|
||||
->push($server)
|
||||
->unique('id')
|
||||
->values();
|
||||
|
||||
if ($servers->contains(fn (V5Server $server) => ! $server->privateKey instanceof PrivateKey)) {
|
||||
return response()->json([
|
||||
'message' => 'The new server and every already-bootstrapped server in this cluster must have a private key before extending the cluster.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$claim = DB::transaction(function () use ($cluster, $server, $installedServers): array {
|
||||
$clusterServers = $cluster->servers()->lockForUpdate()->get();
|
||||
$activeServer = $clusterServers->first(fn (V5Server $candidate): bool => $this->hasActiveBootstrapClaim($candidate));
|
||||
|
||||
if ($activeServer instanceof V5Server) {
|
||||
return ['claimed' => false, 'active_server_id' => $activeServer->id];
|
||||
}
|
||||
|
||||
// Sweep provably dead claims (lost job or killed worker) so retries
|
||||
// are possible and the UI reflects reality.
|
||||
$clusterServers
|
||||
->filter(fn (V5Server $candidate): bool => in_array($candidate->last_bootstrap_status, ['queued', 'running'], true))
|
||||
->each(fn (V5Server $candidate) => $candidate->update([
|
||||
'last_bootstrap_status' => 'failed',
|
||||
'last_bootstrap_output' => 'The previous bootstrap attempt timed out or its worker died. Retry the bootstrap.',
|
||||
]));
|
||||
|
||||
$server->update([
|
||||
'last_bootstrap_action' => $installedServers->isEmpty() ? 'bootstrap' : 'extend',
|
||||
'last_bootstrap_status' => 'queued',
|
||||
'last_bootstrap_output' => "Queued Coolify bootstrap for {$server->name}.",
|
||||
'last_bootstrap_ran_at' => now(),
|
||||
]);
|
||||
|
||||
return ['claimed' => true, 'active_server_id' => null];
|
||||
});
|
||||
|
||||
if (! $claim['claimed']) {
|
||||
return response()->json([
|
||||
'cluster' => app(ClusterSerializer::class)->serializeFresh($cluster),
|
||||
'message' => $claim['active_server_id'] === $server->id
|
||||
? 'Bootstrap is already queued or running for this server.'
|
||||
: 'Another server bootstrap is already queued or running for this cluster.',
|
||||
], 409);
|
||||
}
|
||||
|
||||
V5ClusterUpdated::dispatch($currentTeam->id, $cluster->id);
|
||||
V5BootstrapServerJob::dispatch($cluster->id, $server->id);
|
||||
|
||||
return response()->json([
|
||||
'cluster' => app(ClusterSerializer::class)->serializeFresh($cluster),
|
||||
'message' => 'Bootstrap queued.',
|
||||
], 202);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, V5Cluster $cluster, V5Server $server): Response|JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$this->authorize('delete', [$server, $currentTeam, $cluster]);
|
||||
|
||||
if (V5Application::query()->where('server_id', $server->id)->exists()) {
|
||||
return response()->json([
|
||||
'message' => 'Delete or move applications from this server before deleting it.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$warning = null;
|
||||
|
||||
if ($server->last_bootstrapped_at !== null) {
|
||||
if ($server->isIngress() && $server->status === ServerStatus::Installed->value) {
|
||||
try {
|
||||
StopCaddyIngress::run($server);
|
||||
} catch (\Throwable $exception) {
|
||||
report($exception);
|
||||
$warning = 'Could not stop the Caddy ingress on the server before deleting it.';
|
||||
}
|
||||
}
|
||||
|
||||
if (! RemoveBootstrapMarker::run($server)) {
|
||||
$warning = 'Could not clean up the server over SSH. Remove /etc/coolify/v5-node.json manually before re-adding this server.';
|
||||
}
|
||||
}
|
||||
|
||||
$server->delete();
|
||||
|
||||
return response()->json(array_filter([
|
||||
'cluster' => app(ClusterSerializer::class)->serializeFresh($cluster),
|
||||
'warning' => $warning,
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* A queued claim is active while the job could still pick it up; a running
|
||||
* claim is active until the job timeout (plus margin) has passed. Anything
|
||||
* older is provably dead because the job runs with $tries = 1.
|
||||
*/
|
||||
private function hasActiveBootstrapClaim(V5Server $server): bool
|
||||
{
|
||||
$ranAt = $server->last_bootstrap_ran_at;
|
||||
|
||||
return match ($server->last_bootstrap_status) {
|
||||
'queued' => $ranAt !== null && $ranAt->gt(now()->subMinutes(15)),
|
||||
'running' => $ranAt !== null && $ranAt->gt(now()->subSeconds(V5BootstrapServerJob::TIMEOUT_SECONDS + 300)),
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{listen_port: int|null, endpoint: string|null}
|
||||
*/
|
||||
private function devLimaWireguardOverrides(string $host, int $sshPort): array
|
||||
{
|
||||
if (! app()->environment(['local', 'development', 'testing']) || $host !== 'host.docker.internal') {
|
||||
return ['listen_port' => null, 'endpoint' => null];
|
||||
}
|
||||
|
||||
if ($sshPort < 60001 || $sshPort > 60009) {
|
||||
return ['listen_port' => null, 'endpoint' => null];
|
||||
}
|
||||
|
||||
$wireguardPort = $sshPort - 8180;
|
||||
|
||||
return [
|
||||
'listen_port' => $wireguardPort,
|
||||
'endpoint' => "host.lima.internal:{$wireguardPort}",
|
||||
];
|
||||
}
|
||||
|
||||
private function clusterServerCapacity(V5Cluster $cluster): ?int
|
||||
{
|
||||
$namespaceCount = max(1, count($cluster->namespaces ?? V5Cluster::DEFAULT_NAMESPACES));
|
||||
|
||||
[, $poolPrefix] = array_pad(explode('/', (string) $cluster->container_network_pool, 2), 2, null);
|
||||
$containerPrefix = (int) $cluster->container_network_prefix;
|
||||
|
||||
if (! is_string($poolPrefix) || ! ctype_digit($poolPrefix) || $containerPrefix < (int) $poolPrefix || $containerPrefix > 32) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$containerCapacity = intdiv(2 ** ($containerPrefix - (int) $poolPrefix), $namespaceCount);
|
||||
|
||||
[, $managementPrefix] = array_pad(explode('/', (string) $cluster->wireguard_management_pool, 2), 2, null);
|
||||
$managementCapacity = is_string($managementPrefix) && ctype_digit($managementPrefix) && (int) $managementPrefix <= 30
|
||||
? (2 ** (32 - (int) $managementPrefix)) - 2
|
||||
: null;
|
||||
|
||||
return $managementCapacity === null ? $containerCapacity : min($containerCapacity, $managementCapacity);
|
||||
}
|
||||
|
||||
private function noControlCharactersRule(): \Closure
|
||||
{
|
||||
return function (string $attribute, mixed $value, \Closure $fail): void {
|
||||
if (! is_string($value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (preg_match('/[\x00-\x1F\x7F]/', $value) === 1) {
|
||||
$fail('The :attribute contains invalid control characters.');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private function hostPortRule(): \Closure
|
||||
{
|
||||
return function (string $attribute, mixed $value, \Closure $fail): void {
|
||||
if ($value === null || $value === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! is_string($value)) {
|
||||
$fail('The :attribute must be in host:port format.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
if (preg_match('/^\[(?<host>.+)]:(?<port>\d+)$/', $value, $matches) === 1) {
|
||||
$host = trim((string) $matches['host']);
|
||||
$port = trim((string) $matches['port']);
|
||||
} else {
|
||||
$separatorPosition = strrpos($value, ':');
|
||||
|
||||
if ($separatorPosition === false) {
|
||||
$fail('The :attribute must be in host:port format.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$host = trim(substr($value, 0, $separatorPosition));
|
||||
$port = trim(substr($value, $separatorPosition + 1));
|
||||
|
||||
if (str_contains($host, ':')) {
|
||||
$fail('The :attribute must use [ipv6]:port format for IPv6 addresses.');
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if ($host === '' || $port === '' || ! ctype_digit($port) || (int) $port < 1 || (int) $port > 65535) {
|
||||
$fail('The :attribute must be in host:port format.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$failed = false;
|
||||
(new ValidServerIp)->validate($attribute, $host, function () use (&$failed): void {
|
||||
$failed = true;
|
||||
});
|
||||
|
||||
if ($failed) {
|
||||
$fail('The :attribute must be in host:port format.');
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,8 @@ use App\Http\Middleware\RedirectIfAuthenticated;
|
||||
use App\Http\Middleware\TrimStrings;
|
||||
use App\Http\Middleware\TrustHosts;
|
||||
use App\Http\Middleware\TrustProxies;
|
||||
use App\Http\Middleware\V5\EnsureCurrentTeam as V5EnsureCurrentTeam;
|
||||
use App\Http\Middleware\V5\HandleInertiaRequests as V5HandleInertiaRequests;
|
||||
use App\Http\Middleware\ValidateSignature;
|
||||
use App\Http\Middleware\VerifyCsrfToken;
|
||||
use Illuminate\Auth\Middleware\AuthenticateWithBasicAuth;
|
||||
@@ -77,6 +79,23 @@ class Kernel extends HttpKernel
|
||||
|
||||
],
|
||||
|
||||
'v5.web' => [
|
||||
EncryptCookies::class,
|
||||
AddQueuedCookiesToResponse::class,
|
||||
StartSession::class,
|
||||
ShareErrorsFromSession::class,
|
||||
VerifyCsrfToken::class,
|
||||
SubstituteBindings::class,
|
||||
V5HandleInertiaRequests::class,
|
||||
],
|
||||
|
||||
'v5.authenticated' => [
|
||||
'auth',
|
||||
'verified',
|
||||
'throttle:v5',
|
||||
V5EnsureCurrentTeam::class,
|
||||
],
|
||||
|
||||
'api' => [
|
||||
// \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
|
||||
ThrottleRequests::class.':api',
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware\V5;
|
||||
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureCurrentTeam
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
/** @var User|null $user */
|
||||
$user = $request->user();
|
||||
|
||||
if (! $user) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$currentTeam = $this->resolveCurrentTeam($user);
|
||||
|
||||
if (! $currentTeam) {
|
||||
abort(403, 'No team available for this user.');
|
||||
}
|
||||
|
||||
// The v4 UI stores a full Team model under the same session key and
|
||||
// reads arbitrary columns off it, so only rewrite the session when the
|
||||
// resolved team actually changed — and always store the full model.
|
||||
if (data_get(session('currentTeam'), 'id') !== $currentTeam->id) {
|
||||
session(['currentTeam' => $currentTeam]);
|
||||
}
|
||||
|
||||
$request->attributes->set('v5.currentTeam', $currentTeam);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
private function resolveCurrentTeam(User $user): ?Team
|
||||
{
|
||||
$sessionTeamId = data_get(session('currentTeam'), 'id');
|
||||
|
||||
if ($sessionTeamId) {
|
||||
$sessionTeam = $user->teams()
|
||||
->whereKey($sessionTeamId)
|
||||
->first();
|
||||
|
||||
if ($sessionTeam) {
|
||||
return $sessionTeam;
|
||||
}
|
||||
}
|
||||
|
||||
return $user->teams()
|
||||
->orderBy('teams.id')
|
||||
->first();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware\V5;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Middleware;
|
||||
|
||||
class HandleInertiaRequests extends Middleware
|
||||
{
|
||||
protected $rootView = 'v5.app';
|
||||
|
||||
public function share(Request $request): array
|
||||
{
|
||||
return [
|
||||
...parent::share($request),
|
||||
'auth' => [
|
||||
'user' => $request->user() ? [
|
||||
'id' => $request->user()->id,
|
||||
'name' => $request->user()->name,
|
||||
'email' => $request->user()->email,
|
||||
] : null,
|
||||
],
|
||||
'currentTeam' => $request->attributes->get('v5.currentTeam') ? [
|
||||
'id' => $request->attributes->get('v5.currentTeam')->id,
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,839 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Actions\V5\Proxy\StartCaddyIngress;
|
||||
use App\Enums\V5\ServerStatus;
|
||||
use App\Events\V5ClusterUpdated;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\V5\Cluster as V5Cluster;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Services\Flux\AgentTokenIssuer;
|
||||
use App\Services\Flux\FluxClient;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
|
||||
class V5BootstrapServerJob implements ShouldBeEncrypted, ShouldBeUnique, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
private const BOOTSTRAP_MARKER_PATH = '/etc/coolify/v5-node.json';
|
||||
|
||||
public const TIMEOUT_SECONDS = 7200;
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
public int $timeout = self::TIMEOUT_SECONDS;
|
||||
|
||||
/**
|
||||
* Second idempotency layer on top of the controller's DB bootstrap claim,
|
||||
* aligned with its running-claim window (TIMEOUT_SECONDS plus margin).
|
||||
*/
|
||||
public int $uniqueFor = self::TIMEOUT_SECONDS + 300;
|
||||
|
||||
public function __construct(public int $clusterId, public int $serverId) {}
|
||||
|
||||
public function uniqueId(): string
|
||||
{
|
||||
return (string) $this->serverId;
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$cluster = V5Cluster::query()->findOrFail($this->clusterId);
|
||||
$server = V5Server::query()->with('privateKey')->findOrFail($this->serverId);
|
||||
|
||||
if ($server->cluster_id !== $cluster->id || $server->last_bootstrapped_at !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$installedServers = $cluster->servers()
|
||||
->with('privateKey')
|
||||
->whereNotNull('last_bootstrapped_at')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
$action = $installedServers->isEmpty() ? 'bootstrap' : 'extend';
|
||||
$servers = $installedServers->toBase()
|
||||
->push($server)
|
||||
->unique('id')
|
||||
->values();
|
||||
|
||||
$started = V5Server::query()
|
||||
->whereKey($server->id)
|
||||
->where('last_bootstrap_status', 'queued')
|
||||
->update([
|
||||
'last_bootstrap_action' => $action,
|
||||
'last_bootstrap_status' => 'running',
|
||||
'last_bootstrap_output' => "Starting Coolify CLI {$action} for {$server->name}...",
|
||||
'last_bootstrap_ran_at' => now(),
|
||||
]);
|
||||
|
||||
if ($started === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$server->refresh();
|
||||
|
||||
if ($servers->contains(fn (V5Server $server) => ! $server->privateKey instanceof PrivateKey)) {
|
||||
$this->markFailed($server, $action, 'The new server and every already-bootstrapped server in this cluster must have a private key before extending the cluster.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->broadcastClusterUpdated($server);
|
||||
|
||||
$keyDirectory = storage_path('app/ssh/keys');
|
||||
if (! is_dir($keyDirectory)) {
|
||||
mkdir($keyDirectory, 0700, true);
|
||||
}
|
||||
|
||||
$tempDirectory = $keyDirectory.'/v5_bootstrap_'.str()->random(16);
|
||||
if (! mkdir($tempDirectory, 0700, true) && ! is_dir($tempDirectory)) {
|
||||
$this->markFailed($server, $action, 'Could not create a temporary SSH configuration directory.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$sshConfigLocation = $this->writeBootstrapSshConfig($servers, $tempDirectory);
|
||||
$existingBootstrap = $this->detectExistingBootstrap($server, $sshConfigLocation);
|
||||
|
||||
if (($existingBootstrap['cluster_id'] ?? null) !== null) {
|
||||
$markerClusterUuid = $existingBootstrap['cluster_uuid'] ?? null;
|
||||
|
||||
if (
|
||||
(string) $existingBootstrap['cluster_id'] !== (string) $cluster->id
|
||||
|| (is_string($markerClusterUuid) && $markerClusterUuid !== $cluster->uuid)
|
||||
) {
|
||||
$this->markFailed($server, $action, 'This server is already bootstrapped for another cluster. Reset the host bootstrap state before joining this cluster.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->adoptExistingBootstrap($cluster, $server, $existingBootstrap, $sshConfigLocation);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$result = Process::timeout(7200)
|
||||
->run($this->bootstrapCommand($cluster, $servers, $server, $sshConfigLocation, $action));
|
||||
$output = trim($result->output()."\n".$result->errorOutput());
|
||||
$successful = $result->successful();
|
||||
|
||||
$server->update([
|
||||
'last_bootstrap_action' => $action,
|
||||
'last_bootstrap_status' => $successful ? 'succeeded' : 'failed',
|
||||
'last_bootstrap_output' => str($output !== '' ? $output : 'No output returned.')->limit(20000)->toString(),
|
||||
'last_bootstrap_ran_at' => now(),
|
||||
]);
|
||||
$this->broadcastClusterUpdated($server);
|
||||
|
||||
if (! $successful) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->persistBootstrapAssignments($cluster, $server, $result->output(), $sshConfigLocation);
|
||||
$server->refresh();
|
||||
|
||||
// Resolve the coold version once so the on-host marker and the
|
||||
// database row always agree.
|
||||
$cooldVersion = $this->bootstrappedCooldVersion($cluster, $result->output());
|
||||
|
||||
$this->writeBootstrapMarker($cluster, $server, $sshConfigLocation, $cooldVersion);
|
||||
|
||||
$this->enrollCooldIntoFlux($server, $sshConfigLocation);
|
||||
$this->waitForFluxHostConnection($server);
|
||||
|
||||
$server->update([
|
||||
'status' => ServerStatus::Installed->value,
|
||||
'has_coold' => true,
|
||||
'coold_version' => $cooldVersion,
|
||||
'last_bootstrapped_at' => now(),
|
||||
]);
|
||||
$this->broadcastClusterUpdated($server);
|
||||
|
||||
if ($server->isIngress()) {
|
||||
StartCaddyIngress::run($server->fresh('privateKey'));
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->markFailed($server, $action, $e->getMessage());
|
||||
} finally {
|
||||
$this->deleteDirectory($tempDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
public function failed(?\Throwable $exception): void
|
||||
{
|
||||
$server = V5Server::query()->find($this->serverId);
|
||||
|
||||
if (! $server instanceof V5Server) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->markFailed($server, $server->last_bootstrap_action ?? 'bootstrap', $exception?->getMessage() ?? 'Bootstrap job failed.');
|
||||
|
||||
Log::warning('V5 server bootstrap job failed', [
|
||||
'server_id' => $this->serverId,
|
||||
'cluster_id' => $this->clusterId,
|
||||
'exception' => $exception?->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function markFailed(V5Server $server, string $action, string $output): void
|
||||
{
|
||||
$server->update([
|
||||
'last_bootstrap_action' => $action,
|
||||
'last_bootstrap_status' => 'failed',
|
||||
'last_bootstrap_output' => str($output)->limit(20000)->toString(),
|
||||
'last_bootstrap_ran_at' => now(),
|
||||
]);
|
||||
$this->broadcastClusterUpdated($server);
|
||||
}
|
||||
|
||||
private function broadcastClusterUpdated(V5Server $server): void
|
||||
{
|
||||
V5ClusterUpdated::dispatch($server->team_id, $server->cluster_id);
|
||||
}
|
||||
|
||||
private function bootstrapCommand(V5Cluster $cluster, Collection $servers, V5Server $newServer, string $sshConfigLocation, string $action): array
|
||||
{
|
||||
$command = [
|
||||
$this->coolifyCliBin(),
|
||||
'init',
|
||||
$action,
|
||||
'--format',
|
||||
'json',
|
||||
'--nodes',
|
||||
$servers->map(fn (V5Server $server) => $this->bootstrapNode($server))->implode(','),
|
||||
'--ssh-config',
|
||||
$sshConfigLocation,
|
||||
'--ssh-user',
|
||||
$newServer->ssh_user,
|
||||
'--namespaces',
|
||||
implode(',', $cluster->namespaces ?? V5Cluster::DEFAULT_NAMESPACES),
|
||||
'--container-pool',
|
||||
$cluster->container_network_pool,
|
||||
'--container-prefix',
|
||||
(string) $cluster->container_network_prefix,
|
||||
'--wg-mgmt-pool',
|
||||
$cluster->wireguard_management_pool,
|
||||
'--wg-interface',
|
||||
$cluster->wireguard_interface,
|
||||
'--wg-listen-port',
|
||||
(string) $cluster->wireguard_listen_port,
|
||||
'--coold-version',
|
||||
$cluster->coold_version,
|
||||
'--corrosion-version',
|
||||
$cluster->corrosion_version,
|
||||
'--corrosion-gossip-port',
|
||||
(string) $cluster->corrosion_gossip_port,
|
||||
'--corrosion-api-port',
|
||||
(string) $cluster->corrosion_api_port,
|
||||
];
|
||||
|
||||
if ($action === 'extend') {
|
||||
array_push($command, '--new-nodes', $this->bootstrapNode($newServer));
|
||||
}
|
||||
|
||||
$listenOverrides = $this->wireguardListenPortOverrides($servers);
|
||||
if ($listenOverrides !== '') {
|
||||
array_push($command, '--wg-listen-port-overrides', $listenOverrides);
|
||||
}
|
||||
|
||||
$endpointOverrides = $this->wireguardEndpointOverrides($servers);
|
||||
if ($endpointOverrides !== '') {
|
||||
array_push($command, '--wg-endpoint-overrides', $endpointOverrides);
|
||||
}
|
||||
|
||||
if (! $cluster->default_deny_containers) {
|
||||
$command[] = '--skip-default-deny';
|
||||
}
|
||||
|
||||
$command[] = '--yes';
|
||||
|
||||
return $command;
|
||||
}
|
||||
|
||||
private function coolifyCliBin(): string
|
||||
{
|
||||
$configuredBinary = (string) config('coold.coolify_cli_bin', '/usr/local/bin/coolify');
|
||||
$devBinary = base_path('.dev/bin/coolify');
|
||||
|
||||
if ($configuredBinary === '/usr/local/bin/coolify' && $this->isRunnableDevelopmentCliBinary($devBinary)) {
|
||||
return $devBinary;
|
||||
}
|
||||
|
||||
return $configuredBinary;
|
||||
}
|
||||
|
||||
private function isRunnableDevelopmentCliBinary(string $binary): bool
|
||||
{
|
||||
if (! is_file($binary)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$header = file_get_contents($binary, false, null, 0, 4);
|
||||
|
||||
if ($header === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (str_starts_with($header, '#!')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($header === "\x7FELF") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function bootstrapNode(V5Server $server): string
|
||||
{
|
||||
return 'v5-server-'.($server->uuid ?: $server->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function detectExistingBootstrap(V5Server $server, string $sshConfigLocation): array
|
||||
{
|
||||
$result = Process::timeout(15)->run([
|
||||
'ssh',
|
||||
'-F',
|
||||
$sshConfigLocation,
|
||||
$this->bootstrapNode($server),
|
||||
'if [ -f '.escapeshellarg(self::BOOTSTRAP_MARKER_PATH).' ]; then cat '.escapeshellarg(self::BOOTSTRAP_MARKER_PATH).'; fi',
|
||||
]);
|
||||
|
||||
if (! $result->successful()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$output = trim($result->output());
|
||||
|
||||
if ($output === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$decoded = json_decode($output, true, flags: JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $marker
|
||||
*/
|
||||
private function adoptExistingBootstrap(V5Cluster $cluster, V5Server $server, array $marker, string $sshConfigLocation): void
|
||||
{
|
||||
$bootstrapNode = $this->bootstrapNode($server);
|
||||
$serverUuid = is_string($marker['server_uuid'] ?? null) ? $marker['server_uuid'] : null;
|
||||
$updates = [
|
||||
'wireguard_management_ip' => is_string($marker['wireguard_management_ip'] ?? null) ? $marker['wireguard_management_ip'] : $server->wireguard_management_ip,
|
||||
'wireguard_public_key' => is_string($marker['wireguard_public_key'] ?? null) ? $marker['wireguard_public_key'] : $server->wireguard_public_key,
|
||||
'coold_version' => is_string($marker['coold_version'] ?? null) && trim($marker['coold_version']) !== '' ? trim($marker['coold_version']) : $cluster->coold_version,
|
||||
'container_subnets' => is_array($marker['container_subnets'] ?? null) ? $marker['container_subnets'] : $server->container_subnets,
|
||||
'has_coold' => true,
|
||||
'status' => ServerStatus::Installed->value,
|
||||
'last_bootstrap_status' => 'succeeded',
|
||||
'last_bootstrap_output' => 'Adopted existing Coolify bootstrap state for this cluster.',
|
||||
'last_bootstrap_ran_at' => now(),
|
||||
'last_bootstrapped_at' => now(),
|
||||
];
|
||||
|
||||
if ($serverUuid !== null && ! V5Server::query()->where('uuid', $serverUuid)->whereKeyNot($server->id)->exists()) {
|
||||
$updates['uuid'] = $serverUuid;
|
||||
}
|
||||
|
||||
$server->update($updates);
|
||||
$this->broadcastClusterUpdated($server);
|
||||
|
||||
$this->enrollCooldIntoFlux($server->fresh(), $sshConfigLocation, $bootstrapNode);
|
||||
$this->waitForFluxHostConnection($server->fresh());
|
||||
|
||||
if ($server->isIngress()) {
|
||||
StartCaddyIngress::run($server->fresh('privateKey'));
|
||||
}
|
||||
}
|
||||
|
||||
private function persistBootstrapAssignments(V5Cluster $cluster, V5Server $server, string $output, string $sshConfigLocation): void
|
||||
{
|
||||
$verifiedNode = $this->verifiedBootstrapNode($output, $server);
|
||||
$wireguardManagementIp = is_array($verifiedNode) && is_string($verifiedNode['wireguard_ip'] ?? null)
|
||||
? $verifiedNode['wireguard_ip']
|
||||
: null;
|
||||
$warnings = [];
|
||||
|
||||
if (! is_string($wireguardManagementIp) || $wireguardManagementIp === '') {
|
||||
$wireguardManagementIp = $this->readWireguardManagementIp($cluster, $server, $sshConfigLocation, $warnings);
|
||||
}
|
||||
|
||||
$wireguardPublicKey = $this->readWireguardPublicKey($cluster, $server, $sshConfigLocation, $warnings);
|
||||
$containerSubnets = $this->readContainerSubnets($cluster, $server, $sshConfigLocation, $warnings);
|
||||
$updates = [];
|
||||
|
||||
if ($wireguardManagementIp !== null && $wireguardManagementIp !== '') {
|
||||
$updates['wireguard_management_ip'] = $wireguardManagementIp;
|
||||
|
||||
if (! is_string($server->node_address) || $server->node_address === '' || $server->node_address === $server->host) {
|
||||
$updates['node_address'] = $wireguardManagementIp;
|
||||
}
|
||||
} else {
|
||||
$warnings[] = 'Warning: could not determine the WireGuard management IP from the CLI output.';
|
||||
}
|
||||
|
||||
if ($wireguardPublicKey !== null && $wireguardPublicKey !== '') {
|
||||
$updates['wireguard_public_key'] = $wireguardPublicKey;
|
||||
}
|
||||
|
||||
if ($containerSubnets !== []) {
|
||||
$updates['container_subnets'] = $containerSubnets;
|
||||
}
|
||||
|
||||
if ($warnings !== []) {
|
||||
$updates['last_bootstrap_output'] = str(trim($server->last_bootstrap_output."\n".implode("\n", $warnings)))
|
||||
->limit(20000)
|
||||
->toString();
|
||||
}
|
||||
|
||||
if ($updates !== []) {
|
||||
$server->update($updates);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $warnings
|
||||
*/
|
||||
private function readWireguardManagementIp(V5Cluster $cluster, V5Server $server, string $sshConfigLocation, array &$warnings): ?string
|
||||
{
|
||||
$interface = escapeshellarg($cluster->wireguard_interface);
|
||||
$script = implode("\n", [
|
||||
"SUDO=''",
|
||||
'if [ "$(id -u)" != "0" ]; then SUDO=\'sudo\'; fi',
|
||||
"\$SUDO ip -4 -o addr show dev {$interface} | awk '{print \$4}' | cut -d/ -f1 | head -n1",
|
||||
]);
|
||||
|
||||
$result = Process::timeout(15)->run([
|
||||
'ssh',
|
||||
'-F',
|
||||
$sshConfigLocation,
|
||||
$this->bootstrapNode($server),
|
||||
$script,
|
||||
]);
|
||||
|
||||
$ipAddress = trim($result->output());
|
||||
|
||||
if (! $result->successful() || filter_var($ipAddress, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) === false) {
|
||||
$warnings[] = 'Warning: could not read the WireGuard management IP from the server.';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $ipAddress;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function verifiedBootstrapNode(string $output, V5Server $server): ?array
|
||||
{
|
||||
$decoded = $this->decodedBootstrapOutput($output);
|
||||
|
||||
if (! is_array($decoded)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$verifiedNodes = data_get($decoded, 'verified');
|
||||
|
||||
if (! is_array($verifiedNodes)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$bootstrapNode = $this->bootstrapNode($server);
|
||||
|
||||
foreach ($verifiedNodes as $verifiedNode) {
|
||||
if (! is_array($verifiedNode)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$host = $verifiedNode['host'] ?? $verifiedNode['node'] ?? $verifiedNode['name'] ?? null;
|
||||
|
||||
if ($host === $bootstrapNode || $host === $server->uuid || $host === $server->name || $host === $server->host) {
|
||||
return $verifiedNode;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function decodedBootstrapOutput(string $output): ?array
|
||||
{
|
||||
$output = trim($output);
|
||||
|
||||
if ($output === '' || ! str_starts_with($output, '{')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$decoded = json_decode($output, true, flags: JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The CLI init JSON output does not currently report the installed coold
|
||||
* version, so fall back to the version the cluster asked the CLI to
|
||||
* install (`--coold-version`). If a future CLI adds a `coold_version` key
|
||||
* to its JSON output, prefer that.
|
||||
*/
|
||||
private function bootstrappedCooldVersion(V5Cluster $cluster, string $output): ?string
|
||||
{
|
||||
$reported = data_get($this->decodedBootstrapOutput($output), 'coold_version');
|
||||
|
||||
if (is_string($reported) && trim($reported) !== '') {
|
||||
return trim($reported);
|
||||
}
|
||||
|
||||
return $cluster->coold_version;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $warnings
|
||||
*/
|
||||
private function readWireguardPublicKey(V5Cluster $cluster, V5Server $server, string $sshConfigLocation, array &$warnings): ?string
|
||||
{
|
||||
$interface = escapeshellarg($cluster->wireguard_interface);
|
||||
$script = implode("\n", [
|
||||
"SUDO=''",
|
||||
'if [ "$(id -u)" != "0" ]; then SUDO=\'sudo\'; fi',
|
||||
"\$SUDO wg show {$interface} public-key",
|
||||
]);
|
||||
|
||||
$result = Process::timeout(15)->run([
|
||||
'ssh',
|
||||
'-F',
|
||||
$sshConfigLocation,
|
||||
$this->bootstrapNode($server),
|
||||
$script,
|
||||
]);
|
||||
|
||||
$publicKey = trim($result->output());
|
||||
|
||||
if (! $result->successful() || $publicKey === '') {
|
||||
$warnings[] = 'Warning: could not read the WireGuard public key from the server.';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $publicKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* The container subnets are allocated by the coolify CLI on the host; the podman
|
||||
* networks it creates are the source of truth, so read them back instead of
|
||||
* re-deriving the allocation locally.
|
||||
*
|
||||
* @param array<int, string> $warnings
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function readContainerSubnets(V5Cluster $cluster, V5Server $server, string $sshConfigLocation, array &$warnings): array
|
||||
{
|
||||
$namespaces = $cluster->namespaces ?? V5Cluster::DEFAULT_NAMESPACES;
|
||||
|
||||
if ($namespaces === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$namespaceArguments = collect($namespaces)
|
||||
->map(fn (string $namespace): string => escapeshellarg($namespace))
|
||||
->implode(' ');
|
||||
$script = implode("\n", [
|
||||
"SUDO=''",
|
||||
'if [ "$(id -u)" != "0" ]; then SUDO=\'sudo\'; fi',
|
||||
"for ns in {$namespaceArguments}; do",
|
||||
' printf \'%s=\' "$ns"',
|
||||
' $SUDO podman network inspect "coolify-${ns}-mesh" --format \'{{range .Subnets}}{{.Subnet}}{{end}}\' 2>/dev/null || true',
|
||||
' printf \'\n\'',
|
||||
'done',
|
||||
]);
|
||||
|
||||
$result = Process::timeout(30)->run([
|
||||
'ssh',
|
||||
'-F',
|
||||
$sshConfigLocation,
|
||||
$this->bootstrapNode($server),
|
||||
$script,
|
||||
]);
|
||||
|
||||
if (! $result->successful()) {
|
||||
$warnings[] = 'Warning: could not read the container subnets from the server.';
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
$subnets = [];
|
||||
|
||||
foreach (preg_split('/\r?\n/', trim($result->output())) ?: [] as $line) {
|
||||
[$namespace, $subnet] = array_pad(explode('=', trim($line), 2), 2, null);
|
||||
|
||||
if (! is_string($namespace) || ! in_array($namespace, $namespaces, true) || ! $this->isIpv4Cidr($subnet)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$subnets[$namespace] = $subnet;
|
||||
}
|
||||
|
||||
if (count($subnets) !== count($namespaces)) {
|
||||
$warnings[] = 'Warning: could not read every container subnet from the server; the stored subnets may be incomplete.';
|
||||
}
|
||||
|
||||
return $subnets;
|
||||
}
|
||||
|
||||
private function isIpv4Cidr(?string $value): bool
|
||||
{
|
||||
if (! is_string($value) || ! str_contains($value, '/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
[$ip, $prefix] = explode('/', $value, 2);
|
||||
|
||||
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false
|
||||
&& ctype_digit($prefix)
|
||||
&& (int) $prefix <= 32;
|
||||
}
|
||||
|
||||
private function enrollCooldIntoFlux(V5Server $server, string $sshConfigLocation, ?string $bootstrapNode = null): void
|
||||
{
|
||||
$fluxUrl = trim((string) config('coold.flux_url', ''));
|
||||
|
||||
if ($fluxUrl === '') {
|
||||
throw new \RuntimeException('COOLIFY_COOLD_FLUX_URL is not configured, so the server cannot be enrolled into Flux. Set it and retry the bootstrap.');
|
||||
}
|
||||
|
||||
$jwtPath = trim((string) config('coold.flux_host_jwt_path', '/etc/coolify/host-jwt'));
|
||||
|
||||
if ($jwtPath === '') {
|
||||
$jwtPath = '/etc/coolify/host-jwt';
|
||||
}
|
||||
|
||||
$fluxUrl = str_replace(["\r", "\n"], '', $fluxUrl);
|
||||
$jwtPath = str_replace(["\r", "\n"], '', $jwtPath);
|
||||
$hostId = $server->fluxHostId();
|
||||
$token = app(AgentTokenIssuer::class)->issueForServer($server);
|
||||
$tokenArgument = $this->shellArg($token);
|
||||
$hostId = str_replace(["\r", "\n"], '', $hostId);
|
||||
$jwtPathArgument = $this->shellPathArg($jwtPath);
|
||||
$dropInDirectory = '/etc/systemd/system/coold.service.d';
|
||||
$dropInPath = "{$dropInDirectory}/10-flux.conf";
|
||||
$script = <<<SH
|
||||
set -e
|
||||
SUDO=''
|
||||
if [ "\$(id -u)" != "0" ]; then SUDO='sudo'; fi
|
||||
\$SUDO mkdir -p /etc/coolify {$dropInDirectory}
|
||||
printf %s {$tokenArgument} | \$SUDO tee {$jwtPathArgument} >/dev/null
|
||||
\$SUDO chmod 600 {$jwtPathArgument}
|
||||
cat <<'COOLIFY_FLUX_ENV' | \$SUDO tee {$dropInPath} >/dev/null
|
||||
[Service]
|
||||
Environment=COOLIFY_COOLD_FLUX_URL={$fluxUrl}
|
||||
Environment=COOLIFY_COOLD_HOST_ID={$hostId}
|
||||
Environment=COOLIFY_COOLD_HOST_JWT_PATH={$jwtPath}
|
||||
COOLIFY_FLUX_ENV
|
||||
\$SUDO systemctl daemon-reload
|
||||
\$SUDO systemctl restart coold.service
|
||||
SH;
|
||||
|
||||
$result = Process::timeout(60)->run([
|
||||
'ssh',
|
||||
'-F',
|
||||
$sshConfigLocation,
|
||||
$bootstrapNode ?? $this->bootstrapNode($server),
|
||||
$script,
|
||||
]);
|
||||
|
||||
if (! $result->successful()) {
|
||||
$output = trim($result->output()."\n".$result->errorOutput());
|
||||
|
||||
throw new \RuntimeException(
|
||||
($output !== '' ? $output : 'Could not enroll coold into Flux.')
|
||||
."\nThe WireGuard mesh was created successfully; retrying this bootstrap is safe and will resume from Flux enrollment."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function waitForFluxHostConnection(V5Server $server): void
|
||||
{
|
||||
$timeoutSeconds = (int) config('flux.bootstrap_host_connection_timeout_seconds', 30);
|
||||
|
||||
if ($timeoutSeconds <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$hostId = $server->fluxHostId();
|
||||
|
||||
if (! is_string($hostId) || $hostId === '') {
|
||||
throw new \RuntimeException('Server is missing its Flux host id after bootstrap.');
|
||||
}
|
||||
|
||||
$deadline = time() + $timeoutSeconds;
|
||||
$lastError = null;
|
||||
|
||||
do {
|
||||
try {
|
||||
app(FluxClient::class)->cooldLogs($hostId, 1);
|
||||
|
||||
return;
|
||||
} catch (\Throwable $exception) {
|
||||
$lastError = $exception->getMessage();
|
||||
sleep(1);
|
||||
}
|
||||
} while (time() < $deadline);
|
||||
|
||||
throw new \RuntimeException(
|
||||
'The server was bootstrapped, but coold did not connect to Flux in time. '
|
||||
.'Wait a moment and retry the bootstrap before deploying applications.'
|
||||
.($lastError !== null ? " Last Flux error: {$lastError}" : '')
|
||||
);
|
||||
}
|
||||
|
||||
private function shellArg(string $value): string
|
||||
{
|
||||
return escapeshellarg($value);
|
||||
}
|
||||
|
||||
private function shellPathArg(string $value): string
|
||||
{
|
||||
if (preg_match('/^[A-Za-z0-9_\/:.,@%+=-]+$/', $value) === 1) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return $this->shellArg($value);
|
||||
}
|
||||
|
||||
private function writeBootstrapMarker(V5Cluster $cluster, V5Server $server, string $sshConfigLocation, ?string $cooldVersion = null): void
|
||||
{
|
||||
$payload = base64_encode(json_encode([
|
||||
'cluster_id' => $cluster->id,
|
||||
'cluster_uuid' => $cluster->uuid,
|
||||
'server_uuid' => $server->uuid,
|
||||
'wireguard_management_ip' => $server->wireguard_management_ip,
|
||||
'wireguard_public_key' => $server->wireguard_public_key,
|
||||
'coold_version' => $cooldVersion ?? $server->coold_version ?? $cluster->coold_version,
|
||||
'container_subnets' => $server->container_subnets ?? [],
|
||||
], JSON_THROW_ON_ERROR));
|
||||
|
||||
$result = Process::timeout(15)->run([
|
||||
'ssh',
|
||||
'-F',
|
||||
$sshConfigLocation,
|
||||
$this->bootstrapNode($server),
|
||||
"payload='{$payload}'; if [ \"$(id -u)\" = \"0\" ]; then mkdir -p /etc/coolify && printf %s \"$payload\" | base64 -d > ".escapeshellarg(self::BOOTSTRAP_MARKER_PATH)."; else sudo mkdir -p /etc/coolify && printf %s \"$payload\" | base64 -d | sudo tee ".escapeshellarg(self::BOOTSTRAP_MARKER_PATH).' >/dev/null; fi',
|
||||
]);
|
||||
|
||||
if (! $result->successful()) {
|
||||
$output = trim($result->output()."\n".$result->errorOutput());
|
||||
|
||||
throw new \RuntimeException('Could not write the bootstrap marker to the server: '.($output !== '' ? $output : 'the SSH command failed.'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, V5Server> $servers
|
||||
*/
|
||||
private function writeBootstrapSshConfig(Collection $servers, string $tempDirectory): string
|
||||
{
|
||||
$config = '';
|
||||
|
||||
$servers->each(function (V5Server $server) use (&$config, $tempDirectory): void {
|
||||
$keyLocation = "{$tempDirectory}/server-{$server->id}.key";
|
||||
file_put_contents($keyLocation, $server->privateKey->private_key);
|
||||
chmod($keyLocation, 0600);
|
||||
|
||||
$config .= implode("\n", [
|
||||
'Host '.$this->bootstrapNode($server),
|
||||
' HostName '.$server->host,
|
||||
' Port '.$server->ssh_port,
|
||||
' User '.$server->ssh_user,
|
||||
' IdentityFile '.$keyLocation,
|
||||
' IdentitiesOnly yes',
|
||||
' LogLevel ERROR',
|
||||
' StrictHostKeyChecking no',
|
||||
' UserKnownHostsFile /dev/null',
|
||||
' BatchMode yes',
|
||||
'',
|
||||
]);
|
||||
});
|
||||
|
||||
$sshConfigLocation = "{$tempDirectory}/ssh.config";
|
||||
file_put_contents($sshConfigLocation, $config);
|
||||
chmod($sshConfigLocation, 0600);
|
||||
|
||||
return $sshConfigLocation;
|
||||
}
|
||||
|
||||
private function deleteDirectory(string $directory): void
|
||||
{
|
||||
if (! is_dir($directory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (scandir($directory) ?: [] as $file) {
|
||||
if ($file === '.' || $file === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = "{$directory}/{$file}";
|
||||
|
||||
if (is_dir($path)) {
|
||||
$this->deleteDirectory($path);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@unlink($path);
|
||||
}
|
||||
|
||||
@rmdir($directory);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, V5Server> $servers
|
||||
*/
|
||||
private function wireguardListenPortOverrides(Collection $servers): string
|
||||
{
|
||||
return $servers
|
||||
->filter(fn (V5Server $server) => $server->wireguard_listen_port_override !== null)
|
||||
->map(fn (V5Server $server) => $this->bootstrapNode($server).'='.$server->wireguard_listen_port_override)
|
||||
->implode(',');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, V5Server> $servers
|
||||
*/
|
||||
private function wireguardEndpointOverrides(Collection $servers): string
|
||||
{
|
||||
return $servers
|
||||
->filter(fn (V5Server $server) => $server->wireguard_endpoint_override !== null)
|
||||
->map(fn (V5Server $server) => $this->bootstrapNode($server).'='.$server->wireguard_endpoint_override)
|
||||
->implode(',');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Actions\V5\Application\DeployNginxApplication;
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class V5DeployApplicationJob implements ShouldBeUnique, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
public int $timeout = 300;
|
||||
|
||||
/**
|
||||
* Job timeout plus a safety margin so a lost lock can never block
|
||||
* redeploys of the same application forever.
|
||||
*/
|
||||
public int $uniqueFor = 360;
|
||||
|
||||
public function __construct(public int $applicationId) {}
|
||||
|
||||
public function uniqueId(): string
|
||||
{
|
||||
return (string) $this->applicationId;
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$application = V5Application::query()->find($this->applicationId);
|
||||
|
||||
if (! $application instanceof V5Application) {
|
||||
return;
|
||||
}
|
||||
|
||||
DeployNginxApplication::run($application);
|
||||
}
|
||||
|
||||
public function failed(?\Throwable $exception): void
|
||||
{
|
||||
V5Application::query()->find($this->applicationId)?->update([
|
||||
'status' => 'failed',
|
||||
'status_message' => str($exception?->getMessage() ?? 'The deploy job failed.')->limit(10000)->toString(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Enums\V5\ApplicationStatus;
|
||||
use App\Enums\V5\ContainerState;
|
||||
use App\Enums\V5\ServerStatus;
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\ContainerStatus;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Services\Flux\FluxClient;
|
||||
use App\Support\V5\StatusObservation;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Actively reconciles one v5 server against the containers coold actually
|
||||
* reports. V5 status is normally push-only (coold -> flux -> webhook), so a
|
||||
* dropped webhook leaves rows stale forever; this job is the pull-based
|
||||
* safety net scheduled via V5ReconcileServersJob.
|
||||
*/
|
||||
class V5ReconcileServerStateJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* Reconcile runs on its own queue so the 5-minute fleet fan-out (one
|
||||
* blocking flux call per server) can never starve user-triggered deploys
|
||||
* and bootstraps sharing the default queue. Set via onQueue() in the
|
||||
* constructor rather than a `$queue` property redeclaration, which the
|
||||
* Queueable trait already defines (redeclaring with a default is an
|
||||
* incompatible property composition and fatals on PHP 8.5).
|
||||
*/
|
||||
public int $tries = 1;
|
||||
|
||||
public int $timeout = 120;
|
||||
|
||||
public function __construct(public int $serverId)
|
||||
{
|
||||
$this->onQueue('v5-reconcile');
|
||||
}
|
||||
|
||||
public function handle(FluxClient $fluxClient): void
|
||||
{
|
||||
$server = V5Server::query()->find($this->serverId);
|
||||
|
||||
if (! $server instanceof V5Server) {
|
||||
return;
|
||||
}
|
||||
|
||||
$hostId = $server->fluxHostId();
|
||||
|
||||
if ($hostId === '') {
|
||||
Log::warning('V5 reconcile skipped: server is missing a Flux host id.', ['server_id' => $server->id]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// The moment we query coold is the observation time for every row this
|
||||
// pass writes; a webhook that lands with a newer observation while this
|
||||
// (possibly delayed) snapshot is processed must win the watermark.
|
||||
$observedAt = CarbonImmutable::now();
|
||||
|
||||
try {
|
||||
$containers = collect($fluxClient->listContainers($hostId));
|
||||
} catch (\Throwable $exception) {
|
||||
$this->markServerUnreachable($server, $exception, $observedAt);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->markServerReachable($server, $containers->count(), $observedAt);
|
||||
$this->refreshContainerStatuses($server, $containers, $observedAt);
|
||||
$this->reconcileApplications($server, $containers, $observedAt);
|
||||
}
|
||||
|
||||
private function markServerUnreachable(V5Server $server, \Throwable $exception, CarbonInterface $observedAt): void
|
||||
{
|
||||
Log::warning('V5 reconcile could not reach the server via flux.', [
|
||||
'server_id' => $server->id,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
$attributes = [
|
||||
'last_status_check' => 'reconcile',
|
||||
'last_status_output' => str($exception->getMessage())->limit(1000)->toString(),
|
||||
'last_status_checked_at' => now(),
|
||||
];
|
||||
|
||||
if (! StatusObservation::isStale($observedAt, $server->status_observed_at, 'server status', ['server_id' => $server->id])) {
|
||||
// Only an installed server can degrade to unreachable; added or
|
||||
// failed servers keep their bootstrap-driven status.
|
||||
$attributes['status'] = $server->status === ServerStatus::Installed->value
|
||||
? ServerStatus::Unreachable->value
|
||||
: $server->status;
|
||||
$attributes['status_observed_at'] = $observedAt;
|
||||
}
|
||||
|
||||
$server->update($attributes);
|
||||
}
|
||||
|
||||
private function markServerReachable(V5Server $server, int $containerCount, CarbonInterface $observedAt): void
|
||||
{
|
||||
$attributes = [
|
||||
'last_status_check' => 'reconcile',
|
||||
'last_status_output' => "Reconciled {$containerCount} containers from coold.",
|
||||
'last_status_checked_at' => now(),
|
||||
];
|
||||
|
||||
if (! StatusObservation::isStale($observedAt, $server->status_observed_at, 'server status', ['server_id' => $server->id])) {
|
||||
$attributes['status'] = $server->status === ServerStatus::Unreachable->value
|
||||
? ServerStatus::Installed->value
|
||||
: $server->status;
|
||||
$attributes['status_observed_at'] = $observedAt;
|
||||
}
|
||||
|
||||
$server->update($attributes);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, mixed> $containers
|
||||
*/
|
||||
private function refreshContainerStatuses(V5Server $server, Collection $containers, CarbonInterface $observedAt): void
|
||||
{
|
||||
$containers->each(function (mixed $container) use ($server, $observedAt): void {
|
||||
if (! is_array($container) || ! is_string($container['id'] ?? null) || $container['id'] === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$existing = ContainerStatus::query()
|
||||
->where('server_id', $server->id)
|
||||
->where('container_id', $container['id'])
|
||||
->first();
|
||||
|
||||
if (StatusObservation::isStale($observedAt, $existing?->status_observed_at, 'container status', [
|
||||
'server_id' => $server->id,
|
||||
'container_id' => $container['id'],
|
||||
])) {
|
||||
return;
|
||||
}
|
||||
|
||||
ContainerStatus::query()->updateOrCreate([
|
||||
'server_id' => $server->id,
|
||||
'container_id' => $container['id'],
|
||||
], [
|
||||
'team_id' => $server->team_id,
|
||||
'container_name' => is_string($container['name'] ?? null) ? $container['name'] : null,
|
||||
'image' => is_string($container['image'] ?? null) ? $container['image'] : null,
|
||||
'status' => $this->containerState($container, ContainerState::class),
|
||||
'status_message' => 'Container state reconciled from coold.',
|
||||
'status_observed_at' => $observedAt,
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, mixed> $containers
|
||||
*/
|
||||
private function reconcileApplications(V5Server $server, Collection $containers, CarbonInterface $observedAt): void
|
||||
{
|
||||
V5Application::query()
|
||||
->where('server_id', $server->id)
|
||||
->get()
|
||||
->each(function (V5Application $application) use ($containers, $observedAt): void {
|
||||
try {
|
||||
$this->reconcileApplication($application, $containers, $observedAt);
|
||||
} catch (\Throwable $exception) {
|
||||
Log::warning('V5 reconcile failed for an application.', [
|
||||
'application_id' => $application->id,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, mixed> $containers
|
||||
*/
|
||||
private function reconcileApplication(V5Application $application, Collection $containers, CarbonInterface $observedAt): void
|
||||
{
|
||||
$container = $containers->first(function (mixed $container) use ($application): bool {
|
||||
return is_array($container)
|
||||
&& (($application->runtime_container_id !== null && ($container['id'] ?? null) === $application->runtime_container_id)
|
||||
|| ($container['name'] ?? null) === $application->container_name);
|
||||
});
|
||||
|
||||
if (! is_array($container)) {
|
||||
// A creating application without a container id simply has not
|
||||
// materialized yet; the deploy job will settle it.
|
||||
if ($application->status === ApplicationStatus::Creating->value && $application->runtime_container_id === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (StatusObservation::isStale($observedAt, $application->status_observed_at, 'application status', ['application_id' => $application->id])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$attributes = [
|
||||
'status' => ApplicationStatus::Exited->value,
|
||||
'status_observed_at' => $observedAt,
|
||||
];
|
||||
|
||||
if ($application->status !== ApplicationStatus::Exited->value) {
|
||||
$attributes['status_message'] = 'Container not found on server during reconcile.';
|
||||
}
|
||||
|
||||
$application->update($attributes);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (StatusObservation::isStale($observedAt, $application->status_observed_at, 'application status', ['application_id' => $application->id])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$status = $this->containerState($container, ApplicationStatus::class);
|
||||
|
||||
$attributes = [
|
||||
'status' => $status,
|
||||
'status_observed_at' => $observedAt,
|
||||
'runtime_container_id' => is_string($container['id'] ?? null) && $container['id'] !== ''
|
||||
? $container['id']
|
||||
: $application->runtime_container_id,
|
||||
];
|
||||
|
||||
// Only write status_message when the status actually changes: the
|
||||
// status column is what a viewer cares about, and a constant message
|
||||
// would otherwise fire a broadcast + full re-serialization every cycle.
|
||||
if ($status !== $application->status) {
|
||||
$attributes['status_message'] = 'Container state reconciled from coold.';
|
||||
}
|
||||
|
||||
$application->update($attributes);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $container
|
||||
* @param class-string<ApplicationStatus|ContainerState> $enumClass
|
||||
*/
|
||||
private function containerState(array $container, string $enumClass): string
|
||||
{
|
||||
$state = $container['state'] ?? null;
|
||||
$raw = is_string($state) && $state !== '' ? $state : null;
|
||||
|
||||
return StatusObservation::normalize($raw, $enumClass) ?? $enumClass::Unknown->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Enums\V5\ServerStatus;
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\ContainerStatus;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Scheduled fan-out for the v5 reconciliation loop: dispatches one
|
||||
* V5ReconcileServerStateJob per managed server and prunes container status
|
||||
* rows that no webhook has refreshed within the TTL.
|
||||
*/
|
||||
class V5ReconcileServersJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public const CONTAINER_STATUS_TTL_HOURS = 24;
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
public int $timeout = 60;
|
||||
|
||||
/**
|
||||
* Reconcile runs on its own queue so the 5-minute fleet fan-out can never
|
||||
* starve user-triggered deploys and bootstraps sharing the default queue.
|
||||
* Set via onQueue() rather than a `$queue` property redeclaration, which
|
||||
* the Queueable trait already defines (redeclaring with a default is an
|
||||
* incompatible property composition and fatals on PHP 8.5).
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->onQueue('v5-reconcile');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$this->dispatchReconcileJobs();
|
||||
$this->pruneContainerStatuses();
|
||||
}
|
||||
|
||||
private function dispatchReconcileJobs(): void
|
||||
{
|
||||
V5Server::query()
|
||||
// Unreachable servers stay in the loop so a recovered node is
|
||||
// restored to installed by its next successful reconcile.
|
||||
->whereIn('status', [ServerStatus::Installed->value, ServerStatus::Unreachable->value])
|
||||
->where('has_coold', true)
|
||||
->get()
|
||||
->each(function (V5Server $server): void {
|
||||
try {
|
||||
V5ReconcileServerStateJob::dispatch($server->id);
|
||||
} catch (\Throwable $exception) {
|
||||
Log::warning('V5 reconcile dispatch failed for a server.', [
|
||||
'server_id' => $server->id,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function pruneContainerStatuses(): void
|
||||
{
|
||||
$cutoff = now()->subHours(self::CONTAINER_STATUS_TTL_HOURS);
|
||||
$liveContainerIds = V5Application::query()
|
||||
->whereNotNull('runtime_container_id')
|
||||
->pluck('runtime_container_id')
|
||||
->all();
|
||||
|
||||
ContainerStatus::query()
|
||||
->where(function ($query) use ($cutoff): void {
|
||||
$query
|
||||
->where('last_seen_at', '<', $cutoff)
|
||||
->orWhere(function ($query) use ($cutoff): void {
|
||||
$query->whereNull('last_seen_at')->where('created_at', '<', $cutoff);
|
||||
})
|
||||
->orWhereNotIn('server_id', V5Server::query()->select('id'));
|
||||
})
|
||||
->when($liveContainerIds !== [], fn ($query) => $query->whereNotIn('container_id', $liveContainerIds))
|
||||
->delete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Actions\V5\Server\PushHostAgentToken;
|
||||
use App\Enums\V5\ServerStatus;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Services\Flux\AgentTokenIssuer;
|
||||
use App\Services\Flux\FluxClient;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Re-mints and delivers a fresh host JWT for one managed server before its
|
||||
* on-disk token expires.
|
||||
*
|
||||
* RPC-FIRST, SSH-FALLBACK: the new token is delivered over the live coold RPC
|
||||
* stream by default (Laravel -> flux UDS -> coold's `host.jwt.set` command),
|
||||
* because that reuses the already authenticated flux<->coold channel and works
|
||||
* while the CURRENT token is still valid — which is exactly when rotation runs
|
||||
* (at ~12h remaining, well before the 24h exp). Only if the RPC push fails (the
|
||||
* host's stream is down because its token already lapsed, flux rejects the verb,
|
||||
* a timeout, etc.) do we fall back to the SSH push, which recovers a node whose
|
||||
* token already expired and whose stream is therefore gone.
|
||||
*
|
||||
* PUSH-THEN-PERSIST: the new token is delivered to the host FIRST, and the
|
||||
* server's jti/expires_at are only advanced AFTER a successful delivery via
|
||||
* EITHER path. If both delivery paths fail the DB is left untouched, so the old
|
||||
* expires_at keeps the server inside the dispatcher's rotation window and the
|
||||
* next cycle simply retries — we never advance the watermark on a token the
|
||||
* host never received (which would strand the host on the expiring old token
|
||||
* until it fully lapsed).
|
||||
*
|
||||
* NO-REVOKE-ON-ROTATION: the previously issued jti is intentionally NOT revoked
|
||||
* here. The old token is still legitimately valid until its own exp and coold
|
||||
* may still be connected on it; revoking it would risk cutting the live stream.
|
||||
* Revocation belongs to teardown/re-home (RemoveBootstrapMarker), not routine
|
||||
* rotation — the old token simply ages out on its own exp.
|
||||
*/
|
||||
class V5RotateAgentTokenJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public int $timeout = 60;
|
||||
|
||||
/**
|
||||
* Rotation shares the reconcile queue so the hourly fleet fan-out can never
|
||||
* starve user-triggered deploys and bootstraps on the default queue. Set via
|
||||
* onQueue() rather than a `$queue` property redeclaration, which the
|
||||
* Queueable trait already defines (redeclaring with a default is an
|
||||
* incompatible property composition and fatals on PHP 8.5).
|
||||
*/
|
||||
public function __construct(public int $serverId)
|
||||
{
|
||||
$this->onQueue('v5-reconcile');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$server = V5Server::query()->with('privateKey')->find($this->serverId);
|
||||
|
||||
if (! $server instanceof V5Server) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->isEligible($server)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$hostId = $server->fluxHostId();
|
||||
|
||||
if ($hostId === '') {
|
||||
Log::warning('V5 token rotation skipped: server is missing a Flux host id.', ['server_id' => $server->id]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$ttl = (int) config('flux.host_token_ttl');
|
||||
$jti = (string) Str::uuid();
|
||||
|
||||
$token = app(AgentTokenIssuer::class)->issue($hostId, null, $ttl, [
|
||||
'jti' => $jti,
|
||||
'team_id' => (string) $server->team_id,
|
||||
'cluster_id' => (string) $server->cluster_id,
|
||||
'server_id' => $hostId,
|
||||
'wireguard_management_ip' => (string) $server->wireguard_management_ip,
|
||||
]);
|
||||
|
||||
$delivery = $this->deliverToken($server, $hostId, $token);
|
||||
|
||||
if ($delivery === null) {
|
||||
Log::warning('V5 token rotation could not deliver the new host token; leaving the existing token in place.', [
|
||||
'server_id' => $server->id,
|
||||
'host' => $server->host,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$server->update([
|
||||
'agent_token_jti' => $jti,
|
||||
'agent_token_expires_at' => now()->addSeconds($ttl),
|
||||
]);
|
||||
|
||||
Log::debug('V5 token rotation delivered a fresh host token.', [
|
||||
'server_id' => $server->id,
|
||||
'delivery' => $delivery,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver the freshly minted token to the host, preferring the live coold
|
||||
* RPC stream and falling back to the SSH push on any RPC failure.
|
||||
*
|
||||
* @return 'rpc'|'ssh'|null The path that succeeded, or null if both failed.
|
||||
*/
|
||||
private function deliverToken(V5Server $server, string $hostId, string $token): ?string
|
||||
{
|
||||
try {
|
||||
app(FluxClient::class)->pushHostToken($hostId, $token);
|
||||
|
||||
return 'rpc';
|
||||
} catch (\Throwable $exception) {
|
||||
Log::info('V5 token rotation RPC push failed; falling back to SSH.', [
|
||||
'server_id' => $server->id,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
if (PushHostAgentToken::run($server, $token)) {
|
||||
return 'ssh';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function isEligible(V5Server $server): bool
|
||||
{
|
||||
return $server->status === ServerStatus::Installed->value
|
||||
&& (bool) $server->has_coold
|
||||
&& $server->last_bootstrapped_at !== null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Enums\V5\ServerStatus;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Scheduled fan-out for host JWT rotation: dispatches one V5RotateAgentTokenJob
|
||||
* per managed server whose on-disk token is missing or within the configured
|
||||
* refresh threshold of expiry, so a fresh token is always on disk before the
|
||||
* current one lapses.
|
||||
*/
|
||||
class V5RotateAgentTokensJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
public int $timeout = 60;
|
||||
|
||||
/**
|
||||
* Rotation shares the reconcile queue so the hourly fleet fan-out can never
|
||||
* starve user-triggered deploys and bootstraps on the default queue. Set via
|
||||
* onQueue() rather than a `$queue` property redeclaration, which the
|
||||
* Queueable trait already defines (redeclaring with a default is an
|
||||
* incompatible property composition and fatals on PHP 8.5).
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->onQueue('v5-reconcile');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$threshold = now()->addSeconds((int) config('flux.host_token_refresh_threshold'));
|
||||
|
||||
V5Server::query()
|
||||
->where('status', ServerStatus::Installed->value)
|
||||
->where('has_coold', true)
|
||||
->whereNotNull('last_bootstrapped_at')
|
||||
->where(function ($query) use ($threshold): void {
|
||||
$query
|
||||
->whereNull('agent_token_expires_at')
|
||||
->orWhere('agent_token_expires_at', '<', $threshold);
|
||||
})
|
||||
->get()
|
||||
->each(function (V5Server $server): void {
|
||||
try {
|
||||
V5RotateAgentTokenJob::dispatch($server->id);
|
||||
} catch (\Throwable $exception) {
|
||||
Log::warning('V5 token rotation dispatch failed for a server.', [
|
||||
'server_id' => $server->id,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Actions\V5\Application\DestroyNginxApplication;
|
||||
use App\Actions\V5\Proxy\StopCaddyIngress;
|
||||
use App\Actions\V5\Server\RemoveBootstrapMarker;
|
||||
use App\Enums\V5\ServerStatus;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Team;
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Services\Flux\AgentTokenIssuer;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Best-effort, on-host teardown for a team that is being deleted.
|
||||
*
|
||||
* Deleting a v4 Team DB-cascades every v5_servers / v5_applications /
|
||||
* v5_container_statuses / v5_resource_connections row (see the
|
||||
* cascadeOnDelete() foreign keys in the v5 migrations) WITHOUT running the
|
||||
* app-level teardown that the per-resource destroy flows use. That would leave
|
||||
* orphaned podman containers, a running Caddy ingress, the WireGuard mesh and
|
||||
* coold itself alive on every host with no DB record left to reach them.
|
||||
*
|
||||
* This job mirrors the ServerController::destroy / ApplicationController::destroy
|
||||
* teardown sequence for every v5 server owned by the team:
|
||||
* 1. remove each application's container (DestroyNginxApplication),
|
||||
* 2. stop the Caddy ingress on ingress servers (StopCaddyIngress),
|
||||
* 3. remove the on-host bootstrap identity — marker, host-jwt and Flux
|
||||
* drop-in (RemoveBootstrapMarker).
|
||||
*
|
||||
* Because the cascade deletes the servers, applications and private keys the
|
||||
* moment the team is gone, the payload is captured at dispatch time (from the
|
||||
* Team `deleting` hook, which fires BEFORE the rows vanish) as plain arrays,
|
||||
* including the SSH private-key material needed to reach each host. The job
|
||||
* rebuilds in-memory, non-persisted models from that payload so it can reuse
|
||||
* the exact same actions without touching the (now missing) DB rows.
|
||||
*
|
||||
* BEST-EFFORT / LIMITATIONS: teardown is best-effort. Each host and each action
|
||||
* is guarded so a single unreachable host can never abort teardown of the other
|
||||
* hosts, and the team deletion itself never fails because of teardown. The
|
||||
* payload is fully self-contained (host, SSH creds/private key, applications,
|
||||
* token jti + expiry), so a framework-level queue retry is safe — every step is
|
||||
* idempotent (podman rm -f / ingress stop / rm -f are all no-ops when the target
|
||||
* is already gone).
|
||||
*
|
||||
* RESIDUAL LIMITATION: a host that is unreachable at team-deletion time orphans
|
||||
* its containers, ingress and mesh PERMANENTLY — the DB rows the reconcilers key
|
||||
* off are gone, so there is no later reconciliation. The single operator-facing
|
||||
* signal is the `Log::error` emitted at the end of handle() listing the host
|
||||
* ids/hosts that could not be torn down; grep for "v5 team teardown incomplete"
|
||||
* to find them.
|
||||
*/
|
||||
class V5TeardownTeamJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* A small retry budget: the payload is self-contained and every teardown
|
||||
* step is idempotent, so retrying an unreachable host is safe. There is no
|
||||
* point retrying forever — the host may simply be gone.
|
||||
*/
|
||||
public int $tries = 3;
|
||||
|
||||
public int $timeout = 300;
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $servers Self-contained per-server teardown payload captured before the cascade.
|
||||
*/
|
||||
public function __construct(
|
||||
public int $teamId,
|
||||
public array $servers,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Collect the team's v5 servers (with their applications and SSH key
|
||||
* material) into a self-contained payload and dispatch the teardown job.
|
||||
*
|
||||
* Must be called from the Team `deleting` hook, while the rows still exist.
|
||||
* Returns without dispatching when the team owns no v5 servers.
|
||||
*/
|
||||
public static function dispatchForTeam(Team $team): void
|
||||
{
|
||||
// Guard against contexts where the v5 tables do not exist (e.g. v4-only
|
||||
// schemas) so team deletion is never broken by this teardown.
|
||||
if (! Schema::hasTable('v5_servers')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$servers = V5Server::query()
|
||||
->where('team_id', $team->id)
|
||||
->with('privateKey')
|
||||
->get();
|
||||
|
||||
if ($servers->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$applicationsByServer = V5Application::query()
|
||||
->where('team_id', $team->id)
|
||||
->whereNotNull('server_id')
|
||||
->get()
|
||||
->groupBy('server_id');
|
||||
|
||||
$payload = $servers->map(function (V5Server $server) use ($applicationsByServer): array {
|
||||
return [
|
||||
'id' => $server->id,
|
||||
'uuid' => $server->uuid,
|
||||
'name' => $server->name,
|
||||
'host' => $server->host,
|
||||
'ssh_user' => $server->ssh_user,
|
||||
'ssh_port' => (int) $server->ssh_port,
|
||||
'node_address' => $server->node_address,
|
||||
'wireguard_management_ip' => $server->wireguard_management_ip,
|
||||
'is_ingress' => (bool) $server->is_ingress,
|
||||
'ingress_type' => $server->ingress_type,
|
||||
'status' => $server->status,
|
||||
'last_bootstrapped_at' => $server->last_bootstrapped_at?->toISOString(),
|
||||
// Captured before the cascade removes the row so the job can
|
||||
// revoke the host token after the DB rows are gone.
|
||||
'agent_token_jti' => $server->agent_token_jti,
|
||||
'agent_token_expires_at' => $server->agent_token_expires_at?->toISOString(),
|
||||
// Encrypted at rest on the model; needed to SSH into the host.
|
||||
'private_key' => $server->privateKey instanceof PrivateKey ? $server->privateKey->private_key : null,
|
||||
'applications' => ($applicationsByServer[$server->id] ?? collect())
|
||||
->map(fn (V5Application $application): array => [
|
||||
'id' => $application->id,
|
||||
'container_name' => $application->container_name,
|
||||
'runtime_container_id' => $application->runtime_container_id,
|
||||
])
|
||||
->values()
|
||||
->all(),
|
||||
];
|
||||
})->all();
|
||||
|
||||
self::dispatch($team->id, $payload);
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$incompleteHosts = [];
|
||||
|
||||
foreach ($this->servers as $serverPayload) {
|
||||
if (! $this->teardownServer($serverPayload)) {
|
||||
$incompleteHosts[] = [
|
||||
'server_id' => $serverPayload['id'] ?? null,
|
||||
'host' => $serverPayload['host'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Teardown is best-effort and never fails the job (an unreachable host
|
||||
// must not abort the others), so this is the single operator-facing
|
||||
// signal that some hosts could not be reached and may now hold orphaned
|
||||
// containers/mesh with no DB row left to reconcile them.
|
||||
if ($incompleteHosts !== []) {
|
||||
Log::error('v5 team teardown incomplete — '.count($incompleteHosts).' host(s) may have orphaned containers/mesh', [
|
||||
'team_id' => $this->teamId,
|
||||
'hosts' => $incompleteHosts,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down a single host. Returns false when any on-host teardown step
|
||||
* (container removal, ingress stop, bootstrap-marker removal) failed, so the
|
||||
* caller can surface the host as potentially orphaned. Never throws: a
|
||||
* single unreachable host must not abort teardown of the other hosts.
|
||||
*
|
||||
* @param array<string, mixed> $serverPayload
|
||||
*/
|
||||
private function teardownServer(array $serverPayload): bool
|
||||
{
|
||||
$server = $this->reconstructServer($serverPayload);
|
||||
$serverId = $serverPayload['id'] ?? null;
|
||||
$host = $serverPayload['host'] ?? null;
|
||||
$succeeded = true;
|
||||
|
||||
foreach ($serverPayload['applications'] ?? [] as $applicationPayload) {
|
||||
try {
|
||||
$application = $this->reconstructApplication($applicationPayload, $server);
|
||||
DestroyNginxApplication::run($application);
|
||||
} catch (\Throwable $exception) {
|
||||
$succeeded = false;
|
||||
Log::warning('V5 team teardown: failed to remove application container', [
|
||||
'team_id' => $this->teamId,
|
||||
'server_id' => $serverId,
|
||||
'host' => $host,
|
||||
'container_name' => $applicationPayload['container_name'] ?? null,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($server->isIngress() && $server->status === ServerStatus::Installed->value) {
|
||||
try {
|
||||
StopCaddyIngress::run($server);
|
||||
} catch (\Throwable $exception) {
|
||||
$succeeded = false;
|
||||
Log::warning('V5 team teardown: failed to stop Caddy ingress', [
|
||||
'team_id' => $this->teamId,
|
||||
'server_id' => $serverId,
|
||||
'host' => $host,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (($serverPayload['last_bootstrapped_at'] ?? null) !== null) {
|
||||
try {
|
||||
if (! RemoveBootstrapMarker::run($server)) {
|
||||
$succeeded = false;
|
||||
Log::warning('V5 team teardown: could not remove on-host bootstrap identity over SSH', [
|
||||
'team_id' => $this->teamId,
|
||||
'server_id' => $serverId,
|
||||
'host' => $host,
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $exception) {
|
||||
$succeeded = false;
|
||||
Log::warning('V5 team teardown: bootstrap marker removal threw', [
|
||||
'team_id' => $this->teamId,
|
||||
'server_id' => $serverId,
|
||||
'host' => $host,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Revocation is best-effort and independent of the on-host cleanup: a
|
||||
// failed flux push does not mean the host is orphaned, so it never flips
|
||||
// $succeeded (it is logged separately inside AgentTokenIssuer::revoke).
|
||||
$this->revokeAgentTokenIfSupported($server, $serverId, $host);
|
||||
|
||||
return $succeeded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct a non-persisted V5Server (with its private key relation
|
||||
* pre-set) so the teardown actions never hit the deleted DB rows.
|
||||
*
|
||||
* @param array<string, mixed> $serverPayload
|
||||
*/
|
||||
private function reconstructServer(array $serverPayload): V5Server
|
||||
{
|
||||
$server = new V5Server;
|
||||
$server->forceFill([
|
||||
'id' => $serverPayload['id'] ?? null,
|
||||
'uuid' => $serverPayload['uuid'] ?? null,
|
||||
'name' => $serverPayload['name'] ?? null,
|
||||
'host' => $serverPayload['host'] ?? null,
|
||||
'ssh_user' => $serverPayload['ssh_user'] ?? null,
|
||||
'ssh_port' => $serverPayload['ssh_port'] ?? 22,
|
||||
'node_address' => $serverPayload['node_address'] ?? null,
|
||||
'wireguard_management_ip' => $serverPayload['wireguard_management_ip'] ?? null,
|
||||
'is_ingress' => (bool) ($serverPayload['is_ingress'] ?? false),
|
||||
'ingress_type' => $serverPayload['ingress_type'] ?? null,
|
||||
'status' => $serverPayload['status'] ?? null,
|
||||
'agent_token_jti' => $serverPayload['agent_token_jti'] ?? null,
|
||||
'agent_token_expires_at' => $serverPayload['agent_token_expires_at'] ?? null,
|
||||
]);
|
||||
// Non-persisted: StopCaddyIngress / the actions must not try to update a
|
||||
// row that the cascade already removed.
|
||||
$server->exists = false;
|
||||
|
||||
$privateKeyMaterial = $serverPayload['private_key'] ?? null;
|
||||
if (is_string($privateKeyMaterial) && $privateKeyMaterial !== '') {
|
||||
$privateKey = new PrivateKey;
|
||||
$privateKey->forceFill(['private_key' => $privateKeyMaterial]);
|
||||
$server->setRelation('privateKey', $privateKey);
|
||||
} else {
|
||||
$server->setRelation('privateKey', null);
|
||||
}
|
||||
|
||||
return $server;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $applicationPayload
|
||||
*/
|
||||
private function reconstructApplication(array $applicationPayload, V5Server $server): V5Application
|
||||
{
|
||||
$application = new V5Application;
|
||||
$application->forceFill([
|
||||
'id' => $applicationPayload['id'] ?? null,
|
||||
'container_name' => $applicationPayload['container_name'] ?? null,
|
||||
'runtime_container_id' => $applicationPayload['runtime_container_id'] ?? null,
|
||||
'server_id' => $server->id,
|
||||
]);
|
||||
$application->exists = false;
|
||||
$application->setRelation('server', $server);
|
||||
|
||||
return $application;
|
||||
}
|
||||
|
||||
/**
|
||||
* If a coold-side agent-token revocation ever lands on AgentTokenIssuer,
|
||||
* call it best-effort. Guarded so this job never hard-depends on a method
|
||||
* that may not exist yet.
|
||||
*/
|
||||
private function revokeAgentTokenIfSupported(V5Server $server, mixed $serverId, mixed $host): void
|
||||
{
|
||||
if (! method_exists(AgentTokenIssuer::class, 'revokeForServer')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
app(AgentTokenIssuer::class)->revokeForServer($server);
|
||||
} catch (\Throwable $exception) {
|
||||
Log::warning('V5 team teardown: agent token revocation failed', [
|
||||
'team_id' => $this->teamId,
|
||||
'server_id' => $serverId,
|
||||
'host' => $host,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ namespace App\Livewire\Project\Resource;
|
||||
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Support\V5\V5Feature;
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Component;
|
||||
|
||||
@@ -57,20 +59,26 @@ class Index extends Component
|
||||
|
||||
// Load projects and environments for breadcrumb navigation
|
||||
$this->allProjects = Project::ownedByCurrentTeamCached();
|
||||
$environmentRelations = [
|
||||
'applications:id,uuid,name,environment_id',
|
||||
'services:id,uuid,name,environment_id',
|
||||
'postgresqls:id,uuid,name,environment_id',
|
||||
'redis:id,uuid,name,environment_id',
|
||||
'mongodbs:id,uuid,name,environment_id',
|
||||
'mysqls:id,uuid,name,environment_id',
|
||||
'mariadbs:id,uuid,name,environment_id',
|
||||
'keydbs:id,uuid,name,environment_id',
|
||||
'dragonflies:id,uuid,name,environment_id',
|
||||
'clickhouses:id,uuid,name,environment_id',
|
||||
];
|
||||
|
||||
if (V5Feature::enabled()) {
|
||||
$environmentRelations[] = 'v5Applications:id,uuid,name,environment_id,status';
|
||||
}
|
||||
|
||||
$this->allEnvironments = $project->environments()
|
||||
->select('id', 'uuid', 'name', 'project_id')
|
||||
->with([
|
||||
'applications:id,uuid,name,environment_id',
|
||||
'services:id,uuid,name,environment_id',
|
||||
'postgresqls:id,uuid,name,environment_id',
|
||||
'redis:id,uuid,name,environment_id',
|
||||
'mongodbs:id,uuid,name,environment_id',
|
||||
'mysqls:id,uuid,name,environment_id',
|
||||
'mariadbs:id,uuid,name,environment_id',
|
||||
'keydbs:id,uuid,name,environment_id',
|
||||
'dragonflies:id,uuid,name,environment_id',
|
||||
'clickhouses:id,uuid,name,environment_id',
|
||||
])
|
||||
->with($environmentRelations)
|
||||
->get();
|
||||
|
||||
$this->environment = $environment->loadCount([
|
||||
@@ -103,6 +111,25 @@ class Index extends Component
|
||||
|
||||
return $application;
|
||||
});
|
||||
if (V5Feature::enabled()) {
|
||||
$this->applications = $this->applications->merge(V5Application::query()
|
||||
->where('team_id', currentTeam()->id)
|
||||
->where('project_id', $this->project->id)
|
||||
->where('environment_id', $this->environment->id)
|
||||
->with('server:id,name')
|
||||
->get()
|
||||
->map(function (V5Application $application) use ($projectUuid, $environmentUuid) {
|
||||
$application->hrefLink = route('v5.dashboard', [
|
||||
'project' => $projectUuid,
|
||||
'environment' => $environmentUuid,
|
||||
'application' => $application->uuid,
|
||||
]);
|
||||
|
||||
return $application;
|
||||
}));
|
||||
}
|
||||
|
||||
$this->applications = $this->applications->sortBy('name');
|
||||
|
||||
// Load all database resources in a single query per type
|
||||
$databaseTypes = [
|
||||
@@ -180,16 +207,19 @@ class Index extends Component
|
||||
'uuid' => $item->uuid,
|
||||
'name' => $item->name,
|
||||
'fqdn' => $item->fqdn ?? null,
|
||||
'description' => $item->description ?? null,
|
||||
'description' => $item instanceof V5Application ? 'Managed by Coolify V5' : ($item->description ?? null),
|
||||
'status' => $item->status ?? '',
|
||||
'version' => $item instanceof V5Application ? 'v5' : 'v4',
|
||||
'server_status' => $item->server_status ?? null,
|
||||
'hrefLink' => $item->hrefLink ?? '',
|
||||
'destination' => [
|
||||
'server' => [
|
||||
'name' => $item->destination?->server?->name ?? 'Unknown',
|
||||
'name' => $item instanceof V5Application
|
||||
? ($item->server?->name ?? 'Unknown')
|
||||
: ($item->destination?->server?->name ?? 'Unknown'),
|
||||
],
|
||||
],
|
||||
'tags' => $item->tags->map(fn ($tag) => [
|
||||
'tags' => ($item instanceof V5Application ? collect() : $item->tags)->map(fn ($tag) => [
|
||||
'id' => $tag->id,
|
||||
'name' => $tag->name,
|
||||
])->values()->toArray(),
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\ResourceConnection as V5ResourceConnection;
|
||||
use App\Support\V5\V5Feature;
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -54,7 +57,11 @@ class Environment extends BaseModel
|
||||
|
||||
public function isEmpty()
|
||||
{
|
||||
return $this->applications()->count() == 0 &&
|
||||
return (! V5Feature::enabled() || (
|
||||
! V5Application::query()->where('environment_id', $this->id)->exists() &&
|
||||
! V5ResourceConnection::query()->where('environment_id', $this->id)->exists()
|
||||
)) &&
|
||||
$this->applications()->count() == 0 &&
|
||||
$this->redis()->count() == 0 &&
|
||||
$this->postgresqls()->count() == 0 &&
|
||||
$this->mysqls()->count() == 0 &&
|
||||
@@ -76,6 +83,11 @@ class Environment extends BaseModel
|
||||
return $this->hasMany(Application::class);
|
||||
}
|
||||
|
||||
public function v5Applications()
|
||||
{
|
||||
return $this->hasMany(V5Application::class);
|
||||
}
|
||||
|
||||
public function postgresqls()
|
||||
{
|
||||
return $this->hasMany(StandalonePostgresql::class);
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\ResourceConnection as V5ResourceConnection;
|
||||
use App\Support\V5\V5Feature;
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -144,7 +147,11 @@ class Project extends BaseModel
|
||||
|
||||
public function isEmpty()
|
||||
{
|
||||
return $this->applications()->count() == 0 &&
|
||||
return (! V5Feature::enabled() || (
|
||||
! V5Application::query()->where('project_id', $this->id)->exists() &&
|
||||
! V5ResourceConnection::query()->where('project_id', $this->id)->exists()
|
||||
)) &&
|
||||
$this->applications()->count() == 0 &&
|
||||
$this->redis()->count() == 0 &&
|
||||
$this->postgresqls()->count() == 0 &&
|
||||
$this->mysqls()->count() == 0 &&
|
||||
|
||||
@@ -4,9 +4,11 @@ namespace App\Models;
|
||||
|
||||
use App\Actions\User\RevokeUserTeamTokens;
|
||||
use App\Events\ServerReachabilityChanged;
|
||||
use App\Jobs\V5TeardownTeamJob;
|
||||
use App\Notifications\Channels\SendsDiscord;
|
||||
use App\Notifications\Channels\SendsEmail;
|
||||
use App\Notifications\Channels\SendsPushover;
|
||||
use App\Support\V5\V5Feature;
|
||||
use App\Notifications\Channels\SendsSlack;
|
||||
use App\Traits\HasNotificationSettings;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
@@ -75,6 +77,20 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
|
||||
});
|
||||
|
||||
static::deleting(function (Team $team) {
|
||||
// Best-effort on-host teardown of this team's v5 resources BEFORE the
|
||||
// DB cascade removes the servers/applications/private keys. Captured
|
||||
// synchronously into a queued job so an unreachable host cannot block
|
||||
// or fail the team deletion (see V5TeardownTeamJob). Guarded so a v5
|
||||
// teardown problem never breaks v4 team deletion. This is disabled
|
||||
// with the rest of v5 outside development environments.
|
||||
if (V5Feature::enabled()) {
|
||||
try {
|
||||
V5TeardownTeamJob::dispatchForTeam($team);
|
||||
} catch (\Throwable $exception) {
|
||||
report($exception);
|
||||
}
|
||||
}
|
||||
|
||||
RevokeUserTeamTokens::forTeam($team->id);
|
||||
|
||||
foreach ($team->privateKeys as $key) {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\V5;
|
||||
|
||||
use App\Enums\V5\ApplicationStatus;
|
||||
use App\Events\V5CanvasResourceUpdated;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class Application extends V5Model
|
||||
{
|
||||
protected $table = 'v5_applications';
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'team_id',
|
||||
'project_id',
|
||||
'environment_id',
|
||||
'server_id',
|
||||
'created_by_user_id',
|
||||
'name',
|
||||
'image',
|
||||
'container_name',
|
||||
'status',
|
||||
'status_message',
|
||||
'status_observed_at',
|
||||
'runtime_container_id',
|
||||
'mesh_namespace',
|
||||
'ingress_enabled',
|
||||
'internal_port',
|
||||
'canvas_x',
|
||||
'canvas_y',
|
||||
];
|
||||
|
||||
protected $attributes = [
|
||||
'status' => ApplicationStatus::Creating->value,
|
||||
'mesh_namespace' => 'default',
|
||||
'ingress_enabled' => false,
|
||||
'canvas_x' => 0,
|
||||
'canvas_y' => 0,
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::updated(function (self $application): void {
|
||||
if ($application->wasChanged(['status', 'status_message', 'runtime_container_id'])) {
|
||||
DB::afterCommit(fn () => V5CanvasResourceUpdated::dispatch($application->team_id, $application->id));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status_observed_at' => 'datetime',
|
||||
'ingress_enabled' => 'boolean',
|
||||
'internal_port' => 'integer',
|
||||
'canvas_x' => 'integer',
|
||||
'canvas_y' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function team(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Team::class);
|
||||
}
|
||||
|
||||
public function server(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Server::class);
|
||||
}
|
||||
|
||||
public function project(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Project::class);
|
||||
}
|
||||
|
||||
public function environment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Environment::class);
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by_user_id');
|
||||
}
|
||||
|
||||
public function domains(): HasMany
|
||||
{
|
||||
return $this->hasMany(ApplicationDomain::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\V5;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ApplicationDomain extends V5Model
|
||||
{
|
||||
protected $table = 'v5_application_domains';
|
||||
|
||||
protected bool $hasUuidColumn = false;
|
||||
|
||||
protected $fillable = [
|
||||
'application_id',
|
||||
'domain',
|
||||
];
|
||||
|
||||
public function application(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Application::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\V5;
|
||||
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Cluster extends V5Model
|
||||
{
|
||||
protected $table = 'v5_clusters';
|
||||
|
||||
/**
|
||||
* Single source of truth for cluster defaults: `$attributes` below is
|
||||
* built from these consts, and the column defaults in
|
||||
* 2026_06_16_130649_v5_create_clusters_table mirror them (kept there for
|
||||
* historical rows only — update both when changing a default).
|
||||
*/
|
||||
public const DEFAULT_WIREGUARD_INTERFACE = 'wg0';
|
||||
|
||||
public const DEFAULT_WIREGUARD_MANAGEMENT_POOL = '100.64.0.0/16';
|
||||
|
||||
public const DEFAULT_WIREGUARD_LISTEN_PORT = 51820;
|
||||
|
||||
public const DEFAULT_CONTAINER_NETWORK_POOL = '10.210.0.0/16';
|
||||
|
||||
public const DEFAULT_CONTAINER_NETWORK_PREFIX = 24;
|
||||
|
||||
public const DEFAULT_NAMESPACES = ['default'];
|
||||
|
||||
public const DEFAULT_COOLD_VERSION = 'nightly';
|
||||
|
||||
public const DEFAULT_CORROSION_VERSION = 'v1.0.0';
|
||||
|
||||
public const DEFAULT_CORROSION_GOSSIP_PORT = 8787;
|
||||
|
||||
public const DEFAULT_CORROSION_API_PORT = 8080;
|
||||
|
||||
public const DEFAULT_BUILDER_CAPACITY = 2;
|
||||
|
||||
public const DEFAULT_BUILDER_CPU_QUOTA = '200%';
|
||||
|
||||
public const DEFAULT_BUILDER_MEMORY_MAX = '2G';
|
||||
|
||||
public const DEFAULT_BUILDER_TIMEOUT_SECS = 1800;
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'team_id',
|
||||
'created_by_user_id',
|
||||
'name',
|
||||
'description',
|
||||
'wireguard_interface',
|
||||
'wireguard_management_pool',
|
||||
'wireguard_listen_port',
|
||||
'container_network_pool',
|
||||
'container_network_prefix',
|
||||
'namespaces',
|
||||
'default_deny_containers',
|
||||
'coold_version',
|
||||
'corrosion_version',
|
||||
'corrosion_gossip_port',
|
||||
'corrosion_api_port',
|
||||
'builder_enabled',
|
||||
'builder_capacity',
|
||||
'builder_cpu_quota',
|
||||
'builder_memory_max',
|
||||
'builder_timeout_secs',
|
||||
'last_cli_action',
|
||||
'last_cli_status',
|
||||
'last_cli_summary',
|
||||
'last_cli_ran_at',
|
||||
];
|
||||
|
||||
protected $attributes = [
|
||||
'wireguard_interface' => self::DEFAULT_WIREGUARD_INTERFACE,
|
||||
'wireguard_management_pool' => self::DEFAULT_WIREGUARD_MANAGEMENT_POOL,
|
||||
'wireguard_listen_port' => self::DEFAULT_WIREGUARD_LISTEN_PORT,
|
||||
'container_network_pool' => self::DEFAULT_CONTAINER_NETWORK_POOL,
|
||||
'container_network_prefix' => self::DEFAULT_CONTAINER_NETWORK_PREFIX,
|
||||
'default_deny_containers' => true,
|
||||
'coold_version' => self::DEFAULT_COOLD_VERSION,
|
||||
'corrosion_version' => self::DEFAULT_CORROSION_VERSION,
|
||||
'corrosion_gossip_port' => self::DEFAULT_CORROSION_GOSSIP_PORT,
|
||||
'corrosion_api_port' => self::DEFAULT_CORROSION_API_PORT,
|
||||
'builder_enabled' => true,
|
||||
'builder_capacity' => self::DEFAULT_BUILDER_CAPACITY,
|
||||
'builder_cpu_quota' => self::DEFAULT_BUILDER_CPU_QUOTA,
|
||||
'builder_memory_max' => self::DEFAULT_BUILDER_MEMORY_MAX,
|
||||
'builder_timeout_secs' => self::DEFAULT_BUILDER_TIMEOUT_SECS,
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'namespaces' => 'array',
|
||||
'default_deny_containers' => 'boolean',
|
||||
'builder_enabled' => 'boolean',
|
||||
'last_cli_ran_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function team(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Team::class);
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by_user_id');
|
||||
}
|
||||
|
||||
public function servers(): HasMany
|
||||
{
|
||||
return $this->hasMany(Server::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\V5;
|
||||
|
||||
use App\Models\Team;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ContainerStatus extends V5Model
|
||||
{
|
||||
protected $table = 'v5_container_statuses';
|
||||
|
||||
protected bool $hasUuidColumn = false;
|
||||
|
||||
protected $fillable = [
|
||||
'team_id',
|
||||
'server_id',
|
||||
'container_id',
|
||||
'container_name',
|
||||
'image',
|
||||
'status',
|
||||
'status_message',
|
||||
'status_observed_at',
|
||||
'last_seen_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status_observed_at' => 'datetime',
|
||||
'last_seen_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function team(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Team::class);
|
||||
}
|
||||
|
||||
public function server(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Server::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\V5;
|
||||
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
class ResourceConnection extends V5Model
|
||||
{
|
||||
protected $table = 'v5_resource_connections';
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'team_id',
|
||||
'project_id',
|
||||
'environment_id',
|
||||
'resource_one_type',
|
||||
'resource_one_id',
|
||||
'resource_two_type',
|
||||
'resource_two_id',
|
||||
'resource_pair_key',
|
||||
'created_by_user_id',
|
||||
];
|
||||
|
||||
public function team(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Team::class);
|
||||
}
|
||||
|
||||
public function project(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Project::class);
|
||||
}
|
||||
|
||||
public function environment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Environment::class);
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by_user_id');
|
||||
}
|
||||
|
||||
public function resourceOne(): MorphTo
|
||||
{
|
||||
return $this->morphTo('resource_one');
|
||||
}
|
||||
|
||||
public function resourceTwo(): MorphTo
|
||||
{
|
||||
return $this->morphTo('resource_two');
|
||||
}
|
||||
|
||||
public function rules(): HasMany
|
||||
{
|
||||
return $this->hasMany(ResourceConnectionRule::class, 'connection_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\V5;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
class ResourceConnectionRule extends V5Model
|
||||
{
|
||||
protected $table = 'v5_resource_connection_rules';
|
||||
|
||||
protected bool $hasUuidColumn = false;
|
||||
|
||||
protected $fillable = [
|
||||
'connection_id',
|
||||
'source_resource_type',
|
||||
'source_resource_id',
|
||||
'target_resource_type',
|
||||
'target_resource_id',
|
||||
'protocol',
|
||||
'port',
|
||||
];
|
||||
|
||||
protected $attributes = [
|
||||
'protocol' => 'tcp',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'port' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function connection(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ResourceConnection::class, 'connection_id');
|
||||
}
|
||||
|
||||
public function sourceResource(): MorphTo
|
||||
{
|
||||
return $this->morphTo('source_resource');
|
||||
}
|
||||
|
||||
public function targetResource(): MorphTo
|
||||
{
|
||||
return $this->morphTo('target_resource');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\V5;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* A host-agent JWT that has been revoked (typically on server destroy/re-home).
|
||||
*
|
||||
* flux does not yet consult this list (see AgentTokenIssuer::revoke docblock);
|
||||
* it exists so the Laravel side owns the data and API needed for revocation the
|
||||
* moment flux gains a revocation check.
|
||||
*/
|
||||
class RevokedAgentToken extends V5Model
|
||||
{
|
||||
protected bool $hasUuidColumn = false;
|
||||
|
||||
protected $table = 'v5_revoked_agent_tokens';
|
||||
|
||||
protected $fillable = [
|
||||
'jti',
|
||||
'server_id',
|
||||
'revoked_at',
|
||||
'expires_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'revoked_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Server, $this>
|
||||
*/
|
||||
public function server(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Server::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\V5;
|
||||
|
||||
use App\Enums\V5\IngressStatus;
|
||||
use App\Events\V5CanvasResourceUpdated;
|
||||
use App\Events\V5ClusterUpdated;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class Server extends V5Model
|
||||
{
|
||||
protected $table = 'v5_servers';
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'team_id',
|
||||
'cluster_id',
|
||||
'created_by_user_id',
|
||||
'private_key_id',
|
||||
'name',
|
||||
'host',
|
||||
'ssh_user',
|
||||
'ssh_port',
|
||||
'status',
|
||||
'status_observed_at',
|
||||
'ingress_type',
|
||||
'ingress_status',
|
||||
'capabilities',
|
||||
'has_coold',
|
||||
'is_ingress',
|
||||
'builder_enabled',
|
||||
'builder_capacity',
|
||||
'builder_cpu_quota',
|
||||
'node_address',
|
||||
'wireguard_listen_port_override',
|
||||
'wireguard_endpoint_override',
|
||||
'wireguard_management_ip',
|
||||
'wireguard_public_key',
|
||||
'coold_version',
|
||||
'agent_token_jti',
|
||||
'agent_token_expires_at',
|
||||
'container_subnets',
|
||||
'canvas_x',
|
||||
'canvas_y',
|
||||
'last_bootstrapped_at',
|
||||
'last_bootstrap_action',
|
||||
'last_bootstrap_status',
|
||||
'last_bootstrap_output',
|
||||
'last_bootstrap_ran_at',
|
||||
'last_status_check',
|
||||
'last_status_output',
|
||||
'last_status_checked_at',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::updated(function (self $server): void {
|
||||
if (
|
||||
! $server->wasChanged('status')
|
||||
&& ! $server->wasChanged('ingress_type')
|
||||
&& ! $server->wasChanged('ingress_status')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($server->wasChanged('status') && $server->cluster_id !== null) {
|
||||
DB::afterCommit(fn () => V5ClusterUpdated::dispatch($server->team_id, $server->cluster_id));
|
||||
}
|
||||
|
||||
if ($server->wasChanged('status')) {
|
||||
DB::afterCommit(fn () => V5CanvasResourceUpdated::dispatch(
|
||||
$server->team_id,
|
||||
null,
|
||||
$server->isIngress() ? $server->id : null,
|
||||
$server->id,
|
||||
));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($server->isIngress()) {
|
||||
DB::afterCommit(fn () => V5CanvasResourceUpdated::dispatch($server->team_id, null, $server->id));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'has_coold' => 'boolean',
|
||||
'is_ingress' => 'boolean',
|
||||
'builder_enabled' => 'boolean',
|
||||
'container_subnets' => 'array',
|
||||
'canvas_x' => 'integer',
|
||||
'canvas_y' => 'integer',
|
||||
'status_observed_at' => 'datetime',
|
||||
'agent_token_expires_at' => 'datetime',
|
||||
'last_bootstrapped_at' => 'datetime',
|
||||
'last_bootstrap_ran_at' => 'datetime',
|
||||
'last_status_checked_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function fluxHostId(): string
|
||||
{
|
||||
return (string) $this->uuid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Virtual attribute kept for wire-format compatibility: capabilities are
|
||||
* stored as the indexed has_coold / is_ingress booleans, but reads and
|
||||
* writes of `capabilities` keep working with the historical string array.
|
||||
* Unknown capability names are dropped on write.
|
||||
*
|
||||
* The dropped `capabilities` column intentionally stays in `$fillable`:
|
||||
* call sites still mass-assign it, and this mutator maps those writes
|
||||
* onto the boolean columns.
|
||||
*/
|
||||
protected function capabilities(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => array_values(array_filter([
|
||||
$this->has_coold ? 'coold' : null,
|
||||
$this->is_ingress ? 'ingress' : null,
|
||||
])),
|
||||
set: fn (?array $capabilities) => [
|
||||
'has_coold' => in_array('coold', $capabilities ?? [], true),
|
||||
'is_ingress' => in_array('ingress', $capabilities ?? [], true),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function hasCapability(string $capability): bool
|
||||
{
|
||||
return match ($capability) {
|
||||
'coold' => (bool) $this->has_coold,
|
||||
'ingress' => (bool) $this->is_ingress,
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function withCapability(string $capability): array
|
||||
{
|
||||
return collect($this->capabilities)
|
||||
->push($capability)
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function withoutCapability(string $capability): array
|
||||
{
|
||||
return collect($this->capabilities)
|
||||
->reject(fn (string $existingCapability) => $existingCapability === $capability)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function isIngress(): bool
|
||||
{
|
||||
return (bool) $this->is_ingress;
|
||||
}
|
||||
|
||||
public function ingressStatus(): string
|
||||
{
|
||||
return $this->ingress_status ?? IngressStatus::Unknown->value;
|
||||
}
|
||||
|
||||
public function ingressType(): string
|
||||
{
|
||||
return $this->ingress_type ?? 'caddy';
|
||||
}
|
||||
|
||||
public function cluster(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Cluster::class);
|
||||
}
|
||||
|
||||
public function team(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Team::class);
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by_user_id');
|
||||
}
|
||||
|
||||
public function privateKey(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PrivateKey::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\V5;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
abstract class V5Model extends Model
|
||||
{
|
||||
/**
|
||||
* Whether the model's table has a `uuid` column. Models without one (set
|
||||
* this to false there) skip public-id generation and route on the primary
|
||||
* key instead.
|
||||
*/
|
||||
protected bool $hasUuidColumn = true;
|
||||
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return $this->hasUuidColumn ? 'uuid' : $this->getKeyName();
|
||||
}
|
||||
|
||||
protected static function boot(): void
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function (self $model): void {
|
||||
if ($model->hasUuidColumn && ! $model->getAttribute('uuid')) {
|
||||
$model->setAttribute('uuid', $model->newUniquePublicId());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a public id, regenerating (up to three candidates) when one is
|
||||
* already taken. A concurrent insert between this exists() check and our
|
||||
* own insert can still collide; the unique index then rejects the insert,
|
||||
* which is an acceptable residual race for these cheap, retryable writes.
|
||||
*/
|
||||
protected function newUniquePublicId(): string
|
||||
{
|
||||
$attempts = 0;
|
||||
|
||||
do {
|
||||
$candidate = $this->newPublicIdCandidate();
|
||||
$attempts++;
|
||||
} while (
|
||||
$attempts < 3
|
||||
&& $this->newModelQuery()->where('uuid', $candidate)->exists()
|
||||
);
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
protected function newPublicIdCandidate(): string
|
||||
{
|
||||
return new_public_id();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies\V5;
|
||||
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Models\V5\Application;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
class ApplicationPolicy
|
||||
{
|
||||
public function create(User $user, Team $team): Response
|
||||
{
|
||||
return $user->isAdminOfTeam($team->id)
|
||||
? Response::allow()
|
||||
: Response::deny('You do not have permission to manage applications in this team.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can view the application within the current team.
|
||||
*
|
||||
* Read-only diagnostics (deploy status, container logs) are available to any
|
||||
* member of the owning team; apps from other teams stay hidden as a 404.
|
||||
*/
|
||||
public function view(User $user, Application $application, Team $team): Response
|
||||
{
|
||||
return $this->belongsToTeam($application, $team);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can update the application within the current team.
|
||||
*/
|
||||
public function update(User $user, Application $application, Team $team): Response
|
||||
{
|
||||
return $this->allowIfAdminAndScoped($user, $application, $team);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can update the application's ingress configuration.
|
||||
*/
|
||||
public function updateIngress(User $user, Application $application, Team $team): Response
|
||||
{
|
||||
return $this->allowIfAdminAndScoped($user, $application, $team);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can delete the application within the current team.
|
||||
*/
|
||||
public function delete(User $user, Application $application, Team $team): Response
|
||||
{
|
||||
return $this->allowIfAdminAndScoped($user, $application, $team);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the team scoping check first (mismatch stays hidden as a 404) and
|
||||
* only then the role check (403 for members on their own team's app).
|
||||
*/
|
||||
private function allowIfAdminAndScoped(User $user, Application $application, Team $team): Response
|
||||
{
|
||||
$scope = $this->belongsToTeam($application, $team);
|
||||
|
||||
if ($scope->denied()) {
|
||||
return $scope;
|
||||
}
|
||||
|
||||
return $user->isAdminOfTeam($team->id)
|
||||
? Response::allow()
|
||||
: Response::deny('You do not have permission to manage applications in this team.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Applications outside the current team must stay invisible, so
|
||||
* mismatches deny as not found instead of forbidden.
|
||||
*/
|
||||
private function belongsToTeam(Application $application, Team $team): Response
|
||||
{
|
||||
return $application->team_id === $team->id
|
||||
? Response::allow()
|
||||
: Response::denyAsNotFound();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies\V5;
|
||||
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Models\V5\Cluster;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
class ClusterPolicy
|
||||
{
|
||||
/**
|
||||
* Determine whether the user can view the cluster within the current team.
|
||||
* Read-only, so gated on team membership alone.
|
||||
*/
|
||||
public function view(User $user, Cluster $cluster, Team $team): Response
|
||||
{
|
||||
return $this->belongsToTeam($cluster, $team);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can create a cluster in the current team.
|
||||
* There is no model to scope yet, so this is a pure role gate.
|
||||
*/
|
||||
public function create(User $user, Team $team): Response
|
||||
{
|
||||
return $this->allowIfAdmin($user, $team);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can delete the cluster within the current team.
|
||||
*/
|
||||
public function delete(User $user, Cluster $cluster, Team $team): Response
|
||||
{
|
||||
$scope = $this->belongsToTeam($cluster, $team);
|
||||
|
||||
if ($scope->denied()) {
|
||||
return $scope;
|
||||
}
|
||||
|
||||
return $this->allowIfAdmin($user, $team);
|
||||
}
|
||||
|
||||
/**
|
||||
* Members may read but not mutate; only admins/owners of the team pass.
|
||||
*/
|
||||
private function allowIfAdmin(User $user, Team $team): Response
|
||||
{
|
||||
return $user->isAdminOfTeam($team->id)
|
||||
? Response::allow()
|
||||
: Response::deny('You do not have permission to manage clusters in this team.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clusters outside the current team must stay invisible, so mismatches
|
||||
* deny as not found instead of forbidden.
|
||||
*/
|
||||
private function belongsToTeam(Cluster $cluster, Team $team): Response
|
||||
{
|
||||
return $cluster->team_id === $team->id
|
||||
? Response::allow()
|
||||
: Response::denyAsNotFound();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies\V5;
|
||||
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Models\V5\ResourceConnection;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
class ResourceConnectionPolicy
|
||||
{
|
||||
public function create(User $user, Team $team): Response
|
||||
{
|
||||
return $user->isAdminOfTeam($team->id)
|
||||
? Response::allow()
|
||||
: Response::deny('You do not have permission to manage resource connections in this team.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can update the connection within the current team.
|
||||
*/
|
||||
public function update(User $user, ResourceConnection $connection, Team $team): Response
|
||||
{
|
||||
return $this->allowIfAdminAndScoped($user, $connection, $team);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can delete the connection within the current team.
|
||||
*/
|
||||
public function delete(User $user, ResourceConnection $connection, Team $team): Response
|
||||
{
|
||||
return $this->allowIfAdminAndScoped($user, $connection, $team);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the team scoping check first (mismatch stays hidden as a 404) and
|
||||
* only then the role check (403 for members on their own team's connection).
|
||||
*/
|
||||
private function allowIfAdminAndScoped(User $user, ResourceConnection $connection, Team $team): Response
|
||||
{
|
||||
$scope = $this->belongsToTeam($connection, $team);
|
||||
|
||||
if ($scope->denied()) {
|
||||
return $scope;
|
||||
}
|
||||
|
||||
return $user->isAdminOfTeam($team->id)
|
||||
? Response::allow()
|
||||
: Response::deny('You do not have permission to manage resource connections in this team.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Connections outside the current team must stay invisible, so
|
||||
* mismatches deny as not found instead of forbidden.
|
||||
*/
|
||||
private function belongsToTeam(ResourceConnection $connection, Team $team): Response
|
||||
{
|
||||
return $connection->team_id === $team->id
|
||||
? Response::allow()
|
||||
: Response::denyAsNotFound();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies\V5;
|
||||
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Models\V5\Cluster;
|
||||
use App\Models\V5\Server;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
class ServerPolicy
|
||||
{
|
||||
/**
|
||||
* Determine whether the user can add a server to the cluster within the
|
||||
* current team. Denies as forbidden (not "not found") to preserve the
|
||||
* historical 403 on cluster/team mismatch, and requires an admin/owner
|
||||
* role to mutate cluster infrastructure.
|
||||
*/
|
||||
public function create(User $user, Team $team, Cluster $cluster): Response
|
||||
{
|
||||
if ($cluster->team_id !== $team->id) {
|
||||
return Response::deny();
|
||||
}
|
||||
|
||||
return $this->allowIfAdmin($user, $team);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can update the server within the current team.
|
||||
*/
|
||||
public function update(User $user, Server $server, Team $team, Cluster $cluster): Response
|
||||
{
|
||||
return $this->allowIfAdminAndScoped($user, $server, $team, $cluster);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can delete the server within the current team.
|
||||
*/
|
||||
public function delete(User $user, Server $server, Team $team, Cluster $cluster): Response
|
||||
{
|
||||
return $this->allowIfAdminAndScoped($user, $server, $team, $cluster);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can run a connectivity check against the server.
|
||||
*/
|
||||
public function check(User $user, Server $server, Team $team, Cluster $cluster): Response
|
||||
{
|
||||
return $this->allowIfAdminAndScoped($user, $server, $team, $cluster);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can bootstrap the server.
|
||||
*/
|
||||
public function bootstrap(User $user, Server $server, Team $team, Cluster $cluster): Response
|
||||
{
|
||||
return $this->allowIfAdminAndScoped($user, $server, $team, $cluster);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can restart coold over SSH.
|
||||
*/
|
||||
public function restartCoold(User $user, Server $server, Team $team, Cluster $cluster): Response
|
||||
{
|
||||
return $this->allowIfAdminAndScoped($user, $server, $team, $cluster);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can view server diagnostics (coold logs,
|
||||
* corrosion tables, firewall rules). Read-only, so gated on team
|
||||
* membership alone.
|
||||
*/
|
||||
public function viewDiagnostics(User $user, Server $server, Team $team, Cluster $cluster): Response
|
||||
{
|
||||
return $this->belongsToClusterInTeam($server, $team, $cluster);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can move the server's Caddy ingress card on
|
||||
* the canvas. Non-ingress servers must stay invisible on the canvas, and
|
||||
* moving a card mutates persisted layout so it requires an admin/owner.
|
||||
*/
|
||||
public function updateCanvasPosition(User $user, Server $server, Team $team): Response
|
||||
{
|
||||
if (! ($server->team_id === $team->id && $server->isIngress())) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
return $this->allowIfAdmin($user, $team);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the team/cluster scoping check first (mismatch stays hidden as a 404)
|
||||
* and only then the role check (403 for members on their own team's server).
|
||||
*/
|
||||
private function allowIfAdminAndScoped(User $user, Server $server, Team $team, Cluster $cluster): Response
|
||||
{
|
||||
$scope = $this->belongsToClusterInTeam($server, $team, $cluster);
|
||||
|
||||
if ($scope->denied()) {
|
||||
return $scope;
|
||||
}
|
||||
|
||||
return $this->allowIfAdmin($user, $team);
|
||||
}
|
||||
|
||||
/**
|
||||
* Members may read but not mutate; only admins/owners of the team pass.
|
||||
*/
|
||||
private function allowIfAdmin(User $user, Team $team): Response
|
||||
{
|
||||
return $user->isAdminOfTeam($team->id)
|
||||
? Response::allow()
|
||||
: Response::deny('You do not have permission to manage servers in this team.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Servers outside the current team (or outside the addressed cluster)
|
||||
* must stay invisible, so mismatches deny as not found.
|
||||
*/
|
||||
private function belongsToClusterInTeam(Server $server, Team $team, Cluster $cluster): Response
|
||||
{
|
||||
return $cluster->team_id === $team->id
|
||||
&& $server->team_id === $team->id
|
||||
&& $server->cluster_id === $cluster->id
|
||||
? Response::allow()
|
||||
: Response::denyAsNotFound();
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,10 @@
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\PersonalAccessToken;
|
||||
use App\Models\V5\Application;
|
||||
use App\Support\V5\V5Feature;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
@@ -27,6 +30,12 @@ class AppServiceProvider extends ServiceProvider
|
||||
public function boot(): void
|
||||
{
|
||||
$this->configureCommands();
|
||||
|
||||
if (V5Feature::enabled()) {
|
||||
$this->loadMigrationsFrom(database_path('migrations-v5'));
|
||||
$this->configureMorphMap();
|
||||
}
|
||||
|
||||
$this->configureModels();
|
||||
$this->configurePasswords();
|
||||
$this->configureSanctumModel();
|
||||
@@ -41,6 +50,18 @@ class AppServiceProvider extends ServiceProvider
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map v5 models to stable morph aliases so polymorphic rows survive class
|
||||
* renames. Deliberately NOT enforced: v4 polymorphic relations store FQCNs
|
||||
* and must keep resolving them.
|
||||
*/
|
||||
private function configureMorphMap(): void
|
||||
{
|
||||
Relation::morphMap([
|
||||
'v5.application' => Application::class,
|
||||
]);
|
||||
}
|
||||
|
||||
private function configureModels(): void
|
||||
{
|
||||
// Disabled because it's causing issues with the application
|
||||
|
||||
@@ -36,6 +36,10 @@ use App\Models\StandaloneRedis;
|
||||
use App\Models\SwarmDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\TelegramNotificationSettings;
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\Cluster as V5Cluster;
|
||||
use App\Models\V5\ResourceConnection as V5ResourceConnection;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Models\WebhookNotificationSettings;
|
||||
use App\Policies\ApiTokenPolicy;
|
||||
use App\Policies\ApplicationPolicy;
|
||||
@@ -61,6 +65,10 @@ use App\Policies\SharedEnvironmentVariablePolicy;
|
||||
use App\Policies\StandaloneDockerPolicy;
|
||||
use App\Policies\SwarmDockerPolicy;
|
||||
use App\Policies\TeamPolicy;
|
||||
use App\Policies\V5\ApplicationPolicy as V5ApplicationPolicy;
|
||||
use App\Policies\V5\ClusterPolicy as V5ClusterPolicy;
|
||||
use App\Policies\V5\ResourceConnectionPolicy as V5ResourceConnectionPolicy;
|
||||
use App\Policies\V5\ServerPolicy as V5ServerPolicy;
|
||||
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Laravel\Sanctum\PersonalAccessToken;
|
||||
@@ -122,6 +130,12 @@ class AuthServiceProvider extends ServiceProvider
|
||||
CloudProviderToken::class => CloudProviderTokenPolicy::class,
|
||||
CloudInitScript::class => CloudInitScriptPolicy::class,
|
||||
|
||||
// V5 policies - scoped to the current team resolved from the request
|
||||
V5Application::class => V5ApplicationPolicy::class,
|
||||
V5Cluster::class => V5ClusterPolicy::class,
|
||||
V5ResourceConnection::class => V5ResourceConnectionPolicy::class,
|
||||
V5Server::class => V5ServerPolicy::class,
|
||||
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Support\V5\V5Feature;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -34,6 +35,13 @@ class RouteServiceProvider extends ServiceProvider
|
||||
Route::prefix('webhooks')
|
||||
->group(base_path('routes/webhooks.php'));
|
||||
|
||||
if (V5Feature::enabled()) {
|
||||
Route::middleware('v5.web')
|
||||
->prefix('v5')
|
||||
->as('v5.')
|
||||
->group(base_path('routes/v5.php'));
|
||||
}
|
||||
|
||||
Route::middleware('web')
|
||||
->group(base_path('routes/web.php'));
|
||||
});
|
||||
@@ -55,6 +63,12 @@ class RouteServiceProvider extends ServiceProvider
|
||||
return Limit::perMinute(5)->by($request->user()?->id ?: $request->ip());
|
||||
});
|
||||
|
||||
if (V5Feature::enabled()) {
|
||||
RateLimiter::for('v5', function (Request $request) {
|
||||
return Limit::perMinute(120)->by($request->user()?->id ?: $request->ip());
|
||||
});
|
||||
}
|
||||
|
||||
RateLimiter::for('feedback', function (Request $request) {
|
||||
return Limit::perMinute(3)->by($request->user()?->id ?: $request->ip());
|
||||
});
|
||||
|
||||
@@ -33,10 +33,19 @@ class ValidHostname implements ValidationRule
|
||||
return;
|
||||
}
|
||||
|
||||
// Reject ASCII control characters (including embedded newlines, which
|
||||
// would otherwise slip through the trailing-newline-tolerant `$` anchor
|
||||
// in the per-label regex below).
|
||||
if (preg_match('/[\x00-\x1f\x7f]/', $hostname) === 1) {
|
||||
$fail('The :attribute contains invalid characters. Only letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.) are allowed.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for dangerous shell metacharacters
|
||||
$dangerousChars = [
|
||||
';', '|', '&', '$', '`', '(', ')', '{', '}',
|
||||
'<', '>', '\n', '\r', '\0', '"', "'", '\\',
|
||||
'<', '>', "\n", "\r", "\0", '"', "'", '\\',
|
||||
'!', '*', '?', '[', ']', '~', '^', ':', '#',
|
||||
'@', '%', '=', '+', ',', ' ',
|
||||
];
|
||||
@@ -104,7 +113,7 @@ class ValidHostname implements ValidationRule
|
||||
}
|
||||
|
||||
// Check if label contains only valid characters (letters, digits, hyphens)
|
||||
if (! preg_match('/^[a-z0-9-]+$/', $label)) {
|
||||
if (! preg_match('/^[a-z0-9-]+$/D', $label)) {
|
||||
$fail('The :attribute contains invalid characters. Only letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.) are allowed.');
|
||||
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Flux;
|
||||
|
||||
use App\Models\V5\RevokedAgentToken;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use Firebase\JWT\JWT;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Mints the per-host ES256 JWT that authorizes a coold host agent against flux.
|
||||
*
|
||||
* Capability scoping: by default the token carries the EXPLICIT list of
|
||||
* primitive capability strings coold advertises (config('flux.host_capabilities'),
|
||||
* mirroring coold/coold/src/grpc/client.rs:204-231) rather than the
|
||||
* `host-agent:default` wildcard profile. flux intersects the jwt `caps` with
|
||||
* coold's advertised set (flux/src/main.rs:128-141), so the effective power is
|
||||
* unchanged, but the token no longer depends on flux's
|
||||
* `capability_profile_authorizes_all` wildcard bypass (main.rs:124-126).
|
||||
*
|
||||
* @see config/flux.php for the capability list, escape hatch, TTL and kid config.
|
||||
*/
|
||||
class AgentTokenIssuer
|
||||
{
|
||||
public const DEFAULT_PROFILE = 'host-agent:default';
|
||||
|
||||
private const TTL_FLOOR_SECONDS = 60;
|
||||
|
||||
/**
|
||||
* Mint a host JWT.
|
||||
*
|
||||
* @param array<int, string>|null $capabilities Explicit caps; null resolves the configured default set (or escape-hatch profile).
|
||||
* @param int|null $ttl Lifetime in seconds; null resolves config('flux.host_token_ttl'). Clamped to a 60s floor.
|
||||
* @param array<string, mixed> $extraClaims Extra claims merged in (a `jti` here is honored, otherwise one is generated).
|
||||
*/
|
||||
public function issue(string $hostId, ?array $capabilities = null, ?int $ttl = null, array $extraClaims = []): string
|
||||
{
|
||||
if ($hostId === '') {
|
||||
throw new RuntimeException('Flux host id is required.');
|
||||
}
|
||||
|
||||
$privateKeyPath = config('flux.jwt_private_key_path');
|
||||
|
||||
if (! is_string($privateKeyPath) || $privateKeyPath === '' || ! File::isReadable($privateKeyPath)) {
|
||||
throw new RuntimeException("Flux JWT private key not found at {$privateKeyPath}.");
|
||||
}
|
||||
|
||||
$this->assertPrivateKeyPermissions($privateKeyPath);
|
||||
|
||||
$capabilities ??= $this->defaultCapabilities();
|
||||
$ttl ??= (int) config('flux.host_token_ttl', 3600);
|
||||
|
||||
$jti = $extraClaims['jti'] ?? (string) Str::uuid();
|
||||
unset($extraClaims['jti']);
|
||||
|
||||
$now = time();
|
||||
$keyId = (string) config('flux.jwt_kid', 'flux-default');
|
||||
|
||||
return JWT::encode(array_merge($extraClaims, [
|
||||
'sub' => $hostId,
|
||||
'aud' => 'coold',
|
||||
'caps' => $this->normalizeCapabilities($capabilities),
|
||||
'jti' => $jti,
|
||||
'iat' => $now,
|
||||
'exp' => $now + max(self::TTL_FLOOR_SECONDS, $ttl),
|
||||
]), File::get($privateKeyPath), 'ES256', $keyId !== '' ? $keyId : null);
|
||||
}
|
||||
|
||||
public function issueForServer(V5Server $server, ?int $ttl = null): string
|
||||
{
|
||||
$hostId = $server->fluxHostId();
|
||||
|
||||
if ($hostId === '') {
|
||||
throw new RuntimeException('Server is missing a valid Flux host id.');
|
||||
}
|
||||
|
||||
$jti = (string) Str::uuid();
|
||||
$ttl ??= (int) config('flux.host_token_ttl', 3600);
|
||||
|
||||
// team_id/cluster_id/server_id are minted as STRINGS: flux deserializes
|
||||
// the `team_id` claim as a string (coold/flux/src/auth.rs Claims), and
|
||||
// rejects the whole token with a JSON type error if it arrives as a JSON
|
||||
// integer. Keep the sibling ids string-typed for consistency.
|
||||
$token = $this->issue($hostId, $this->defaultCapabilities(), $ttl, [
|
||||
'jti' => $jti,
|
||||
'team_id' => (string) $server->team_id,
|
||||
'cluster_id' => (string) $server->cluster_id,
|
||||
'server_id' => $hostId,
|
||||
'wireguard_management_ip' => (string) $server->wireguard_management_ip,
|
||||
]);
|
||||
|
||||
// Persist the freshly issued jti (so a later destroy/re-home knows which
|
||||
// token to revoke) and its expiry (so the scheduled rotation loop knows
|
||||
// when to re-mint). Use a targeted update keyed by id so this neither
|
||||
// inserts an unsaved model nor flushes unrelated dirty attributes, and
|
||||
// does not depend on the Server model's $fillable.
|
||||
if ($server->exists) {
|
||||
$expiresAt = now()->addSeconds(max(self::TTL_FLOOR_SECONDS, $ttl));
|
||||
|
||||
V5Server::query()->whereKey($server->getKey())->update([
|
||||
'agent_token_jti' => $jti,
|
||||
'agent_token_expires_at' => $expiresAt,
|
||||
]);
|
||||
$server->setAttribute('agent_token_jti', $jti);
|
||||
$server->setAttribute('agent_token_expires_at', $expiresAt);
|
||||
$server->syncOriginalAttribute('agent_token_jti');
|
||||
$server->syncOriginalAttribute('agent_token_expires_at');
|
||||
}
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the server's currently-issued host token jti as revoked AND push
|
||||
* the revocation to flux so it rejects the jti at verify immediately.
|
||||
*
|
||||
* flux now consults a revocation denylist (flux/src/auth.rs `is_revoked`,
|
||||
* fed by `POST /v1/tokens/revoke` on the flux UDS), and Laravel pushes to it
|
||||
* here. The local `RevokedAgentToken` record remains the source of truth
|
||||
* Laravel owns; the flux push is best-effort — if flux is unreachable the
|
||||
* revocation is logged and the local record still stands, with the short TTL
|
||||
* and hourly rotation bounding the exposure until flux is reachable again.
|
||||
*/
|
||||
public function revoke(V5Server $server): void
|
||||
{
|
||||
$jti = $server->agent_token_jti;
|
||||
|
||||
if (! is_string($jti) || $jti === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$expiresAt = $server->agent_token_expires_at;
|
||||
$expiresAtUnix = $expiresAt instanceof \DateTimeInterface ? $expiresAt->getTimestamp() : null;
|
||||
|
||||
RevokedAgentToken::query()->updateOrCreate(
|
||||
['jti' => $jti],
|
||||
[
|
||||
'server_id' => $server->id,
|
||||
'revoked_at' => now(),
|
||||
'expires_at' => $expiresAt,
|
||||
]
|
||||
);
|
||||
|
||||
// Best-effort: a destroy/teardown must never fail because flux is down.
|
||||
try {
|
||||
app(FluxClient::class)->revokeToken($jti, $expiresAtUnix);
|
||||
} catch (\Throwable $exception) {
|
||||
Log::warning('Failed to push agent token revocation to Flux.', [
|
||||
'server_id' => $server->id,
|
||||
'jti' => $jti,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($server->exists) {
|
||||
V5Server::query()->whereKey($server->getKey())->update(['agent_token_jti' => null]);
|
||||
$server->setAttribute('agent_token_jti', null);
|
||||
$server->syncOriginalAttribute('agent_token_jti');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke the server's currently-issued host token. Alias of {@see revoke()}
|
||||
* kept as the name the team-teardown job resolves via `method_exists`.
|
||||
*/
|
||||
public function revokeForServer(V5Server $server): void
|
||||
{
|
||||
$this->revoke($server);
|
||||
}
|
||||
|
||||
public function isRevoked(string $jti): bool
|
||||
{
|
||||
if ($jti === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return RevokedAgentToken::query()->where('jti', $jti)->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* The default capability set for production host tokens: the explicit
|
||||
* advertised primitive list, unless the emergency escape hatch profile is
|
||||
* configured (then that single profile is minted instead).
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function defaultCapabilities(): array
|
||||
{
|
||||
$profile = config('flux.host_capability_profile');
|
||||
|
||||
if (is_string($profile) && trim($profile) !== '') {
|
||||
return [trim($profile)];
|
||||
}
|
||||
|
||||
$configured = config('flux.host_capabilities');
|
||||
|
||||
if (is_array($configured) && $configured !== []) {
|
||||
return array_values($configured);
|
||||
}
|
||||
|
||||
return [self::DEFAULT_PROFILE];
|
||||
}
|
||||
|
||||
/**
|
||||
* Warn (but do not hard-fail — that could break existing installs) when the
|
||||
* private key file is readable by group/other or is not owner-readable. The
|
||||
* key should be generated 0600, e.g.:
|
||||
* openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 \
|
||||
* -out storage/app/flux/jwt.priv && chmod 600 storage/app/flux/jwt.priv
|
||||
*/
|
||||
private function assertPrivateKeyPermissions(string $path): void
|
||||
{
|
||||
$perms = @fileperms($path);
|
||||
|
||||
if ($perms === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$mode = $perms & 0777;
|
||||
|
||||
if (($mode & 0077) !== 0 || ($mode & 0400) === 0) {
|
||||
Log::warning('Flux JWT private key has insecure permissions.', [
|
||||
'path' => $path,
|
||||
'mode' => sprintf('%04o', $mode),
|
||||
'expected' => '0600',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $capabilities
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function normalizeCapabilities(array $capabilities): array
|
||||
{
|
||||
$normalized = collect($capabilities)
|
||||
->map(fn (string $capability) => trim($capability))
|
||||
->filter()
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($normalized === []) {
|
||||
return [self::DEFAULT_PROFILE];
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Flux;
|
||||
|
||||
use App\Exceptions\V5\UnsupportedCooldVerb;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
class FluxClient
|
||||
{
|
||||
/**
|
||||
* @return array<int, array{id?: string, name?: string, image?: string, state?: string, networks?: array<int, string>}>
|
||||
*/
|
||||
public function listContainers(string $hostId): array
|
||||
{
|
||||
$payload = $this->dispatch($hostId, [
|
||||
'type' => 'containers.list',
|
||||
]);
|
||||
|
||||
$data = $payload['data'] ?? [];
|
||||
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
public function pullImage(string $hostId, string $image): string
|
||||
{
|
||||
$payload = $this->dispatch($hostId, [
|
||||
'type' => 'images.pull',
|
||||
'reference' => $image,
|
||||
]);
|
||||
|
||||
return $this->output($payload, 'Image pulled.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $spec
|
||||
*/
|
||||
public function createContainer(string $hostId, array $spec): string
|
||||
{
|
||||
$payload = $this->dispatch($hostId, [
|
||||
'type' => 'containers.create',
|
||||
...$spec,
|
||||
]);
|
||||
$data = $payload['data'] ?? [];
|
||||
$id = is_array($data) && is_string($data['id'] ?? null) ? $data['id'] : '';
|
||||
|
||||
if ($id === '') {
|
||||
throw new RuntimeException('Flux did not return a container id.');
|
||||
}
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
public function startContainer(string $hostId, string $id): string
|
||||
{
|
||||
$payload = $this->dispatch($hostId, [
|
||||
'type' => 'containers.start',
|
||||
'id' => $id,
|
||||
]);
|
||||
|
||||
return $this->output($payload, 'Container started.');
|
||||
}
|
||||
|
||||
public function stopContainer(string $hostId, string $id, int $timeoutSeconds = 10): string
|
||||
{
|
||||
$payload = $this->dispatch($hostId, [
|
||||
'type' => 'containers.stop',
|
||||
'id' => $id,
|
||||
'timeout_seconds' => max(0, $timeoutSeconds),
|
||||
]);
|
||||
|
||||
return $this->output($payload, 'Container stopped.');
|
||||
}
|
||||
|
||||
public function removeContainer(string $hostId, string $id, bool $force = false): string
|
||||
{
|
||||
$payload = $this->dispatch($hostId, [
|
||||
'type' => 'containers.delete',
|
||||
'id' => $id,
|
||||
'force' => $force,
|
||||
]);
|
||||
|
||||
return $this->output($payload, 'Container removed.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function inspectContainer(string $hostId, string $id): array
|
||||
{
|
||||
$payload = $this->dispatch($hostId, [
|
||||
'type' => 'containers.inspect',
|
||||
'id' => $id,
|
||||
]);
|
||||
$data = $payload['data'] ?? [];
|
||||
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{name: string, config: string}> $apps
|
||||
*/
|
||||
public function applyIngress(string $hostId, string $kind, string $config, array $apps = [], string $meshNetwork = 'coolify-default-mesh'): string
|
||||
{
|
||||
$payload = $this->dispatch($hostId, [
|
||||
'type' => 'ingress.apply',
|
||||
'kind' => $kind,
|
||||
'config' => $config,
|
||||
'apps' => $apps,
|
||||
'mesh_network' => $meshNetwork,
|
||||
]);
|
||||
|
||||
return $this->output($payload, 'Ingress applied.');
|
||||
}
|
||||
|
||||
public function stopIngress(string $hostId, string $kind): string
|
||||
{
|
||||
$payload = $this->dispatch($hostId, [
|
||||
'type' => 'ingress.stop',
|
||||
'kind' => $kind,
|
||||
]);
|
||||
|
||||
return $this->output($payload, 'Ingress stopped.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{id: string, namespace: string, src: string, dst: string, proto: string, port: int} $rule
|
||||
*/
|
||||
public function applyFirewallRule(string $hostId, array $rule): string
|
||||
{
|
||||
$payload = $this->dispatch($hostId, [
|
||||
'type' => 'firewall.allow',
|
||||
...$rule,
|
||||
]);
|
||||
|
||||
$data = $payload['data'] ?? [];
|
||||
$id = is_array($data) && is_string($data['id'] ?? null) ? $data['id'] : '';
|
||||
|
||||
return $id !== '' ? $id : $this->output($payload, 'Firewall rule applied.');
|
||||
}
|
||||
|
||||
public function revokeFirewallRule(string $hostId, string $id): string
|
||||
{
|
||||
$payload = $this->dispatch($hostId, [
|
||||
'type' => 'firewall.revoke',
|
||||
'id' => $id,
|
||||
]);
|
||||
|
||||
return $this->output($payload, 'Firewall rule removed.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{id?: string, namespace?: string, src?: string, dst?: string, proto?: string, port?: int}>
|
||||
*/
|
||||
public function listFirewallRules(string $hostId, string $namespace = ''): array
|
||||
{
|
||||
$payload = $this->dispatch($hostId, [
|
||||
'type' => 'firewall.list',
|
||||
'namespace' => $namespace,
|
||||
]);
|
||||
|
||||
$data = $payload['data'] ?? [];
|
||||
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
public function cooldLogs(string $hostId, int $tail = 200): string
|
||||
{
|
||||
$payload = $this->dispatch($hostId, [
|
||||
'type' => 'coold.logs',
|
||||
'tail' => max(1, min($tail, 1000)),
|
||||
]);
|
||||
|
||||
return $this->output($payload, 'No coold logs returned.');
|
||||
}
|
||||
|
||||
public function containerLogs(string $hostId, string $containerId, int $tail = 200): string
|
||||
{
|
||||
$payload = $this->dispatch($hostId, [
|
||||
'type' => 'containers.logs',
|
||||
'id' => $containerId,
|
||||
'tail' => max(1, min($tail, 1000)),
|
||||
'stdout' => true,
|
||||
'stderr' => true,
|
||||
]);
|
||||
|
||||
return $this->output($payload, 'No container logs returned.');
|
||||
}
|
||||
|
||||
public function corrosionTables(string $hostId, int $limit = 200): string
|
||||
{
|
||||
$payload = $this->dispatch($hostId, [
|
||||
'type' => 'corrosion.tables',
|
||||
'limit' => max(1, min($limit, 1000)),
|
||||
]);
|
||||
|
||||
return $this->output($payload, '{"limit":200,"tables":[]}');
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver a freshly minted host JWT to the node over the live coold RPC
|
||||
* stream (flux gates the `host.jwt.set` capability; the token must carry
|
||||
* it). Preferred over the SSH push because it reuses the already
|
||||
* authenticated flux<->coold channel and works while the current token is
|
||||
* still valid — exactly the rotation window. Throws like the sibling
|
||||
* dispatch methods (host not connected / UnsupportedCooldVerb / generic
|
||||
* failure) so the caller can catch and fall back to SSH.
|
||||
*/
|
||||
public function pushHostToken(string $hostId, string $token): void
|
||||
{
|
||||
$this->dispatch($hostId, [
|
||||
'type' => 'host.jwt.set',
|
||||
'jwt' => $token,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a host token by its `jti` on the flux revocation store so flux
|
||||
* rejects it at verify immediately, instead of waiting for the token's TTL
|
||||
* to lapse (flux/src/unix_bridge.rs `POST /v1/tokens/revoke`,
|
||||
* flux/src/auth.rs `is_revoked`). The optional `expiresAt` (the token `exp`,
|
||||
* unix seconds) lets flux prune the denylist entry once it can no longer
|
||||
* matter.
|
||||
*
|
||||
* Best-effort like the sibling dispatch methods: throws a RuntimeException on
|
||||
* connection failure / timeout / non-2xx so the caller can catch and treat
|
||||
* an unreachable flux as non-fatal (the local revocation record still
|
||||
* stands and the short TTL + rotation bound the exposure).
|
||||
*/
|
||||
public function revokeToken(string $jti, ?int $expiresAt = null): void
|
||||
{
|
||||
if (trim($jti) === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$requestBody = ['jti' => $jti];
|
||||
|
||||
if ($expiresAt !== null) {
|
||||
$requestBody['expires_at'] = $expiresAt;
|
||||
}
|
||||
|
||||
$body = json_encode($requestBody, JSON_THROW_ON_ERROR);
|
||||
$response = $this->sendOverSocket('/v1/tokens/revoke', $body);
|
||||
$statusCode = $this->statusCode($response);
|
||||
|
||||
if ($statusCode < 200 || $statusCode >= 300) {
|
||||
$responseBody = $this->responseBody($response);
|
||||
$payload = $responseBody === '' ? null : json_decode($responseBody, true);
|
||||
|
||||
throw new RuntimeException(
|
||||
$this->errorMessage($payload, $responseBody) ?? "Flux token revocation returned HTTP {$statusCode}."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $command
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function dispatch(string $hostId, array $command): array
|
||||
{
|
||||
$body = json_encode([
|
||||
'host_id' => $hostId,
|
||||
'request_id' => (string) Str::uuid(),
|
||||
'command' => $command,
|
||||
], JSON_THROW_ON_ERROR);
|
||||
|
||||
$response = $this->sendOverSocket('/v1/coold/dispatch', $body);
|
||||
|
||||
$statusCode = $this->statusCode($response);
|
||||
$responseBody = $this->responseBody($response);
|
||||
$payload = $responseBody === '' ? null : json_decode($responseBody, true);
|
||||
|
||||
if ($statusCode < 200 || $statusCode >= 300) {
|
||||
throw $this->dispatchException(
|
||||
$command,
|
||||
$statusCode,
|
||||
$this->errorMessage($payload, $responseBody) ?? "Flux dispatch returned HTTP {$statusCode}."
|
||||
);
|
||||
}
|
||||
|
||||
if (! is_array($payload)) {
|
||||
throw new RuntimeException('Flux dispatch returned an invalid response.');
|
||||
}
|
||||
|
||||
if (($payload['status'] ?? null) === 'error') {
|
||||
$message = is_string($payload['message'] ?? null) ? $payload['message'] : 'Flux dispatch failed.';
|
||||
|
||||
throw $this->dispatchException($command, $statusCode, $message);
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a single HTTP/1.1 request over the flux Unix-domain socket and return
|
||||
* the raw response. Shared by every flux verb (coold dispatch, host token
|
||||
* rotation, token revocation) — only the request path and JSON body differ.
|
||||
*/
|
||||
private function sendOverSocket(string $path, string $body): string
|
||||
{
|
||||
$socketPath = config('flux.unix_socket_path');
|
||||
|
||||
if (! is_string($socketPath) || $socketPath === '') {
|
||||
throw new RuntimeException('Flux socket is not configured.');
|
||||
}
|
||||
|
||||
if (! file_exists($socketPath)) {
|
||||
throw new RuntimeException('Flux socket was not found.');
|
||||
}
|
||||
|
||||
$connectionTimeout = (float) config('flux.connection_timeout_seconds', 1.0);
|
||||
$dispatchTimeout = (float) config('flux.dispatch_timeout_seconds', 35.0);
|
||||
$stream = @stream_socket_client("unix://{$socketPath}", $errorCode, $errorMessage, $connectionTimeout);
|
||||
|
||||
if ($stream === false) {
|
||||
throw new RuntimeException($errorMessage ?: "Could not connect to Flux socket ({$errorCode}).");
|
||||
}
|
||||
|
||||
stream_set_timeout($stream, (int) ceil($dispatchTimeout));
|
||||
|
||||
fwrite($stream, implode("\r\n", [
|
||||
"POST {$path} HTTP/1.1",
|
||||
'Host: flux',
|
||||
'Accept: application/json',
|
||||
'Content-Type: application/json',
|
||||
'Content-Length: '.strlen($body),
|
||||
'Connection: close',
|
||||
'',
|
||||
$body,
|
||||
]));
|
||||
|
||||
$response = stream_get_contents($stream) ?: '';
|
||||
fclose($stream);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flux answers a verb the node's coold did not advertise with HTTP 501 and
|
||||
* the message "primitive <verb> is not supported by host" (coold repo:
|
||||
* flux/src/routing.rs:50-53, flux/src/unix_bridge.rs:227-245). Anything
|
||||
* else — including coold-side command failures relayed with their own
|
||||
* status code — is a generic dispatch failure.
|
||||
*
|
||||
* @param array<string, mixed> $command
|
||||
*/
|
||||
private function dispatchException(array $command, int $statusCode, string $message): RuntimeException
|
||||
{
|
||||
$verb = is_string($command['type'] ?? null) ? $command['type'] : 'unknown';
|
||||
|
||||
if ($statusCode === 501 || preg_match('/primitive .+ is not supported by host/i', $message) === 1) {
|
||||
return new UnsupportedCooldVerb($verb, $message);
|
||||
}
|
||||
|
||||
return new RuntimeException($message);
|
||||
}
|
||||
|
||||
private function statusCode(string $response): int
|
||||
{
|
||||
if ($response === '') {
|
||||
throw new RuntimeException('Flux did not return a response before the timeout. Check that coold is connected to Flux and try again.');
|
||||
}
|
||||
|
||||
if (preg_match('/^HTTP\/\d(?:\.\d)?\s+(\d{3})/', $response, $matches) !== 1) {
|
||||
throw new RuntimeException('Could not talk to Flux. Check that Flux is running in the Coolify container.');
|
||||
}
|
||||
|
||||
return (int) $matches[1];
|
||||
}
|
||||
|
||||
private function responseBody(string $response): string
|
||||
{
|
||||
$position = strpos($response, "\r\n\r\n");
|
||||
|
||||
return $position === false ? '' : substr($response, $position + 4);
|
||||
}
|
||||
|
||||
private function errorMessage(mixed $payload, string $responseBody): ?string
|
||||
{
|
||||
if (is_array($payload) && is_string($payload['message'] ?? null) && $payload['message'] !== '') {
|
||||
return $payload['message'];
|
||||
}
|
||||
|
||||
$message = trim($responseBody);
|
||||
|
||||
return $message === '' ? null : Str::limit($message, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function output(array $payload, string $fallback): string
|
||||
{
|
||||
$data = $payload['data'] ?? [];
|
||||
$output = is_array($data) && is_string($data['output'] ?? null) ? $data['output'] : '';
|
||||
|
||||
return $output !== '' ? $output : $fallback;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Flux;
|
||||
|
||||
class FluxHealth
|
||||
{
|
||||
/**
|
||||
* @return array{available: bool, label: string, message: string, socket: string|null}
|
||||
*/
|
||||
public function check(): array
|
||||
{
|
||||
$socketPath = config('flux.unix_socket_path');
|
||||
|
||||
if (! is_string($socketPath) || $socketPath === '') {
|
||||
return $this->unavailable(null, 'Flux socket is not configured.');
|
||||
}
|
||||
|
||||
if (! file_exists($socketPath)) {
|
||||
return $this->unavailable($socketPath, 'Flux socket was not found.');
|
||||
}
|
||||
|
||||
$timeout = (float) config('flux.health_timeout_seconds', 1.0);
|
||||
$stream = @stream_socket_client("unix://{$socketPath}", $errorCode, $errorMessage, $timeout);
|
||||
|
||||
if ($stream === false) {
|
||||
return $this->unavailable($socketPath, $errorMessage ?: "Could not connect to Flux socket ({$errorCode}).");
|
||||
}
|
||||
|
||||
stream_set_timeout($stream, (int) ceil($timeout));
|
||||
|
||||
fwrite($stream, "GET /v1/health HTTP/1.1\r\nHost: flux\r\nAccept: application/json\r\nConnection: close\r\n\r\n");
|
||||
$response = stream_get_contents($stream) ?: '';
|
||||
fclose($stream);
|
||||
|
||||
if (! str_starts_with($response, 'HTTP/1.1 200') && ! str_starts_with($response, 'HTTP/1.0 200')) {
|
||||
return $this->unavailable($socketPath, 'Flux health endpoint did not return HTTP 200.');
|
||||
}
|
||||
|
||||
$body = str_contains($response, "\r\n\r\n") ? substr($response, strpos($response, "\r\n\r\n") + 4) : '';
|
||||
$payload = json_decode($body, true);
|
||||
|
||||
if (! is_array($payload) || ($payload['ok'] ?? false) !== true) {
|
||||
return $this->unavailable($socketPath, 'Flux health endpoint returned an invalid response.');
|
||||
}
|
||||
|
||||
return [
|
||||
'available' => true,
|
||||
'label' => 'Running',
|
||||
'message' => 'Flux is running.',
|
||||
'socket' => $socketPath,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{available: false, label: string, message: string, socket: string|null}
|
||||
*/
|
||||
private function unavailable(?string $socketPath, string $message): array
|
||||
{
|
||||
return [
|
||||
'available' => false,
|
||||
'label' => 'Unavailable',
|
||||
'message' => $message,
|
||||
'socket' => $socketPath,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\V5;
|
||||
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
|
||||
/**
|
||||
* Single source of truth for the canvas resource payloads served by the
|
||||
* dashboard Inertia props and broadcast by V5CanvasResourceUpdated — the two
|
||||
* must stay identical for websocket vs. initial-load parity.
|
||||
*/
|
||||
class CanvasResourceSerializer
|
||||
{
|
||||
public const CARD_WIDTH = 320;
|
||||
|
||||
public const CARD_HEIGHT = 144;
|
||||
|
||||
public const CARD_GAP = 32;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function serializeApplication(V5Application $application): array
|
||||
{
|
||||
$application->loadMissing(['server', 'domains', 'project', 'environment']);
|
||||
$server = $application->server;
|
||||
$isServerReachable = ! $server instanceof V5Server || $this->isServerReachable($server);
|
||||
|
||||
return [
|
||||
'id' => $application->uuid,
|
||||
'name' => $application->name,
|
||||
'image' => $application->image,
|
||||
'containerName' => $application->container_name,
|
||||
'status' => $application->status,
|
||||
'statusMessage' => $application->status_message,
|
||||
'effectiveStatus' => $isServerReachable ? $application->status : 'unknown',
|
||||
'effectiveStatusMessage' => $isServerReachable
|
||||
? $application->status_message
|
||||
: $this->serverStatusMessage($server),
|
||||
'runtimeContainerId' => $application->runtime_container_id,
|
||||
'serverName' => $server?->name,
|
||||
'serverStatus' => $server?->status,
|
||||
'serverStatusMessage' => $server instanceof V5Server ? $this->serverStatusMessage($server) : null,
|
||||
'isServerReachable' => $isServerReachable,
|
||||
'serverIngressEnabled' => (bool) $server?->isIngress(),
|
||||
'meshNamespace' => $application->mesh_namespace,
|
||||
'ingressEnabled' => $application->ingress_enabled,
|
||||
'internalPort' => $application->internal_port,
|
||||
'domains' => $application->domains->pluck('domain')->values()->all(),
|
||||
'meshFqdn' => $application->container_name.'.'.($application->mesh_namespace ?: 'default').'.coolify.internal',
|
||||
'projectUuid' => $application->project?->uuid,
|
||||
'environmentUuid' => $application->environment?->uuid,
|
||||
'canvasX' => $application->canvas_x,
|
||||
'canvasY' => $application->canvas_y,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function serializeCaddyIngress(V5Server $server, int $index = 0): array
|
||||
{
|
||||
$isServerReachable = $this->isServerReachable($server);
|
||||
|
||||
return [
|
||||
'id' => $server->uuid,
|
||||
'name' => $server->name,
|
||||
'host' => $server->host,
|
||||
'type' => $server->ingressType(),
|
||||
'status' => $isServerReachable ? $server->ingressStatus() : 'unreachable',
|
||||
'statusMessage' => $isServerReachable ? null : $this->serverStatusMessage($server),
|
||||
'canvasX' => $server->canvas_x ?? -(self::CARD_WIDTH + self::CARD_GAP),
|
||||
'canvasY' => $server->canvas_y ?? $index * (self::CARD_HEIGHT + self::CARD_GAP),
|
||||
];
|
||||
}
|
||||
|
||||
private function isServerReachable(V5Server $server): bool
|
||||
{
|
||||
return $server->status !== 'unreachable';
|
||||
}
|
||||
|
||||
private function serverStatusMessage(?V5Server $server): ?string
|
||||
{
|
||||
return $server?->last_status_output ?: null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\V5;
|
||||
|
||||
use App\Models\V5\Cluster as V5Cluster;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
|
||||
/**
|
||||
* Single source of truth for the cluster payload served by the Clusters
|
||||
* Inertia props and broadcast by V5ClusterUpdated — the two must stay
|
||||
* identical for websocket vs. initial-load parity.
|
||||
*/
|
||||
class ClusterSerializer
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function serialize(V5Cluster $cluster): array
|
||||
{
|
||||
return [
|
||||
'id' => $cluster->uuid,
|
||||
'name' => $cluster->name,
|
||||
'description' => $cluster->description,
|
||||
'wireguardInterface' => $cluster->wireguard_interface,
|
||||
'wireguardManagementPool' => $cluster->wireguard_management_pool,
|
||||
'wireguardListenPort' => $cluster->wireguard_listen_port,
|
||||
'containerNetworkPool' => $cluster->container_network_pool,
|
||||
'containerNetworkPrefix' => $cluster->container_network_prefix,
|
||||
'namespaces' => $cluster->namespaces ?? V5Cluster::DEFAULT_NAMESPACES,
|
||||
'defaultDenyContainers' => $cluster->default_deny_containers,
|
||||
'cooldVersion' => $cluster->coold_version,
|
||||
'corrosionVersion' => $cluster->corrosion_version,
|
||||
'corrosionGossipPort' => $cluster->corrosion_gossip_port,
|
||||
'corrosionApiPort' => $cluster->corrosion_api_port,
|
||||
'builderEnabled' => $cluster->builder_enabled,
|
||||
'builderCapacity' => $cluster->builder_capacity,
|
||||
'builderCpuQuota' => $cluster->builder_cpu_quota,
|
||||
'builderMemoryMax' => $cluster->builder_memory_max,
|
||||
'builderTimeoutSecs' => $cluster->builder_timeout_secs,
|
||||
'lastCliAction' => $cluster->last_cli_action,
|
||||
'lastCliStatus' => $cluster->last_cli_status,
|
||||
'lastCliSummary' => $cluster->last_cli_summary,
|
||||
'lastCliRanAt' => $cluster->last_cli_ran_at?->toJSON(),
|
||||
'serversCount' => $cluster->servers_count ?? $cluster->servers->count(),
|
||||
'servers' => $cluster->servers->map(fn (V5Server $server) => [
|
||||
'id' => $server->uuid,
|
||||
'name' => $server->name,
|
||||
'host' => $server->host,
|
||||
'status' => $server->status,
|
||||
'capabilities' => $server->capabilities ?? [],
|
||||
'builderEnabled' => $server->builder_enabled,
|
||||
'builderCapacity' => $server->builder_capacity,
|
||||
'builderCpuQuota' => $server->builder_cpu_quota,
|
||||
'ingressEnabled' => $server->isIngress(),
|
||||
'ingressType' => $server->ingress_type,
|
||||
'uuid' => $server->uuid,
|
||||
'nodeAddress' => $server->node_address,
|
||||
'wireguardListenPortOverride' => $server->wireguard_listen_port_override,
|
||||
'wireguardEndpointOverride' => $server->wireguard_endpoint_override,
|
||||
'wireguardManagementIp' => $server->wireguard_management_ip,
|
||||
'wireguardPublicKey' => $server->wireguard_public_key,
|
||||
'containerSubnets' => $server->container_subnets ?? [],
|
||||
'privateKeyName' => $server->privateKey?->name,
|
||||
'lastBootstrappedAt' => $server->last_bootstrapped_at?->toJSON(),
|
||||
'lastBootstrapAction' => $server->last_bootstrap_action,
|
||||
'lastBootstrapStatus' => $server->last_bootstrap_status,
|
||||
'lastBootstrapOutput' => $server->last_bootstrap_output,
|
||||
'lastBootstrapRanAt' => $server->last_bootstrap_ran_at?->toJSON(),
|
||||
'lastStatusOutput' => $server->last_status_output,
|
||||
'lastStatusCheckedAt' => $server->last_status_checked_at?->toJSON(),
|
||||
])->all(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload servers (with keys) and counts before serializing so the payload
|
||||
* always reflects the latest database state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function serializeFresh(V5Cluster $cluster): array
|
||||
{
|
||||
$cluster->load(['servers' => fn ($query) => $query
|
||||
->with('privateKey')
|
||||
->orderBy('name')]);
|
||||
$cluster->loadCount('servers');
|
||||
|
||||
return $this->serialize($cluster);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\V5;
|
||||
|
||||
use App\Exceptions\V5\UnsupportedCooldVerb;
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\ResourceConnection;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Services\Flux\FluxClient;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Single source of truth for deriving node firewall rules from a resource
|
||||
* connection's DB rules and converging them through Flux. Reusable from
|
||||
* controllers, jobs, and events alike; deterministic rule ids keep repeated
|
||||
* syncs and compensating rollbacks idempotent.
|
||||
*/
|
||||
class ConnectionFirewallSync
|
||||
{
|
||||
/**
|
||||
* @return Collection<int, array{id: string, hostId: string, rule: array{id: string, namespace: string, src: string, dst: string, proto: string, port: int}}>
|
||||
*/
|
||||
public function rulesFor(ResourceConnection $connection): Collection
|
||||
{
|
||||
$applicationIds = $connection->rules
|
||||
->flatMap(fn ($rule) => [$rule->source_resource_id, $rule->target_resource_id])
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
$applications = V5Application::query()
|
||||
->whereIn('id', $applicationIds)
|
||||
->with('server')
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
return $connection->rules
|
||||
->flatMap(function ($rule) use ($applications, $connection): Collection {
|
||||
$source = $applications->get($rule->source_resource_id);
|
||||
$target = $applications->get($rule->target_resource_id);
|
||||
|
||||
if (! $source instanceof V5Application || ! $target instanceof V5Application) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$missingHost = collect([$source, $target])
|
||||
->first(function (V5Application $application): bool {
|
||||
$hostId = $application->server?->fluxHostId();
|
||||
|
||||
return ! is_string($hostId) || $hostId === '';
|
||||
});
|
||||
|
||||
if ($missingHost instanceof V5Application) {
|
||||
throw new \RuntimeException("Application {$missingHost->name} has no reachable server host id, so its firewall rules cannot be synced.");
|
||||
}
|
||||
|
||||
$hostIds = collect([$source->server, $target->server])
|
||||
->map(fn (V5Server $server) => $server->fluxHostId())
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
$firewallRule = [
|
||||
'id' => $this->ruleId($connection, $rule),
|
||||
'namespace' => $target->mesh_namespace ?: 'default',
|
||||
'src' => $source->container_name,
|
||||
'dst' => $target->container_name,
|
||||
'proto' => $rule->protocol ?: 'tcp',
|
||||
'port' => (int) $rule->port,
|
||||
];
|
||||
|
||||
return $hostIds->map(fn (string $hostId): array => [
|
||||
'id' => $firewallRule['id'],
|
||||
'hostId' => $hostId,
|
||||
'rule' => $firewallRule,
|
||||
]);
|
||||
})
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, array{id: string, hostId: string, rule: array{id: string, namespace: string, src: string, dst: string, proto: string, port: int}}> $oldRules
|
||||
* @param Collection<int, array{id: string, hostId: string, rule: array{id: string, namespace: string, src: string, dst: string, proto: string, port: int}}> $newRules
|
||||
*/
|
||||
public function sync(FluxClient $fluxClient, Collection $oldRules, Collection $newRules): void
|
||||
{
|
||||
$newRuleKeys = $newRules->map(fn (array $rule): string => $this->syncKey($rule))->all();
|
||||
$oldRuleKeys = $oldRules->map(fn (array $rule): string => $this->syncKey($rule))->all();
|
||||
|
||||
$oldRules
|
||||
->reject(fn (array $oldRule): bool => in_array($this->syncKey($oldRule), $newRuleKeys, true))
|
||||
->each(fn (array $oldRule): ?string => $this->revokeRuleIfPresent($fluxClient, $oldRule['hostId'], $oldRule['id']));
|
||||
|
||||
$newRules
|
||||
->reject(fn (array $newRule): bool => in_array($this->syncKey($newRule), $oldRuleKeys, true))
|
||||
->each(function (array $newRule) use ($fluxClient): void {
|
||||
try {
|
||||
$fluxClient->applyFirewallRule($newRule['hostId'], $newRule['rule']);
|
||||
} catch (UnsupportedCooldVerb $exception) {
|
||||
Log::warning('V5 resource connection firewall rule skipped: coold verb unsupported', [
|
||||
'host_id' => $newRule['hostId'],
|
||||
'rule_id' => $newRule['id'],
|
||||
'verb' => $exception->verb,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function revokeRuleIfPresent(FluxClient $fluxClient, string $hostId, string $ruleId): ?string
|
||||
{
|
||||
try {
|
||||
return $fluxClient->revokeFirewallRule($hostId, $ruleId);
|
||||
} catch (UnsupportedCooldVerb $exception) {
|
||||
Log::warning('V5 resource connection firewall revoke skipped: coold verb unsupported', [
|
||||
'host_id' => $hostId,
|
||||
'rule_id' => $ruleId,
|
||||
'verb' => $exception->verb,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
} catch (\RuntimeException $exception) {
|
||||
if (str_contains(Str::lower($exception->getMessage()), 'not found')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic node-side rule id derived only from the connection id and
|
||||
* the rule's stable attributes — never from the rule row's primary key —
|
||||
* so rewritten or restored DB rows resolve to the same firewall rule ids
|
||||
* and compensating re-syncs stay idempotent.
|
||||
*/
|
||||
public function ruleId(ResourceConnection $connection, mixed $rule): string
|
||||
{
|
||||
return implode(':', [
|
||||
'v5-resource-connection',
|
||||
$connection->id,
|
||||
$rule->source_resource_id,
|
||||
$rule->target_resource_id,
|
||||
$rule->protocol ?: 'tcp',
|
||||
(int) $rule->port,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{id: string, hostId: string, rule: array{id: string, namespace: string, src: string, dst: string, proto: string, port: int}} $rule
|
||||
*/
|
||||
private function syncKey(array $rule): string
|
||||
{
|
||||
return $rule['hostId'].'|'.$rule['id'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\V5;
|
||||
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\ResourceConnection;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Single source of truth for the resource connection payloads served by the
|
||||
* dashboard Inertia props and the connection endpoints — the wire format is
|
||||
* consumed by resources/js/v5/types.ts and must stay stable.
|
||||
*/
|
||||
class ResourceConnectionSerializer
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function serialize(ResourceConnection $connection): array
|
||||
{
|
||||
$applications = $this->applicationsById($connection);
|
||||
$resourceOneUuid = $applications->get($connection->resource_one_id)?->uuid;
|
||||
$resourceTwoUuid = $applications->get($connection->resource_two_id)?->uuid;
|
||||
$applicationsById = $applications;
|
||||
|
||||
return [
|
||||
'id' => $connection->uuid,
|
||||
'applicationIds' => array_values(array_filter([
|
||||
$resourceOneUuid,
|
||||
$resourceTwoUuid,
|
||||
])),
|
||||
'fromApplicationId' => $resourceOneUuid,
|
||||
'toApplicationId' => $resourceTwoUuid,
|
||||
'portsByDirection' => $connection->rules
|
||||
->groupBy(function ($rule) use ($applicationsById): string {
|
||||
$sourceUuid = $applicationsById->get($rule->source_resource_id)?->uuid;
|
||||
$targetUuid = $applicationsById->get($rule->target_resource_id)?->uuid;
|
||||
|
||||
return "{$sourceUuid}->{$targetUuid}";
|
||||
})
|
||||
->filter(fn (Collection $rules, string $direction): bool => ! str_starts_with($direction, '->') && ! str_ends_with($direction, '->'))
|
||||
->map(fn (Collection $rules) => $rules
|
||||
->sortBy('port')
|
||||
->pluck('port')
|
||||
->map(fn ($port) => (string) $port)
|
||||
->values()
|
||||
->all())
|
||||
->all(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<string, V5Application>
|
||||
*/
|
||||
public function applicationsByUuid(ResourceConnection $connection): Collection
|
||||
{
|
||||
return $this->applicationsById($connection)->keyBy('uuid');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, V5Application>
|
||||
*/
|
||||
public function applicationsById(ResourceConnection $connection): Collection
|
||||
{
|
||||
return V5Application::query()
|
||||
->whereIn('id', [
|
||||
(int) $connection->resource_one_id,
|
||||
(int) $connection->resource_two_id,
|
||||
])
|
||||
->get()
|
||||
->keyBy('id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\V5;
|
||||
|
||||
use App\Enums\V5\ApplicationStatus;
|
||||
use App\Enums\V5\ContainerState;
|
||||
use App\Enums\V5\IngressStatus;
|
||||
use App\Enums\V5\ServerStatus;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Shared status-observation watermarking and enum normalization used by every
|
||||
* v5 status write path (the flux webhook, the reconcile job, and the manual
|
||||
* refresh endpoint) so out-of-order updates are dropped and raw coold states
|
||||
* are normalized identically everywhere.
|
||||
*/
|
||||
class StatusObservation
|
||||
{
|
||||
/**
|
||||
* A write whose observation timestamp is older than the one already
|
||||
* persisted is stale (delivered or computed out of order) and must not
|
||||
* clobber the newer state.
|
||||
*
|
||||
* @param array<string, mixed> $logContext
|
||||
*/
|
||||
public static function isStale(?CarbonInterface $observedAt, ?CarbonInterface $currentObservedAt, string $context, array $logContext): bool
|
||||
{
|
||||
if ($observedAt === null || $currentObservedAt === null || ! $observedAt->lt($currentObservedAt)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Log::debug("Dropping stale flux {$context} update.", [
|
||||
...$logContext,
|
||||
'observed_at' => $observedAt->toIso8601String(),
|
||||
'current_status_observed_at' => $currentObservedAt->toIso8601String(),
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a raw status string onto the given status enum. Unknown values are
|
||||
* never written to the database: they fall back to the enum's Unknown case
|
||||
* and are logged. Returns null only when no raw value is supplied.
|
||||
*
|
||||
* @param class-string<ApplicationStatus|ContainerState|IngressStatus|ServerStatus> $enumClass
|
||||
*/
|
||||
public static function normalize(?string $raw, string $enumClass): ?string
|
||||
{
|
||||
if ($raw === null || $raw === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$status = $enumClass::tryFrom(strtolower($raw));
|
||||
|
||||
if ($status === null) {
|
||||
Log::warning('Received unknown flux resource status; falling back to unknown.', [
|
||||
'raw_status' => $raw,
|
||||
'status_enum' => $enumClass,
|
||||
]);
|
||||
|
||||
return $enumClass::Unknown->value;
|
||||
}
|
||||
|
||||
return $status->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\V5;
|
||||
|
||||
class V5Feature
|
||||
{
|
||||
private const DEVELOPMENT_ENVIRONMENTS = ['local', 'development', 'dev', 'testing'];
|
||||
|
||||
public static function enabled(): bool
|
||||
{
|
||||
return (bool) config('v5.enabled');
|
||||
}
|
||||
|
||||
public static function enabledForEnvironment(string $environment): bool
|
||||
{
|
||||
return in_array($environment, self::DEVELOPMENT_ENVIRONMENTS, true);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
project_name: "Coolify"
|
||||
default_status: "To Do"
|
||||
statuses: ["To Do", "In Progress", "Done"]
|
||||
labels: []
|
||||
milestones: []
|
||||
date_format: yyyy-mm-dd
|
||||
max_column_width: 20
|
||||
default_editor: "vim"
|
||||
auto_open_browser: true
|
||||
default_port: 6420
|
||||
remote_operations: true
|
||||
auto_commit: false
|
||||
zero_padded_ids: 5
|
||||
bypass_git_hooks: true
|
||||
check_active_branches: true
|
||||
active_branch_days: 30
|
||||
-58
@@ -1,58 +0,0 @@
|
||||
---
|
||||
id: task-00001
|
||||
title: Implement Docker build caching for Coolify staging builds
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2025-08-26 12:15'
|
||||
updated_date: '2025-08-26 12:16'
|
||||
labels:
|
||||
- heyandras
|
||||
- performance
|
||||
- docker
|
||||
- ci-cd
|
||||
- build-optimization
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Implement comprehensive Docker build caching to reduce staging build times by 50-70% through BuildKit cache mounts for dependencies and GitHub Actions registry caching. This optimization will significantly reduce build times from ~10-15 minutes to ~3-5 minutes, decrease network usage, and lower GitHub Actions costs.
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Docker BuildKit cache mounts are added to Composer dependency installation in production Dockerfile
|
||||
- [ ] #2 Docker BuildKit cache mounts are added to NPM dependency installation in production Dockerfile
|
||||
- [ ] #3 GitHub Actions BuildX setup is configured for both AMD64 and AARCH64 jobs
|
||||
- [ ] #4 Registry cache-from and cache-to configurations are implemented for both architecture builds
|
||||
- [ ] #5 Build time reduction of at least 40% is achieved in staging builds
|
||||
- [ ] #6 GitHub Actions minutes consumption is reduced compared to baseline
|
||||
- [ ] #7 All existing build functionality remains intact with no regressions
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
1. Modify docker/production/Dockerfile to add BuildKit cache mounts:
|
||||
- Add cache mount for Composer dependencies at line 30: --mount=type=cache,target=/var/www/.composer/cache
|
||||
- Add cache mount for NPM dependencies at line 41: --mount=type=cache,target=/root/.npm
|
||||
|
||||
2. Update .github/workflows/coolify-staging-build.yml for AMD64 job:
|
||||
- Add docker/setup-buildx-action@v3 step after checkout
|
||||
- Configure cache-from and cache-to parameters in build-push-action
|
||||
- Use registry caching with buildcache-amd64 tags
|
||||
|
||||
3. Update .github/workflows/coolify-staging-build.yml for AARCH64 job:
|
||||
- Add docker/setup-buildx-action@v3 step after checkout
|
||||
- Configure cache-from and cache-to parameters in build-push-action
|
||||
- Use registry caching with buildcache-aarch64 tags
|
||||
|
||||
4. Test implementation:
|
||||
- Measure baseline build times before changes
|
||||
- Deploy changes and monitor initial build (will be slower due to cache population)
|
||||
- Measure subsequent build times to verify 40%+ improvement
|
||||
- Validate all build outputs and functionality remain unchanged
|
||||
|
||||
5. Monitor and validate:
|
||||
- Track GitHub Actions minutes consumption reduction
|
||||
- Ensure Docker registry storage usage is reasonable
|
||||
- Verify no build failures or regressions introduced
|
||||
@@ -1,24 +0,0 @@
|
||||
---
|
||||
id: task-00001.01
|
||||
title: Add BuildKit cache mounts to Dockerfile
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2025-08-26 12:19'
|
||||
labels:
|
||||
- docker
|
||||
- buildkit
|
||||
- performance
|
||||
- dockerfile
|
||||
dependencies: []
|
||||
parent_task_id: task-00001
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Modify the production Dockerfile to include BuildKit cache mounts for Composer and NPM dependencies to speed up subsequent builds by reusing cached dependency installations
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Cache mount for Composer dependencies is added at line 30 with --mount=type=cache target=/var/www/.composer/cache,Cache mount for NPM dependencies is added at line 41 with --mount=type=cache target=/root/.npm,Dockerfile syntax remains valid and builds successfully,All existing functionality is preserved with no regressions
|
||||
<!-- AC:END -->
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
---
|
||||
id: task-00001.02
|
||||
title: Configure BuildX and registry caching for AMD64 staging builds
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2025-08-26 12:19'
|
||||
labels:
|
||||
- github-actions
|
||||
- buildx
|
||||
- caching
|
||||
- amd64
|
||||
dependencies: []
|
||||
parent_task_id: task-00001
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Update the GitHub Actions workflow to add BuildX setup and configure registry-based caching for the AMD64 build job to leverage Docker layer caching across builds
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 docker/setup-buildx-action@v3 step is added after checkout in AMD64 job,Registry cache configuration is added to build-push-action with cache-from and cache-to parameters,Cache tags use buildcache-amd64 naming convention for architecture-specific caching,Build job runs successfully with caching enabled,No impact on existing build outputs or functionality
|
||||
<!-- AC:END -->
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user