193
.claude/agents/project-manager-backlog.md
Normal file
193
.claude/agents/project-manager-backlog.md
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
---
|
||||||
|
name: project-manager-backlog
|
||||||
|
description: Use this agent when you need to manage project tasks using the backlog.md CLI tool. This includes creating new tasks, editing tasks, ensuring tasks follow the proper format and guidelines, breaking down large tasks into atomic units, and maintaining the project's task management workflow. Examples: <example>Context: User wants to create a new task for adding a feature. user: "I need to add a new authentication system to the project" assistant: "I'll use the project-manager-backlog agent that will use backlog cli to create a properly structured task for this feature." <commentary>Since the user needs to create a task for the project, use the Task tool to launch the project-manager-backlog agent to ensure the task follows backlog.md guidelines.</commentary></example> <example>Context: User has multiple related features to implement. user: "We need to implement user profiles, settings page, and notification preferences" assistant: "Let me use the project-manager-backlog agent to break these down into atomic, independent tasks." <commentary>The user has a complex set of features that need to be broken down into proper atomic tasks following backlog.md structure.</commentary></example> <example>Context: User wants to review if their task description is properly formatted. user: "Can you check if this task follows our guidelines: 'task-123 - Implement user login'" assistant: "I'll use the project-manager-backlog agent to review this task against our backlog.md standards." <commentary>The user needs task review, so use the project-manager-backlog agent to ensure compliance with project guidelines.</commentary></example>
|
||||||
|
color: blue
|
||||||
|
---
|
||||||
|
|
||||||
|
You are an expert project manager specializing in the backlog.md task management system. You have deep expertise in creating well-structured, atomic, and testable tasks that follow software development best practices.
|
||||||
|
|
||||||
|
## Backlog.md CLI Tool
|
||||||
|
|
||||||
|
**IMPORTANT: Backlog.md uses standard CLI commands, NOT slash commands.**
|
||||||
|
|
||||||
|
You use the `backlog` CLI tool to manage project tasks. This tool allows you to create, edit, and manage tasks in a structured way using Markdown files. You will never create tasks manually; instead, you will use the CLI commands to ensure all tasks are properly formatted and adhere to the project's guidelines.
|
||||||
|
|
||||||
|
The backlog CLI is installed globally and available in the PATH. Here are the exact commands you should use:
|
||||||
|
|
||||||
|
### Creating Tasks
|
||||||
|
```bash
|
||||||
|
backlog task create "Task title" -d "Description" --ac "First criteria,Second criteria" -l label1,label2
|
||||||
|
```
|
||||||
|
|
||||||
|
### Editing Tasks
|
||||||
|
```bash
|
||||||
|
backlog task edit 123 -s "In Progress" -a @claude
|
||||||
|
```
|
||||||
|
|
||||||
|
### Listing Tasks
|
||||||
|
```bash
|
||||||
|
backlog task list --plain
|
||||||
|
```
|
||||||
|
|
||||||
|
**NEVER use slash commands like `/create-task` or `/edit`. These do not exist in Backlog.md.**
|
||||||
|
**ALWAYS use the standard CLI format: `backlog task create` (without any slash prefix).**
|
||||||
|
|
||||||
|
### Example Usage
|
||||||
|
|
||||||
|
When a user asks you to create a task, here's exactly what you should do:
|
||||||
|
|
||||||
|
**User**: "Create a task to add user authentication"
|
||||||
|
**You should run**:
|
||||||
|
```bash
|
||||||
|
backlog task create "Add user authentication system" -d "Implement a secure authentication system to allow users to register and login" --ac "Users can register with email and password,Users can login with valid credentials,Invalid login attempts show appropriate error messages" -l authentication,backend
|
||||||
|
```
|
||||||
|
|
||||||
|
**NOT**: `/create-task "Add user authentication"` ❌ (This is wrong - slash commands don't exist)
|
||||||
|
|
||||||
|
## Your Core Responsibilities
|
||||||
|
|
||||||
|
1. **Task Creation**: You create tasks that strictly adhere to the backlog.md cli commands. Never create tasks manually. Use available task create parameters to ensure tasks are properly structured and follow the guidelines.
|
||||||
|
2. **Task Review**: You ensure all tasks meet the quality standards for atomicity, testability, and independence and task anatomy from below.
|
||||||
|
3. **Task Breakdown**: You expertly decompose large features into smaller, manageable tasks
|
||||||
|
4. **Context understanding**: You analyze user requests against the project codebase and existing tasks to ensure relevance and accuracy
|
||||||
|
5. **Handling ambiguity**: You clarify vague or ambiguous requests by asking targeted questions to the user to gather necessary details
|
||||||
|
|
||||||
|
## Task Creation Guidelines
|
||||||
|
|
||||||
|
### **Title (one liner)**
|
||||||
|
|
||||||
|
Use a clear brief title that summarizes the task.
|
||||||
|
|
||||||
|
### **Description**: (The **"why"**)
|
||||||
|
|
||||||
|
Provide a concise summary of the task purpose and its goal. Do not add implementation details here. It
|
||||||
|
should explain the purpose, the scope and context of the task. Code snippets should be avoided.
|
||||||
|
|
||||||
|
### **Acceptance Criteria**: (The **"what"**)
|
||||||
|
|
||||||
|
List specific, measurable outcomes that define what means to reach the goal from the description. Use checkboxes (`- [ ]`) for tracking.
|
||||||
|
When defining `## Acceptance Criteria` for a task, focus on **outcomes, behaviors, and verifiable requirements** rather
|
||||||
|
than step-by-step implementation details.
|
||||||
|
Acceptance Criteria (AC) define *what* conditions must be met for the task to be considered complete.
|
||||||
|
They should be testable and confirm that the core purpose of the task is achieved.
|
||||||
|
**Key Principles for Good ACs:**
|
||||||
|
|
||||||
|
- **Outcome-Oriented:** Focus on the result, not the method.
|
||||||
|
- **Testable/Verifiable:** Each criterion should be something that can be objectively tested or verified.
|
||||||
|
- **Clear and Concise:** Unambiguous language.
|
||||||
|
- **Complete:** Collectively, ACs should cover the scope of the task.
|
||||||
|
- **User-Focused (where applicable):** Frame ACs from the perspective of the end-user or the system's external behavior.
|
||||||
|
|
||||||
|
- *Good Example:* "- [ ] User can successfully log in with valid credentials."
|
||||||
|
- *Good Example:* "- [ ] System processes 1000 requests per second without errors."
|
||||||
|
- *Bad Example (Implementation Step):* "- [ ] Add a new function `handleLogin()` in `auth.ts`."
|
||||||
|
|
||||||
|
### Task file
|
||||||
|
|
||||||
|
Once a task is created using backlog cli, it will be stored in `backlog/tasks/` directory as a Markdown file with the format
|
||||||
|
`task-<id> - <title>.md` (e.g. `task-42 - Add GraphQL resolver.md`).
|
||||||
|
|
||||||
|
## Task Breakdown Strategy
|
||||||
|
|
||||||
|
When breaking down features:
|
||||||
|
1. Identify the foundational components first
|
||||||
|
2. Create tasks in dependency order (foundations before features)
|
||||||
|
3. Ensure each task delivers value independently
|
||||||
|
4. Avoid creating tasks that block each other
|
||||||
|
|
||||||
|
### Additional task requirements
|
||||||
|
|
||||||
|
- Tasks must be **atomic** and **testable**. If a task is too large, break it down into smaller subtasks.
|
||||||
|
Each task should represent a single unit of work that can be completed in a single PR.
|
||||||
|
|
||||||
|
- **Never** reference tasks that are to be done in the future or that are not yet created. You can only reference
|
||||||
|
previous tasks (id < current task id).
|
||||||
|
|
||||||
|
- When creating multiple tasks, ensure they are **independent** and they do not depend on future tasks.
|
||||||
|
Example of correct tasks splitting: task 1: "Add system for handling API requests", task 2: "Add user model and DB
|
||||||
|
schema", task 3: "Add API endpoint for user data".
|
||||||
|
Example of wrong tasks splitting: task 1: "Add API endpoint for user data", task 2: "Define the user model and DB
|
||||||
|
schema".
|
||||||
|
|
||||||
|
## Recommended Task Anatomy
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# task‑42 - Add GraphQL resolver
|
||||||
|
|
||||||
|
## Description (the why)
|
||||||
|
|
||||||
|
Short, imperative explanation of the goal of the task and why it is needed.
|
||||||
|
|
||||||
|
## Acceptance Criteria (the what)
|
||||||
|
|
||||||
|
- [ ] Resolver returns correct data for happy path
|
||||||
|
- [ ] Error response matches REST
|
||||||
|
- [ ] P95 latency ≤ 50 ms under 100 RPS
|
||||||
|
|
||||||
|
## Implementation Plan (the how) (added after putting the task in progress but before implementing any code change)
|
||||||
|
|
||||||
|
1. Research existing GraphQL resolver patterns
|
||||||
|
2. Implement basic resolver with error handling
|
||||||
|
3. Add performance monitoring
|
||||||
|
4. Write unit and integration tests
|
||||||
|
5. Benchmark performance under load
|
||||||
|
|
||||||
|
## Implementation Notes (for reviewers) (only added after finishing the code implementation of a task)
|
||||||
|
|
||||||
|
- Approach taken
|
||||||
|
- Features implemented or modified
|
||||||
|
- Technical decisions and trade-offs
|
||||||
|
- Modified or added files
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quality Checks
|
||||||
|
|
||||||
|
Before finalizing any task creation, verify:
|
||||||
|
- [ ] Title is clear and brief
|
||||||
|
- [ ] Description explains WHY without HOW
|
||||||
|
- [ ] Each AC is outcome-focused and testable
|
||||||
|
- [ ] Task is atomic (single PR scope)
|
||||||
|
- [ ] No dependencies on future tasks
|
||||||
|
|
||||||
|
You are meticulous about these standards and will guide users to create high-quality tasks that enhance project productivity and maintainability.
|
||||||
|
|
||||||
|
## Self reflection
|
||||||
|
When creating a task, always think from the perspective of an AI Agent that will have to work with this task in the future.
|
||||||
|
Ensure that the task is structured in a way that it can be easily understood and processed by AI coding agents.
|
||||||
|
|
||||||
|
## Handy CLI Commands
|
||||||
|
|
||||||
|
| Action | Example |
|
||||||
|
|-------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
|
| Create task | `backlog task create "Add OAuth System"` |
|
||||||
|
| Create with description | `backlog task create "Feature" -d "Add authentication system"` |
|
||||||
|
| Create with assignee | `backlog task create "Feature" -a @sara` |
|
||||||
|
| Create with status | `backlog task create "Feature" -s "In Progress"` |
|
||||||
|
| Create with labels | `backlog task create "Feature" -l auth,backend` |
|
||||||
|
| Create with priority | `backlog task create "Feature" --priority high` |
|
||||||
|
| Create with plan | `backlog task create "Feature" --plan "1. Research\n2. Implement"` |
|
||||||
|
| Create with AC | `backlog task create "Feature" --ac "Must work,Must be tested"` |
|
||||||
|
| Create with notes | `backlog task create "Feature" --notes "Started initial research"` |
|
||||||
|
| Create with deps | `backlog task create "Feature" --dep task-1,task-2` |
|
||||||
|
| Create sub task | `backlog task create -p 14 "Add Login with Google"` |
|
||||||
|
| Create (all options) | `backlog task create "Feature" -d "Description" -a @sara -s "To Do" -l auth --priority high --ac "Must work" --notes "Initial setup done" --dep task-1 -p 14` |
|
||||||
|
| List tasks | `backlog task list [-s <status>] [-a <assignee>] [-p <parent>]` |
|
||||||
|
| List by parent | `backlog task list --parent 42` or `backlog task list -p task-42` |
|
||||||
|
| View detail | `backlog task 7` (interactive UI, press 'E' to edit in editor) |
|
||||||
|
| View (AI mode) | `backlog task 7 --plain` |
|
||||||
|
| Edit | `backlog task edit 7 -a @sara -l auth,backend` |
|
||||||
|
| Add plan | `backlog task edit 7 --plan "Implementation approach"` |
|
||||||
|
| Add AC | `backlog task edit 7 --ac "New criterion,Another one"` |
|
||||||
|
| Add notes | `backlog task edit 7 --notes "Completed X, working on Y"` |
|
||||||
|
| Add deps | `backlog task edit 7 --dep task-1 --dep task-2` |
|
||||||
|
| Archive | `backlog task archive 7` |
|
||||||
|
| Create draft | `backlog task create "Feature" --draft` |
|
||||||
|
| Draft flow | `backlog draft create "Spike GraphQL"` → `backlog draft promote 3.1` |
|
||||||
|
| Demote to draft | `backlog task demote <id>` |
|
||||||
|
|
||||||
|
Full help: `backlog --help`
|
||||||
|
|
||||||
|
## Tips for AI Agents
|
||||||
|
|
||||||
|
- **Always use `--plain` flag** when listing or viewing tasks for AI-friendly text output instead of using Backlog.md
|
||||||
|
interactive UI.
|
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
description:
|
description: Complete guide to Coolify Cursor rules and development patterns
|
||||||
globs:
|
globs: .cursor/rules/*.mdc
|
||||||
alwaysApply: false
|
alwaysApply: false
|
||||||
---
|
---
|
||||||
# Coolify Cursor Rules - Complete Guide
|
# Coolify Cursor Rules - Complete Guide
|
||||||
@@ -18,6 +18,7 @@ This comprehensive set of Cursor Rules provides deep insights into **Coolify**,
|
|||||||
|
|
||||||
### 🎨 Frontend Development
|
### 🎨 Frontend Development
|
||||||
- **[frontend-patterns.mdc](mdc:.cursor/rules/frontend-patterns.mdc)** - Livewire + Alpine.js + Tailwind architecture
|
- **[frontend-patterns.mdc](mdc:.cursor/rules/frontend-patterns.mdc)** - Livewire + Alpine.js + Tailwind architecture
|
||||||
|
- **[form-components.mdc](mdc:.cursor/rules/form-components.mdc)** - Enhanced form components with built-in authorization
|
||||||
|
|
||||||
### 🗄️ Data & Backend
|
### 🗄️ Data & Backend
|
||||||
- **[database-patterns.mdc](mdc:.cursor/rules/database-patterns.mdc)** - Database architecture, models, and data management
|
- **[database-patterns.mdc](mdc:.cursor/rules/database-patterns.mdc)** - Database architecture, models, and data management
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
description:
|
description: RESTful API design, routing patterns, webhooks, and HTTP communication
|
||||||
globs:
|
globs: routes/*.php, app/Http/Controllers/**/*.php, app/Http/Resources/*.php, app/Http/Requests/*.php
|
||||||
alwaysApply: false
|
alwaysApply: false
|
||||||
---
|
---
|
||||||
# Coolify API & Routing Architecture
|
# Coolify API & Routing Architecture
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
description:
|
description: Laravel application structure, patterns, and architectural decisions
|
||||||
globs:
|
globs: app/**/*.php, config/*.php, bootstrap/**/*.php
|
||||||
alwaysApply: false
|
alwaysApply: false
|
||||||
---
|
---
|
||||||
# Coolify Application Architecture
|
# Coolify Application Architecture
|
||||||
|
398
.cursor/rules/backlog-guildlines.md
Normal file
398
.cursor/rules/backlog-guildlines.md
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
|
||||||
|
# === BACKLOG.MD GUIDELINES START ===
|
||||||
|
# Instructions for the usage of Backlog.md CLI Tool
|
||||||
|
|
||||||
|
## What is Backlog.md?
|
||||||
|
|
||||||
|
**Backlog.md is the complete project management system for this codebase.** It provides everything needed to manage tasks, track progress, and collaborate on development - all through a powerful CLI that operates on markdown files.
|
||||||
|
|
||||||
|
### Core Capabilities
|
||||||
|
|
||||||
|
✅ **Task Management**: Create, edit, assign, prioritize, and track tasks with full metadata
|
||||||
|
✅ **Acceptance Criteria**: Granular control with add/remove/check/uncheck by index
|
||||||
|
✅ **Board Visualization**: Terminal-based Kanban board (`backlog board`) and web UI (`backlog browser`)
|
||||||
|
✅ **Git Integration**: Automatic tracking of task states across branches
|
||||||
|
✅ **Dependencies**: Task relationships and subtask hierarchies
|
||||||
|
✅ **Documentation & Decisions**: Structured docs and architectural decision records
|
||||||
|
✅ **Export & Reporting**: Generate markdown reports and board snapshots
|
||||||
|
✅ **AI-Optimized**: `--plain` flag provides clean text output for AI processing
|
||||||
|
|
||||||
|
### Why This Matters to You (AI Agent)
|
||||||
|
|
||||||
|
1. **Comprehensive system** - Full project management capabilities through CLI
|
||||||
|
2. **The CLI is the interface** - All operations go through `backlog` commands
|
||||||
|
3. **Unified interaction model** - You can use CLI for both reading (`backlog task 1 --plain`) and writing (`backlog task edit 1`)
|
||||||
|
4. **Metadata stays synchronized** - The CLI handles all the complex relationships
|
||||||
|
|
||||||
|
### Key Understanding
|
||||||
|
|
||||||
|
- **Tasks** live in `backlog/tasks/` as `task-<id> - <title>.md` files
|
||||||
|
- **You interact via CLI only**: `backlog task create`, `backlog task edit`, etc.
|
||||||
|
- **Use `--plain` flag** for AI-friendly output when viewing/listing
|
||||||
|
- **Never bypass the CLI** - It handles Git, metadata, file naming, and relationships
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# ⚠️ CRITICAL: NEVER EDIT TASK FILES DIRECTLY
|
||||||
|
|
||||||
|
**ALL task operations MUST use the Backlog.md CLI commands**
|
||||||
|
- ✅ **DO**: Use `backlog task edit` and other CLI commands
|
||||||
|
- ✅ **DO**: Use `backlog task create` to create new tasks
|
||||||
|
- ✅ **DO**: Use `backlog task edit <id> --check-ac <index>` to mark acceptance criteria
|
||||||
|
- ❌ **DON'T**: Edit markdown files directly
|
||||||
|
- ❌ **DON'T**: Manually change checkboxes in files
|
||||||
|
- ❌ **DON'T**: Add or modify text in task files without using CLI
|
||||||
|
|
||||||
|
**Why?** Direct file editing breaks metadata synchronization, Git tracking, and task relationships.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Source of Truth & File Structure
|
||||||
|
|
||||||
|
### 📖 **UNDERSTANDING** (What you'll see when reading)
|
||||||
|
- Markdown task files live under **`backlog/tasks/`** (drafts under **`backlog/drafts/`**)
|
||||||
|
- Files are named: `task-<id> - <title>.md` (e.g., `task-42 - Add GraphQL resolver.md`)
|
||||||
|
- Project documentation is in **`backlog/docs/`**
|
||||||
|
- Project decisions are in **`backlog/decisions/`**
|
||||||
|
|
||||||
|
### 🔧 **ACTING** (How to change things)
|
||||||
|
- **All task operations MUST use the Backlog.md CLI tool**
|
||||||
|
- This ensures metadata is correctly updated and the project stays in sync
|
||||||
|
- **Always use `--plain` flag** when listing or viewing tasks for AI-friendly text output
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Common Mistakes to Avoid
|
||||||
|
|
||||||
|
### ❌ **WRONG: Direct File Editing**
|
||||||
|
```markdown
|
||||||
|
# DON'T DO THIS:
|
||||||
|
1. Open backlog/tasks/task-7 - Feature.md in editor
|
||||||
|
2. Change "- [ ]" to "- [x]" manually
|
||||||
|
3. Add notes directly to the file
|
||||||
|
4. Save the file
|
||||||
|
```
|
||||||
|
|
||||||
|
### ✅ **CORRECT: Using CLI Commands**
|
||||||
|
```bash
|
||||||
|
# DO THIS INSTEAD:
|
||||||
|
backlog task edit 7 --check-ac 1 # Mark AC #1 as complete
|
||||||
|
backlog task edit 7 --notes "Implementation complete" # Add notes
|
||||||
|
backlog task edit 7 -s "In Progress" -a @agent-k # Multiple commands: change status and assign the task
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Understanding Task Format (Read-Only Reference)
|
||||||
|
|
||||||
|
⚠️ **FORMAT REFERENCE ONLY** - The following sections show what you'll SEE in task files.
|
||||||
|
**Never edit these directly! Use CLI commands to make changes.**
|
||||||
|
|
||||||
|
### Task Structure You'll See
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
id: task-42
|
||||||
|
title: Add GraphQL resolver
|
||||||
|
status: To Do
|
||||||
|
assignee: [@sara]
|
||||||
|
labels: [backend, api]
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
Brief explanation of the task purpose.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [ ] #1 First criterion
|
||||||
|
- [x] #2 Second criterion (completed)
|
||||||
|
- [ ] #3 Third criterion
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
1. Research approach
|
||||||
|
2. Implement solution
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
Summary of what was done.
|
||||||
|
```
|
||||||
|
|
||||||
|
### How to Modify Each Section
|
||||||
|
|
||||||
|
| What You Want to Change | CLI Command to Use |
|
||||||
|
|------------------------|-------------------|
|
||||||
|
| Title | `backlog task edit 42 -t "New Title"` |
|
||||||
|
| Status | `backlog task edit 42 -s "In Progress"` |
|
||||||
|
| Assignee | `backlog task edit 42 -a @sara` |
|
||||||
|
| Labels | `backlog task edit 42 -l backend,api` |
|
||||||
|
| Description | `backlog task edit 42 -d "New description"` |
|
||||||
|
| Add AC | `backlog task edit 42 --ac "New criterion"` |
|
||||||
|
| Check AC #1 | `backlog task edit 42 --check-ac 1` |
|
||||||
|
| Uncheck AC #2 | `backlog task edit 42 --uncheck-ac 2` |
|
||||||
|
| Remove AC #3 | `backlog task edit 42 --remove-ac 3` |
|
||||||
|
| Add Plan | `backlog task edit 42 --plan "1. Step one\n2. Step two"` |
|
||||||
|
| Add Notes | `backlog task edit 42 --notes "What I did"` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Defining Tasks
|
||||||
|
|
||||||
|
### Creating New Tasks
|
||||||
|
|
||||||
|
**Always use CLI to create tasks:**
|
||||||
|
```bash
|
||||||
|
backlog task create "Task title" -d "Description" --ac "First criterion" --ac "Second criterion"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Title (one liner)
|
||||||
|
Use a clear brief title that summarizes the task.
|
||||||
|
|
||||||
|
### Description (The "why")
|
||||||
|
Provide a concise summary of the task purpose and its goal. Explains the context without implementation details.
|
||||||
|
|
||||||
|
### Acceptance Criteria (The "what")
|
||||||
|
|
||||||
|
**Understanding the Format:**
|
||||||
|
- Acceptance criteria appear as numbered checkboxes in the markdown files
|
||||||
|
- Format: `- [ ] #1 Criterion text` (unchecked) or `- [x] #1 Criterion text` (checked)
|
||||||
|
|
||||||
|
**Managing Acceptance Criteria via CLI:**
|
||||||
|
|
||||||
|
⚠️ **IMPORTANT: How AC Commands Work**
|
||||||
|
- **Adding criteria (`--ac`)** accepts multiple flags: `--ac "First" --ac "Second"` ✅
|
||||||
|
- **Checking/unchecking/removing** accept multiple flags too: `--check-ac 1 --check-ac 2` ✅
|
||||||
|
- **Mixed operations** work in a single command: `--check-ac 1 --uncheck-ac 2 --remove-ac 3` ✅
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Add new criteria (MULTIPLE values allowed)
|
||||||
|
backlog task edit 42 --ac "User can login" --ac "Session persists"
|
||||||
|
|
||||||
|
# Check specific criteria by index (MULTIPLE values supported)
|
||||||
|
backlog task edit 42 --check-ac 1 --check-ac 2 --check-ac 3 # Check multiple ACs
|
||||||
|
# Or check them individually if you prefer:
|
||||||
|
backlog task edit 42 --check-ac 1 # Mark #1 as complete
|
||||||
|
backlog task edit 42 --check-ac 2 # Mark #2 as complete
|
||||||
|
|
||||||
|
# Mixed operations in single command
|
||||||
|
backlog task edit 42 --check-ac 1 --uncheck-ac 2 --remove-ac 3
|
||||||
|
|
||||||
|
# ❌ STILL WRONG - These formats don't work:
|
||||||
|
# backlog task edit 42 --check-ac 1,2,3 # No comma-separated values
|
||||||
|
# backlog task edit 42 --check-ac 1-3 # No ranges
|
||||||
|
# backlog task edit 42 --check 1 # Wrong flag name
|
||||||
|
|
||||||
|
# Multiple operations of same type
|
||||||
|
backlog task edit 42 --uncheck-ac 1 --uncheck-ac 2 # Uncheck multiple ACs
|
||||||
|
backlog task edit 42 --remove-ac 2 --remove-ac 4 # Remove multiple ACs (processed high-to-low)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Principles for Good ACs:**
|
||||||
|
- **Outcome-Oriented:** Focus on the result, not the method
|
||||||
|
- **Testable/Verifiable:** Each criterion should be objectively testable
|
||||||
|
- **Clear and Concise:** Unambiguous language
|
||||||
|
- **Complete:** Collectively cover the task scope
|
||||||
|
- **User-Focused:** Frame from end-user or system behavior perspective
|
||||||
|
|
||||||
|
Good Examples:
|
||||||
|
- "User can successfully log in with valid credentials"
|
||||||
|
- "System processes 1000 requests per second without errors"
|
||||||
|
|
||||||
|
Bad Example (Implementation Step):
|
||||||
|
- "Add a new function handleLogin() in auth.ts"
|
||||||
|
|
||||||
|
### Task Breakdown Strategy
|
||||||
|
|
||||||
|
1. Identify foundational components first
|
||||||
|
2. Create tasks in dependency order (foundations before features)
|
||||||
|
3. Ensure each task delivers value independently
|
||||||
|
4. Avoid creating tasks that block each other
|
||||||
|
|
||||||
|
### Task Requirements
|
||||||
|
|
||||||
|
- Tasks must be **atomic** and **testable** or **verifiable**
|
||||||
|
- Each task should represent a single unit of work for one PR
|
||||||
|
- **Never** reference future tasks (only tasks with id < current task id)
|
||||||
|
- Ensure tasks are **independent** and don't depend on future work
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Implementing Tasks
|
||||||
|
|
||||||
|
### Implementation Plan (The "how") (only after starting work)
|
||||||
|
```bash
|
||||||
|
backlog task edit 42 -s "In Progress" -a @{myself}
|
||||||
|
backlog task edit 42 --plan "1. Research patterns\n2. Implement\n3. Test"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Implementation Notes (Imagine you need to copy paste this into a PR description)
|
||||||
|
```bash
|
||||||
|
backlog task edit 42 --notes "Implemented using pattern X, modified files Y and Z"
|
||||||
|
```
|
||||||
|
|
||||||
|
**IMPORTANT**: Do NOT include an Implementation Plan when creating a task. The plan is added only after you start implementation.
|
||||||
|
- Creation phase: provide Title, Description, Acceptance Criteria, and optionally labels/priority/assignee.
|
||||||
|
- When you begin work, switch to edit and add the plan: `backlog task edit <id> --plan "..."`.
|
||||||
|
- Add Implementation Notes only after completing the work: `backlog task edit <id> --notes "..."`.
|
||||||
|
|
||||||
|
Phase discipline: What goes where
|
||||||
|
- Creation: Title, Description, Acceptance Criteria, labels/priority/assignee.
|
||||||
|
- Implementation: Implementation Plan (after moving to In Progress).
|
||||||
|
- Wrap-up: Implementation Notes, AC and Definition of Done checks.
|
||||||
|
|
||||||
|
**IMPORTANT**: Only implement what's in the Acceptance Criteria. If you need to do more, either:
|
||||||
|
1. Update the AC first: `backlog task edit 42 --ac "New requirement"`
|
||||||
|
2. Or create a new task: `backlog task create "Additional feature"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Typical Workflow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Identify work
|
||||||
|
backlog task list -s "To Do" --plain
|
||||||
|
|
||||||
|
# 2. Read task details
|
||||||
|
backlog task 42 --plain
|
||||||
|
|
||||||
|
# 3. Start work: assign yourself & change status
|
||||||
|
backlog task edit 42 -a @myself -s "In Progress"
|
||||||
|
|
||||||
|
# 4. Add implementation plan
|
||||||
|
backlog task edit 42 --plan "1. Analyze\n2. Refactor\n3. Test"
|
||||||
|
|
||||||
|
# 5. Work on the task (write code, test, etc.)
|
||||||
|
|
||||||
|
# 6. Mark acceptance criteria as complete (supports multiple in one command)
|
||||||
|
backlog task edit 42 --check-ac 1 --check-ac 2 --check-ac 3 # Check all at once
|
||||||
|
# Or check them individually if preferred:
|
||||||
|
# backlog task edit 42 --check-ac 1
|
||||||
|
# backlog task edit 42 --check-ac 2
|
||||||
|
# backlog task edit 42 --check-ac 3
|
||||||
|
|
||||||
|
# 7. Add implementation notes
|
||||||
|
backlog task edit 42 --notes "Refactored using strategy pattern, updated tests"
|
||||||
|
|
||||||
|
# 8. Mark task as done
|
||||||
|
backlog task edit 42 -s Done
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Definition of Done (DoD)
|
||||||
|
|
||||||
|
A task is **Done** only when **ALL** of the following are complete:
|
||||||
|
|
||||||
|
### ✅ Via CLI Commands:
|
||||||
|
1. **All acceptance criteria checked**: Use `backlog task edit <id> --check-ac <index>` for each
|
||||||
|
2. **Implementation notes added**: Use `backlog task edit <id> --notes "..."`
|
||||||
|
3. **Status set to Done**: Use `backlog task edit <id> -s Done`
|
||||||
|
|
||||||
|
### ✅ Via Code/Testing:
|
||||||
|
4. **Tests pass**: Run test suite and linting
|
||||||
|
5. **Documentation updated**: Update relevant docs if needed
|
||||||
|
6. **Code reviewed**: Self-review your changes
|
||||||
|
7. **No regressions**: Performance, security checks pass
|
||||||
|
|
||||||
|
⚠️ **NEVER mark a task as Done without completing ALL items above**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Quick Reference: DO vs DON'T
|
||||||
|
|
||||||
|
### Viewing Tasks
|
||||||
|
| Task | ✅ DO | ❌ DON'T |
|
||||||
|
|------|-------|----------|
|
||||||
|
| View task | `backlog task 42 --plain` | Open and read .md file directly |
|
||||||
|
| List tasks | `backlog task list --plain` | Browse backlog/tasks folder |
|
||||||
|
| Check status | `backlog task 42 --plain` | Look at file content |
|
||||||
|
|
||||||
|
### Modifying Tasks
|
||||||
|
| Task | ✅ DO | ❌ DON'T |
|
||||||
|
|------|-------|----------|
|
||||||
|
| Check AC | `backlog task edit 42 --check-ac 1` | Change `- [ ]` to `- [x]` in file |
|
||||||
|
| Add notes | `backlog task edit 42 --notes "..."` | Type notes into .md file |
|
||||||
|
| Change status | `backlog task edit 42 -s Done` | Edit status in frontmatter |
|
||||||
|
| Add AC | `backlog task edit 42 --ac "New"` | Add `- [ ] New` to file |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Complete CLI Command Reference
|
||||||
|
|
||||||
|
### Task Creation
|
||||||
|
| Action | Command |
|
||||||
|
|--------|---------|
|
||||||
|
| Create task | `backlog task create "Title"` |
|
||||||
|
| With description | `backlog task create "Title" -d "Description"` |
|
||||||
|
| With AC | `backlog task create "Title" --ac "Criterion 1" --ac "Criterion 2"` |
|
||||||
|
| With all options | `backlog task create "Title" -d "Desc" -a @sara -s "To Do" -l auth --priority high` |
|
||||||
|
| Create draft | `backlog task create "Title" --draft` |
|
||||||
|
| Create subtask | `backlog task create "Title" -p 42` |
|
||||||
|
|
||||||
|
### Task Modification
|
||||||
|
| Action | Command |
|
||||||
|
|--------|---------|
|
||||||
|
| Edit title | `backlog task edit 42 -t "New Title"` |
|
||||||
|
| Edit description | `backlog task edit 42 -d "New description"` |
|
||||||
|
| Change status | `backlog task edit 42 -s "In Progress"` |
|
||||||
|
| Assign | `backlog task edit 42 -a @sara` |
|
||||||
|
| Add labels | `backlog task edit 42 -l backend,api` |
|
||||||
|
| Set priority | `backlog task edit 42 --priority high` |
|
||||||
|
|
||||||
|
### Acceptance Criteria Management
|
||||||
|
| Action | Command |
|
||||||
|
|--------|---------|
|
||||||
|
| Add AC | `backlog task edit 42 --ac "New criterion" --ac "Another"` |
|
||||||
|
| Remove AC #2 | `backlog task edit 42 --remove-ac 2` |
|
||||||
|
| Remove multiple ACs | `backlog task edit 42 --remove-ac 2 --remove-ac 4` |
|
||||||
|
| Check AC #1 | `backlog task edit 42 --check-ac 1` |
|
||||||
|
| Check multiple ACs | `backlog task edit 42 --check-ac 1 --check-ac 3` |
|
||||||
|
| Uncheck AC #3 | `backlog task edit 42 --uncheck-ac 3` |
|
||||||
|
| Mixed operations | `backlog task edit 42 --check-ac 1 --uncheck-ac 2 --remove-ac 3 --ac "New"` |
|
||||||
|
|
||||||
|
### Task Content
|
||||||
|
| Action | Command |
|
||||||
|
|--------|---------|
|
||||||
|
| Add plan | `backlog task edit 42 --plan "1. Step one\n2. Step two"` |
|
||||||
|
| Add notes | `backlog task edit 42 --notes "Implementation details"` |
|
||||||
|
| Add dependencies | `backlog task edit 42 --dep task-1 --dep task-2` |
|
||||||
|
|
||||||
|
### Task Operations
|
||||||
|
| Action | Command |
|
||||||
|
|--------|---------|
|
||||||
|
| View task | `backlog task 42 --plain` |
|
||||||
|
| List tasks | `backlog task list --plain` |
|
||||||
|
| Filter by status | `backlog task list -s "In Progress" --plain` |
|
||||||
|
| Filter by assignee | `backlog task list -a @sara --plain` |
|
||||||
|
| Archive task | `backlog task archive 42` |
|
||||||
|
| Demote to draft | `backlog task demote 42` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Troubleshooting
|
||||||
|
|
||||||
|
### If You Accidentally Edited a File Directly
|
||||||
|
|
||||||
|
1. **DON'T PANIC** - But don't save or commit
|
||||||
|
2. Revert the changes
|
||||||
|
3. Make changes properly via CLI
|
||||||
|
4. If already saved, the metadata might be out of sync - use `backlog task edit` to fix
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
| Problem | Solution |
|
||||||
|
|---------|----------|
|
||||||
|
| "Task not found" | Check task ID with `backlog task list --plain` |
|
||||||
|
| AC won't check | Use correct index: `backlog task 42 --plain` to see AC numbers |
|
||||||
|
| Changes not saving | Ensure you're using CLI, not editing files |
|
||||||
|
| Metadata out of sync | Re-edit via CLI to fix: `backlog task edit 42 -s <current-status>` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Remember: The Golden Rule
|
||||||
|
|
||||||
|
**🎯 If you want to change ANYTHING in a task, use the `backlog task edit` command.**
|
||||||
|
**📖 Only READ task files directly, never WRITE to them.**
|
||||||
|
|
||||||
|
Full help available: `backlog --help`
|
||||||
|
|
||||||
|
# === BACKLOG.MD GUIDELINES END ===
|
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
description:
|
description: Database architecture, models, migrations, relationships, and data management patterns
|
||||||
globs:
|
globs: app/Models/*.php, database/migrations/*.php, database/seeders/*.php, app/Actions/Database/*.php
|
||||||
alwaysApply: false
|
alwaysApply: false
|
||||||
---
|
---
|
||||||
# Coolify Database Architecture & Patterns
|
# Coolify Database Architecture & Patterns
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
description:
|
description: Docker orchestration, deployment workflows, and containerization patterns
|
||||||
globs:
|
globs: app/Jobs/*.php, app/Actions/Application/*.php, app/Actions/Server/*.php, docker/*.*, *.yml, *.yaml
|
||||||
alwaysApply: false
|
alwaysApply: false
|
||||||
---
|
---
|
||||||
# Coolify Deployment Architecture
|
# Coolify Deployment Architecture
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
description:
|
description: Development setup, coding standards, contribution guidelines, and best practices
|
||||||
globs:
|
globs: **/*.php, composer.json, package.json, *.md, .env.example
|
||||||
alwaysApply: false
|
alwaysApply: false
|
||||||
---
|
---
|
||||||
# Coolify Development Workflow
|
# Coolify Development Workflow
|
||||||
|
452
.cursor/rules/form-components.mdc
Normal file
452
.cursor/rules/form-components.mdc
Normal file
@@ -0,0 +1,452 @@
|
|||||||
|
---
|
||||||
|
description: Enhanced form components with built-in authorization system
|
||||||
|
globs: resources/views/**/*.blade.php, app/View/Components/Forms/*.php
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Enhanced Form Components with Authorization
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Coolify's form components now feature **built-in authorization** that automatically handles permission-based UI control, dramatically reducing code duplication and improving security consistency.
|
||||||
|
|
||||||
|
## Enhanced Components
|
||||||
|
|
||||||
|
All form components now support the `canGate` authorization system:
|
||||||
|
|
||||||
|
- **[Input.php](mdc:app/View/Components/Forms/Input.php)** - Text, password, and other input fields
|
||||||
|
- **[Select.php](mdc:app/View/Components/Forms/Select.php)** - Dropdown selection components
|
||||||
|
- **[Textarea.php](mdc:app/View/Components/Forms/Textarea.php)** - Multi-line text areas
|
||||||
|
- **[Checkbox.php](mdc:app/View/Components/Forms/Checkbox.php)** - Boolean toggle components
|
||||||
|
- **[Button.php](mdc:app/View/Components/Forms/Button.php)** - Action buttons
|
||||||
|
|
||||||
|
## Authorization Parameters
|
||||||
|
|
||||||
|
### Core Parameters
|
||||||
|
```php
|
||||||
|
public ?string $canGate = null; // Gate name: 'update', 'view', 'deploy', 'delete'
|
||||||
|
public mixed $canResource = null; // Resource model instance to check against
|
||||||
|
public bool $autoDisable = true; // Automatically disable if no permission
|
||||||
|
```
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
```php
|
||||||
|
// Automatic authorization logic in each component
|
||||||
|
if ($this->canGate && $this->canResource && $this->autoDisable) {
|
||||||
|
$hasPermission = Gate::allows($this->canGate, $this->canResource);
|
||||||
|
|
||||||
|
if (! $hasPermission) {
|
||||||
|
$this->disabled = true;
|
||||||
|
// For Checkbox: also sets $this->instantSave = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage Patterns
|
||||||
|
|
||||||
|
### ✅ Recommended: Single Line Pattern
|
||||||
|
|
||||||
|
**Before (Verbose, 6+ lines per element):**
|
||||||
|
```html
|
||||||
|
@can('update', $application)
|
||||||
|
<x-forms.input id="application.name" label="Name" />
|
||||||
|
<x-forms.checkbox instantSave id="application.settings.is_static" label="Static Site" />
|
||||||
|
<x-forms.button type="submit">Save</x-forms.button>
|
||||||
|
@else
|
||||||
|
<x-forms.input disabled id="application.name" label="Name" />
|
||||||
|
<x-forms.checkbox disabled id="application.settings.is_static" label="Static Site" />
|
||||||
|
@endcan
|
||||||
|
```
|
||||||
|
|
||||||
|
**After (Clean, 1 line per element):**
|
||||||
|
```html
|
||||||
|
<x-forms.input canGate="update" :canResource="$application" id="application.name" label="Name" />
|
||||||
|
<x-forms.checkbox instantSave canGate="update" :canResource="$application" id="application.settings.is_static" label="Static Site" />
|
||||||
|
<x-forms.button canGate="update" :canResource="$application" type="submit">Save</x-forms.button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result: 90% code reduction!**
|
||||||
|
|
||||||
|
### Component-Specific Examples
|
||||||
|
|
||||||
|
#### Input Fields
|
||||||
|
```html
|
||||||
|
<!-- Basic input with authorization -->
|
||||||
|
<x-forms.input
|
||||||
|
canGate="update"
|
||||||
|
:canResource="$application"
|
||||||
|
id="application.name"
|
||||||
|
label="Application Name" />
|
||||||
|
|
||||||
|
<!-- Password input with authorization -->
|
||||||
|
<x-forms.input
|
||||||
|
type="password"
|
||||||
|
canGate="update"
|
||||||
|
:canResource="$application"
|
||||||
|
id="application.database_password"
|
||||||
|
label="Database Password" />
|
||||||
|
|
||||||
|
<!-- Required input with authorization -->
|
||||||
|
<x-forms.input
|
||||||
|
required
|
||||||
|
canGate="update"
|
||||||
|
:canResource="$application"
|
||||||
|
id="application.fqdn"
|
||||||
|
label="Domain" />
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Select Dropdowns
|
||||||
|
```html
|
||||||
|
<!-- Build pack selection -->
|
||||||
|
<x-forms.select
|
||||||
|
canGate="update"
|
||||||
|
:canResource="$application"
|
||||||
|
id="application.build_pack"
|
||||||
|
label="Build Pack"
|
||||||
|
required>
|
||||||
|
<option value="nixpacks">Nixpacks</option>
|
||||||
|
<option value="static">Static</option>
|
||||||
|
<option value="dockerfile">Dockerfile</option>
|
||||||
|
</x-forms.select>
|
||||||
|
|
||||||
|
<!-- Server selection -->
|
||||||
|
<x-forms.select
|
||||||
|
canGate="createAnyResource"
|
||||||
|
:canResource="auth()->user()->currentTeam"
|
||||||
|
id="server_id"
|
||||||
|
label="Target Server">
|
||||||
|
@foreach($servers as $server)
|
||||||
|
<option value="{{ $server->id }}">{{ $server->name }}</option>
|
||||||
|
@endforeach
|
||||||
|
</x-forms.select>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Checkboxes with InstantSave
|
||||||
|
```html
|
||||||
|
<!-- Static site toggle -->
|
||||||
|
<x-forms.checkbox
|
||||||
|
instantSave
|
||||||
|
canGate="update"
|
||||||
|
:canResource="$application"
|
||||||
|
id="application.settings.is_static"
|
||||||
|
label="Is it a static site?"
|
||||||
|
helper="Enable if your application serves static files" />
|
||||||
|
|
||||||
|
<!-- Debug mode toggle -->
|
||||||
|
<x-forms.checkbox
|
||||||
|
instantSave
|
||||||
|
canGate="update"
|
||||||
|
:canResource="$application"
|
||||||
|
id="application.settings.is_debug_enabled"
|
||||||
|
label="Debug Mode"
|
||||||
|
helper="Enable debug logging for troubleshooting" />
|
||||||
|
|
||||||
|
<!-- Build server toggle -->
|
||||||
|
<x-forms.checkbox
|
||||||
|
instantSave
|
||||||
|
canGate="update"
|
||||||
|
:canResource="$application"
|
||||||
|
id="application.settings.is_build_server_enabled"
|
||||||
|
label="Use Build Server"
|
||||||
|
helper="Use a dedicated build server for compilation" />
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Textareas
|
||||||
|
```html
|
||||||
|
<!-- Configuration textarea -->
|
||||||
|
<x-forms.textarea
|
||||||
|
canGate="update"
|
||||||
|
:canResource="$application"
|
||||||
|
id="application.docker_compose_raw"
|
||||||
|
label="Docker Compose Configuration"
|
||||||
|
rows="10"
|
||||||
|
monacoEditorLanguage="yaml"
|
||||||
|
useMonacoEditor />
|
||||||
|
|
||||||
|
<!-- Custom commands -->
|
||||||
|
<x-forms.textarea
|
||||||
|
canGate="update"
|
||||||
|
:canResource="$application"
|
||||||
|
id="application.post_deployment_command"
|
||||||
|
label="Post-Deployment Commands"
|
||||||
|
placeholder="php artisan migrate"
|
||||||
|
helper="Commands to run after deployment" />
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Buttons
|
||||||
|
```html
|
||||||
|
<!-- Save button -->
|
||||||
|
<x-forms.button
|
||||||
|
canGate="update"
|
||||||
|
:canResource="$application"
|
||||||
|
type="submit">
|
||||||
|
Save Configuration
|
||||||
|
</x-forms.button>
|
||||||
|
|
||||||
|
<!-- Deploy button -->
|
||||||
|
<x-forms.button
|
||||||
|
canGate="deploy"
|
||||||
|
:canResource="$application"
|
||||||
|
wire:click="deploy">
|
||||||
|
Deploy Application
|
||||||
|
</x-forms.button>
|
||||||
|
|
||||||
|
<!-- Delete button -->
|
||||||
|
<x-forms.button
|
||||||
|
canGate="delete"
|
||||||
|
:canResource="$application"
|
||||||
|
wire:click="confirmDelete"
|
||||||
|
class="button-danger">
|
||||||
|
Delete Application
|
||||||
|
</x-forms.button>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Advanced Usage
|
||||||
|
|
||||||
|
### Custom Authorization Logic
|
||||||
|
```html
|
||||||
|
<!-- Disable auto-control for complex permissions -->
|
||||||
|
<x-forms.input
|
||||||
|
canGate="update"
|
||||||
|
:canResource="$application"
|
||||||
|
autoDisable="false"
|
||||||
|
:disabled="$application->is_deployed || !$application->canModifySettings()"
|
||||||
|
id="deployment.setting"
|
||||||
|
label="Advanced Setting" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multiple Permission Checks
|
||||||
|
```html
|
||||||
|
<!-- Combine multiple authorization requirements -->
|
||||||
|
<x-forms.checkbox
|
||||||
|
canGate="deploy"
|
||||||
|
:canResource="$application"
|
||||||
|
autoDisable="false"
|
||||||
|
:disabled="!$application->hasDockerfile() || !Gate::allows('deploy', $application)"
|
||||||
|
id="docker.setting"
|
||||||
|
label="Docker-Specific Setting" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conditional Resources
|
||||||
|
```html
|
||||||
|
<!-- Different resources based on context -->
|
||||||
|
<x-forms.button
|
||||||
|
:canGate="$isEditing ? 'update' : 'view'"
|
||||||
|
:canResource="$resource"
|
||||||
|
type="submit">
|
||||||
|
{{ $isEditing ? 'Save Changes' : 'View Details' }}
|
||||||
|
</x-forms.button>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Supported Gates
|
||||||
|
|
||||||
|
### Resource-Level Gates
|
||||||
|
- `view` - Read access to resource details
|
||||||
|
- `update` - Modify resource configuration and settings
|
||||||
|
- `deploy` - Deploy, restart, or manage resource state
|
||||||
|
- `delete` - Remove or destroy resource
|
||||||
|
- `clone` - Duplicate resource to another location
|
||||||
|
|
||||||
|
### Global Gates
|
||||||
|
- `createAnyResource` - Create new resources of any type
|
||||||
|
- `manageTeam` - Team administration permissions
|
||||||
|
- `accessServer` - Server-level access permissions
|
||||||
|
|
||||||
|
## Supported Resources
|
||||||
|
|
||||||
|
### Primary Resources
|
||||||
|
- `$application` - Application instances and configurations
|
||||||
|
- `$service` - Docker Compose services and components
|
||||||
|
- `$database` - Database instances (PostgreSQL, MySQL, etc.)
|
||||||
|
- `$server` - Physical or virtual server instances
|
||||||
|
|
||||||
|
### Container Resources
|
||||||
|
- `$project` - Project containers and environments
|
||||||
|
- `$environment` - Environment-specific configurations
|
||||||
|
- `$team` - Team and organization contexts
|
||||||
|
|
||||||
|
### Infrastructure Resources
|
||||||
|
- `$privateKey` - SSH private keys and certificates
|
||||||
|
- `$source` - Git sources and repositories
|
||||||
|
- `$destination` - Deployment destinations and targets
|
||||||
|
|
||||||
|
## Component Behavior
|
||||||
|
|
||||||
|
### Input Components (Input, Select, Textarea)
|
||||||
|
When authorization fails:
|
||||||
|
- **disabled = true** - Field becomes non-editable
|
||||||
|
- **Visual styling** - Opacity reduction and disabled cursor
|
||||||
|
- **Form submission** - Values are ignored in forms
|
||||||
|
- **User feedback** - Clear visual indication of restricted access
|
||||||
|
|
||||||
|
### Checkbox Components
|
||||||
|
When authorization fails:
|
||||||
|
- **disabled = true** - Checkbox becomes non-clickable
|
||||||
|
- **instantSave = false** - Automatic saving is disabled
|
||||||
|
- **State preservation** - Current value is maintained but read-only
|
||||||
|
- **Visual styling** - Disabled appearance with reduced opacity
|
||||||
|
|
||||||
|
### Button Components
|
||||||
|
When authorization fails:
|
||||||
|
- **disabled = true** - Button becomes non-clickable
|
||||||
|
- **Event blocking** - Click handlers are ignored
|
||||||
|
- **Visual styling** - Disabled appearance and cursor
|
||||||
|
- **Loading states** - Loading indicators are disabled
|
||||||
|
|
||||||
|
## Migration Guide
|
||||||
|
|
||||||
|
### Converting Existing Forms
|
||||||
|
|
||||||
|
**Old Pattern:**
|
||||||
|
```html
|
||||||
|
<form wire:submit='submit'>
|
||||||
|
@can('update', $application)
|
||||||
|
<x-forms.input id="name" label="Name" />
|
||||||
|
<x-forms.select id="type" label="Type">...</x-forms.select>
|
||||||
|
<x-forms.checkbox instantSave id="enabled" label="Enabled" />
|
||||||
|
<x-forms.button type="submit">Save</x-forms.button>
|
||||||
|
@else
|
||||||
|
<x-forms.input disabled id="name" label="Name" />
|
||||||
|
<x-forms.select disabled id="type" label="Type">...</x-forms.select>
|
||||||
|
<x-forms.checkbox disabled id="enabled" label="Enabled" />
|
||||||
|
@endcan
|
||||||
|
</form>
|
||||||
|
```
|
||||||
|
|
||||||
|
**New Pattern:**
|
||||||
|
```html
|
||||||
|
<form wire:submit='submit'>
|
||||||
|
<x-forms.input canGate="update" :canResource="$application" id="name" label="Name" />
|
||||||
|
<x-forms.select canGate="update" :canResource="$application" id="type" label="Type">...</x-forms.select>
|
||||||
|
<x-forms.checkbox instantSave canGate="update" :canResource="$application" id="enabled" label="Enabled" />
|
||||||
|
<x-forms.button canGate="update" :canResource="$application" type="submit">Save</x-forms.button>
|
||||||
|
</form>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gradual Migration Strategy
|
||||||
|
|
||||||
|
1. **Start with new forms** - Use the new pattern for all new components
|
||||||
|
2. **Convert high-traffic areas** - Migrate frequently used forms first
|
||||||
|
3. **Batch convert similar forms** - Group similar authorization patterns
|
||||||
|
4. **Test thoroughly** - Verify authorization behavior matches expectations
|
||||||
|
5. **Remove old patterns** - Clean up legacy @can/@else blocks
|
||||||
|
|
||||||
|
## Testing Patterns
|
||||||
|
|
||||||
|
### Component Authorization Tests
|
||||||
|
```php
|
||||||
|
// Test authorization integration in components
|
||||||
|
test('input component respects authorization', function () {
|
||||||
|
$user = User::factory()->member()->create();
|
||||||
|
$application = Application::factory()->create();
|
||||||
|
|
||||||
|
// Member should see disabled input
|
||||||
|
$component = Livewire::actingAs($user)
|
||||||
|
->test(TestComponent::class, [
|
||||||
|
'canGate' => 'update',
|
||||||
|
'canResource' => $application
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($component->get('disabled'))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('checkbox disables instantSave for unauthorized users', function () {
|
||||||
|
$user = User::factory()->member()->create();
|
||||||
|
$application = Application::factory()->create();
|
||||||
|
|
||||||
|
$component = Livewire::actingAs($user)
|
||||||
|
->test(CheckboxComponent::class, [
|
||||||
|
'instantSave' => true,
|
||||||
|
'canGate' => 'update',
|
||||||
|
'canResource' => $application
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($component->get('disabled'))->toBeTrue();
|
||||||
|
expect($component->get('instantSave'))->toBeFalse();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
```php
|
||||||
|
// Test full form authorization behavior
|
||||||
|
test('application form respects member permissions', function () {
|
||||||
|
$member = User::factory()->member()->create();
|
||||||
|
$application = Application::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($member)
|
||||||
|
->get(route('application.edit', $application))
|
||||||
|
->assertSee('disabled')
|
||||||
|
->assertDontSee('Save Configuration');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### Consistent Gate Usage
|
||||||
|
- Use `update` for configuration changes
|
||||||
|
- Use `deploy` for operational actions
|
||||||
|
- Use `view` for read-only access
|
||||||
|
- Use `delete` for destructive actions
|
||||||
|
|
||||||
|
### Resource Context
|
||||||
|
- Always pass the specific resource being acted upon
|
||||||
|
- Use team context for creation permissions
|
||||||
|
- Consider nested resource relationships
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
- Provide clear feedback for disabled components
|
||||||
|
- Use helper text to explain permission requirements
|
||||||
|
- Consider tooltips for disabled buttons
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- Authorization checks are cached per request
|
||||||
|
- Use eager loading for resource relationships
|
||||||
|
- Consider query optimization for complex permissions
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Application Configuration Forms
|
||||||
|
```html
|
||||||
|
<!-- Application settings with consistent authorization -->
|
||||||
|
<x-forms.input canGate="update" :canResource="$application" id="application.name" label="Name" />
|
||||||
|
<x-forms.select canGate="update" :canResource="$application" id="application.build_pack" label="Build Pack">...</x-forms.select>
|
||||||
|
<x-forms.checkbox instantSave canGate="update" :canResource="$application" id="application.settings.is_static" label="Static Site" />
|
||||||
|
<x-forms.button canGate="update" :canResource="$application" type="submit">Save</x-forms.button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Service Configuration Forms
|
||||||
|
```html
|
||||||
|
<!-- Service stack configuration with authorization -->
|
||||||
|
<x-forms.input canGate="update" :canResource="$service" id="service.name" label="Service Name" />
|
||||||
|
<x-forms.input canGate="update" :canResource="$service" id="service.description" label="Description" />
|
||||||
|
<x-forms.checkbox canGate="update" :canResource="$service" instantSave id="service.connect_to_docker_network" label="Connect To Predefined Network" />
|
||||||
|
<x-forms.button canGate="update" :canResource="$service" type="submit">Save</x-forms.button>
|
||||||
|
|
||||||
|
<!-- Service-specific fields -->
|
||||||
|
<x-forms.input canGate="update" :canResource="$service" type="{{ data_get($field, 'isPassword') ? 'password' : 'text' }}"
|
||||||
|
required="{{ str(data_get($field, 'rules'))?->contains('required') }}"
|
||||||
|
id="fields.{{ $serviceName }}.value"></x-forms.input>
|
||||||
|
|
||||||
|
<!-- Service restart modal - wrapped with @can -->
|
||||||
|
@can('update', $service)
|
||||||
|
<x-modal-confirmation title="Confirm Service Application Restart?"
|
||||||
|
buttonTitle="Restart"
|
||||||
|
submitAction="restartApplication({{ $application->id }})" />
|
||||||
|
@endcan
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server Management Forms
|
||||||
|
```html
|
||||||
|
<!-- Server configuration with appropriate gates -->
|
||||||
|
<x-forms.input canGate="update" :canResource="$server" id="server.name" label="Server Name" />
|
||||||
|
<x-forms.select canGate="update" :canResource="$server" id="server.type" label="Server Type">...</x-forms.select>
|
||||||
|
<x-forms.button canGate="delete" :canResource="$server" wire:click="deleteServer">Delete Server</x-forms.button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Resource Creation Forms
|
||||||
|
```html
|
||||||
|
<!-- New resource creation -->
|
||||||
|
<x-forms.input canGate="createAnyResource" :canResource="auth()->user()->currentTeam" id="name" label="Name" />
|
||||||
|
<x-forms.select canGate="createAnyResource" :canResource="auth()->user()->currentTeam" id="server_id" label="Server">...</x-forms.select>
|
||||||
|
<x-forms.button canGate="createAnyResource" :canResource="auth()->user()->currentTeam" type="submit">Create Application</x-forms.button>
|
||||||
|
```
|
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
description:
|
description: Livewire components, Alpine.js patterns, Tailwind CSS, and enhanced form components
|
||||||
globs:
|
globs: app/Livewire/**/*.php, resources/views/**/*.blade.php, resources/js/**/*.js, resources/css/**/*.css
|
||||||
alwaysApply: false
|
alwaysApply: false
|
||||||
---
|
---
|
||||||
# Coolify Frontend Architecture & Patterns
|
# Coolify Frontend Architecture & Patterns
|
||||||
@@ -230,6 +230,41 @@ class ServerList extends Component
|
|||||||
- **Asset bundling** and compression
|
- **Asset bundling** and compression
|
||||||
- **CDN integration** for static assets
|
- **CDN integration** for static assets
|
||||||
|
|
||||||
|
## Enhanced Form Components
|
||||||
|
|
||||||
|
### Built-in Authorization System
|
||||||
|
Coolify features **enhanced form components** with automatic authorization handling:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- ✅ New Pattern: Single line with built-in authorization -->
|
||||||
|
<x-forms.input canGate="update" :canResource="$application" id="application.name" label="Name" />
|
||||||
|
<x-forms.checkbox instantSave canGate="update" :canResource="$application" id="application.settings.is_static" label="Static Site" />
|
||||||
|
<x-forms.button canGate="update" :canResource="$application" type="submit">Save</x-forms.button>
|
||||||
|
|
||||||
|
<!-- ❌ Old Pattern: Verbose @can/@else blocks (deprecated) -->
|
||||||
|
@can('update', $application)
|
||||||
|
<x-forms.input id="application.name" label="Name" />
|
||||||
|
@else
|
||||||
|
<x-forms.input disabled id="application.name" label="Name" />
|
||||||
|
@endcan
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authorization Parameters
|
||||||
|
```php
|
||||||
|
// Available on all form components (Input, Select, Textarea, Checkbox, Button)
|
||||||
|
public ?string $canGate = null; // Gate name: 'update', 'view', 'deploy', 'delete'
|
||||||
|
public mixed $canResource = null; // Resource model instance to check against
|
||||||
|
public bool $autoDisable = true; // Automatically disable if no permission (default: true)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Benefits
|
||||||
|
- **90% code reduction** for authorization-protected forms
|
||||||
|
- **Consistent security** across all form components
|
||||||
|
- **Automatic disabling** for unauthorized users
|
||||||
|
- **Smart behavior** (disables instantSave on checkboxes for unauthorized users)
|
||||||
|
|
||||||
|
For complete documentation, see **[form-components.mdc](mdc:.cursor/rules/form-components.mdc)**
|
||||||
|
|
||||||
## Form Handling Patterns
|
## Form Handling Patterns
|
||||||
|
|
||||||
### Livewire Forms
|
### Livewire Forms
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
description:
|
description: High-level project mission, core concepts, and architectural overview
|
||||||
globs:
|
globs: README.md, CONTRIBUTING.md, CHANGELOG.md, *.md
|
||||||
alwaysApply: false
|
alwaysApply: false
|
||||||
---
|
---
|
||||||
# Coolify Project Overview
|
# Coolify Project Overview
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
description:
|
description: Security architecture, authentication, authorization patterns, and enhanced form component security
|
||||||
globs:
|
globs: app/Policies/*.php, app/View/Components/Forms/*.php, app/Http/Middleware/*.php, resources/views/**/*.blade.php
|
||||||
alwaysApply: false
|
alwaysApply: true
|
||||||
---
|
---
|
||||||
# Coolify Security Architecture & Patterns
|
# Coolify Security Architecture & Patterns
|
||||||
|
|
||||||
@@ -63,6 +63,323 @@ class User extends Authenticatable
|
|||||||
|
|
||||||
## Authorization & Access Control
|
## Authorization & Access Control
|
||||||
|
|
||||||
|
### Enhanced Form Component Authorization System
|
||||||
|
|
||||||
|
Coolify now features a **centralized authorization system** built into all form components (`Input`, `Select`, `Textarea`, `Checkbox`, `Button`) that automatically handles permission-based UI control.
|
||||||
|
|
||||||
|
#### Component Authorization Parameters
|
||||||
|
```php
|
||||||
|
// Available on all form components
|
||||||
|
public ?string $canGate = null; // Gate name (e.g., 'update', 'view', 'delete')
|
||||||
|
public mixed $canResource = null; // Resource to check against (model instance)
|
||||||
|
public bool $autoDisable = true; // Auto-disable if no permission (default: true)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Smart Authorization Logic
|
||||||
|
```php
|
||||||
|
// Automatic authorization handling in component constructor
|
||||||
|
if ($this->canGate && $this->canResource && $this->autoDisable) {
|
||||||
|
$hasPermission = Gate::allows($this->canGate, $this->canResource);
|
||||||
|
|
||||||
|
if (! $hasPermission) {
|
||||||
|
$this->disabled = true;
|
||||||
|
// For Checkbox: also disables instantSave
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Usage Examples
|
||||||
|
|
||||||
|
**✅ Recommended Pattern (Single Line):**
|
||||||
|
```html
|
||||||
|
<!-- Input with automatic authorization -->
|
||||||
|
<x-forms.input
|
||||||
|
canGate="update"
|
||||||
|
:canResource="$application"
|
||||||
|
id="application.name"
|
||||||
|
label="Application Name" />
|
||||||
|
|
||||||
|
<!-- Select with automatic authorization -->
|
||||||
|
<x-forms.select
|
||||||
|
canGate="update"
|
||||||
|
:canResource="$application"
|
||||||
|
id="application.build_pack"
|
||||||
|
label="Build Pack">
|
||||||
|
<option value="nixpacks">Nixpacks</option>
|
||||||
|
<option value="static">Static</option>
|
||||||
|
</x-forms.select>
|
||||||
|
|
||||||
|
<!-- Checkbox with automatic instantSave control -->
|
||||||
|
<x-forms.checkbox
|
||||||
|
instantSave
|
||||||
|
canGate="update"
|
||||||
|
:canResource="$application"
|
||||||
|
id="application.settings.is_static"
|
||||||
|
label="Is Static Site?" />
|
||||||
|
|
||||||
|
<!-- Button with automatic disable -->
|
||||||
|
<x-forms.button
|
||||||
|
canGate="update"
|
||||||
|
:canResource="$application"
|
||||||
|
type="submit">
|
||||||
|
Save Configuration
|
||||||
|
</x-forms.button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**❌ Old Pattern (Verbose, Deprecated):**
|
||||||
|
```html
|
||||||
|
<!-- DON'T use this repetitive pattern anymore -->
|
||||||
|
@can('update', $application)
|
||||||
|
<x-forms.input id="application.name" label="Application Name" />
|
||||||
|
<x-forms.button type="submit">Save</x-forms.button>
|
||||||
|
@else
|
||||||
|
<x-forms.input disabled id="application.name" label="Application Name" />
|
||||||
|
@endcan
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Advanced Usage with Custom Control
|
||||||
|
|
||||||
|
**Custom Authorization Logic:**
|
||||||
|
```html
|
||||||
|
<!-- Disable auto-control, use custom logic -->
|
||||||
|
<x-forms.input
|
||||||
|
canGate="update"
|
||||||
|
:canResource="$application"
|
||||||
|
autoDisable="false"
|
||||||
|
:disabled="$application->is_deployed || !Gate::allows('update', $application)"
|
||||||
|
id="advanced.setting"
|
||||||
|
label="Advanced Setting" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Multiple Permission Checks:**
|
||||||
|
```html
|
||||||
|
<!-- Complex permission requirements -->
|
||||||
|
<x-forms.checkbox
|
||||||
|
canGate="deploy"
|
||||||
|
:canResource="$application"
|
||||||
|
autoDisable="false"
|
||||||
|
:disabled="!$application->canDeploy() || !auth()->user()->hasAdvancedPermissions()"
|
||||||
|
id="deployment.setting"
|
||||||
|
label="Advanced Deployment Setting" />
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Supported Gates and Resources
|
||||||
|
|
||||||
|
**Common Gates:**
|
||||||
|
- `view` - Read access to resource
|
||||||
|
- `update` - Modify resource configuration
|
||||||
|
- `deploy` - Deploy/restart resource
|
||||||
|
- `delete` - Remove resource
|
||||||
|
- `createAnyResource` - Create new resources
|
||||||
|
|
||||||
|
**Resource Types:**
|
||||||
|
- `Application` - Application instances
|
||||||
|
- `Service` - Docker Compose services
|
||||||
|
- `Server` - Server instances
|
||||||
|
- `Project` - Project containers
|
||||||
|
- `Environment` - Environment contexts
|
||||||
|
- `Database` - Database instances
|
||||||
|
|
||||||
|
#### Benefits
|
||||||
|
|
||||||
|
**🔥 Massive Code Reduction:**
|
||||||
|
- **90% less code** for authorization-protected forms
|
||||||
|
- **Single line** instead of 6-12 lines per form element
|
||||||
|
- **No more @can/@else blocks** cluttering templates
|
||||||
|
|
||||||
|
**🛡️ Consistent Security:**
|
||||||
|
- **Unified authorization logic** across all form components
|
||||||
|
- **Automatic disabling** for unauthorized users
|
||||||
|
- **Smart behavior** (like disabling instantSave on checkboxes)
|
||||||
|
|
||||||
|
**🎨 Better UX:**
|
||||||
|
- **Consistent disabled styling** across all components
|
||||||
|
- **Proper visual feedback** for restricted access
|
||||||
|
- **Clean, professional interface**
|
||||||
|
|
||||||
|
#### Implementation Details
|
||||||
|
|
||||||
|
**Component Enhancement:**
|
||||||
|
```php
|
||||||
|
// Enhanced in all form components
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
// ... existing parameters
|
||||||
|
public ?string $canGate = null,
|
||||||
|
public mixed $canResource = null,
|
||||||
|
public bool $autoDisable = true,
|
||||||
|
) {
|
||||||
|
// Handle authorization-based disabling
|
||||||
|
if ($this->canGate && $this->canResource && $this->autoDisable) {
|
||||||
|
$hasPermission = Gate::allows($this->canGate, $this->canResource);
|
||||||
|
|
||||||
|
if (! $hasPermission) {
|
||||||
|
$this->disabled = true;
|
||||||
|
// For Checkbox: $this->instantSave = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Backward Compatibility:**
|
||||||
|
- All existing form components continue to work unchanged
|
||||||
|
- New authorization parameters are optional
|
||||||
|
- Legacy @can/@else patterns still function but are discouraged
|
||||||
|
|
||||||
|
### Custom Component Authorization Patterns
|
||||||
|
|
||||||
|
When dealing with **custom Alpine.js components** or complex UI elements that don't use the standard `x-forms.*` components, manual authorization protection is required since the automatic `canGate` system only applies to enhanced form components.
|
||||||
|
|
||||||
|
#### Common Custom Components Requiring Manual Protection
|
||||||
|
|
||||||
|
**⚠️ Custom Components That Need Manual Authorization:**
|
||||||
|
- Custom dropdowns/selects with Alpine.js
|
||||||
|
- Complex form widgets with JavaScript interactions
|
||||||
|
- Multi-step wizards or dynamic forms
|
||||||
|
- Third-party component integrations
|
||||||
|
- Custom date/time pickers
|
||||||
|
- File upload components with drag-and-drop
|
||||||
|
|
||||||
|
#### Manual Authorization Pattern
|
||||||
|
|
||||||
|
**✅ Proper Manual Authorization:**
|
||||||
|
```html
|
||||||
|
<!-- Custom timezone dropdown example -->
|
||||||
|
<div class="w-full">
|
||||||
|
<div class="flex items-center mb-1">
|
||||||
|
<label for="customComponent">Component Label</label>
|
||||||
|
<x-helper helper="Component description" />
|
||||||
|
</div>
|
||||||
|
@can('update', $resource)
|
||||||
|
<!-- Full interactive component for authorized users -->
|
||||||
|
<div x-data="{
|
||||||
|
open: false,
|
||||||
|
value: '{{ $currentValue }}',
|
||||||
|
options: @js($options),
|
||||||
|
init() { /* Alpine.js initialization */ }
|
||||||
|
}">
|
||||||
|
<input x-model="value" @focus="open = true"
|
||||||
|
wire:model="propertyName" class="w-full input">
|
||||||
|
<div x-show="open">
|
||||||
|
<!-- Interactive dropdown content -->
|
||||||
|
<template x-for="option in options" :key="option">
|
||||||
|
<div @click="value = option; open = false; $wire.submit()"
|
||||||
|
x-text="option"></div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<!-- Read-only version for unauthorized users -->
|
||||||
|
<div class="relative">
|
||||||
|
<input readonly disabled autocomplete="off"
|
||||||
|
class="w-full input opacity-50 cursor-not-allowed"
|
||||||
|
value="{{ $currentValue ?: 'No value set' }}">
|
||||||
|
<svg class="absolute right-0 mr-2 w-4 h-4 opacity-50">
|
||||||
|
<!-- Disabled icon -->
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
@endcan
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Implementation Checklist
|
||||||
|
|
||||||
|
When implementing authorization for custom components:
|
||||||
|
|
||||||
|
**🔍 1. Identify Custom Components:**
|
||||||
|
- Look for Alpine.js `x-data` declarations
|
||||||
|
- Find components not using `x-forms.*` prefix
|
||||||
|
- Check for JavaScript-heavy interactions
|
||||||
|
- Review complex form widgets
|
||||||
|
|
||||||
|
**🛡️ 2. Wrap with Authorization:**
|
||||||
|
- Use `@can('gate', $resource)` / `@else` / `@endcan` structure
|
||||||
|
- Provide full functionality in the `@can` block
|
||||||
|
- Create disabled/readonly version in the `@else` block
|
||||||
|
|
||||||
|
**🎨 3. Design Disabled State:**
|
||||||
|
- Apply `readonly disabled` attributes to inputs
|
||||||
|
- Add `opacity-50 cursor-not-allowed` classes for visual feedback
|
||||||
|
- Remove interactive JavaScript behaviors
|
||||||
|
- Show current value or appropriate placeholder
|
||||||
|
|
||||||
|
**🔒 4. Backend Protection:**
|
||||||
|
- Ensure corresponding Livewire methods check authorization
|
||||||
|
- Add `$this->authorize('gate', $resource)` in relevant methods
|
||||||
|
- Validate permissions before processing any changes
|
||||||
|
|
||||||
|
#### Real-World Examples
|
||||||
|
|
||||||
|
**Custom Date Range Picker:**
|
||||||
|
```html
|
||||||
|
@can('update', $application)
|
||||||
|
<div x-data="dateRangePicker()" class="date-picker">
|
||||||
|
<!-- Interactive date picker with calendar -->
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input readonly disabled value="{{ $startDate }}" class="input opacity-50">
|
||||||
|
<input readonly disabled value="{{ $endDate }}" class="input opacity-50">
|
||||||
|
</div>
|
||||||
|
@endcan
|
||||||
|
```
|
||||||
|
|
||||||
|
**Multi-Select Component:**
|
||||||
|
```html
|
||||||
|
@can('update', $server)
|
||||||
|
<div x-data="multiSelect({ options: @js($options) })">
|
||||||
|
<!-- Interactive multi-select with checkboxes -->
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="space-y-2">
|
||||||
|
@foreach($selectedValues as $value)
|
||||||
|
<div class="px-3 py-1 bg-gray-100 rounded text-sm opacity-50">
|
||||||
|
{{ $value }}
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endcan
|
||||||
|
```
|
||||||
|
|
||||||
|
**File Upload Widget:**
|
||||||
|
```html
|
||||||
|
@can('update', $application)
|
||||||
|
<div x-data="fileUploader()" @drop.prevent="handleDrop">
|
||||||
|
<!-- Drag-and-drop file upload interface -->
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center opacity-50">
|
||||||
|
<p class="text-gray-500">File upload restricted</p>
|
||||||
|
@if($currentFile)
|
||||||
|
<p class="text-sm">Current: {{ $currentFile }}</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endcan
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Key Principles
|
||||||
|
|
||||||
|
**🎯 Consistency:**
|
||||||
|
- Maintain similar visual styling between enabled/disabled states
|
||||||
|
- Use consistent disabled patterns across the application
|
||||||
|
- Apply the same opacity and cursor styling
|
||||||
|
|
||||||
|
**🔐 Security First:**
|
||||||
|
- Always implement backend authorization checks
|
||||||
|
- Never rely solely on frontend hiding/disabling
|
||||||
|
- Validate permissions on every server action
|
||||||
|
|
||||||
|
**💡 User Experience:**
|
||||||
|
- Show current values in disabled state when appropriate
|
||||||
|
- Provide clear visual feedback about restricted access
|
||||||
|
- Maintain layout stability between states
|
||||||
|
|
||||||
|
**🚀 Performance:**
|
||||||
|
- Minimize Alpine.js initialization for disabled components
|
||||||
|
- Avoid loading unnecessary JavaScript for unauthorized users
|
||||||
|
- Use simple HTML structures for read-only states
|
||||||
|
|
||||||
### Team-Based Multi-Tenancy
|
### Team-Based Multi-Tenancy
|
||||||
- **[Team.php](mdc:app/Models/Team.php)** - Multi-tenant organization structure (8.9KB, 308 lines)
|
- **[Team.php](mdc:app/Models/Team.php)** - Multi-tenant organization structure (8.9KB, 308 lines)
|
||||||
- **[TeamInvitation.php](mdc:app/Models/TeamInvitation.php)** - Secure team collaboration
|
- **[TeamInvitation.php](mdc:app/Models/TeamInvitation.php)** - Secure team collaboration
|
||||||
|
@@ -31,19 +31,6 @@ alwaysApply: true
|
|||||||
- Related rules have been updated
|
- Related rules have been updated
|
||||||
- Implementation details have changed
|
- Implementation details have changed
|
||||||
|
|
||||||
- **Example Pattern Recognition:**
|
|
||||||
```typescript
|
|
||||||
// If you see repeated patterns like:
|
|
||||||
const data = await prisma.user.findMany({
|
|
||||||
select: { id: true, email: true },
|
|
||||||
where: { status: 'ACTIVE' }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Consider adding to [prisma.mdc](mdc:.cursor/rules/prisma.mdc):
|
|
||||||
// - Standard select fields
|
|
||||||
// - Common where conditions
|
|
||||||
// - Performance optimization patterns
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Rule Quality Checks:**
|
- **Rule Quality Checks:**
|
||||||
- Rules should be actionable and specific
|
- Rules should be actionable and specific
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
description:
|
description: Complete technology stack, dependencies, and infrastructure components
|
||||||
globs:
|
globs: composer.json, package.json, docker-compose*.yml, config/*.php
|
||||||
alwaysApply: false
|
alwaysApply: false
|
||||||
---
|
---
|
||||||
# Coolify Technology Stack
|
# Coolify Technology Stack
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
description:
|
description: Testing strategies with Pest PHP, Laravel Dusk, and quality assurance patterns
|
||||||
globs:
|
globs: tests/**/*.php, database/factories/*.php
|
||||||
alwaysApply: false
|
alwaysApply: false
|
||||||
---
|
---
|
||||||
# Coolify Testing Architecture & Patterns
|
# Coolify Testing Architecture & Patterns
|
||||||
|
79
.github/workflows/claude-code-review.yml
vendored
Normal file
79
.github/workflows/claude-code-review.yml
vendored
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
name: Claude Code Review
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize]
|
||||||
|
# Optional: Only run on specific file changes
|
||||||
|
# paths:
|
||||||
|
# - "src/**/*.ts"
|
||||||
|
# - "src/**/*.tsx"
|
||||||
|
# - "src/**/*.js"
|
||||||
|
# - "src/**/*.jsx"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
claude-review:
|
||||||
|
if: false
|
||||||
|
# Optional: Filter by PR author
|
||||||
|
# if: |
|
||||||
|
# github.event.pull_request.user.login == 'external-contributor' ||
|
||||||
|
# github.event.pull_request.user.login == 'new-developer' ||
|
||||||
|
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
||||||
|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: read
|
||||||
|
issues: read
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Run Claude Code Review
|
||||||
|
id: claude-review
|
||||||
|
uses: anthropics/claude-code-action@beta
|
||||||
|
with:
|
||||||
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
|
||||||
|
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4.1)
|
||||||
|
# model: "claude-opus-4-1-20250805"
|
||||||
|
|
||||||
|
# Direct prompt for automated review (no @claude mention needed)
|
||||||
|
direct_prompt: |
|
||||||
|
Please review this pull request and provide feedback on:
|
||||||
|
- Code quality and best practices
|
||||||
|
- Potential bugs or issues
|
||||||
|
- Performance considerations
|
||||||
|
- Security concerns
|
||||||
|
- Test coverage
|
||||||
|
|
||||||
|
Be constructive and helpful in your feedback.
|
||||||
|
|
||||||
|
# Optional: Use sticky comments to make Claude reuse the same comment on subsequent pushes to the same PR
|
||||||
|
# use_sticky_comment: true
|
||||||
|
|
||||||
|
# Optional: Customize review based on file types
|
||||||
|
# direct_prompt: |
|
||||||
|
# Review this PR focusing on:
|
||||||
|
# - For TypeScript files: Type safety and proper interface usage
|
||||||
|
# - For API endpoints: Security, input validation, and error handling
|
||||||
|
# - For React components: Performance, accessibility, and best practices
|
||||||
|
# - For tests: Coverage, edge cases, and test quality
|
||||||
|
|
||||||
|
# Optional: Different prompts for different authors
|
||||||
|
# direct_prompt: |
|
||||||
|
# ${{ github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' &&
|
||||||
|
# 'Welcome! Please review this PR from a first-time contributor. Be encouraging and provide detailed explanations for any suggestions.' ||
|
||||||
|
# 'Please provide a thorough code review focusing on our coding standards and best practices.' }}
|
||||||
|
|
||||||
|
# Optional: Add specific tools for running tests or linting
|
||||||
|
# allowed_tools: "Bash(npm run test),Bash(npm run lint),Bash(npm run typecheck)"
|
||||||
|
|
||||||
|
# Optional: Skip review for certain conditions
|
||||||
|
# if: |
|
||||||
|
# !contains(github.event.pull_request.title, '[skip-review]') &&
|
||||||
|
# !contains(github.event.pull_request.title, '[WIP]')
|
||||||
|
|
64
.github/workflows/claude.yml
vendored
Normal file
64
.github/workflows/claude.yml
vendored
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
name: Claude Code
|
||||||
|
|
||||||
|
on:
|
||||||
|
issue_comment:
|
||||||
|
types: [created]
|
||||||
|
pull_request_review_comment:
|
||||||
|
types: [created]
|
||||||
|
issues:
|
||||||
|
types: [opened, assigned]
|
||||||
|
pull_request_review:
|
||||||
|
types: [submitted]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
claude:
|
||||||
|
if: |
|
||||||
|
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||||
|
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||||
|
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||||
|
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: read
|
||||||
|
issues: read
|
||||||
|
id-token: write
|
||||||
|
actions: read # Required for Claude to read CI results on PRs
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Run Claude Code
|
||||||
|
id: claude
|
||||||
|
uses: anthropics/claude-code-action@beta
|
||||||
|
with:
|
||||||
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
|
||||||
|
# This is an optional setting that allows Claude to read CI results on PRs
|
||||||
|
additional_permissions: |
|
||||||
|
actions: read
|
||||||
|
|
||||||
|
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4.1)
|
||||||
|
# model: "claude-opus-4-1-20250805"
|
||||||
|
|
||||||
|
# Optional: Customize the trigger phrase (default: @claude)
|
||||||
|
# trigger_phrase: "/claude"
|
||||||
|
|
||||||
|
# Optional: Trigger when specific user is assigned to an issue
|
||||||
|
# assignee_trigger: "claude-bot"
|
||||||
|
|
||||||
|
# Optional: Allow Claude to run specific commands
|
||||||
|
# allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)"
|
||||||
|
|
||||||
|
# Optional: Add custom instructions for Claude to customize its behavior for your project
|
||||||
|
# custom_instructions: |
|
||||||
|
# Follow our coding standards
|
||||||
|
# Ensure all new code has tests
|
||||||
|
# Use TypeScript for new files
|
||||||
|
|
||||||
|
# Optional: Custom environment variables for Claude
|
||||||
|
# claude_env: |
|
||||||
|
# NODE_ENV: test
|
||||||
|
|
@@ -13,6 +13,7 @@ on:
|
|||||||
- docker/testing-host/Dockerfile
|
- docker/testing-host/Dockerfile
|
||||||
- templates/**
|
- templates/**
|
||||||
- CHANGELOG.md
|
- CHANGELOG.md
|
||||||
|
- backlog/**
|
||||||
|
|
||||||
env:
|
env:
|
||||||
GITHUB_REGISTRY: ghcr.io
|
GITHUB_REGISTRY: ghcr.io
|
||||||
|
1
.github/workflows/coolify-staging-build.yml
vendored
1
.github/workflows/coolify-staging-build.yml
vendored
@@ -16,6 +16,7 @@ on:
|
|||||||
- docker/testing-host/Dockerfile
|
- docker/testing-host/Dockerfile
|
||||||
- templates/**
|
- templates/**
|
||||||
- CHANGELOG.md
|
- CHANGELOG.md
|
||||||
|
- backlog/**
|
||||||
|
|
||||||
env:
|
env:
|
||||||
GITHUB_REGISTRY: ghcr.io
|
GITHUB_REGISTRY: ghcr.io
|
||||||
|
252
CLAUDE.md
Normal file
252
CLAUDE.md
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Coolify is an open-source, self-hostable platform for deploying applications and managing servers - an alternative to Heroku/Netlify/Vercel. It's built with Laravel (PHP) and uses Docker for containerization.
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
### Frontend Development
|
||||||
|
- `npm run dev` - Start Vite development server for frontend assets
|
||||||
|
- `npm run build` - Build frontend assets for production
|
||||||
|
|
||||||
|
### Backend Development
|
||||||
|
Only run artisan commands inside "coolify" container when in development.
|
||||||
|
- `php artisan serve` - Start Laravel development server
|
||||||
|
- `php artisan migrate` - Run database migrations
|
||||||
|
- `php artisan queue:work` - Start queue worker for background jobs
|
||||||
|
- `php artisan horizon` - Start Laravel Horizon for queue monitoring
|
||||||
|
- `php artisan tinker` - Start interactive PHP REPL
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
- `./vendor/bin/pint` - Run Laravel Pint for code formatting
|
||||||
|
- `./vendor/bin/phpstan` - Run PHPStan for static analysis
|
||||||
|
- `./vendor/bin/pest` - Run Pest tests
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
### Technology Stack
|
||||||
|
- **Backend**: Laravel 12 (PHP 8.4)
|
||||||
|
- **Frontend**: Livewire 3.5+ with Alpine.js and Tailwind CSS 4.1+
|
||||||
|
- **Database**: PostgreSQL 15 (primary), Redis 7 (cache/queues)
|
||||||
|
- **Real-time**: Soketi (WebSocket server)
|
||||||
|
- **Containerization**: Docker & Docker Compose
|
||||||
|
- **Queue Management**: Laravel Horizon
|
||||||
|
|
||||||
|
### Key Components
|
||||||
|
|
||||||
|
#### Core Models
|
||||||
|
- `Application` - Deployed applications with Git integration (74KB, highly complex)
|
||||||
|
- `Server` - Remote servers managed by Coolify (46KB, complex)
|
||||||
|
- `Service` - Docker Compose services (58KB, complex)
|
||||||
|
- `Database` - Standalone database instances (PostgreSQL, MySQL, MongoDB, Redis, etc.)
|
||||||
|
- `Team` - Multi-tenancy support
|
||||||
|
- `Project` - Grouping of environments and resources
|
||||||
|
- `Environment` - Environment isolation (staging, production, etc.)
|
||||||
|
|
||||||
|
#### Job System
|
||||||
|
- Uses Laravel Horizon for queue management
|
||||||
|
- Key jobs: `ApplicationDeploymentJob`, `ServerCheckJob`, `DatabaseBackupJob`
|
||||||
|
- `ServerManagerJob` and `ServerConnectionCheckJob` handle job scheduling
|
||||||
|
|
||||||
|
#### Deployment Flow
|
||||||
|
1. Git webhook triggers deployment
|
||||||
|
2. `ApplicationDeploymentJob` handles build and deployment
|
||||||
|
3. Docker containers are managed on target servers
|
||||||
|
4. Proxy configuration (Nginx/Traefik) is updated
|
||||||
|
|
||||||
|
#### Server Management
|
||||||
|
- SSH-based server communication via `ExecuteRemoteCommand` trait
|
||||||
|
- Docker installation and management
|
||||||
|
- Proxy configuration generation
|
||||||
|
- Resource monitoring and cleanup
|
||||||
|
|
||||||
|
### Directory Structure
|
||||||
|
- `app/Actions/` - Domain-specific actions (Application, Database, Server, etc.)
|
||||||
|
- `app/Jobs/` - Background queue jobs
|
||||||
|
- `app/Livewire/` - Frontend components (full-stack with Livewire)
|
||||||
|
- `app/Models/` - Eloquent models
|
||||||
|
- `app/Rules/` - Custom validation rules
|
||||||
|
- `app/Http/Middleware/` - HTTP middleware
|
||||||
|
- `bootstrap/helpers/` - Helper functions for various domains
|
||||||
|
- `database/migrations/` - Database schema evolution
|
||||||
|
- `routes/` - Application routing (web.php, api.php, webhooks.php, channels.php)
|
||||||
|
- `resources/views/livewire/` - Livewire component views
|
||||||
|
- `tests/` - Pest tests (Feature and Unit)
|
||||||
|
|
||||||
|
## Development Guidelines
|
||||||
|
|
||||||
|
### Frontend Philosophy
|
||||||
|
Coolify uses a **server-side first** approach with minimal JavaScript:
|
||||||
|
- **Livewire** for server-side rendering with reactive components
|
||||||
|
- **Alpine.js** for lightweight client-side interactions
|
||||||
|
- **Tailwind CSS** for utility-first styling with dark mode support
|
||||||
|
- **Enhanced Form Components** with built-in authorization system
|
||||||
|
- Real-time updates via WebSocket without page refreshes
|
||||||
|
|
||||||
|
### Form Authorization Pattern
|
||||||
|
**IMPORTANT**: When creating or editing forms, ALWAYS include authorization:
|
||||||
|
|
||||||
|
#### For Form Components (Input, Select, Textarea, Checkbox, Button):
|
||||||
|
Use `canGate` and `canResource` attributes for automatic authorization:
|
||||||
|
```html
|
||||||
|
<x-forms.input canGate="update" :canResource="$resource" id="name" label="Name" />
|
||||||
|
<x-forms.select canGate="update" :canResource="$resource" id="type" label="Type">...</x-forms.select>
|
||||||
|
<x-forms.checkbox instantSave canGate="update" :canResource="$resource" id="enabled" label="Enabled" />
|
||||||
|
<x-forms.button canGate="update" :canResource="$resource" type="submit">Save</x-forms.button>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### For Modal Components:
|
||||||
|
Wrap with `@can` directives:
|
||||||
|
```html
|
||||||
|
@can('update', $resource)
|
||||||
|
<x-modal-confirmation title="Confirm Action?" buttonTitle="Confirm">...</x-modal-confirmation>
|
||||||
|
<x-modal-input buttonTitle="Edit" title="Edit Settings">...</x-modal-input>
|
||||||
|
@endcan
|
||||||
|
```
|
||||||
|
|
||||||
|
#### In Livewire Components:
|
||||||
|
Always add the `AuthorizesRequests` trait and check permissions:
|
||||||
|
```php
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
|
|
||||||
|
class MyComponent extends Component
|
||||||
|
{
|
||||||
|
use AuthorizesRequests;
|
||||||
|
|
||||||
|
public function mount()
|
||||||
|
{
|
||||||
|
$this->authorize('view', $this->resource);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update()
|
||||||
|
{
|
||||||
|
$this->authorize('update', $this->resource);
|
||||||
|
// ... update logic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Livewire Component Structure
|
||||||
|
- Components located in `app/Livewire/`
|
||||||
|
- Views in `resources/views/livewire/`
|
||||||
|
- State management handled on the server
|
||||||
|
- Use wire:model for two-way data binding
|
||||||
|
- Dispatch events for component communication
|
||||||
|
|
||||||
|
### Code Organization Patterns
|
||||||
|
- **Actions Pattern**: Use Actions for complex business logic (`app/Actions/`)
|
||||||
|
- **Livewire Components**: Handle UI and user interactions
|
||||||
|
- **Jobs**: Handle asynchronous operations
|
||||||
|
- **Traits**: Provide shared functionality (e.g., `ExecuteRemoteCommand`)
|
||||||
|
- **Helper Functions**: Domain-specific helpers in `bootstrap/helpers/`
|
||||||
|
|
||||||
|
### Database Patterns
|
||||||
|
- Use Eloquent ORM for database interactions
|
||||||
|
- Implement relationships properly (HasMany, BelongsTo, etc.)
|
||||||
|
- Use database transactions for critical operations
|
||||||
|
- Leverage query scopes for reusable queries
|
||||||
|
- Apply indexes for performance-critical queries
|
||||||
|
|
||||||
|
### Security Best Practices
|
||||||
|
- **Authentication**: Multi-provider auth via Laravel Fortify & Sanctum
|
||||||
|
- **Authorization**: Team-based access control with policies and enhanced form components
|
||||||
|
- **Form Component Security**: Built-in `canGate` authorization system for UI components
|
||||||
|
- **API Security**: Token-based auth with IP allowlisting
|
||||||
|
- **Secrets Management**: Never log or expose sensitive data
|
||||||
|
- **Input Validation**: Always validate user input with Form Requests or Rules
|
||||||
|
- **SQL Injection Prevention**: Use Eloquent ORM or parameterized queries
|
||||||
|
|
||||||
|
### API Development
|
||||||
|
- RESTful endpoints in `routes/api.php`
|
||||||
|
- Use API Resources for response formatting
|
||||||
|
- Implement rate limiting for public endpoints
|
||||||
|
- Version APIs when making breaking changes
|
||||||
|
- Document endpoints with clear examples
|
||||||
|
|
||||||
|
### Testing Strategy
|
||||||
|
- **Framework**: Pest for expressive testing
|
||||||
|
- **Structure**: Feature tests for user flows, Unit tests for isolated logic
|
||||||
|
- **Coverage**: Test critical paths and edge cases
|
||||||
|
- **Mocking**: Use Laravel's built-in mocking for external services
|
||||||
|
- **Database**: Use RefreshDatabase trait for test isolation
|
||||||
|
|
||||||
|
### Routing Conventions
|
||||||
|
- Group routes by middleware and prefix
|
||||||
|
- Use route model binding for cleaner controllers
|
||||||
|
- Name routes consistently (resource.action)
|
||||||
|
- Implement proper HTTP verbs (GET, POST, PUT, DELETE)
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
- Use `handleError()` helper for consistent error handling
|
||||||
|
- Log errors with appropriate context
|
||||||
|
- Return user-friendly error messages
|
||||||
|
- Implement proper HTTP status codes
|
||||||
|
|
||||||
|
### Performance Considerations
|
||||||
|
- Use eager loading to prevent N+1 queries
|
||||||
|
- Implement caching for frequently accessed data
|
||||||
|
- Queue heavy operations
|
||||||
|
- Optimize database queries with proper indexes
|
||||||
|
- Use chunking for large data operations
|
||||||
|
|
||||||
|
### Code Style
|
||||||
|
- Follow PSR-12 coding standards
|
||||||
|
- Use Laravel Pint for automatic formatting
|
||||||
|
- Write descriptive variable and method names
|
||||||
|
- Keep methods small and focused
|
||||||
|
- Document complex logic with clear comments
|
||||||
|
|
||||||
|
## Cloud Instance Considerations
|
||||||
|
|
||||||
|
We have a cloud instance of Coolify (hosted version) with:
|
||||||
|
- 2 Horizon worker servers
|
||||||
|
- Thousands of connected servers
|
||||||
|
- Thousands of active users
|
||||||
|
- High-availability requirements
|
||||||
|
|
||||||
|
When developing features:
|
||||||
|
- Consider scalability implications
|
||||||
|
- Test with large datasets
|
||||||
|
- Implement efficient queries
|
||||||
|
- Use queues for heavy operations
|
||||||
|
- Consider rate limiting and resource constraints
|
||||||
|
- Implement proper error recovery mechanisms
|
||||||
|
|
||||||
|
## Important Reminders
|
||||||
|
|
||||||
|
- Always run code formatting: `./vendor/bin/pint`
|
||||||
|
- Test your changes: `./vendor/bin/pest`
|
||||||
|
- Check for static analysis issues: `./vendor/bin/phpstan`
|
||||||
|
- Use existing patterns and helpers
|
||||||
|
- Follow the established directory structure
|
||||||
|
- Maintain backward compatibility
|
||||||
|
- Document breaking changes
|
||||||
|
- Consider performance impact on large-scale deployments
|
||||||
|
|
||||||
|
## Additional Documentation
|
||||||
|
|
||||||
|
For more detailed guidelines and patterns, refer to the `.cursor/rules/` directory:
|
||||||
|
|
||||||
|
### Architecture & Patterns
|
||||||
|
- [Application Architecture](.cursor/rules/application-architecture.mdc) - Detailed application structure
|
||||||
|
- [Deployment Architecture](.cursor/rules/deployment-architecture.mdc) - Deployment patterns and flows
|
||||||
|
- [Database Patterns](.cursor/rules/database-patterns.mdc) - Database design and query patterns
|
||||||
|
- [Frontend Patterns](.cursor/rules/frontend-patterns.mdc) - Livewire and Alpine.js patterns
|
||||||
|
- [API & Routing](.cursor/rules/api-and-routing.mdc) - API design and routing conventions
|
||||||
|
|
||||||
|
### Development & Security
|
||||||
|
- [Development Workflow](.cursor/rules/development-workflow.mdc) - Development best practices
|
||||||
|
- [Security Patterns](.cursor/rules/security-patterns.mdc) - Security implementation details
|
||||||
|
- [Form Components](.cursor/rules/form-components.mdc) - Enhanced form components with authorization
|
||||||
|
- [Testing Patterns](.cursor/rules/testing-patterns.mdc) - Testing strategies and examples
|
||||||
|
|
||||||
|
### Project Information
|
||||||
|
- [Project Overview](.cursor/rules/project-overview.mdc) - High-level project structure
|
||||||
|
- [Technology Stack](.cursor/rules/technology-stack.mdc) - Detailed tech stack information
|
||||||
|
- [Cursor Rules Guide](.cursor/rules/cursor_rules.mdc) - How to maintain cursor rules
|
||||||
|
|
||||||
|
## Backlog.md Information
|
||||||
|
- [Backlog.md Guidelines](.cursor/rules/backlog-guildlines.md) - Backlog.md guidelines and commands
|
@@ -53,6 +53,7 @@ Thank you so much!
|
|||||||
|
|
||||||
## Big Sponsors
|
## Big Sponsors
|
||||||
|
|
||||||
|
* [CubePath](https://cubepath.com/?ref=coolify.io) - Dedicated Servers & Instant Deploy
|
||||||
* [GlueOps](https://www.glueops.dev?ref=coolify.io) - DevOps automation and infrastructure management
|
* [GlueOps](https://www.glueops.dev?ref=coolify.io) - DevOps automation and infrastructure management
|
||||||
* [Algora](https://algora.io?ref=coolify.io) - Open source contribution platform
|
* [Algora](https://algora.io?ref=coolify.io) - Open source contribution platform
|
||||||
* [Ubicloud](https://www.ubicloud.com?ref=coolify.io) - Open source cloud infrastructure platform
|
* [Ubicloud](https://www.ubicloud.com?ref=coolify.io) - Open source cloud infrastructure platform
|
||||||
@@ -87,8 +88,11 @@ Thank you so much!
|
|||||||
* [Gozunga](https://gozunga.com?ref=coolify.io) - Seriously Simple Cloud Infrastructure
|
* [Gozunga](https://gozunga.com?ref=coolify.io) - Seriously Simple Cloud Infrastructure
|
||||||
* [Macarne](https://macarne.com?ref=coolify.io) - Best IP Transit & Carrier Ethernet Solutions for Simplified Network Connectivity
|
* [Macarne](https://macarne.com?ref=coolify.io) - Best IP Transit & Carrier Ethernet Solutions for Simplified Network Connectivity
|
||||||
|
|
||||||
|
|
||||||
## Small Sponsors
|
## Small Sponsors
|
||||||
|
|
||||||
|
<a href="https://open-elements.com/?utm_source=coolify.io"><img width="60px" alt="OpenElements" src="https://github.com/OpenElements.png"/></a>
|
||||||
|
<a href="https://xaman.app/?utm_source=coolify.io"><img width="60px" alt="XamanApp" src="https://github.com/XamanApp.png"/></a>
|
||||||
<a href="https://www.uxwizz.com/?utm_source=coolify.io"><img width="60px" alt="UXWizz" src="https://github.com/UXWizz.png"/></a>
|
<a href="https://www.uxwizz.com/?utm_source=coolify.io"><img width="60px" alt="UXWizz" src="https://github.com/UXWizz.png"/></a>
|
||||||
<a href="https://evercam.io/?utm_source=coolify.io"><img width="60px" alt="Evercam" src="https://github.com/evercam.png"/></a>
|
<a href="https://evercam.io/?utm_source=coolify.io"><img width="60px" alt="Evercam" src="https://github.com/evercam.png"/></a>
|
||||||
<a href="https://github.com/iujlaki"><img width="60px" alt="Imre Ujlaki" src="https://github.com/iujlaki.png"/></a>
|
<a href="https://github.com/iujlaki"><img width="60px" alt="Imre Ujlaki" src="https://github.com/iujlaki.png"/></a>
|
||||||
|
@@ -49,7 +49,7 @@ class StopApplication
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($dockerCleanup) {
|
if ($dockerCleanup) {
|
||||||
CleanupDocker::dispatch($server, true);
|
CleanupDocker::dispatch($server, false, false);
|
||||||
}
|
}
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
return $e->getMessage();
|
return $e->getMessage();
|
||||||
|
@@ -185,6 +185,8 @@ class StartPostgresql
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$command = ['postgres'];
|
||||||
|
|
||||||
if (filled($this->database->postgres_conf)) {
|
if (filled($this->database->postgres_conf)) {
|
||||||
$docker_compose['services'][$container_name]['volumes'] = array_merge(
|
$docker_compose['services'][$container_name]['volumes'] = array_merge(
|
||||||
$docker_compose['services'][$container_name]['volumes'],
|
$docker_compose['services'][$container_name]['volumes'],
|
||||||
@@ -195,29 +197,25 @@ class StartPostgresql
|
|||||||
'read_only' => true,
|
'read_only' => true,
|
||||||
]]
|
]]
|
||||||
);
|
);
|
||||||
$docker_compose['services'][$container_name]['command'] = [
|
$command = array_merge($command, ['-c', 'config_file=/etc/postgresql/postgresql.conf']);
|
||||||
'postgres',
|
|
||||||
'-c',
|
|
||||||
'config_file=/etc/postgresql/postgresql.conf',
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->database->enable_ssl) {
|
if ($this->database->enable_ssl) {
|
||||||
$docker_compose['services'][$container_name]['command'] = [
|
$command = array_merge($command, [
|
||||||
'postgres',
|
'-c', 'ssl=on',
|
||||||
'-c',
|
'-c', 'ssl_cert_file=/var/lib/postgresql/certs/server.crt',
|
||||||
'ssl=on',
|
'-c', 'ssl_key_file=/var/lib/postgresql/certs/server.key',
|
||||||
'-c',
|
]);
|
||||||
'ssl_cert_file=/var/lib/postgresql/certs/server.crt',
|
|
||||||
'-c',
|
|
||||||
'ssl_key_file=/var/lib/postgresql/certs/server.key',
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add custom docker run options
|
// Add custom docker run options
|
||||||
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
|
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
|
||||||
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
|
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
|
||||||
|
|
||||||
|
if (count($command) > 1) {
|
||||||
|
$docker_compose['services'][$container_name]['command'] = $command;
|
||||||
|
}
|
||||||
|
|
||||||
$docker_compose = Yaml::dump($docker_compose, 10);
|
$docker_compose = Yaml::dump($docker_compose, 10);
|
||||||
$docker_compose_base64 = base64_encode($docker_compose);
|
$docker_compose_base64 = base64_encode($docker_compose);
|
||||||
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
|
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
|
||||||
|
@@ -18,7 +18,7 @@ class StopDatabase
|
|||||||
{
|
{
|
||||||
use AsAction;
|
use AsAction;
|
||||||
|
|
||||||
public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database, bool $isDeleteOperation = false, bool $dockerCleanup = true)
|
public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database, bool $dockerCleanup = true)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$server = $database->destination->server;
|
$server = $database->destination->server;
|
||||||
@@ -29,7 +29,7 @@ class StopDatabase
|
|||||||
$this->stopContainer($database, $database->uuid, 30);
|
$this->stopContainer($database, $database->uuid, 30);
|
||||||
|
|
||||||
if ($dockerCleanup) {
|
if ($dockerCleanup) {
|
||||||
CleanupDocker::dispatch($server, true);
|
CleanupDocker::dispatch($server, false, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($database->is_public) {
|
if ($database->is_public) {
|
||||||
|
@@ -66,7 +66,7 @@ class CheckProxy
|
|||||||
if ($server->id === 0) {
|
if ($server->id === 0) {
|
||||||
$ip = 'host.docker.internal';
|
$ip = 'host.docker.internal';
|
||||||
}
|
}
|
||||||
$portsToCheck = ['80', '443'];
|
$portsToCheck = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if ($server->proxyType() !== ProxyTypes::NONE->value) {
|
if ($server->proxyType() !== ProxyTypes::NONE->value) {
|
||||||
|
@@ -11,7 +11,7 @@ class CleanupDocker
|
|||||||
|
|
||||||
public string $jobQueue = 'high';
|
public string $jobQueue = 'high';
|
||||||
|
|
||||||
public function handle(Server $server)
|
public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $deleteUnusedNetworks = false)
|
||||||
{
|
{
|
||||||
$settings = instanceSettings();
|
$settings = instanceSettings();
|
||||||
$realtimeImage = config('constants.coolify.realtime_image');
|
$realtimeImage = config('constants.coolify.realtime_image');
|
||||||
@@ -36,11 +36,11 @@ class CleanupDocker
|
|||||||
"docker images --filter before=$realtimeImageWithoutPrefixVersion --filter reference=$realtimeImageWithoutPrefix | grep $realtimeImageWithoutPrefix | awk '{print $3}' | xargs -r docker rmi -f",
|
"docker images --filter before=$realtimeImageWithoutPrefixVersion --filter reference=$realtimeImageWithoutPrefix | grep $realtimeImageWithoutPrefix | awk '{print $3}' | xargs -r docker rmi -f",
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($server->settings->delete_unused_volumes) {
|
if ($deleteUnusedVolumes) {
|
||||||
$commands[] = 'docker volume prune -af';
|
$commands[] = 'docker volume prune -af';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($server->settings->delete_unused_networks) {
|
if ($deleteUnusedNetworks) {
|
||||||
$commands[] = 'docker network prune -f';
|
$commands[] = 'docker network prune -f';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Actions\Server;
|
namespace App\Actions\Server;
|
||||||
|
|
||||||
|
use App\Events\SentinelRestarted;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use Lorisleiva\Actions\Concerns\AsAction;
|
use Lorisleiva\Actions\Concerns\AsAction;
|
||||||
|
|
||||||
@@ -61,5 +62,8 @@ class StartSentinel
|
|||||||
$server->settings->is_sentinel_enabled = true;
|
$server->settings->is_sentinel_enabled = true;
|
||||||
$server->settings->save();
|
$server->settings->save();
|
||||||
$server->sentinelHeartbeat();
|
$server->sentinelHeartbeat();
|
||||||
|
|
||||||
|
// Dispatch event to notify UI components
|
||||||
|
SentinelRestarted::dispatch($server, $version);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -29,7 +29,7 @@ class UpdateCoolify
|
|||||||
if (! $this->server) {
|
if (! $this->server) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
CleanupDocker::dispatch($this->server);
|
CleanupDocker::dispatch($this->server, false, false);
|
||||||
$this->latestVersion = get_latest_version_of_coolify();
|
$this->latestVersion = get_latest_version_of_coolify();
|
||||||
$this->currentVersion = config('constants.coolify.version');
|
$this->currentVersion = config('constants.coolify.version');
|
||||||
if (! $manual_update) {
|
if (! $manual_update) {
|
||||||
|
@@ -11,7 +11,7 @@ class DeleteService
|
|||||||
{
|
{
|
||||||
use AsAction;
|
use AsAction;
|
||||||
|
|
||||||
public function handle(Service $service, bool $deleteConfigurations, bool $deleteVolumes, bool $dockerCleanup, bool $deleteConnectedNetworks)
|
public function handle(Service $service, bool $deleteVolumes, bool $deleteConnectedNetworks, bool $deleteConfigurations, bool $dockerCleanup)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$server = data_get($service, 'server');
|
$server = data_get($service, 'server');
|
||||||
@@ -71,7 +71,7 @@ class DeleteService
|
|||||||
$service->forceDelete();
|
$service->forceDelete();
|
||||||
|
|
||||||
if ($dockerCleanup) {
|
if ($dockerCleanup) {
|
||||||
CleanupDocker::dispatch($server, true);
|
CleanupDocker::dispatch($server, false, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -14,7 +14,7 @@ class StopService
|
|||||||
|
|
||||||
public string $jobQueue = 'high';
|
public string $jobQueue = 'high';
|
||||||
|
|
||||||
public function handle(Service $service, bool $isDeleteOperation = false, bool $dockerCleanup = true)
|
public function handle(Service $service, bool $deleteConnectedNetworks = false, bool $dockerCleanup = true)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$server = $service->destination->server;
|
$server = $service->destination->server;
|
||||||
@@ -36,11 +36,11 @@ class StopService
|
|||||||
$this->stopContainersInParallel($containersToStop, $server);
|
$this->stopContainersInParallel($containersToStop, $server);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($isDeleteOperation) {
|
if ($deleteConnectedNetworks) {
|
||||||
$service->deleteConnectedNetworks();
|
$service->deleteConnectedNetworks();
|
||||||
}
|
}
|
||||||
if ($dockerCleanup) {
|
if ($dockerCleanup) {
|
||||||
CleanupDocker::dispatch($server, true);
|
CleanupDocker::dispatch($server, false, false);
|
||||||
}
|
}
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
return $e->getMessage();
|
return $e->getMessage();
|
||||||
|
248
app/Console/Commands/CleanupNames.php
Normal file
248
app/Console/Commands/CleanupNames.php
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Application;
|
||||||
|
use App\Models\Environment;
|
||||||
|
use App\Models\PrivateKey;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\S3Storage;
|
||||||
|
use App\Models\ScheduledTask;
|
||||||
|
use App\Models\Server;
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Models\StandaloneClickhouse;
|
||||||
|
use App\Models\StandaloneDragonfly;
|
||||||
|
use App\Models\StandaloneKeydb;
|
||||||
|
use App\Models\StandaloneMariadb;
|
||||||
|
use App\Models\StandaloneMongodb;
|
||||||
|
use App\Models\StandaloneMysql;
|
||||||
|
use App\Models\StandalonePostgresql;
|
||||||
|
use App\Models\StandaloneRedis;
|
||||||
|
use App\Models\Tag;
|
||||||
|
use App\Models\Team;
|
||||||
|
use App\Support\ValidationPatterns;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class CleanupNames extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'cleanup:names
|
||||||
|
{--dry-run : Preview changes without applying them}
|
||||||
|
{--model= : Clean specific model (e.g., Project, Server)}
|
||||||
|
{--backup : Create database backup before changes}
|
||||||
|
{--force : Skip confirmation prompt}';
|
||||||
|
|
||||||
|
protected $description = 'Sanitize name fields by removing invalid characters (keeping only letters, numbers, spaces, dashes, underscores, dots, slashes, colons, parentheses)';
|
||||||
|
|
||||||
|
protected array $modelsToClean = [
|
||||||
|
'Project' => Project::class,
|
||||||
|
'Environment' => Environment::class,
|
||||||
|
'Application' => Application::class,
|
||||||
|
'Service' => Service::class,
|
||||||
|
'Server' => Server::class,
|
||||||
|
'Team' => Team::class,
|
||||||
|
'StandalonePostgresql' => StandalonePostgresql::class,
|
||||||
|
'StandaloneMysql' => StandaloneMysql::class,
|
||||||
|
'StandaloneRedis' => StandaloneRedis::class,
|
||||||
|
'StandaloneMongodb' => StandaloneMongodb::class,
|
||||||
|
'StandaloneMariadb' => StandaloneMariadb::class,
|
||||||
|
'StandaloneKeydb' => StandaloneKeydb::class,
|
||||||
|
'StandaloneDragonfly' => StandaloneDragonfly::class,
|
||||||
|
'StandaloneClickhouse' => StandaloneClickhouse::class,
|
||||||
|
'S3Storage' => S3Storage::class,
|
||||||
|
'Tag' => Tag::class,
|
||||||
|
'PrivateKey' => PrivateKey::class,
|
||||||
|
'ScheduledTask' => ScheduledTask::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
protected array $changes = [];
|
||||||
|
|
||||||
|
protected int $totalProcessed = 0;
|
||||||
|
|
||||||
|
protected int $totalCleaned = 0;
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$this->info('🔍 Scanning for invalid characters in name fields...');
|
||||||
|
|
||||||
|
if ($this->option('backup') && ! $this->option('dry-run')) {
|
||||||
|
$this->createBackup();
|
||||||
|
}
|
||||||
|
|
||||||
|
$modelFilter = $this->option('model');
|
||||||
|
$modelsToProcess = $modelFilter
|
||||||
|
? [$modelFilter => $this->modelsToClean[$modelFilter] ?? null]
|
||||||
|
: $this->modelsToClean;
|
||||||
|
|
||||||
|
if ($modelFilter && ! isset($this->modelsToClean[$modelFilter])) {
|
||||||
|
$this->error("❌ Unknown model: {$modelFilter}");
|
||||||
|
$this->info('Available models: '.implode(', ', array_keys($this->modelsToClean)));
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($modelsToProcess as $modelName => $modelClass) {
|
||||||
|
if (! $modelClass) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$this->processModel($modelName, $modelClass);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->displaySummary();
|
||||||
|
|
||||||
|
if (! $this->option('dry-run') && $this->totalCleaned > 0) {
|
||||||
|
$this->logChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function processModel(string $modelName, string $modelClass): void
|
||||||
|
{
|
||||||
|
$this->info("\n📋 Processing {$modelName}...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
$records = $modelClass::all(['id', 'name']);
|
||||||
|
$cleaned = 0;
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
$this->totalProcessed++;
|
||||||
|
|
||||||
|
$originalName = $record->name;
|
||||||
|
$sanitizedName = $this->sanitizeName($originalName);
|
||||||
|
|
||||||
|
if ($sanitizedName !== $originalName) {
|
||||||
|
$this->changes[] = [
|
||||||
|
'model' => $modelName,
|
||||||
|
'id' => $record->id,
|
||||||
|
'original' => $originalName,
|
||||||
|
'sanitized' => $sanitizedName,
|
||||||
|
'timestamp' => now(),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (! $this->option('dry-run')) {
|
||||||
|
// Update without triggering events/mutators to avoid conflicts
|
||||||
|
$modelClass::where('id', $record->id)->update(['name' => $sanitizedName]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$cleaned++;
|
||||||
|
$this->totalCleaned++;
|
||||||
|
|
||||||
|
$this->warn(" 🧹 {$modelName} #{$record->id}:");
|
||||||
|
$this->line(' From: '.$this->truncate($originalName, 80));
|
||||||
|
$this->line(' To: '.$this->truncate($sanitizedName, 80));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($cleaned > 0) {
|
||||||
|
$action = $this->option('dry-run') ? 'would be sanitized' : 'sanitized';
|
||||||
|
$this->info(" ✅ {$cleaned}/{$records->count()} records {$action}");
|
||||||
|
} else {
|
||||||
|
$this->info(' ✨ No invalid characters found');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->error(" ❌ Error processing {$modelName}: ".$e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function sanitizeName(string $name): string
|
||||||
|
{
|
||||||
|
// Remove all characters that don't match the allowed pattern
|
||||||
|
// Use the shared ValidationPatterns to ensure consistency
|
||||||
|
$allowedPattern = str_replace(['/', '^', '$'], '', ValidationPatterns::NAME_PATTERN);
|
||||||
|
$sanitized = preg_replace('/[^'.$allowedPattern.']+/', '', $name);
|
||||||
|
|
||||||
|
// Clean up excessive whitespace but preserve other allowed characters
|
||||||
|
$sanitized = preg_replace('/\s+/', ' ', $sanitized);
|
||||||
|
$sanitized = trim($sanitized);
|
||||||
|
|
||||||
|
// If result is empty, provide a default name
|
||||||
|
if (empty($sanitized)) {
|
||||||
|
$sanitized = 'sanitized-item';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function displaySummary(): void
|
||||||
|
{
|
||||||
|
$this->info("\n".str_repeat('=', 60));
|
||||||
|
$this->info('📊 CLEANUP SUMMARY');
|
||||||
|
$this->info(str_repeat('=', 60));
|
||||||
|
|
||||||
|
$this->line("Records processed: {$this->totalProcessed}");
|
||||||
|
$this->line("Records with invalid characters: {$this->totalCleaned}");
|
||||||
|
|
||||||
|
if ($this->option('dry-run')) {
|
||||||
|
$this->warn("\n🔍 DRY RUN - No changes were made to the database");
|
||||||
|
$this->info('Run without --dry-run to apply these changes');
|
||||||
|
} else {
|
||||||
|
if ($this->totalCleaned > 0) {
|
||||||
|
$this->info("\n✅ Database successfully sanitized!");
|
||||||
|
$this->info('Changes logged to storage/logs/name-cleanup.log');
|
||||||
|
} else {
|
||||||
|
$this->info("\n✨ No cleanup needed - all names are valid!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function logChanges(): void
|
||||||
|
{
|
||||||
|
$logFile = storage_path('logs/name-cleanup.log');
|
||||||
|
$logData = [
|
||||||
|
'timestamp' => now()->toISOString(),
|
||||||
|
'total_processed' => $this->totalProcessed,
|
||||||
|
'total_cleaned' => $this->totalCleaned,
|
||||||
|
'changes' => $this->changes,
|
||||||
|
];
|
||||||
|
|
||||||
|
file_put_contents($logFile, json_encode($logData, JSON_PRETTY_PRINT)."\n", FILE_APPEND);
|
||||||
|
|
||||||
|
Log::info('Name Sanitization completed', [
|
||||||
|
'total_processed' => $this->totalProcessed,
|
||||||
|
'total_sanitized' => $this->totalCleaned,
|
||||||
|
'changes_count' => count($this->changes),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function createBackup(): void
|
||||||
|
{
|
||||||
|
$this->info('💾 Creating database backup...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$backupFile = storage_path('backups/name-cleanup-backup-'.now()->format('Y-m-d-H-i-s').'.sql');
|
||||||
|
|
||||||
|
// Ensure backup directory exists
|
||||||
|
if (! file_exists(dirname($backupFile))) {
|
||||||
|
mkdir(dirname($backupFile), 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$dbConfig = config('database.connections.'.config('database.default'));
|
||||||
|
$command = sprintf(
|
||||||
|
'pg_dump -h %s -p %s -U %s -d %s > %s',
|
||||||
|
$dbConfig['host'],
|
||||||
|
$dbConfig['port'],
|
||||||
|
$dbConfig['username'],
|
||||||
|
$dbConfig['database'],
|
||||||
|
$backupFile
|
||||||
|
);
|
||||||
|
|
||||||
|
exec($command, $output, $returnCode);
|
||||||
|
|
||||||
|
if ($returnCode === 0) {
|
||||||
|
$this->info("✅ Backup created: {$backupFile}");
|
||||||
|
} else {
|
||||||
|
$this->warn('⚠️ Backup creation may have failed. Proceeding anyway...');
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->warn('⚠️ Could not create backup: '.$e->getMessage());
|
||||||
|
$this->warn('Proceeding without backup...');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function truncate(string $text, int $length): string
|
||||||
|
{
|
||||||
|
return strlen($text) > $length ? substr($text, 0, $length).'...' : $text;
|
||||||
|
}
|
||||||
|
}
|
@@ -16,7 +16,7 @@ class Services extends Command
|
|||||||
/**
|
/**
|
||||||
* {@inheritdoc}
|
* {@inheritdoc}
|
||||||
*/
|
*/
|
||||||
protected $description = 'Generate service-templates.yaml based on /templates/compose directory';
|
protected $description = 'Generates service-templates json file based on /templates/compose directory';
|
||||||
|
|
||||||
public function handle(): int
|
public function handle(): int
|
||||||
{
|
{
|
||||||
@@ -33,7 +33,10 @@ class Services extends Command
|
|||||||
];
|
];
|
||||||
})->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
})->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||||
|
|
||||||
file_put_contents(base_path('templates/service-templates.json'), $serviceTemplatesJson.PHP_EOL);
|
file_put_contents(base_path('templates/'.config('constants.services.file_name')), $serviceTemplatesJson.PHP_EOL);
|
||||||
|
|
||||||
|
// Generate service-templates.json with SERVICE_URL changed to SERVICE_FQDN
|
||||||
|
$this->generateServiceTemplatesWithFqdn();
|
||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
@@ -71,6 +74,7 @@ class Services extends Command
|
|||||||
'slogan' => $data->get('slogan', str($file)->headline()),
|
'slogan' => $data->get('slogan', str($file)->headline()),
|
||||||
'compose' => $compose,
|
'compose' => $compose,
|
||||||
'tags' => $tags,
|
'tags' => $tags,
|
||||||
|
'category' => $data->get('category'),
|
||||||
'logo' => $data->get('logo', 'svgs/default.webp'),
|
'logo' => $data->get('logo', 'svgs/default.webp'),
|
||||||
'minversion' => $data->get('minversion', '0.0.0'),
|
'minversion' => $data->get('minversion', '0.0.0'),
|
||||||
];
|
];
|
||||||
@@ -86,4 +90,145 @@ class Services extends Command
|
|||||||
|
|
||||||
return $payload;
|
return $payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function generateServiceTemplatesWithFqdn(): void
|
||||||
|
{
|
||||||
|
$serviceTemplatesWithFqdn = collect(array_merge(
|
||||||
|
glob(base_path('templates/compose/*.yaml')),
|
||||||
|
glob(base_path('templates/compose/*.yml'))
|
||||||
|
))
|
||||||
|
->mapWithKeys(function ($file): array {
|
||||||
|
$file = basename($file);
|
||||||
|
$parsed = $this->processFileWithFqdn($file);
|
||||||
|
|
||||||
|
return $parsed === false ? [] : [
|
||||||
|
Arr::pull($parsed, 'name') => $parsed,
|
||||||
|
];
|
||||||
|
})->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||||
|
|
||||||
|
file_put_contents(base_path('templates/service-templates.json'), $serviceTemplatesWithFqdn.PHP_EOL);
|
||||||
|
|
||||||
|
// Generate service-templates-raw.json with non-base64 encoded compose content
|
||||||
|
// $this->generateServiceTemplatesRaw();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function processFileWithFqdn(string $file): false|array
|
||||||
|
{
|
||||||
|
$content = file_get_contents(base_path("templates/compose/$file"));
|
||||||
|
|
||||||
|
$data = collect(explode(PHP_EOL, $content))->mapWithKeys(function ($line): array {
|
||||||
|
preg_match('/^#(?<key>.*):(?<value>.*)$/U', $line, $m);
|
||||||
|
|
||||||
|
return $m ? [trim($m['key']) => trim($m['value'])] : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
if (str($data->get('ignore'))->toBoolean()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$documentation = $data->get('documentation');
|
||||||
|
$documentation = $documentation ? $documentation.'?utm_source=coolify.io' : 'https://coolify.io/docs';
|
||||||
|
|
||||||
|
// Replace SERVICE_URL with SERVICE_FQDN in the content
|
||||||
|
$modifiedContent = str_replace('SERVICE_URL', 'SERVICE_FQDN', $content);
|
||||||
|
|
||||||
|
$json = Yaml::parse($modifiedContent);
|
||||||
|
$compose = base64_encode(Yaml::dump($json, 10, 2));
|
||||||
|
|
||||||
|
$tags = str($data->get('tags'))->lower()->explode(',')->map(fn ($tag) => trim($tag))->filter();
|
||||||
|
$tags = $tags->isEmpty() ? null : $tags->all();
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'name' => pathinfo($file, PATHINFO_FILENAME),
|
||||||
|
'documentation' => $documentation,
|
||||||
|
'slogan' => $data->get('slogan', str($file)->headline()),
|
||||||
|
'compose' => $compose,
|
||||||
|
'tags' => $tags,
|
||||||
|
'category' => $data->get('category'),
|
||||||
|
'logo' => $data->get('logo', 'svgs/default.webp'),
|
||||||
|
'minversion' => $data->get('minversion', '0.0.0'),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($port = $data->get('port')) {
|
||||||
|
$payload['port'] = $port;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($envFile = $data->get('env_file')) {
|
||||||
|
$envFileContent = file_get_contents(base_path("templates/compose/$envFile"));
|
||||||
|
// Also replace SERVICE_URL with SERVICE_FQDN in env file content
|
||||||
|
$modifiedEnvContent = str_replace('SERVICE_URL', 'SERVICE_FQDN', $envFileContent);
|
||||||
|
$payload['envs'] = base64_encode($modifiedEnvContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateServiceTemplatesRaw(): void
|
||||||
|
{
|
||||||
|
$serviceTemplatesRaw = collect(array_merge(
|
||||||
|
glob(base_path('templates/compose/*.yaml')),
|
||||||
|
glob(base_path('templates/compose/*.yml'))
|
||||||
|
))
|
||||||
|
->mapWithKeys(function ($file): array {
|
||||||
|
$file = basename($file);
|
||||||
|
$parsed = $this->processFileWithFqdnRaw($file);
|
||||||
|
|
||||||
|
return $parsed === false ? [] : [
|
||||||
|
Arr::pull($parsed, 'name') => $parsed,
|
||||||
|
];
|
||||||
|
})->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||||
|
|
||||||
|
file_put_contents(base_path('templates/service-templates-raw.json'), $serviceTemplatesRaw.PHP_EOL);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function processFileWithFqdnRaw(string $file): false|array
|
||||||
|
{
|
||||||
|
$content = file_get_contents(base_path("templates/compose/$file"));
|
||||||
|
|
||||||
|
$data = collect(explode(PHP_EOL, $content))->mapWithKeys(function ($line): array {
|
||||||
|
preg_match('/^#(?<key>.*):(?<value>.*)$/U', $line, $m);
|
||||||
|
|
||||||
|
return $m ? [trim($m['key']) => trim($m['value'])] : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
if (str($data->get('ignore'))->toBoolean()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$documentation = $data->get('documentation');
|
||||||
|
$documentation = $documentation ? $documentation.'?utm_source=coolify.io' : 'https://coolify.io/docs';
|
||||||
|
|
||||||
|
// Replace SERVICE_URL with SERVICE_FQDN in the content
|
||||||
|
$modifiedContent = str_replace('SERVICE_URL', 'SERVICE_FQDN', $content);
|
||||||
|
|
||||||
|
$json = Yaml::parse($modifiedContent);
|
||||||
|
$compose = Yaml::dump($json, 10, 2); // Not base64 encoded
|
||||||
|
|
||||||
|
$tags = str($data->get('tags'))->lower()->explode(',')->map(fn ($tag) => trim($tag))->filter();
|
||||||
|
$tags = $tags->isEmpty() ? null : $tags->all();
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'name' => pathinfo($file, PATHINFO_FILENAME),
|
||||||
|
'documentation' => $documentation,
|
||||||
|
'slogan' => $data->get('slogan', str($file)->headline()),
|
||||||
|
'compose' => $compose,
|
||||||
|
'tags' => $tags,
|
||||||
|
'category' => $data->get('category'),
|
||||||
|
'logo' => $data->get('logo', 'svgs/default.webp'),
|
||||||
|
'minversion' => $data->get('minversion', '0.0.0'),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($port = $data->get('port')) {
|
||||||
|
$payload['port'] = $port;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($envFile = $data->get('env_file')) {
|
||||||
|
$envFileContent = file_get_contents(base_path("templates/compose/$envFile"));
|
||||||
|
// Also replace SERVICE_URL with SERVICE_FQDN in env file content (not base64 encoded)
|
||||||
|
$modifiedEnvContent = str_replace('SERVICE_URL', 'SERVICE_FQDN', $envFileContent);
|
||||||
|
$payload['envs'] = $modifiedEnvContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -5,6 +5,7 @@ namespace App\Console\Commands;
|
|||||||
use App\Enums\ActivityTypes;
|
use App\Enums\ActivityTypes;
|
||||||
use App\Enums\ApplicationDeploymentStatus;
|
use App\Enums\ApplicationDeploymentStatus;
|
||||||
use App\Jobs\CheckHelperImageJob;
|
use App\Jobs\CheckHelperImageJob;
|
||||||
|
use App\Jobs\PullChangelogFromGitHub;
|
||||||
use App\Models\ApplicationDeploymentQueue;
|
use App\Models\ApplicationDeploymentQueue;
|
||||||
use App\Models\Environment;
|
use App\Models\Environment;
|
||||||
use App\Models\ScheduledDatabaseBackup;
|
use App\Models\ScheduledDatabaseBackup;
|
||||||
@@ -52,6 +53,11 @@ class Init extends Command
|
|||||||
|
|
||||||
$this->call('cleanup:redis');
|
$this->call('cleanup:redis');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->call('cleanup:names');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
echo "Error in cleanup:names command: {$e->getMessage()}\n";
|
||||||
|
}
|
||||||
$this->call('cleanup:stucked-resources');
|
$this->call('cleanup:stucked-resources');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -62,21 +68,43 @@ class Init extends Command
|
|||||||
|
|
||||||
if (isCloud()) {
|
if (isCloud()) {
|
||||||
try {
|
try {
|
||||||
$this->cleanupUnnecessaryDynamicProxyConfiguration();
|
$this->cleanupInProgressApplicationDeployments();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
echo "Could not cleanup inprogress deployments: {$e->getMessage()}\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
$this->pullTemplatesFromCDN();
|
$this->pullTemplatesFromCDN();
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
echo "Could not pull templates from CDN: {$e->getMessage()}\n";
|
echo "Could not pull templates from CDN: {$e->getMessage()}\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->pullChangelogFromGitHub();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
echo "Could not changelogs from github: {$e->getMessage()}\n";
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->cleanupInProgressApplicationDeployments();
|
$this->cleanupInProgressApplicationDeployments();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
echo "Could not cleanup inprogress deployments: {$e->getMessage()}\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
$this->pullTemplatesFromCDN();
|
$this->pullTemplatesFromCDN();
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
echo "Could not pull templates from CDN: {$e->getMessage()}\n";
|
echo "Could not pull templates from CDN: {$e->getMessage()}\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->pullChangelogFromGitHub();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
echo "Could not changelogs from github: {$e->getMessage()}\n";
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
$localhost = $this->servers->where('id', 0)->first();
|
$localhost = $this->servers->where('id', 0)->first();
|
||||||
$localhost->setupDynamicProxyConfiguration();
|
$localhost->setupDynamicProxyConfiguration();
|
||||||
@@ -105,7 +133,17 @@ class Init extends Command
|
|||||||
$response = Http::retry(3, 1000)->get(config('constants.services.official'));
|
$response = Http::retry(3, 1000)->get(config('constants.services.official'));
|
||||||
if ($response->successful()) {
|
if ($response->successful()) {
|
||||||
$services = $response->json();
|
$services = $response->json();
|
||||||
File::put(base_path('templates/service-templates.json'), json_encode($services));
|
File::put(base_path('templates/'.config('constants.services.file_name')), json_encode($services));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function pullChangelogFromGitHub()
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
PullChangelogFromGitHub::dispatch();
|
||||||
|
echo "Changelog fetch initiated\n";
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
echo "Could not fetch changelog from GitHub: {$e->getMessage()}\n";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
98
app/Console/Commands/InitChangelog.php
Normal file
98
app/Console/Commands/InitChangelog.php
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class InitChangelog extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'changelog:init {month? : Month in YYYY-MM format (defaults to current month)}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Initialize a new monthly changelog file with example structure';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*/
|
||||||
|
public function handle()
|
||||||
|
{
|
||||||
|
$month = $this->argument('month') ?: Carbon::now()->format('Y-m');
|
||||||
|
|
||||||
|
// Validate month format
|
||||||
|
if (! preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $month)) {
|
||||||
|
$this->error('Invalid month format. Use YYYY-MM format with valid months 01-12 (e.g., 2025-08)');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$changelogsDir = base_path('changelogs');
|
||||||
|
$filePath = $changelogsDir."/{$month}.json";
|
||||||
|
|
||||||
|
// Create changelogs directory if it doesn't exist
|
||||||
|
if (! is_dir($changelogsDir)) {
|
||||||
|
mkdir($changelogsDir, 0755, true);
|
||||||
|
$this->info("Created changelogs directory: {$changelogsDir}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if file already exists
|
||||||
|
if (file_exists($filePath)) {
|
||||||
|
if (! $this->confirm("File {$month}.json already exists. Overwrite?")) {
|
||||||
|
$this->info('Operation cancelled');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the month for example data
|
||||||
|
$carbonMonth = Carbon::createFromFormat('Y-m', $month);
|
||||||
|
$monthName = $carbonMonth->format('F Y');
|
||||||
|
$sampleDate = $carbonMonth->addDays(14)->toISOString(); // Mid-month
|
||||||
|
|
||||||
|
// Get version from config
|
||||||
|
$version = 'v'.config('constants.coolify.version');
|
||||||
|
|
||||||
|
// Create example changelog structure
|
||||||
|
$exampleData = [
|
||||||
|
'entries' => [
|
||||||
|
[
|
||||||
|
'version' => $version,
|
||||||
|
'title' => 'Example Feature Release',
|
||||||
|
'content' => "This is an example changelog entry for {$monthName}. Replace this with your actual release notes. Include details about new features, improvements, bug fixes, and any breaking changes.",
|
||||||
|
'published_at' => $sampleDate,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Write the file
|
||||||
|
$jsonContent = json_encode($exampleData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||||
|
|
||||||
|
if (file_put_contents($filePath, $jsonContent) === false) {
|
||||||
|
$this->error("Failed to create changelog file: {$filePath}");
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("✅ Created changelog file: changelogs/{$month}.json");
|
||||||
|
$this->line(" Example entry created for {$monthName}");
|
||||||
|
$this->line(' Edit the file to add your actual changelog entries');
|
||||||
|
|
||||||
|
// Show the file contents
|
||||||
|
if ($this->option('verbose')) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->line('File contents:');
|
||||||
|
$this->line($jsonContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
@@ -13,6 +13,7 @@ class RunScheduledJobsManually extends Command
|
|||||||
{
|
{
|
||||||
protected $signature = 'schedule:run-manual
|
protected $signature = 'schedule:run-manual
|
||||||
{--type=all : Type of jobs to run (all, backups, tasks)}
|
{--type=all : Type of jobs to run (all, backups, tasks)}
|
||||||
|
{--frequency= : Filter by frequency (daily, hourly, weekly, monthly, yearly, or cron expression)}
|
||||||
{--chunk=5 : Number of jobs to process in each batch}
|
{--chunk=5 : Number of jobs to process in each batch}
|
||||||
{--delay=30 : Delay in seconds between batches}
|
{--delay=30 : Delay in seconds between batches}
|
||||||
{--max= : Maximum number of jobs to process (useful for testing)}
|
{--max= : Maximum number of jobs to process (useful for testing)}
|
||||||
@@ -23,37 +24,52 @@ class RunScheduledJobsManually extends Command
|
|||||||
public function handle()
|
public function handle()
|
||||||
{
|
{
|
||||||
$type = $this->option('type');
|
$type = $this->option('type');
|
||||||
|
$frequency = $this->option('frequency');
|
||||||
$chunkSize = (int) $this->option('chunk');
|
$chunkSize = (int) $this->option('chunk');
|
||||||
$delay = (int) $this->option('delay');
|
$delay = (int) $this->option('delay');
|
||||||
$maxJobs = $this->option('max') ? (int) $this->option('max') : null;
|
$maxJobs = $this->option('max') ? (int) $this->option('max') : null;
|
||||||
$dryRun = $this->option('dry-run');
|
$dryRun = $this->option('dry-run');
|
||||||
|
|
||||||
$this->info('Starting manual execution of scheduled jobs...'.($dryRun ? ' (DRY RUN)' : ''));
|
$this->info('Starting manual execution of scheduled jobs...'.($dryRun ? ' (DRY RUN)' : ''));
|
||||||
$this->info("Type: {$type}, Chunk size: {$chunkSize}, Delay: {$delay}s".($maxJobs ? ", Max jobs: {$maxJobs}" : '').($dryRun ? ', Dry run: enabled' : ''));
|
$this->info("Type: {$type}".($frequency ? ", Frequency: {$frequency}" : '').", Chunk size: {$chunkSize}, Delay: {$delay}s".($maxJobs ? ", Max jobs: {$maxJobs}" : '').($dryRun ? ', Dry run: enabled' : ''));
|
||||||
|
|
||||||
if ($dryRun) {
|
if ($dryRun) {
|
||||||
$this->warn('DRY RUN MODE: No jobs will actually be dispatched');
|
$this->warn('DRY RUN MODE: No jobs will actually be dispatched');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($type === 'all' || $type === 'backups') {
|
if ($type === 'all' || $type === 'backups') {
|
||||||
$this->runScheduledBackups($chunkSize, $delay, $maxJobs, $dryRun);
|
$this->runScheduledBackups($chunkSize, $delay, $maxJobs, $dryRun, $frequency);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($type === 'all' || $type === 'tasks') {
|
if ($type === 'all' || $type === 'tasks') {
|
||||||
$this->runScheduledTasks($chunkSize, $delay, $maxJobs, $dryRun);
|
$this->runScheduledTasks($chunkSize, $delay, $maxJobs, $dryRun, $frequency);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->info('Completed manual execution of scheduled jobs.'.($dryRun ? ' (DRY RUN)' : ''));
|
$this->info('Completed manual execution of scheduled jobs.'.($dryRun ? ' (DRY RUN)' : ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function runScheduledBackups(int $chunkSize, int $delay, ?int $maxJobs = null, bool $dryRun = false): void
|
private function runScheduledBackups(int $chunkSize, int $delay, ?int $maxJobs = null, bool $dryRun = false, ?string $frequency = null): void
|
||||||
{
|
{
|
||||||
$this->info('Processing scheduled database backups...');
|
$this->info('Processing scheduled database backups...');
|
||||||
|
|
||||||
$scheduled_backups = ScheduledDatabaseBackup::where('enabled', true)->get();
|
$query = ScheduledDatabaseBackup::where('enabled', true);
|
||||||
|
|
||||||
|
if ($frequency) {
|
||||||
|
$query->where(function ($q) use ($frequency) {
|
||||||
|
// Handle human-readable frequency strings
|
||||||
|
if (in_array($frequency, ['daily', 'hourly', 'weekly', 'monthly', 'yearly', 'every_minute'])) {
|
||||||
|
$q->where('frequency', $frequency);
|
||||||
|
} else {
|
||||||
|
// Handle cron expressions
|
||||||
|
$q->where('frequency', $frequency);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$scheduled_backups = $query->get();
|
||||||
|
|
||||||
if ($scheduled_backups->isEmpty()) {
|
if ($scheduled_backups->isEmpty()) {
|
||||||
$this->info('No enabled scheduled backups found.');
|
$this->info('No enabled scheduled backups found'.($frequency ? " with frequency '{$frequency}'" : '').'.');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -96,7 +112,7 @@ class RunScheduledJobsManually extends Command
|
|||||||
$this->info("Limited to {$maxJobs} scheduled backups for testing");
|
$this->info("Limited to {$maxJobs} scheduled backups for testing");
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->info("Found {$finalScheduledBackups->count()} valid scheduled backups to process");
|
$this->info("Found {$finalScheduledBackups->count()} valid scheduled backups to process".($frequency ? " with frequency '{$frequency}'" : ''));
|
||||||
|
|
||||||
$chunks = $finalScheduledBackups->chunk($chunkSize);
|
$chunks = $finalScheduledBackups->chunk($chunkSize);
|
||||||
foreach ($chunks as $index => $chunk) {
|
foreach ($chunks as $index => $chunk) {
|
||||||
@@ -105,10 +121,10 @@ class RunScheduledJobsManually extends Command
|
|||||||
foreach ($chunk as $scheduled_backup) {
|
foreach ($chunk as $scheduled_backup) {
|
||||||
try {
|
try {
|
||||||
if ($dryRun) {
|
if ($dryRun) {
|
||||||
$this->info("🔍 Would dispatch backup job for: {$scheduled_backup->name} (ID: {$scheduled_backup->id})");
|
$this->info("🔍 Would dispatch backup job for: {$scheduled_backup->name} (ID: {$scheduled_backup->id}, Frequency: {$scheduled_backup->frequency})");
|
||||||
} else {
|
} else {
|
||||||
DatabaseBackupJob::dispatch($scheduled_backup);
|
DatabaseBackupJob::dispatch($scheduled_backup);
|
||||||
$this->info("✓ Dispatched backup job for: {$scheduled_backup->name} (ID: {$scheduled_backup->id})");
|
$this->info("✓ Dispatched backup job for: {$scheduled_backup->name} (ID: {$scheduled_backup->id}, Frequency: {$scheduled_backup->frequency})");
|
||||||
}
|
}
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->error("✗ Failed to dispatch backup job for {$scheduled_backup->id}: ".$e->getMessage());
|
$this->error("✗ Failed to dispatch backup job for {$scheduled_backup->id}: ".$e->getMessage());
|
||||||
@@ -123,14 +139,28 @@ class RunScheduledJobsManually extends Command
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function runScheduledTasks(int $chunkSize, int $delay, ?int $maxJobs = null, bool $dryRun = false): void
|
private function runScheduledTasks(int $chunkSize, int $delay, ?int $maxJobs = null, bool $dryRun = false, ?string $frequency = null): void
|
||||||
{
|
{
|
||||||
$this->info('Processing scheduled tasks...');
|
$this->info('Processing scheduled tasks...');
|
||||||
|
|
||||||
$scheduled_tasks = ScheduledTask::where('enabled', true)->get();
|
$query = ScheduledTask::where('enabled', true);
|
||||||
|
|
||||||
|
if ($frequency) {
|
||||||
|
$query->where(function ($q) use ($frequency) {
|
||||||
|
// Handle human-readable frequency strings
|
||||||
|
if (in_array($frequency, ['daily', 'hourly', 'weekly', 'monthly', 'yearly', 'every_minute'])) {
|
||||||
|
$q->where('frequency', $frequency);
|
||||||
|
} else {
|
||||||
|
// Handle cron expressions
|
||||||
|
$q->where('frequency', $frequency);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$scheduled_tasks = $query->get();
|
||||||
|
|
||||||
if ($scheduled_tasks->isEmpty()) {
|
if ($scheduled_tasks->isEmpty()) {
|
||||||
$this->info('No enabled scheduled tasks found.');
|
$this->info('No enabled scheduled tasks found'.($frequency ? " with frequency '{$frequency}'" : '').'.');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -188,7 +218,7 @@ class RunScheduledJobsManually extends Command
|
|||||||
$this->info("Limited to {$maxJobs} scheduled tasks for testing");
|
$this->info("Limited to {$maxJobs} scheduled tasks for testing");
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->info("Found {$finalScheduledTasks->count()} valid scheduled tasks to process");
|
$this->info("Found {$finalScheduledTasks->count()} valid scheduled tasks to process".($frequency ? " with frequency '{$frequency}'" : ''));
|
||||||
|
|
||||||
$chunks = $finalScheduledTasks->chunk($chunkSize);
|
$chunks = $finalScheduledTasks->chunk($chunkSize);
|
||||||
foreach ($chunks as $index => $chunk) {
|
foreach ($chunks as $index => $chunk) {
|
||||||
@@ -197,10 +227,10 @@ class RunScheduledJobsManually extends Command
|
|||||||
foreach ($chunk as $scheduled_task) {
|
foreach ($chunk as $scheduled_task) {
|
||||||
try {
|
try {
|
||||||
if ($dryRun) {
|
if ($dryRun) {
|
||||||
$this->info("🔍 Would dispatch task job for: {$scheduled_task->name} (ID: {$scheduled_task->id})");
|
$this->info("🔍 Would dispatch task job for: {$scheduled_task->name} (ID: {$scheduled_task->id}, Frequency: {$scheduled_task->frequency})");
|
||||||
} else {
|
} else {
|
||||||
ScheduledTaskJob::dispatch($scheduled_task);
|
ScheduledTaskJob::dispatch($scheduled_task);
|
||||||
$this->info("✓ Dispatched task job for: {$scheduled_task->name} (ID: {$scheduled_task->id})");
|
$this->info("✓ Dispatched task job for: {$scheduled_task->name} (ID: {$scheduled_task->id}, Frequency: {$scheduled_task->frequency})");
|
||||||
}
|
}
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->error("✗ Failed to dispatch task job for {$scheduled_task->id}: ".$e->getMessage());
|
$this->error("✗ Failed to dispatch task job for {$scheduled_task->id}: ".$e->getMessage());
|
||||||
|
@@ -45,7 +45,7 @@ class SyncBunny extends Command
|
|||||||
$install_script = 'install.sh';
|
$install_script = 'install.sh';
|
||||||
$upgrade_script = 'upgrade.sh';
|
$upgrade_script = 'upgrade.sh';
|
||||||
$production_env = '.env.production';
|
$production_env = '.env.production';
|
||||||
$service_template = 'service-templates.json';
|
$service_template = config('constants.services.file_name');
|
||||||
$versions = 'versions.json';
|
$versions = 'versions.json';
|
||||||
|
|
||||||
$compose_file_location = "$parent_dir/$compose_file";
|
$compose_file_location = "$parent_dir/$compose_file";
|
||||||
@@ -102,7 +102,7 @@ class SyncBunny extends Command
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($only_template) {
|
if ($only_template) {
|
||||||
$this->info('About to sync service-templates.json to BunnyCDN.');
|
$this->info('About to sync '.config('constants.services.file_name').' to BunnyCDN.');
|
||||||
$confirmed = confirm('Are you sure you want to sync?');
|
$confirmed = confirm('Are you sure you want to sync?');
|
||||||
if (! $confirmed) {
|
if (! $confirmed) {
|
||||||
return;
|
return;
|
||||||
|
278
app/Console/Commands/ViewScheduledLogs.php
Normal file
278
app/Console/Commands/ViewScheduledLogs.php
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
|
||||||
|
class ViewScheduledLogs extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'logs:scheduled
|
||||||
|
{--lines=50 : Number of lines to display}
|
||||||
|
{--follow : Follow the log file (tail -f)}
|
||||||
|
{--date= : Specific date (Y-m-d format, defaults to today)}
|
||||||
|
{--task-name= : Filter by task name (partial match)}
|
||||||
|
{--task-id= : Filter by task ID}
|
||||||
|
{--backup-name= : Filter by backup name (partial match)}
|
||||||
|
{--backup-id= : Filter by backup ID}
|
||||||
|
{--errors : View error logs only}
|
||||||
|
{--all : View both normal and error logs}
|
||||||
|
{--hourly : Filter hourly jobs}
|
||||||
|
{--daily : Filter daily jobs}
|
||||||
|
{--weekly : Filter weekly jobs}
|
||||||
|
{--monthly : Filter monthly jobs}
|
||||||
|
{--frequency= : Filter by specific cron expression}';
|
||||||
|
|
||||||
|
protected $description = 'View scheduled backups and tasks logs with optional filtering';
|
||||||
|
|
||||||
|
public function handle()
|
||||||
|
{
|
||||||
|
$date = $this->option('date') ?: now()->format('Y-m-d');
|
||||||
|
$logPaths = $this->getLogPaths($date);
|
||||||
|
|
||||||
|
if (empty($logPaths)) {
|
||||||
|
$this->showAvailableLogFiles($date);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines = $this->option('lines');
|
||||||
|
$follow = $this->option('follow');
|
||||||
|
|
||||||
|
// Build grep filters
|
||||||
|
$filters = $this->buildFilters();
|
||||||
|
$filterDescription = $this->getFilterDescription();
|
||||||
|
$logTypeDescription = $this->getLogTypeDescription();
|
||||||
|
|
||||||
|
if ($follow) {
|
||||||
|
$this->info("Following {$logTypeDescription} logs for {$date}{$filterDescription} (Press Ctrl+C to stop)...");
|
||||||
|
$this->line('');
|
||||||
|
|
||||||
|
if (count($logPaths) === 1) {
|
||||||
|
$logPath = $logPaths[0];
|
||||||
|
if ($filters) {
|
||||||
|
passthru("tail -f {$logPath} | grep -E '{$filters}'");
|
||||||
|
} else {
|
||||||
|
passthru("tail -f {$logPath}");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Multiple files - use multitail or tail with process substitution
|
||||||
|
$logPathsStr = implode(' ', $logPaths);
|
||||||
|
if ($filters) {
|
||||||
|
passthru("tail -f {$logPathsStr} | grep -E '{$filters}'");
|
||||||
|
} else {
|
||||||
|
passthru("tail -f {$logPathsStr}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$this->info("Showing last {$lines} lines of {$logTypeDescription} logs for {$date}{$filterDescription}:");
|
||||||
|
$this->line('');
|
||||||
|
|
||||||
|
if (count($logPaths) === 1) {
|
||||||
|
$logPath = $logPaths[0];
|
||||||
|
if ($filters) {
|
||||||
|
passthru("tail -n {$lines} {$logPath} | grep -E '{$filters}'");
|
||||||
|
} else {
|
||||||
|
passthru("tail -n {$lines} {$logPath}");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Multiple files - concatenate and sort by timestamp
|
||||||
|
$logPathsStr = implode(' ', $logPaths);
|
||||||
|
if ($filters) {
|
||||||
|
passthru("tail -n {$lines} {$logPathsStr} | sort | grep -E '{$filters}'");
|
||||||
|
} else {
|
||||||
|
passthru("tail -n {$lines} {$logPathsStr} | sort");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getLogPaths(string $date): array
|
||||||
|
{
|
||||||
|
$paths = [];
|
||||||
|
|
||||||
|
if ($this->option('errors')) {
|
||||||
|
// Error logs only
|
||||||
|
$errorPath = storage_path("logs/scheduled-errors-{$date}.log");
|
||||||
|
if (File::exists($errorPath)) {
|
||||||
|
$paths[] = $errorPath;
|
||||||
|
}
|
||||||
|
} elseif ($this->option('all')) {
|
||||||
|
// Both normal and error logs
|
||||||
|
$normalPath = storage_path("logs/scheduled-{$date}.log");
|
||||||
|
$errorPath = storage_path("logs/scheduled-errors-{$date}.log");
|
||||||
|
|
||||||
|
if (File::exists($normalPath)) {
|
||||||
|
$paths[] = $normalPath;
|
||||||
|
}
|
||||||
|
if (File::exists($errorPath)) {
|
||||||
|
$paths[] = $errorPath;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Normal logs only (default)
|
||||||
|
$normalPath = storage_path("logs/scheduled-{$date}.log");
|
||||||
|
if (File::exists($normalPath)) {
|
||||||
|
$paths[] = $normalPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function showAvailableLogFiles(string $date): void
|
||||||
|
{
|
||||||
|
$logType = $this->getLogTypeDescription();
|
||||||
|
$this->warn("No {$logType} logs found for date {$date}");
|
||||||
|
|
||||||
|
// Show available log files
|
||||||
|
$normalFiles = File::glob(storage_path('logs/scheduled-*.log'));
|
||||||
|
$errorFiles = File::glob(storage_path('logs/scheduled-errors-*.log'));
|
||||||
|
|
||||||
|
if (! empty($normalFiles) || ! empty($errorFiles)) {
|
||||||
|
$this->info('Available scheduled log files:');
|
||||||
|
|
||||||
|
if (! empty($normalFiles)) {
|
||||||
|
$this->line(' Normal logs:');
|
||||||
|
foreach ($normalFiles as $file) {
|
||||||
|
$basename = basename($file);
|
||||||
|
$this->line(" - {$basename}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($errorFiles)) {
|
||||||
|
$this->line(' Error logs:');
|
||||||
|
foreach ($errorFiles as $file) {
|
||||||
|
$basename = basename($file);
|
||||||
|
$this->line(" - {$basename}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getLogTypeDescription(): string
|
||||||
|
{
|
||||||
|
if ($this->option('errors')) {
|
||||||
|
return 'error';
|
||||||
|
} elseif ($this->option('all')) {
|
||||||
|
return 'all';
|
||||||
|
} else {
|
||||||
|
return 'normal';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildFilters(): ?string
|
||||||
|
{
|
||||||
|
$filters = [];
|
||||||
|
|
||||||
|
if ($taskName = $this->option('task-name')) {
|
||||||
|
$filters[] = '"task_name":"[^"]*'.preg_quote($taskName, '/').'[^"]*"';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($taskId = $this->option('task-id')) {
|
||||||
|
$filters[] = '"task_id":'.preg_quote($taskId, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($backupName = $this->option('backup-name')) {
|
||||||
|
$filters[] = '"backup_name":"[^"]*'.preg_quote($backupName, '/').'[^"]*"';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($backupId = $this->option('backup-id')) {
|
||||||
|
$filters[] = '"backup_id":'.preg_quote($backupId, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frequency filters
|
||||||
|
if ($this->option('hourly')) {
|
||||||
|
$filters[] = $this->getFrequencyPattern('hourly');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->option('daily')) {
|
||||||
|
$filters[] = $this->getFrequencyPattern('daily');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->option('weekly')) {
|
||||||
|
$filters[] = $this->getFrequencyPattern('weekly');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->option('monthly')) {
|
||||||
|
$filters[] = $this->getFrequencyPattern('monthly');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($frequency = $this->option('frequency')) {
|
||||||
|
$filters[] = '"frequency":"'.preg_quote($frequency, '/').'"';
|
||||||
|
}
|
||||||
|
|
||||||
|
return empty($filters) ? null : implode('|', $filters);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getFrequencyPattern(string $type): string
|
||||||
|
{
|
||||||
|
$patterns = [
|
||||||
|
'hourly' => [
|
||||||
|
'0 \* \* \* \*', // 0 * * * *
|
||||||
|
'@hourly', // @hourly
|
||||||
|
],
|
||||||
|
'daily' => [
|
||||||
|
'0 0 \* \* \*', // 0 0 * * *
|
||||||
|
'@daily', // @daily
|
||||||
|
'@midnight', // @midnight
|
||||||
|
],
|
||||||
|
'weekly' => [
|
||||||
|
'0 0 \* \* [0-6]', // 0 0 * * 0-6 (any day of week)
|
||||||
|
'@weekly', // @weekly
|
||||||
|
],
|
||||||
|
'monthly' => [
|
||||||
|
'0 0 1 \* \*', // 0 0 1 * * (first of month)
|
||||||
|
'@monthly', // @monthly
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$typePatterns = $patterns[$type] ?? [];
|
||||||
|
|
||||||
|
// For grep, we need to match the frequency field in JSON
|
||||||
|
return '"frequency":"('.implode('|', $typePatterns).')"';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getFilterDescription(): string
|
||||||
|
{
|
||||||
|
$descriptions = [];
|
||||||
|
|
||||||
|
if ($taskName = $this->option('task-name')) {
|
||||||
|
$descriptions[] = "task name: {$taskName}";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($taskId = $this->option('task-id')) {
|
||||||
|
$descriptions[] = "task ID: {$taskId}";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($backupName = $this->option('backup-name')) {
|
||||||
|
$descriptions[] = "backup name: {$backupName}";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($backupId = $this->option('backup-id')) {
|
||||||
|
$descriptions[] = "backup ID: {$backupId}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frequency filters
|
||||||
|
if ($this->option('hourly')) {
|
||||||
|
$descriptions[] = 'hourly jobs';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->option('daily')) {
|
||||||
|
$descriptions[] = 'daily jobs';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->option('weekly')) {
|
||||||
|
$descriptions[] = 'weekly jobs';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->option('monthly')) {
|
||||||
|
$descriptions[] = 'monthly jobs';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($frequency = $this->option('frequency')) {
|
||||||
|
$descriptions[] = "frequency: {$frequency}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return empty($descriptions) ? '' : ' (filtered by '.implode(', ', $descriptions).')';
|
||||||
|
}
|
||||||
|
}
|
@@ -6,23 +6,17 @@ use App\Jobs\CheckAndStartSentinelJob;
|
|||||||
use App\Jobs\CheckForUpdatesJob;
|
use App\Jobs\CheckForUpdatesJob;
|
||||||
use App\Jobs\CheckHelperImageJob;
|
use App\Jobs\CheckHelperImageJob;
|
||||||
use App\Jobs\CleanupInstanceStuffsJob;
|
use App\Jobs\CleanupInstanceStuffsJob;
|
||||||
use App\Jobs\DatabaseBackupJob;
|
use App\Jobs\PullChangelogFromGitHub;
|
||||||
use App\Jobs\DockerCleanupJob;
|
|
||||||
use App\Jobs\PullTemplatesFromCDN;
|
use App\Jobs\PullTemplatesFromCDN;
|
||||||
use App\Jobs\RegenerateSslCertJob;
|
use App\Jobs\RegenerateSslCertJob;
|
||||||
use App\Jobs\ScheduledTaskJob;
|
use App\Jobs\ScheduledJobManager;
|
||||||
use App\Jobs\ServerCheckJob;
|
use App\Jobs\ServerManagerJob;
|
||||||
use App\Jobs\ServerPatchCheckJob;
|
|
||||||
use App\Jobs\ServerStorageCheckJob;
|
|
||||||
use App\Jobs\UpdateCoolifyJob;
|
use App\Jobs\UpdateCoolifyJob;
|
||||||
use App\Models\InstanceSettings;
|
use App\Models\InstanceSettings;
|
||||||
use App\Models\ScheduledDatabaseBackup;
|
|
||||||
use App\Models\ScheduledTask;
|
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
use Illuminate\Console\Scheduling\Schedule;
|
use Illuminate\Console\Scheduling\Schedule;
|
||||||
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
|
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class Kernel extends ConsoleKernel
|
class Kernel extends ConsoleKernel
|
||||||
@@ -61,10 +55,10 @@ class Kernel extends ConsoleKernel
|
|||||||
$this->scheduleInstance->job(new CheckHelperImageJob)->everyTenMinutes()->onOneServer();
|
$this->scheduleInstance->job(new CheckHelperImageJob)->everyTenMinutes()->onOneServer();
|
||||||
|
|
||||||
// Server Jobs
|
// Server Jobs
|
||||||
$this->checkResources();
|
$this->scheduleInstance->job(new ServerManagerJob)->everyMinute()->onOneServer();
|
||||||
|
|
||||||
$this->checkScheduledBackups();
|
// Scheduled Jobs (Backups & Tasks)
|
||||||
$this->checkScheduledTasks();
|
$this->scheduleInstance->job(new ScheduledJobManager)->everyMinute()->onOneServer();
|
||||||
|
|
||||||
$this->scheduleInstance->command('uploads:clear')->everyTwoMinutes();
|
$this->scheduleInstance->command('uploads:clear')->everyTwoMinutes();
|
||||||
|
|
||||||
@@ -74,17 +68,18 @@ class Kernel extends ConsoleKernel
|
|||||||
$this->scheduleInstance->command('cleanup:unreachable-servers')->daily()->onOneServer();
|
$this->scheduleInstance->command('cleanup:unreachable-servers')->daily()->onOneServer();
|
||||||
|
|
||||||
$this->scheduleInstance->job(new PullTemplatesFromCDN)->cron($this->updateCheckFrequency)->timezone($this->instanceTimezone)->onOneServer();
|
$this->scheduleInstance->job(new PullTemplatesFromCDN)->cron($this->updateCheckFrequency)->timezone($this->instanceTimezone)->onOneServer();
|
||||||
|
$this->scheduleInstance->job(new PullChangelogFromGitHub)->cron($this->updateCheckFrequency)->timezone($this->instanceTimezone)->onOneServer();
|
||||||
|
|
||||||
$this->scheduleInstance->job(new CleanupInstanceStuffsJob)->everyTwoMinutes()->onOneServer();
|
$this->scheduleInstance->job(new CleanupInstanceStuffsJob)->everyTwoMinutes()->onOneServer();
|
||||||
$this->scheduleUpdates();
|
$this->scheduleUpdates();
|
||||||
|
|
||||||
// Server Jobs
|
// Server Jobs
|
||||||
$this->checkResources();
|
$this->scheduleInstance->job(new ServerManagerJob)->everyMinute()->onOneServer();
|
||||||
|
|
||||||
$this->pullImages();
|
$this->pullImages();
|
||||||
|
|
||||||
$this->checkScheduledBackups();
|
// Scheduled Jobs (Backups & Tasks)
|
||||||
$this->checkScheduledTasks();
|
$this->scheduleInstance->job(new ScheduledJobManager)->everyMinute()->onOneServer();
|
||||||
|
|
||||||
$this->scheduleInstance->job(new RegenerateSslCertJob)->twiceDaily();
|
$this->scheduleInstance->job(new RegenerateSslCertJob)->twiceDaily();
|
||||||
|
|
||||||
@@ -135,182 +130,6 @@ class Kernel extends ConsoleKernel
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function checkResources(): void
|
|
||||||
{
|
|
||||||
if (isCloud()) {
|
|
||||||
$servers = $this->allServers->whereRelation('team.subscription', 'stripe_invoice_paid', true)->get();
|
|
||||||
$own = Team::find(0)->servers;
|
|
||||||
$servers = $servers->merge($own);
|
|
||||||
} else {
|
|
||||||
$servers = $this->allServers->get();
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($servers as $server) {
|
|
||||||
try {
|
|
||||||
$serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone);
|
|
||||||
if (validate_timezone($serverTimezone) === false) {
|
|
||||||
$serverTimezone = config('app.timezone');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sentinel check
|
|
||||||
$lastSentinelUpdate = $server->sentinel_updated_at;
|
|
||||||
if (Carbon::parse($lastSentinelUpdate)->isBefore(now()->subSeconds($server->waitBeforeDoingSshCheck()))) {
|
|
||||||
// Check container status every minute if Sentinel does not activated
|
|
||||||
if (isCloud()) {
|
|
||||||
$this->scheduleInstance->job(new ServerCheckJob($server))->timezone($serverTimezone)->everyFiveMinutes()->onOneServer();
|
|
||||||
} else {
|
|
||||||
$this->scheduleInstance->job(new ServerCheckJob($server))->timezone($serverTimezone)->everyMinute()->onOneServer();
|
|
||||||
}
|
|
||||||
// $this->scheduleInstance->job(new \App\Jobs\ServerCheckNewJob($server))->everyFiveMinutes()->onOneServer();
|
|
||||||
|
|
||||||
$serverDiskUsageCheckFrequency = data_get($server->settings, 'server_disk_usage_check_frequency', '0 * * * *');
|
|
||||||
if (isset(VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency])) {
|
|
||||||
$serverDiskUsageCheckFrequency = VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency];
|
|
||||||
}
|
|
||||||
$this->scheduleInstance->job(new ServerStorageCheckJob($server))->cron($serverDiskUsageCheckFrequency)->timezone($serverTimezone)->onOneServer();
|
|
||||||
}
|
|
||||||
|
|
||||||
$dockerCleanupFrequency = data_get($server->settings, 'docker_cleanup_frequency', '0 * * * *');
|
|
||||||
if (isset(VALID_CRON_STRINGS[$dockerCleanupFrequency])) {
|
|
||||||
$dockerCleanupFrequency = VALID_CRON_STRINGS[$dockerCleanupFrequency];
|
|
||||||
}
|
|
||||||
$this->scheduleInstance->job(new DockerCleanupJob($server))->cron($dockerCleanupFrequency)->timezone($serverTimezone)->onOneServer();
|
|
||||||
|
|
||||||
// Server patch check - weekly
|
|
||||||
$this->scheduleInstance->job(new ServerPatchCheckJob($server))->weekly()->timezone($serverTimezone)->onOneServer();
|
|
||||||
|
|
||||||
// Cleanup multiplexed connections every hour
|
|
||||||
// $this->scheduleInstance->job(new ServerCleanupMux($server))->hourly()->onOneServer();
|
|
||||||
|
|
||||||
// Temporary solution until we have better memory management for Sentinel
|
|
||||||
if ($server->isSentinelEnabled()) {
|
|
||||||
$this->scheduleInstance->job(function () use ($server) {
|
|
||||||
$server->restartContainer('coolify-sentinel');
|
|
||||||
})->daily()->onOneServer();
|
|
||||||
}
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
Log::error('Error checking resources: '.$e->getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function checkScheduledBackups(): void
|
|
||||||
{
|
|
||||||
$scheduled_backups = ScheduledDatabaseBackup::where('enabled', true)->get();
|
|
||||||
if ($scheduled_backups->isEmpty()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$finalScheduledBackups = collect();
|
|
||||||
foreach ($scheduled_backups as $scheduled_backup) {
|
|
||||||
if (blank(data_get($scheduled_backup, 'database'))) {
|
|
||||||
$scheduled_backup->delete();
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$server = $scheduled_backup->server();
|
|
||||||
if (blank($server)) {
|
|
||||||
$scheduled_backup->delete();
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ($server->isFunctional() === false) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$finalScheduledBackups->push($scheduled_backup);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($finalScheduledBackups as $scheduled_backup) {
|
|
||||||
try {
|
|
||||||
if (isset(VALID_CRON_STRINGS[$scheduled_backup->frequency])) {
|
|
||||||
$scheduled_backup->frequency = VALID_CRON_STRINGS[$scheduled_backup->frequency];
|
|
||||||
}
|
|
||||||
$server = $scheduled_backup->server();
|
|
||||||
$serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone);
|
|
||||||
|
|
||||||
if (validate_timezone($serverTimezone) === false) {
|
|
||||||
$serverTimezone = config('app.timezone');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isset(VALID_CRON_STRINGS[$scheduled_backup->frequency])) {
|
|
||||||
$scheduled_backup->frequency = VALID_CRON_STRINGS[$scheduled_backup->frequency];
|
|
||||||
}
|
|
||||||
$serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone);
|
|
||||||
$this->scheduleInstance->job(new DatabaseBackupJob(
|
|
||||||
backup: $scheduled_backup
|
|
||||||
))->cron($scheduled_backup->frequency)->timezone($serverTimezone)->onOneServer();
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
Log::error('Error scheduling backup: '.$e->getMessage());
|
|
||||||
Log::error($e->getTraceAsString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function checkScheduledTasks(): void
|
|
||||||
{
|
|
||||||
$scheduled_tasks = ScheduledTask::where('enabled', true)->get();
|
|
||||||
if ($scheduled_tasks->isEmpty()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$finalScheduledTasks = collect();
|
|
||||||
foreach ($scheduled_tasks as $scheduled_task) {
|
|
||||||
$service = $scheduled_task->service;
|
|
||||||
$application = $scheduled_task->application;
|
|
||||||
|
|
||||||
$server = $scheduled_task->server();
|
|
||||||
if (blank($server)) {
|
|
||||||
$scheduled_task->delete();
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($server->isFunctional() === false) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $service && ! $application) {
|
|
||||||
$scheduled_task->delete();
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($application && str($application->status)->contains('running') === false) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ($service && str($service->status)->contains('running') === false) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$finalScheduledTasks->push($scheduled_task);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($finalScheduledTasks as $scheduled_task) {
|
|
||||||
try {
|
|
||||||
$server = $scheduled_task->server();
|
|
||||||
if (isset(VALID_CRON_STRINGS[$scheduled_task->frequency])) {
|
|
||||||
$scheduled_task->frequency = VALID_CRON_STRINGS[$scheduled_task->frequency];
|
|
||||||
}
|
|
||||||
$serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone);
|
|
||||||
|
|
||||||
if (validate_timezone($serverTimezone) === false) {
|
|
||||||
$serverTimezone = config('app.timezone');
|
|
||||||
}
|
|
||||||
$this->scheduleInstance->job(new ScheduledTaskJob(
|
|
||||||
task: $scheduled_task
|
|
||||||
))->cron($scheduled_task->frequency)->timezone($serverTimezone)->onOneServer();
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
Log::error('Error scheduling task: '.$e->getMessage());
|
|
||||||
Log::error($e->getTraceAsString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function commands(): void
|
protected function commands(): void
|
||||||
{
|
{
|
||||||
$this->load(__DIR__.'/Commands');
|
$this->load(__DIR__.'/Commands');
|
||||||
|
@@ -7,8 +7,9 @@ use Illuminate\Broadcasting\PrivateChannel;
|
|||||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||||
use Illuminate\Foundation\Events\Dispatchable;
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Laravel\Horizon\Contracts\Silenced;
|
||||||
|
|
||||||
class BackupCreated implements ShouldBroadcast
|
class BackupCreated implements ShouldBroadcast, Silenced
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
|
39
app/Events/SentinelRestarted.php
Normal file
39
app/Events/SentinelRestarted.php
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Events;
|
||||||
|
|
||||||
|
use App\Models\Server;
|
||||||
|
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||||
|
use Illuminate\Broadcasting\PrivateChannel;
|
||||||
|
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class SentinelRestarted implements ShouldBroadcast
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
|
public ?int $teamId = null;
|
||||||
|
|
||||||
|
public ?string $version = null;
|
||||||
|
|
||||||
|
public string $serverUuid;
|
||||||
|
|
||||||
|
public function __construct(Server $server, ?string $version = null)
|
||||||
|
{
|
||||||
|
$this->teamId = $server->team_id;
|
||||||
|
$this->serverUuid = $server->uuid;
|
||||||
|
$this->version = $version;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function broadcastOn(): array
|
||||||
|
{
|
||||||
|
if (is_null($this->teamId)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
new PrivateChannel("team.{$this->teamId}"),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
@@ -7,8 +7,9 @@ use Illuminate\Broadcasting\PrivateChannel;
|
|||||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||||
use Illuminate\Foundation\Events\Dispatchable;
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Laravel\Horizon\Contracts\Silenced;
|
||||||
|
|
||||||
class ServiceChecked implements ShouldBroadcast
|
class ServiceChecked implements ShouldBroadcast, Silenced
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
|
@@ -53,6 +53,35 @@ class Handler extends ExceptionHandler
|
|||||||
return redirect()->guest($exception->redirectTo($request) ?? route('login'));
|
return redirect()->guest($exception->redirectTo($request) ?? route('login'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render an exception into an HTTP response.
|
||||||
|
*/
|
||||||
|
public function render($request, Throwable $e)
|
||||||
|
{
|
||||||
|
// Handle authorization exceptions for API routes
|
||||||
|
if ($e instanceof \Illuminate\Auth\Access\AuthorizationException) {
|
||||||
|
if ($request->is('api/*') || $request->expectsJson()) {
|
||||||
|
// Get the custom message from the policy if available
|
||||||
|
$message = $e->getMessage();
|
||||||
|
|
||||||
|
// Clean up the message for API responses (remove HTML tags if present)
|
||||||
|
$message = strip_tags(str_replace('<br/>', ' ', $message));
|
||||||
|
|
||||||
|
// If no custom message, use a default one
|
||||||
|
if (empty($message) || $message === 'This action is unauthorized.') {
|
||||||
|
$message = 'You are not authorized to perform this action.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => $message,
|
||||||
|
'error' => 'Unauthorized',
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::render($request, $e);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register the exception handling callbacks for the application.
|
* Register the exception handling callbacks for the application.
|
||||||
*/
|
*/
|
||||||
|
@@ -15,6 +15,8 @@ use App\Models\PrivateKey;
|
|||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use App\Models\Service;
|
use App\Models\Service;
|
||||||
|
use App\Rules\ValidGitBranch;
|
||||||
|
use App\Rules\ValidGitRepositoryUrl;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
use OpenApi\Attributes as OA;
|
use OpenApi\Attributes as OA;
|
||||||
@@ -738,6 +740,8 @@ class ApplicationsController extends Controller
|
|||||||
return invalidTokenResponse();
|
return invalidTokenResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->authorize('create', Application::class);
|
||||||
|
|
||||||
$return = validateIncomingRequest($request);
|
$return = validateIncomingRequest($request);
|
||||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||||
return $return;
|
return $return;
|
||||||
@@ -831,8 +835,8 @@ class ApplicationsController extends Controller
|
|||||||
$destination = $destinations->first();
|
$destination = $destinations->first();
|
||||||
if ($type === 'public') {
|
if ($type === 'public') {
|
||||||
$validationRules = [
|
$validationRules = [
|
||||||
'git_repository' => 'string|required',
|
'git_repository' => ['string', 'required', new ValidGitRepositoryUrl],
|
||||||
'git_branch' => 'string|required',
|
'git_branch' => ['string', 'required', new ValidGitBranch],
|
||||||
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
|
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
|
||||||
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
|
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
|
||||||
'docker_compose_location' => 'string',
|
'docker_compose_location' => 'string',
|
||||||
@@ -883,7 +887,7 @@ class ApplicationsController extends Controller
|
|||||||
$application->source_type = GithubApp::class;
|
$application->source_type = GithubApp::class;
|
||||||
$application->source_id = GithubApp::find(0)->id;
|
$application->source_id = GithubApp::find(0)->id;
|
||||||
}
|
}
|
||||||
$application->git_repository = $repository_url_parsed->getSegment(1).'/'.$repository_url_parsed->getSegment(2);
|
$application->git_repository = str($repository_url_parsed->getSegment(1).'/'.$repository_url_parsed->getSegment(2))->trim()->toString();
|
||||||
$application->fqdn = $fqdn;
|
$application->fqdn = $fqdn;
|
||||||
$application->destination_id = $destination->id;
|
$application->destination_id = $destination->id;
|
||||||
$application->destination_type = $destination->getMorphClass();
|
$application->destination_type = $destination->getMorphClass();
|
||||||
@@ -935,7 +939,7 @@ class ApplicationsController extends Controller
|
|||||||
} elseif ($type === 'private-gh-app') {
|
} elseif ($type === 'private-gh-app') {
|
||||||
$validationRules = [
|
$validationRules = [
|
||||||
'git_repository' => 'string|required',
|
'git_repository' => 'string|required',
|
||||||
'git_branch' => 'string|required',
|
'git_branch' => ['string', 'required', new ValidGitBranch],
|
||||||
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
|
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
|
||||||
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
|
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
|
||||||
'github_app_uuid' => 'string|required',
|
'github_app_uuid' => 'string|required',
|
||||||
@@ -1043,7 +1047,7 @@ class ApplicationsController extends Controller
|
|||||||
$application->docker_compose_domains = $dockerComposeDomainsJson;
|
$application->docker_compose_domains = $dockerComposeDomainsJson;
|
||||||
}
|
}
|
||||||
$application->fqdn = $fqdn;
|
$application->fqdn = $fqdn;
|
||||||
$application->git_repository = $gitRepository;
|
$application->git_repository = str($gitRepository)->trim()->toString();
|
||||||
$application->destination_id = $destination->id;
|
$application->destination_id = $destination->id;
|
||||||
$application->destination_type = $destination->getMorphClass();
|
$application->destination_type = $destination->getMorphClass();
|
||||||
$application->environment_id = $environment->id;
|
$application->environment_id = $environment->id;
|
||||||
@@ -1090,8 +1094,8 @@ class ApplicationsController extends Controller
|
|||||||
} elseif ($type === 'private-deploy-key') {
|
} elseif ($type === 'private-deploy-key') {
|
||||||
|
|
||||||
$validationRules = [
|
$validationRules = [
|
||||||
'git_repository' => 'string|required',
|
'git_repository' => ['string', 'required', new ValidGitRepositoryUrl],
|
||||||
'git_branch' => 'string|required',
|
'git_branch' => ['string', 'required', new ValidGitBranch],
|
||||||
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
|
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
|
||||||
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
|
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
|
||||||
'private_key_uuid' => 'string|required',
|
'private_key_uuid' => 'string|required',
|
||||||
@@ -1519,6 +1523,8 @@ class ApplicationsController extends Controller
|
|||||||
return response()->json(['message' => 'Application not found.'], 404);
|
return response()->json(['message' => 'Application not found.'], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->authorize('view', $application);
|
||||||
|
|
||||||
return response()->json($this->removeSensitiveData($application));
|
return response()->json($this->removeSensitiveData($application));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1697,12 +1703,14 @@ class ApplicationsController extends Controller
|
|||||||
], 404);
|
], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->authorize('delete', $application);
|
||||||
|
|
||||||
DeleteResourceJob::dispatch(
|
DeleteResourceJob::dispatch(
|
||||||
resource: $application,
|
resource: $application,
|
||||||
deleteConfigurations: $request->query->get('delete_configurations', true),
|
|
||||||
deleteVolumes: $request->query->get('delete_volumes', true),
|
deleteVolumes: $request->query->get('delete_volumes', true),
|
||||||
dockerCleanup: $request->query->get('docker_cleanup', true),
|
deleteConnectedNetworks: $request->query->get('delete_connected_networks', true),
|
||||||
deleteConnectedNetworks: $request->query->get('delete_connected_networks', true)
|
deleteConfigurations: $request->query->get('delete_configurations', true),
|
||||||
|
dockerCleanup: $request->query->get('docker_cleanup', true)
|
||||||
);
|
);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
@@ -1854,6 +1862,9 @@ class ApplicationsController extends Controller
|
|||||||
'message' => 'Application not found',
|
'message' => 'Application not found',
|
||||||
], 404);
|
], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->authorize('update', $application);
|
||||||
|
|
||||||
$server = $application->destination->server;
|
$server = $application->destination->server;
|
||||||
$allowedFields = ['name', 'description', 'is_static', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network'];
|
$allowedFields = ['name', 'description', 'is_static', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network'];
|
||||||
|
|
||||||
@@ -2138,6 +2149,9 @@ class ApplicationsController extends Controller
|
|||||||
'message' => 'Application not found',
|
'message' => 'Application not found',
|
||||||
], 404);
|
], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->authorize('view', $application);
|
||||||
|
|
||||||
$envs = $application->environment_variables->sortBy('id')->merge($application->environment_variables_preview->sortBy('id'));
|
$envs = $application->environment_variables->sortBy('id')->merge($application->environment_variables_preview->sortBy('id'));
|
||||||
|
|
||||||
$envs = $envs->map(function ($env) {
|
$envs = $envs->map(function ($env) {
|
||||||
@@ -2252,6 +2266,9 @@ class ApplicationsController extends Controller
|
|||||||
'message' => 'Application not found',
|
'message' => 'Application not found',
|
||||||
], 404);
|
], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->authorize('manageEnvironment', $application);
|
||||||
|
|
||||||
$validator = customApiValidator($request->all(), [
|
$validator = customApiValidator($request->all(), [
|
||||||
'key' => 'string|required',
|
'key' => 'string|required',
|
||||||
'value' => 'string|nullable',
|
'value' => 'string|nullable',
|
||||||
@@ -2442,6 +2459,8 @@ class ApplicationsController extends Controller
|
|||||||
], 404);
|
], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->authorize('manageEnvironment', $application);
|
||||||
|
|
||||||
$bulk_data = $request->get('data');
|
$bulk_data = $request->get('data');
|
||||||
if (! $bulk_data) {
|
if (! $bulk_data) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
@@ -2626,6 +2645,9 @@ class ApplicationsController extends Controller
|
|||||||
'message' => 'Application not found',
|
'message' => 'Application not found',
|
||||||
], 404);
|
], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->authorize('manageEnvironment', $application);
|
||||||
|
|
||||||
$validator = customApiValidator($request->all(), [
|
$validator = customApiValidator($request->all(), [
|
||||||
'key' => 'string|required',
|
'key' => 'string|required',
|
||||||
'value' => 'string|nullable',
|
'value' => 'string|nullable',
|
||||||
@@ -2776,6 +2798,9 @@ class ApplicationsController extends Controller
|
|||||||
'message' => 'Application not found.',
|
'message' => 'Application not found.',
|
||||||
], 404);
|
], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->authorize('manageEnvironment', $application);
|
||||||
|
|
||||||
$found_env = EnvironmentVariable::where('uuid', $request->env_uuid)
|
$found_env = EnvironmentVariable::where('uuid', $request->env_uuid)
|
||||||
->where('resourceable_type', Application::class)
|
->where('resourceable_type', Application::class)
|
||||||
->where('resourceable_id', $application->id)
|
->where('resourceable_id', $application->id)
|
||||||
@@ -2879,6 +2904,8 @@ class ApplicationsController extends Controller
|
|||||||
return response()->json(['message' => 'Application not found.'], 404);
|
return response()->json(['message' => 'Application not found.'], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->authorize('deploy', $application);
|
||||||
|
|
||||||
$deployment_uuid = new Cuid2;
|
$deployment_uuid = new Cuid2;
|
||||||
|
|
||||||
$result = queue_application_deployment(
|
$result = queue_application_deployment(
|
||||||
@@ -2971,6 +2998,9 @@ class ApplicationsController extends Controller
|
|||||||
if (! $application) {
|
if (! $application) {
|
||||||
return response()->json(['message' => 'Application not found.'], 404);
|
return response()->json(['message' => 'Application not found.'], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->authorize('deploy', $application);
|
||||||
|
|
||||||
StopApplication::dispatch($application);
|
StopApplication::dispatch($application);
|
||||||
|
|
||||||
return response()->json(
|
return response()->json(
|
||||||
@@ -3048,6 +3078,8 @@ class ApplicationsController extends Controller
|
|||||||
return response()->json(['message' => 'Application not found.'], 404);
|
return response()->json(['message' => 'Application not found.'], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->authorize('deploy', $application);
|
||||||
|
|
||||||
$deployment_uuid = new Cuid2;
|
$deployment_uuid = new Cuid2;
|
||||||
|
|
||||||
$result = queue_application_deployment(
|
$result = queue_application_deployment(
|
||||||
|
@@ -12,6 +12,7 @@ use App\Http\Controllers\Controller;
|
|||||||
use App\Jobs\DeleteResourceJob;
|
use App\Jobs\DeleteResourceJob;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
|
use App\Models\StandalonePostgresql;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use OpenApi\Attributes as OA;
|
use OpenApi\Attributes as OA;
|
||||||
|
|
||||||
@@ -143,6 +144,8 @@ class DatabasesController extends Controller
|
|||||||
return response()->json(['message' => 'Database not found.'], 404);
|
return response()->json(['message' => 'Database not found.'], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->authorize('view', $database);
|
||||||
|
|
||||||
return response()->json($this->removeSensitiveData($database));
|
return response()->json($this->removeSensitiveData($database));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,6 +279,9 @@ class DatabasesController extends Controller
|
|||||||
if (! $database) {
|
if (! $database) {
|
||||||
return response()->json(['message' => 'Database not found.'], 404);
|
return response()->json(['message' => 'Database not found.'], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->authorize('update', $database);
|
||||||
|
|
||||||
if ($request->is_public && $request->public_port) {
|
if ($request->is_public && $request->public_port) {
|
||||||
if (isPublicPortAlreadyUsed($database->destination->server, $request->public_port, $database->id)) {
|
if (isPublicPortAlreadyUsed($database->destination->server, $request->public_port, $database->id)) {
|
||||||
return response()->json(['message' => 'Public port already used by another database.'], 400);
|
return response()->json(['message' => 'Public port already used by another database.'], 400);
|
||||||
@@ -1028,6 +1034,9 @@ class DatabasesController extends Controller
|
|||||||
return invalidTokenResponse();
|
return invalidTokenResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use a generic authorization for database creation - using PostgreSQL as representative model
|
||||||
|
$this->authorize('create', StandalonePostgresql::class);
|
||||||
|
|
||||||
$return = validateIncomingRequest($request);
|
$return = validateIncomingRequest($request);
|
||||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||||
return $return;
|
return $return;
|
||||||
@@ -1606,12 +1615,14 @@ class DatabasesController extends Controller
|
|||||||
return response()->json(['message' => 'Database not found.'], 404);
|
return response()->json(['message' => 'Database not found.'], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->authorize('delete', $database);
|
||||||
|
|
||||||
DeleteResourceJob::dispatch(
|
DeleteResourceJob::dispatch(
|
||||||
resource: $database,
|
resource: $database,
|
||||||
deleteConfigurations: $request->query->get('delete_configurations', true),
|
|
||||||
deleteVolumes: $request->query->get('delete_volumes', true),
|
deleteVolumes: $request->query->get('delete_volumes', true),
|
||||||
dockerCleanup: $request->query->get('docker_cleanup', true),
|
deleteConnectedNetworks: $request->query->get('delete_connected_networks', true),
|
||||||
deleteConnectedNetworks: $request->query->get('delete_connected_networks', true)
|
deleteConfigurations: $request->query->get('delete_configurations', true),
|
||||||
|
dockerCleanup: $request->query->get('docker_cleanup', true)
|
||||||
);
|
);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
@@ -1684,6 +1695,9 @@ class DatabasesController extends Controller
|
|||||||
if (! $database) {
|
if (! $database) {
|
||||||
return response()->json(['message' => 'Database not found.'], 404);
|
return response()->json(['message' => 'Database not found.'], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->authorize('manage', $database);
|
||||||
|
|
||||||
if (str($database->status)->contains('running')) {
|
if (str($database->status)->contains('running')) {
|
||||||
return response()->json(['message' => 'Database is already running.'], 400);
|
return response()->json(['message' => 'Database is already running.'], 400);
|
||||||
}
|
}
|
||||||
@@ -1762,6 +1776,9 @@ class DatabasesController extends Controller
|
|||||||
if (! $database) {
|
if (! $database) {
|
||||||
return response()->json(['message' => 'Database not found.'], 404);
|
return response()->json(['message' => 'Database not found.'], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->authorize('manage', $database);
|
||||||
|
|
||||||
if (str($database->status)->contains('stopped') || str($database->status)->contains('exited')) {
|
if (str($database->status)->contains('stopped') || str($database->status)->contains('exited')) {
|
||||||
return response()->json(['message' => 'Database is already stopped.'], 400);
|
return response()->json(['message' => 'Database is already stopped.'], 400);
|
||||||
}
|
}
|
||||||
@@ -1840,6 +1857,9 @@ class DatabasesController extends Controller
|
|||||||
if (! $database) {
|
if (! $database) {
|
||||||
return response()->json(['message' => 'Database not found.'], 404);
|
return response()->json(['message' => 'Database not found.'], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->authorize('manage', $database);
|
||||||
|
|
||||||
RestartDatabase::dispatch($database);
|
RestartDatabase::dispatch($database);
|
||||||
|
|
||||||
return response()->json(
|
return response()->json(
|
||||||
|
@@ -299,6 +299,12 @@ class DeployController extends Controller
|
|||||||
}
|
}
|
||||||
switch ($resource?->getMorphClass()) {
|
switch ($resource?->getMorphClass()) {
|
||||||
case Application::class:
|
case Application::class:
|
||||||
|
// Check authorization for application deployment
|
||||||
|
try {
|
||||||
|
$this->authorize('deploy', $resource);
|
||||||
|
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||||
|
return ['message' => 'Unauthorized to deploy this application.', 'deployment_uuid' => null];
|
||||||
|
}
|
||||||
$deployment_uuid = new Cuid2;
|
$deployment_uuid = new Cuid2;
|
||||||
$result = queue_application_deployment(
|
$result = queue_application_deployment(
|
||||||
application: $resource,
|
application: $resource,
|
||||||
@@ -313,11 +319,22 @@ class DeployController extends Controller
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case Service::class:
|
case Service::class:
|
||||||
|
// Check authorization for service deployment
|
||||||
|
try {
|
||||||
|
$this->authorize('deploy', $resource);
|
||||||
|
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||||
|
return ['message' => 'Unauthorized to deploy this service.', 'deployment_uuid' => null];
|
||||||
|
}
|
||||||
StartService::run($resource);
|
StartService::run($resource);
|
||||||
$message = "Service {$resource->name} started. It could take a while, be patient.";
|
$message = "Service {$resource->name} started. It could take a while, be patient.";
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
// Database resource
|
// Database resource - check authorization
|
||||||
|
try {
|
||||||
|
$this->authorize('manage', $resource);
|
||||||
|
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||||
|
return ['message' => 'Unauthorized to start this database.', 'deployment_uuid' => null];
|
||||||
|
}
|
||||||
StartDatabase::dispatch($resource);
|
StartDatabase::dispatch($resource);
|
||||||
|
|
||||||
$resource->started_at ??= now();
|
$resource->started_at ??= now();
|
||||||
@@ -423,6 +440,10 @@ class DeployController extends Controller
|
|||||||
if (is_null($application)) {
|
if (is_null($application)) {
|
||||||
return response()->json(['message' => 'Application not found'], 404);
|
return response()->json(['message' => 'Application not found'], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check authorization to view application deployments
|
||||||
|
$this->authorize('view', $application);
|
||||||
|
|
||||||
$deployments = $application->deployments($skip, $take);
|
$deployments = $application->deployments($skip, $take);
|
||||||
|
|
||||||
return response()->json($deployments);
|
return response()->json($deployments);
|
||||||
|
@@ -4,7 +4,9 @@ namespace App\Http\Controllers\Api;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
|
use App\Support\ValidationPatterns;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
use OpenApi\Attributes as OA;
|
use OpenApi\Attributes as OA;
|
||||||
|
|
||||||
class ProjectController extends Controller
|
class ProjectController extends Controller
|
||||||
@@ -227,10 +229,10 @@ class ProjectController extends Controller
|
|||||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||||
return $return;
|
return $return;
|
||||||
}
|
}
|
||||||
$validator = customApiValidator($request->all(), [
|
$validator = Validator::make($request->all(), [
|
||||||
'name' => 'string|max:255|required',
|
'name' => ValidationPatterns::nameRules(),
|
||||||
'description' => 'string|nullable',
|
'description' => ValidationPatterns::descriptionRules(),
|
||||||
]);
|
], ValidationPatterns::combinedMessages());
|
||||||
|
|
||||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||||
if ($validator->fails() || ! empty($extraFields)) {
|
if ($validator->fails() || ! empty($extraFields)) {
|
||||||
@@ -337,10 +339,10 @@ class ProjectController extends Controller
|
|||||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||||
return $return;
|
return $return;
|
||||||
}
|
}
|
||||||
$validator = customApiValidator($request->all(), [
|
$validator = Validator::make($request->all(), [
|
||||||
'name' => 'string|max:255|nullable',
|
'name' => ValidationPatterns::nameRules(required: false),
|
||||||
'description' => 'string|nullable',
|
'description' => ValidationPatterns::descriptionRules(),
|
||||||
]);
|
], ValidationPatterns::combinedMessages());
|
||||||
|
|
||||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||||
if ($validator->fails() || ! empty($extraFields)) {
|
if ($validator->fails() || ! empty($extraFields)) {
|
||||||
@@ -447,4 +449,255 @@ class ProjectController extends Controller
|
|||||||
|
|
||||||
return response()->json(['message' => 'Project deleted.']);
|
return response()->json(['message' => 'Project deleted.']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[OA\Get(
|
||||||
|
summary: 'List Environments',
|
||||||
|
description: 'List all environments in a project.',
|
||||||
|
path: '/projects/{uuid}/environments',
|
||||||
|
operationId: 'get-environments',
|
||||||
|
security: [
|
||||||
|
['bearerAuth' => []],
|
||||||
|
],
|
||||||
|
tags: ['Projects'],
|
||||||
|
parameters: [
|
||||||
|
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Project UUID', schema: new OA\Schema(type: 'string')),
|
||||||
|
],
|
||||||
|
responses: [
|
||||||
|
new OA\Response(
|
||||||
|
response: 200,
|
||||||
|
description: 'List of environments',
|
||||||
|
content: [
|
||||||
|
new OA\MediaType(
|
||||||
|
mediaType: 'application/json',
|
||||||
|
schema: new OA\Schema(
|
||||||
|
type: 'array',
|
||||||
|
items: new OA\Items(ref: '#/components/schemas/Environment')
|
||||||
|
)
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
new OA\Response(
|
||||||
|
response: 401,
|
||||||
|
ref: '#/components/responses/401',
|
||||||
|
),
|
||||||
|
new OA\Response(
|
||||||
|
response: 400,
|
||||||
|
ref: '#/components/responses/400',
|
||||||
|
),
|
||||||
|
new OA\Response(
|
||||||
|
response: 404,
|
||||||
|
description: 'Project not found.',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
public function get_environments(Request $request)
|
||||||
|
{
|
||||||
|
$teamId = getTeamIdFromToken();
|
||||||
|
if (is_null($teamId)) {
|
||||||
|
return invalidTokenResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $request->uuid) {
|
||||||
|
return response()->json(['message' => 'Project UUID is required.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$project = Project::whereTeamId($teamId)->whereUuid($request->uuid)->first();
|
||||||
|
if (! $project) {
|
||||||
|
return response()->json(['message' => 'Project not found.'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$environments = $project->environments()->select('id', 'name', 'uuid')->get();
|
||||||
|
|
||||||
|
return response()->json(serializeApiResponse($environments));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[OA\Post(
|
||||||
|
summary: 'Create Environment',
|
||||||
|
description: 'Create environment in project.',
|
||||||
|
path: '/projects/{uuid}/environments',
|
||||||
|
operationId: 'create-environment',
|
||||||
|
security: [
|
||||||
|
['bearerAuth' => []],
|
||||||
|
],
|
||||||
|
tags: ['Projects'],
|
||||||
|
parameters: [
|
||||||
|
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Project UUID', schema: new OA\Schema(type: 'string')),
|
||||||
|
],
|
||||||
|
requestBody: new OA\RequestBody(
|
||||||
|
required: true,
|
||||||
|
description: 'Environment created.',
|
||||||
|
content: new OA\MediaType(
|
||||||
|
mediaType: 'application/json',
|
||||||
|
schema: new OA\Schema(
|
||||||
|
type: 'object',
|
||||||
|
properties: [
|
||||||
|
'name' => ['type' => 'string', 'description' => 'The name of the environment.'],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
responses: [
|
||||||
|
new OA\Response(
|
||||||
|
response: 201,
|
||||||
|
description: 'Environment created.',
|
||||||
|
content: [
|
||||||
|
new OA\MediaType(
|
||||||
|
mediaType: 'application/json',
|
||||||
|
schema: new OA\Schema(
|
||||||
|
type: 'object',
|
||||||
|
properties: [
|
||||||
|
'uuid' => ['type' => 'string', 'example' => 'env123', 'description' => 'The UUID of the environment.'],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
new OA\Response(
|
||||||
|
response: 401,
|
||||||
|
ref: '#/components/responses/401',
|
||||||
|
),
|
||||||
|
new OA\Response(
|
||||||
|
response: 400,
|
||||||
|
ref: '#/components/responses/400',
|
||||||
|
),
|
||||||
|
new OA\Response(
|
||||||
|
response: 404,
|
||||||
|
description: 'Project not found.',
|
||||||
|
),
|
||||||
|
new OA\Response(
|
||||||
|
response: 409,
|
||||||
|
description: 'Environment with this name already exists.',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
public function create_environment(Request $request)
|
||||||
|
{
|
||||||
|
$allowedFields = ['name'];
|
||||||
|
|
||||||
|
$teamId = getTeamIdFromToken();
|
||||||
|
if (is_null($teamId)) {
|
||||||
|
return invalidTokenResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
$return = validateIncomingRequest($request);
|
||||||
|
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||||
|
return $return;
|
||||||
|
}
|
||||||
|
$validator = Validator::make($request->all(), [
|
||||||
|
'name' => ValidationPatterns::nameRules(),
|
||||||
|
], ValidationPatterns::nameMessages());
|
||||||
|
|
||||||
|
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||||
|
if ($validator->fails() || ! empty($extraFields)) {
|
||||||
|
$errors = $validator->errors();
|
||||||
|
if (! empty($extraFields)) {
|
||||||
|
foreach ($extraFields as $field) {
|
||||||
|
$errors->add($field, 'This field is not allowed.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Validation failed.',
|
||||||
|
'errors' => $errors,
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $request->uuid) {
|
||||||
|
return response()->json(['message' => 'Project UUID is required.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$project = Project::whereTeamId($teamId)->whereUuid($request->uuid)->first();
|
||||||
|
if (! $project) {
|
||||||
|
return response()->json(['message' => 'Project not found.'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$existingEnvironment = $project->environments()->where('name', $request->name)->first();
|
||||||
|
if ($existingEnvironment) {
|
||||||
|
return response()->json(['message' => 'Environment with this name already exists.'], 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
$environment = $project->environments()->create([
|
||||||
|
'name' => $request->name,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'uuid' => $environment->uuid,
|
||||||
|
])->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[OA\Delete(
|
||||||
|
summary: 'Delete Environment',
|
||||||
|
description: 'Delete environment by name or UUID. Environment must be empty.',
|
||||||
|
path: '/projects/{uuid}/environments/{environment_name_or_uuid}',
|
||||||
|
operationId: 'delete-environment',
|
||||||
|
security: [
|
||||||
|
['bearerAuth' => []],
|
||||||
|
],
|
||||||
|
tags: ['Projects'],
|
||||||
|
parameters: [
|
||||||
|
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Project UUID', schema: new OA\Schema(type: 'string')),
|
||||||
|
new OA\Parameter(name: 'environment_name_or_uuid', in: 'path', required: true, description: 'Environment name or UUID', schema: new OA\Schema(type: 'string')),
|
||||||
|
],
|
||||||
|
responses: [
|
||||||
|
new OA\Response(
|
||||||
|
response: 200,
|
||||||
|
description: 'Environment deleted.',
|
||||||
|
content: [
|
||||||
|
new OA\MediaType(
|
||||||
|
mediaType: 'application/json',
|
||||||
|
schema: new OA\Schema(
|
||||||
|
type: 'object',
|
||||||
|
properties: [
|
||||||
|
'message' => ['type' => 'string', 'example' => 'Environment deleted.'],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
new OA\Response(
|
||||||
|
response: 401,
|
||||||
|
ref: '#/components/responses/401',
|
||||||
|
),
|
||||||
|
new OA\Response(
|
||||||
|
response: 400,
|
||||||
|
description: 'Environment has resources, so it cannot be deleted.',
|
||||||
|
),
|
||||||
|
new OA\Response(
|
||||||
|
response: 404,
|
||||||
|
description: 'Project or environment not found.',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
public function delete_environment(Request $request)
|
||||||
|
{
|
||||||
|
$teamId = getTeamIdFromToken();
|
||||||
|
if (is_null($teamId)) {
|
||||||
|
return invalidTokenResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $request->uuid) {
|
||||||
|
return response()->json(['message' => 'Project UUID is required.'], 422);
|
||||||
|
}
|
||||||
|
if (! $request->environment_name_or_uuid) {
|
||||||
|
return response()->json(['message' => 'Environment name or UUID is required.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$project = Project::whereTeamId($teamId)->whereUuid($request->uuid)->first();
|
||||||
|
if (! $project) {
|
||||||
|
return response()->json(['message' => 'Project not found.'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$environment = $project->environments()->whereName($request->environment_name_or_uuid)->first();
|
||||||
|
if (! $environment) {
|
||||||
|
$environment = $project->environments()->whereUuid($request->environment_name_or_uuid)->first();
|
||||||
|
}
|
||||||
|
if (! $environment) {
|
||||||
|
return response()->json(['message' => 'Environment not found.'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $environment->isEmpty()) {
|
||||||
|
return response()->json(['message' => 'Environment has resources, so it cannot be deleted.'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$environment->delete();
|
||||||
|
|
||||||
|
return response()->json(['message' => 'Environment deleted.']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -43,6 +43,10 @@ class ResourcesController extends Controller
|
|||||||
if (is_null($teamId)) {
|
if (is_null($teamId)) {
|
||||||
return invalidTokenResponse();
|
return invalidTokenResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// General authorization check for viewing resources - using Project as base resource type
|
||||||
|
$this->authorize('viewAny', Project::class);
|
||||||
|
|
||||||
$projects = Project::where('team_id', $teamId)->get();
|
$projects = Project::where('team_id', $teamId)->get();
|
||||||
$resources = collect();
|
$resources = collect();
|
||||||
$resources->push($projects->pluck('applications')->flatten());
|
$resources->push($projects->pluck('applications')->flatten());
|
||||||
|
@@ -246,6 +246,8 @@ class ServicesController extends Controller
|
|||||||
return invalidTokenResponse();
|
return invalidTokenResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->authorize('create', Service::class);
|
||||||
|
|
||||||
$return = validateIncomingRequest($request);
|
$return = validateIncomingRequest($request);
|
||||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||||
return $return;
|
return $return;
|
||||||
@@ -377,14 +379,118 @@ class ServicesController extends Controller
|
|||||||
|
|
||||||
return response()->json(['message' => 'Service not found.', 'valid_service_types' => $serviceKeys], 404);
|
return response()->json(['message' => 'Service not found.', 'valid_service_types' => $serviceKeys], 404);
|
||||||
} elseif (filled($request->docker_compose_raw)) {
|
} elseif (filled($request->docker_compose_raw)) {
|
||||||
|
$allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network'];
|
||||||
|
|
||||||
$service = new Service;
|
$validator = customApiValidator($request->all(), [
|
||||||
$result = $this->upsert_service($request, $service, $teamId);
|
'project_uuid' => 'string|required',
|
||||||
if ($result instanceof \Illuminate\Http\JsonResponse) {
|
'environment_name' => 'string|nullable',
|
||||||
return $result;
|
'environment_uuid' => 'string|nullable',
|
||||||
|
'server_uuid' => 'string|required',
|
||||||
|
'destination_uuid' => 'string',
|
||||||
|
'name' => 'string|max:255',
|
||||||
|
'description' => 'string|nullable',
|
||||||
|
'instant_deploy' => 'boolean',
|
||||||
|
'connect_to_docker_network' => 'boolean',
|
||||||
|
'docker_compose_raw' => 'string|required',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||||
|
if ($validator->fails() || ! empty($extraFields)) {
|
||||||
|
$errors = $validator->errors();
|
||||||
|
if (! empty($extraFields)) {
|
||||||
|
foreach ($extraFields as $field) {
|
||||||
|
$errors->add($field, 'This field is not allowed.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Validation failed.',
|
||||||
|
'errors' => $errors,
|
||||||
|
], 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json(serializeApiResponse($result))->setStatusCode(201);
|
$environmentUuid = $request->environment_uuid;
|
||||||
|
$environmentName = $request->environment_name;
|
||||||
|
if (blank($environmentUuid) && blank($environmentName)) {
|
||||||
|
return response()->json(['message' => 'You need to provide at least one of environment_name or environment_uuid.'], 422);
|
||||||
|
}
|
||||||
|
$serverUuid = $request->server_uuid;
|
||||||
|
$projectUuid = $request->project_uuid;
|
||||||
|
$project = Project::whereTeamId($teamId)->whereUuid($projectUuid)->first();
|
||||||
|
if (! $project) {
|
||||||
|
return response()->json(['message' => 'Project not found.'], 404);
|
||||||
|
}
|
||||||
|
$environment = $project->environments()->where('name', $environmentName)->first();
|
||||||
|
if (! $environment) {
|
||||||
|
$environment = $project->environments()->where('uuid', $environmentUuid)->first();
|
||||||
|
}
|
||||||
|
if (! $environment) {
|
||||||
|
return response()->json(['message' => 'Environment not found.'], 404);
|
||||||
|
}
|
||||||
|
$server = Server::whereTeamId($teamId)->whereUuid($serverUuid)->first();
|
||||||
|
if (! $server) {
|
||||||
|
return response()->json(['message' => 'Server not found.'], 404);
|
||||||
|
}
|
||||||
|
$destinations = $server->destinations();
|
||||||
|
if ($destinations->count() == 0) {
|
||||||
|
return response()->json(['message' => 'Server has no destinations.'], 400);
|
||||||
|
}
|
||||||
|
if ($destinations->count() > 1 && ! $request->has('destination_uuid')) {
|
||||||
|
return response()->json(['message' => 'Server has multiple destinations and you do not set destination_uuid.'], 400);
|
||||||
|
}
|
||||||
|
$destination = $destinations->first();
|
||||||
|
if (! isBase64Encoded($request->docker_compose_raw)) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Validation failed.',
|
||||||
|
'errors' => [
|
||||||
|
'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.',
|
||||||
|
],
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
$dockerComposeRaw = base64_decode($request->docker_compose_raw);
|
||||||
|
if (mb_detect_encoding($dockerComposeRaw, 'ASCII', true) === false) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Validation failed.',
|
||||||
|
'errors' => [
|
||||||
|
'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.',
|
||||||
|
],
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
$dockerCompose = base64_decode($request->docker_compose_raw);
|
||||||
|
$dockerComposeRaw = Yaml::dump(Yaml::parse($dockerCompose), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
|
||||||
|
|
||||||
|
$connectToDockerNetwork = $request->connect_to_docker_network ?? false;
|
||||||
|
$instantDeploy = $request->instant_deploy ?? false;
|
||||||
|
|
||||||
|
$service = new Service;
|
||||||
|
$service->name = $request->name ?? 'service-'.str()->random(10);
|
||||||
|
$service->description = $request->description;
|
||||||
|
$service->docker_compose_raw = $dockerComposeRaw;
|
||||||
|
$service->environment_id = $environment->id;
|
||||||
|
$service->server_id = $server->id;
|
||||||
|
$service->destination_id = $destination->id;
|
||||||
|
$service->destination_type = $destination->getMorphClass();
|
||||||
|
$service->connect_to_docker_network = $connectToDockerNetwork;
|
||||||
|
$service->save();
|
||||||
|
|
||||||
|
$service->parse(isNew: true);
|
||||||
|
if ($instantDeploy) {
|
||||||
|
StartService::dispatch($service);
|
||||||
|
}
|
||||||
|
|
||||||
|
$domains = $service->applications()->get()->pluck('fqdn')->sort();
|
||||||
|
$domains = $domains->map(function ($domain) {
|
||||||
|
if (count(explode(':', $domain)) > 2) {
|
||||||
|
return str($domain)->beforeLast(':')->value();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $domain;
|
||||||
|
})->values();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'uuid' => $service->uuid,
|
||||||
|
'domains' => $domains,
|
||||||
|
])->setStatusCode(201);
|
||||||
} else {
|
} else {
|
||||||
return response()->json(['message' => 'No service type or docker_compose_raw provided.'], 400);
|
return response()->json(['message' => 'No service type or docker_compose_raw provided.'], 400);
|
||||||
}
|
}
|
||||||
@@ -443,6 +549,8 @@ class ServicesController extends Controller
|
|||||||
return response()->json(['message' => 'Service not found.'], 404);
|
return response()->json(['message' => 'Service not found.'], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->authorize('view', $service);
|
||||||
|
|
||||||
$service = $service->load(['applications', 'databases']);
|
$service = $service->load(['applications', 'databases']);
|
||||||
|
|
||||||
return response()->json($this->removeSensitiveData($service));
|
return response()->json($this->removeSensitiveData($service));
|
||||||
@@ -508,12 +616,14 @@ class ServicesController extends Controller
|
|||||||
return response()->json(['message' => 'Service not found.'], 404);
|
return response()->json(['message' => 'Service not found.'], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->authorize('delete', $service);
|
||||||
|
|
||||||
DeleteResourceJob::dispatch(
|
DeleteResourceJob::dispatch(
|
||||||
resource: $service,
|
resource: $service,
|
||||||
deleteConfigurations: $request->query->get('delete_configurations', true),
|
|
||||||
deleteVolumes: $request->query->get('delete_volumes', true),
|
deleteVolumes: $request->query->get('delete_volumes', true),
|
||||||
dockerCleanup: $request->query->get('docker_cleanup', true),
|
deleteConnectedNetworks: $request->query->get('delete_connected_networks', true),
|
||||||
deleteConnectedNetworks: $request->query->get('delete_connected_networks', true)
|
deleteConfigurations: $request->query->get('delete_configurations', true),
|
||||||
|
dockerCleanup: $request->query->get('docker_cleanup', true)
|
||||||
);
|
);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
@@ -550,7 +660,6 @@ class ServicesController extends Controller
|
|||||||
mediaType: 'application/json',
|
mediaType: 'application/json',
|
||||||
schema: new OA\Schema(
|
schema: new OA\Schema(
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['server_uuid', 'project_uuid', 'environment_name', 'environment_uuid', 'docker_compose_raw'],
|
|
||||||
properties: [
|
properties: [
|
||||||
'name' => ['type' => 'string', 'description' => 'The service name.'],
|
'name' => ['type' => 'string', 'description' => 'The service name.'],
|
||||||
'description' => ['type' => 'string', 'description' => 'The service description.'],
|
'description' => ['type' => 'string', 'description' => 'The service description.'],
|
||||||
@@ -615,28 +724,16 @@ class ServicesController extends Controller
|
|||||||
return response()->json(['message' => 'Service not found.'], 404);
|
return response()->json(['message' => 'Service not found.'], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = $this->upsert_service($request, $service, $teamId);
|
$this->authorize('update', $service);
|
||||||
if ($result instanceof \Illuminate\Http\JsonResponse) {
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json(serializeApiResponse($result))->setStatusCode(200);
|
$allowedFields = ['name', 'description', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network'];
|
||||||
}
|
|
||||||
|
|
||||||
private function upsert_service(Request $request, Service $service, string $teamId)
|
|
||||||
{
|
|
||||||
$allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network'];
|
|
||||||
$validator = customApiValidator($request->all(), [
|
$validator = customApiValidator($request->all(), [
|
||||||
'project_uuid' => 'string|required',
|
|
||||||
'environment_name' => 'string|nullable',
|
|
||||||
'environment_uuid' => 'string|nullable',
|
|
||||||
'server_uuid' => 'string|required',
|
|
||||||
'destination_uuid' => 'string',
|
|
||||||
'name' => 'string|max:255',
|
'name' => 'string|max:255',
|
||||||
'description' => 'string|nullable',
|
'description' => 'string|nullable',
|
||||||
'instant_deploy' => 'boolean',
|
'instant_deploy' => 'boolean',
|
||||||
'connect_to_docker_network' => 'boolean',
|
'connect_to_docker_network' => 'boolean',
|
||||||
'docker_compose_raw' => 'string|required',
|
'docker_compose_raw' => 'string|nullable',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||||
@@ -653,70 +750,42 @@ class ServicesController extends Controller
|
|||||||
'errors' => $errors,
|
'errors' => $errors,
|
||||||
], 422);
|
], 422);
|
||||||
}
|
}
|
||||||
|
if ($request->has('docker_compose_raw')) {
|
||||||
|
if (! isBase64Encoded($request->docker_compose_raw)) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Validation failed.',
|
||||||
|
'errors' => [
|
||||||
|
'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.',
|
||||||
|
],
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
$dockerComposeRaw = base64_decode($request->docker_compose_raw);
|
||||||
|
if (mb_detect_encoding($dockerComposeRaw, 'ASCII', true) === false) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Validation failed.',
|
||||||
|
'errors' => [
|
||||||
|
'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.',
|
||||||
|
],
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
$dockerCompose = base64_decode($request->docker_compose_raw);
|
||||||
|
$dockerComposeRaw = Yaml::dump(Yaml::parse($dockerCompose), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
|
||||||
|
$service->docker_compose_raw = $dockerComposeRaw;
|
||||||
|
}
|
||||||
|
|
||||||
$environmentUuid = $request->environment_uuid;
|
if ($request->has('name')) {
|
||||||
$environmentName = $request->environment_name;
|
$service->name = $request->name;
|
||||||
if (blank($environmentUuid) && blank($environmentName)) {
|
|
||||||
return response()->json(['message' => 'You need to provide at least one of environment_name or environment_uuid.'], 422);
|
|
||||||
}
|
}
|
||||||
$serverUuid = $request->server_uuid;
|
if ($request->has('description')) {
|
||||||
$instantDeploy = $request->instant_deploy ?? false;
|
$service->description = $request->description;
|
||||||
$project = Project::whereTeamId($teamId)->whereUuid($request->project_uuid)->first();
|
|
||||||
if (! $project) {
|
|
||||||
return response()->json(['message' => 'Project not found.'], 404);
|
|
||||||
}
|
}
|
||||||
$environment = $project->environments()->where('name', $environmentName)->first();
|
if ($request->has('connect_to_docker_network')) {
|
||||||
if (! $environment) {
|
$service->connect_to_docker_network = $request->connect_to_docker_network;
|
||||||
$environment = $project->environments()->where('uuid', $environmentUuid)->first();
|
|
||||||
}
|
}
|
||||||
if (! $environment) {
|
|
||||||
return response()->json(['message' => 'Environment not found.'], 404);
|
|
||||||
}
|
|
||||||
$server = Server::whereTeamId($teamId)->whereUuid($serverUuid)->first();
|
|
||||||
if (! $server) {
|
|
||||||
return response()->json(['message' => 'Server not found.'], 404);
|
|
||||||
}
|
|
||||||
$destinations = $server->destinations();
|
|
||||||
if ($destinations->count() == 0) {
|
|
||||||
return response()->json(['message' => 'Server has no destinations.'], 400);
|
|
||||||
}
|
|
||||||
if ($destinations->count() > 1 && ! $request->has('destination_uuid')) {
|
|
||||||
return response()->json(['message' => 'Server has multiple destinations and you do not set destination_uuid.'], 400);
|
|
||||||
}
|
|
||||||
$destination = $destinations->first();
|
|
||||||
if (! isBase64Encoded($request->docker_compose_raw)) {
|
|
||||||
return response()->json([
|
|
||||||
'message' => 'Validation failed.',
|
|
||||||
'errors' => [
|
|
||||||
'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.',
|
|
||||||
],
|
|
||||||
], 422);
|
|
||||||
}
|
|
||||||
$dockerComposeRaw = base64_decode($request->docker_compose_raw);
|
|
||||||
if (mb_detect_encoding($dockerComposeRaw, 'ASCII', true) === false) {
|
|
||||||
return response()->json([
|
|
||||||
'message' => 'Validation failed.',
|
|
||||||
'errors' => [
|
|
||||||
'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.',
|
|
||||||
],
|
|
||||||
], 422);
|
|
||||||
}
|
|
||||||
$dockerCompose = base64_decode($request->docker_compose_raw);
|
|
||||||
$dockerComposeRaw = Yaml::dump(Yaml::parse($dockerCompose), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
|
|
||||||
$connectToDockerNetwork = $request->connect_to_docker_network ?? false;
|
|
||||||
|
|
||||||
$service->name = $request->name ?? null;
|
|
||||||
$service->description = $request->description ?? null;
|
|
||||||
$service->docker_compose_raw = $dockerComposeRaw;
|
|
||||||
$service->environment_id = $environment->id;
|
|
||||||
$service->server_id = $server->id;
|
|
||||||
$service->destination_id = $destination->id;
|
|
||||||
$service->destination_type = $destination->getMorphClass();
|
|
||||||
$service->connect_to_docker_network = $connectToDockerNetwork;
|
|
||||||
$service->save();
|
$service->save();
|
||||||
|
|
||||||
$service->parse();
|
$service->parse();
|
||||||
if ($instantDeploy) {
|
if ($request->instant_deploy) {
|
||||||
StartService::dispatch($service);
|
StartService::dispatch($service);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -729,10 +798,10 @@ class ServicesController extends Controller
|
|||||||
return $domain;
|
return $domain;
|
||||||
})->values();
|
})->values();
|
||||||
|
|
||||||
return [
|
return response()->json([
|
||||||
'uuid' => $service->uuid,
|
'uuid' => $service->uuid,
|
||||||
'domains' => $domains,
|
'domains' => $domains,
|
||||||
];
|
])->setStatusCode(200);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[OA\Get(
|
#[OA\Get(
|
||||||
@@ -795,6 +864,8 @@ class ServicesController extends Controller
|
|||||||
return response()->json(['message' => 'Service not found.'], 404);
|
return response()->json(['message' => 'Service not found.'], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->authorize('manageEnvironment', $service);
|
||||||
|
|
||||||
$envs = $service->environment_variables->map(function ($env) {
|
$envs = $service->environment_variables->map(function ($env) {
|
||||||
$env->makeHidden([
|
$env->makeHidden([
|
||||||
'application_id',
|
'application_id',
|
||||||
@@ -899,6 +970,8 @@ class ServicesController extends Controller
|
|||||||
return response()->json(['message' => 'Service not found.'], 404);
|
return response()->json(['message' => 'Service not found.'], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->authorize('manageEnvironment', $service);
|
||||||
|
|
||||||
$validator = customApiValidator($request->all(), [
|
$validator = customApiValidator($request->all(), [
|
||||||
'key' => 'string|required',
|
'key' => 'string|required',
|
||||||
'value' => 'string|nullable',
|
'value' => 'string|nullable',
|
||||||
@@ -1020,6 +1093,8 @@ class ServicesController extends Controller
|
|||||||
return response()->json(['message' => 'Service not found.'], 404);
|
return response()->json(['message' => 'Service not found.'], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->authorize('manageEnvironment', $service);
|
||||||
|
|
||||||
$bulk_data = $request->get('data');
|
$bulk_data = $request->get('data');
|
||||||
if (! $bulk_data) {
|
if (! $bulk_data) {
|
||||||
return response()->json(['message' => 'Bulk data is required.'], 400);
|
return response()->json(['message' => 'Bulk data is required.'], 400);
|
||||||
@@ -1136,6 +1211,8 @@ class ServicesController extends Controller
|
|||||||
return response()->json(['message' => 'Service not found.'], 404);
|
return response()->json(['message' => 'Service not found.'], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->authorize('manageEnvironment', $service);
|
||||||
|
|
||||||
$validator = customApiValidator($request->all(), [
|
$validator = customApiValidator($request->all(), [
|
||||||
'key' => 'string|required',
|
'key' => 'string|required',
|
||||||
'value' => 'string|nullable',
|
'value' => 'string|nullable',
|
||||||
@@ -1238,6 +1315,8 @@ class ServicesController extends Controller
|
|||||||
return response()->json(['message' => 'Service not found.'], 404);
|
return response()->json(['message' => 'Service not found.'], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->authorize('manageEnvironment', $service);
|
||||||
|
|
||||||
$env = EnvironmentVariable::where('uuid', $request->env_uuid)
|
$env = EnvironmentVariable::where('uuid', $request->env_uuid)
|
||||||
->where('resourceable_type', Service::class)
|
->where('resourceable_type', Service::class)
|
||||||
->where('resourceable_id', $service->id)
|
->where('resourceable_id', $service->id)
|
||||||
@@ -1317,6 +1396,9 @@ class ServicesController extends Controller
|
|||||||
if (! $service) {
|
if (! $service) {
|
||||||
return response()->json(['message' => 'Service not found.'], 404);
|
return response()->json(['message' => 'Service not found.'], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->authorize('deploy', $service);
|
||||||
|
|
||||||
if (str($service->status)->contains('running')) {
|
if (str($service->status)->contains('running')) {
|
||||||
return response()->json(['message' => 'Service is already running.'], 400);
|
return response()->json(['message' => 'Service is already running.'], 400);
|
||||||
}
|
}
|
||||||
@@ -1395,6 +1477,9 @@ class ServicesController extends Controller
|
|||||||
if (! $service) {
|
if (! $service) {
|
||||||
return response()->json(['message' => 'Service not found.'], 404);
|
return response()->json(['message' => 'Service not found.'], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->authorize('stop', $service);
|
||||||
|
|
||||||
if (str($service->status)->contains('stopped') || str($service->status)->contains('exited')) {
|
if (str($service->status)->contains('stopped') || str($service->status)->contains('exited')) {
|
||||||
return response()->json(['message' => 'Service is already stopped.'], 400);
|
return response()->json(['message' => 'Service is already stopped.'], 400);
|
||||||
}
|
}
|
||||||
@@ -1482,6 +1567,9 @@ class ServicesController extends Controller
|
|||||||
if (! $service) {
|
if (! $service) {
|
||||||
return response()->json(['message' => 'Service not found.'], 404);
|
return response()->json(['message' => 'Service not found.'], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->authorize('deploy', $service);
|
||||||
|
|
||||||
$pullLatest = $request->boolean('latest');
|
$pullLatest = $request->boolean('latest');
|
||||||
RestartService::dispatch($service, $pullLatest);
|
RestartService::dispatch($service, $pullLatest);
|
||||||
|
|
||||||
|
@@ -143,12 +143,13 @@ class Bitbucket extends Controller
|
|||||||
]);
|
]);
|
||||||
$pr_app->generate_preview_fqdn_compose();
|
$pr_app->generate_preview_fqdn_compose();
|
||||||
} else {
|
} else {
|
||||||
ApplicationPreview::create([
|
$pr_app = ApplicationPreview::create([
|
||||||
'git_type' => 'bitbucket',
|
'git_type' => 'bitbucket',
|
||||||
'application_id' => $application->id,
|
'application_id' => $application->id,
|
||||||
'pull_request_id' => $pull_request_id,
|
'pull_request_id' => $pull_request_id,
|
||||||
'pull_request_html_url' => $pull_request_html_url,
|
'pull_request_html_url' => $pull_request_html_url,
|
||||||
]);
|
]);
|
||||||
|
$pr_app->generate_preview_fqdn();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$result = queue_application_deployment(
|
$result = queue_application_deployment(
|
||||||
|
@@ -175,12 +175,13 @@ class Gitea extends Controller
|
|||||||
]);
|
]);
|
||||||
$pr_app->generate_preview_fqdn_compose();
|
$pr_app->generate_preview_fqdn_compose();
|
||||||
} else {
|
} else {
|
||||||
ApplicationPreview::create([
|
$pr_app = ApplicationPreview::create([
|
||||||
'git_type' => 'gitea',
|
'git_type' => 'gitea',
|
||||||
'application_id' => $application->id,
|
'application_id' => $application->id,
|
||||||
'pull_request_id' => $pull_request_id,
|
'pull_request_id' => $pull_request_id,
|
||||||
'pull_request_html_url' => $pull_request_html_url,
|
'pull_request_html_url' => $pull_request_html_url,
|
||||||
]);
|
]);
|
||||||
|
$pr_app->generate_preview_fqdn();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$result = queue_application_deployment(
|
$result = queue_application_deployment(
|
||||||
|
@@ -183,12 +183,13 @@ class Github extends Controller
|
|||||||
]);
|
]);
|
||||||
$pr_app->generate_preview_fqdn_compose();
|
$pr_app->generate_preview_fqdn_compose();
|
||||||
} else {
|
} else {
|
||||||
ApplicationPreview::create([
|
$pr_app = ApplicationPreview::create([
|
||||||
'git_type' => 'github',
|
'git_type' => 'github',
|
||||||
'application_id' => $application->id,
|
'application_id' => $application->id,
|
||||||
'pull_request_id' => $pull_request_id,
|
'pull_request_id' => $pull_request_id,
|
||||||
'pull_request_html_url' => $pull_request_html_url,
|
'pull_request_html_url' => $pull_request_html_url,
|
||||||
]);
|
]);
|
||||||
|
$pr_app->generate_preview_fqdn();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -202,12 +202,13 @@ class Gitlab extends Controller
|
|||||||
]);
|
]);
|
||||||
$pr_app->generate_preview_fqdn_compose();
|
$pr_app->generate_preview_fqdn_compose();
|
||||||
} else {
|
} else {
|
||||||
ApplicationPreview::create([
|
$pr_app = ApplicationPreview::create([
|
||||||
'git_type' => 'gitlab',
|
'git_type' => 'gitlab',
|
||||||
'application_id' => $application->id,
|
'application_id' => $application->id,
|
||||||
'pull_request_id' => $pull_request_id,
|
'pull_request_id' => $pull_request_id,
|
||||||
'pull_request_html_url' => $pull_request_html_url,
|
'pull_request_html_url' => $pull_request_html_url,
|
||||||
]);
|
]);
|
||||||
|
$pr_app->generate_preview_fqdn();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$result = queue_application_deployment(
|
$result = queue_application_deployment(
|
||||||
|
@@ -71,5 +71,8 @@ class Kernel extends HttpKernel
|
|||||||
'ability' => \Laravel\Sanctum\Http\Middleware\CheckForAnyAbility::class,
|
'ability' => \Laravel\Sanctum\Http\Middleware\CheckForAnyAbility::class,
|
||||||
'api.ability' => \App\Http\Middleware\ApiAbility::class,
|
'api.ability' => \App\Http\Middleware\ApiAbility::class,
|
||||||
'api.sensitive' => \App\Http\Middleware\ApiSensitiveData::class,
|
'api.sensitive' => \App\Http\Middleware\ApiSensitiveData::class,
|
||||||
|
'can.create.resources' => \App\Http\Middleware\CanCreateResources::class,
|
||||||
|
'can.update.resource' => \App\Http\Middleware\CanUpdateResource::class,
|
||||||
|
'can.access.terminal' => \App\Http\Middleware\CanAccessTerminal::class,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
@@ -18,12 +18,18 @@ class ApiAllowed
|
|||||||
return response()->json(['success' => true, 'message' => 'API is disabled.'], 403);
|
return response()->json(['success' => true, 'message' => 'API is disabled.'], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! isDev()) {
|
if ($settings->allowed_ips) {
|
||||||
if ($settings->allowed_ips) {
|
// Check for special case: 0.0.0.0 means allow all
|
||||||
$allowedIps = explode(',', $settings->allowed_ips);
|
if (trim($settings->allowed_ips) === '0.0.0.0') {
|
||||||
if (! in_array($request->ip(), $allowedIps)) {
|
return $next($request);
|
||||||
return response()->json(['success' => true, 'message' => 'You are not allowed to access the API.'], 403);
|
}
|
||||||
}
|
|
||||||
|
$allowedIps = explode(',', $settings->allowed_ips);
|
||||||
|
$allowedIps = array_map('trim', $allowedIps);
|
||||||
|
$allowedIps = array_filter($allowedIps); // Remove empty entries
|
||||||
|
|
||||||
|
if (! empty($allowedIps) && ! check_ip_against_allowlist($request->ip(), $allowedIps)) {
|
||||||
|
return response()->json(['success' => true, 'message' => 'You are not allowed to access the API.'], 403);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
31
app/Http/Middleware/CanAccessTerminal.php
Normal file
31
app/Http/Middleware/CanAccessTerminal.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class CanAccessTerminal
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*
|
||||||
|
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
return $next($request);
|
||||||
|
|
||||||
|
// if (! auth()->check()) {
|
||||||
|
// abort(401, 'Authentication required');
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Only admins/owners can access terminal functionality
|
||||||
|
// if (! auth()->user()->can('canAccessTerminal')) {
|
||||||
|
// abort(403, 'Access to terminal functionality is restricted to team administrators');
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return $next($request);
|
||||||
|
}
|
||||||
|
}
|
26
app/Http/Middleware/CanCreateResources.php
Normal file
26
app/Http/Middleware/CanCreateResources.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class CanCreateResources
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*
|
||||||
|
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
return $next($request);
|
||||||
|
// if (! Gate::allows('createAnyResource')) {
|
||||||
|
// abort(403, 'You do not have permission to create resources.');
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return $next($request);
|
||||||
|
}
|
||||||
|
}
|
75
app/Http/Middleware/CanUpdateResource.php
Normal file
75
app/Http/Middleware/CanUpdateResource.php
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Models\Application;
|
||||||
|
use App\Models\Environment;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Models\ServiceApplication;
|
||||||
|
use App\Models\ServiceDatabase;
|
||||||
|
use App\Models\StandaloneClickhouse;
|
||||||
|
use App\Models\StandaloneDragonfly;
|
||||||
|
use App\Models\StandaloneKeydb;
|
||||||
|
use App\Models\StandaloneMariadb;
|
||||||
|
use App\Models\StandaloneMongodb;
|
||||||
|
use App\Models\StandaloneMysql;
|
||||||
|
use App\Models\StandalonePostgresql;
|
||||||
|
use App\Models\StandaloneRedis;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class CanUpdateResource
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
return $next($request);
|
||||||
|
|
||||||
|
// Get resource from route parameters
|
||||||
|
// $resource = null;
|
||||||
|
// if ($request->route('application_uuid')) {
|
||||||
|
// $resource = Application::where('uuid', $request->route('application_uuid'))->first();
|
||||||
|
// } elseif ($request->route('service_uuid')) {
|
||||||
|
// $resource = Service::where('uuid', $request->route('service_uuid'))->first();
|
||||||
|
// } elseif ($request->route('stack_service_uuid')) {
|
||||||
|
// // Handle ServiceApplication or ServiceDatabase
|
||||||
|
// $stack_service_uuid = $request->route('stack_service_uuid');
|
||||||
|
// $resource = ServiceApplication::where('uuid', $stack_service_uuid)->first() ??
|
||||||
|
// ServiceDatabase::where('uuid', $stack_service_uuid)->first();
|
||||||
|
// } elseif ($request->route('database_uuid')) {
|
||||||
|
// // Try different database types
|
||||||
|
// $database_uuid = $request->route('database_uuid');
|
||||||
|
// $resource = StandalonePostgresql::where('uuid', $database_uuid)->first() ??
|
||||||
|
// StandaloneMysql::where('uuid', $database_uuid)->first() ??
|
||||||
|
// StandaloneMariadb::where('uuid', $database_uuid)->first() ??
|
||||||
|
// StandaloneRedis::where('uuid', $database_uuid)->first() ??
|
||||||
|
// StandaloneKeydb::where('uuid', $database_uuid)->first() ??
|
||||||
|
// StandaloneDragonfly::where('uuid', $database_uuid)->first() ??
|
||||||
|
// StandaloneClickhouse::where('uuid', $database_uuid)->first() ??
|
||||||
|
// StandaloneMongodb::where('uuid', $database_uuid)->first();
|
||||||
|
// } elseif ($request->route('server_uuid')) {
|
||||||
|
// // For server routes, check if user can manage servers
|
||||||
|
// if (! auth()->user()->isAdmin()) {
|
||||||
|
// abort(403, 'You do not have permission to access this resource.');
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return $next($request);
|
||||||
|
// } elseif ($request->route('environment_uuid')) {
|
||||||
|
// $resource = Environment::where('uuid', $request->route('environment_uuid'))->first();
|
||||||
|
// } elseif ($request->route('project_uuid')) {
|
||||||
|
// $resource = Project::ownedByCurrentTeam()->where('uuid', $request->route('project_uuid'))->first();
|
||||||
|
// }
|
||||||
|
|
||||||
|
// if (! $resource) {
|
||||||
|
// abort(404, 'Resource not found.');
|
||||||
|
// }
|
||||||
|
|
||||||
|
// if (! Gate::allows('update', $resource)) {
|
||||||
|
// abort(403, 'You do not have permission to update this resource.');
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return $next($request);
|
||||||
|
}
|
||||||
|
}
|
@@ -12,6 +12,6 @@ class VerifyCsrfToken extends Middleware
|
|||||||
* @var array<int, string>
|
* @var array<int, string>
|
||||||
*/
|
*/
|
||||||
protected $except = [
|
protected $except = [
|
||||||
//
|
'webhooks/*',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
@@ -229,7 +229,14 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
|
|
||||||
// Set preview fqdn
|
// Set preview fqdn
|
||||||
if ($this->pull_request_id !== 0) {
|
if ($this->pull_request_id !== 0) {
|
||||||
$this->preview = $this->application->generate_preview_fqdn($this->pull_request_id);
|
$this->preview = ApplicationPreview::findPreviewByApplicationAndPullId($this->application->id, $this->pull_request_id);
|
||||||
|
if ($this->preview) {
|
||||||
|
if ($this->application->build_pack === 'dockercompose') {
|
||||||
|
$this->preview->generate_preview_fqdn_compose();
|
||||||
|
} else {
|
||||||
|
$this->preview->generate_preview_fqdn();
|
||||||
|
}
|
||||||
|
}
|
||||||
if ($this->application->is_github_based()) {
|
if ($this->application->is_github_based()) {
|
||||||
ApplicationPullRequestUpdateJob::dispatch(application: $this->application, preview: $this->preview, deployment_uuid: $this->deployment_uuid, status: ProcessStatus::IN_PROGRESS);
|
ApplicationPullRequestUpdateJob::dispatch(application: $this->application, preview: $this->preview, deployment_uuid: $this->deployment_uuid, status: ProcessStatus::IN_PROGRESS);
|
||||||
}
|
}
|
||||||
@@ -1421,6 +1428,19 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
if ($this->pull_request_id !== 0) {
|
if ($this->pull_request_id !== 0) {
|
||||||
$local_branch = "pull/{$this->pull_request_id}/head";
|
$local_branch = "pull/{$this->pull_request_id}/head";
|
||||||
}
|
}
|
||||||
|
// Build an exact refspec for ls-remote so we don't match similarly named branches (e.g., changeset-release/main)
|
||||||
|
if ($this->pull_request_id === 0) {
|
||||||
|
$lsRemoteRef = "refs/heads/{$local_branch}";
|
||||||
|
} else {
|
||||||
|
if ($this->git_type === 'github' || $this->git_type === 'gitea') {
|
||||||
|
$lsRemoteRef = "refs/pull/{$this->pull_request_id}/head";
|
||||||
|
} elseif ($this->git_type === 'gitlab') {
|
||||||
|
$lsRemoteRef = "refs/merge-requests/{$this->pull_request_id}/head";
|
||||||
|
} else {
|
||||||
|
// Fallback to the original value if provider-specific ref is unknown
|
||||||
|
$lsRemoteRef = $local_branch;
|
||||||
|
}
|
||||||
|
}
|
||||||
$private_key = data_get($this->application, 'private_key.private_key');
|
$private_key = data_get($this->application, 'private_key.private_key');
|
||||||
if ($private_key) {
|
if ($private_key) {
|
||||||
$private_key = base64_encode($private_key);
|
$private_key = base64_encode($private_key);
|
||||||
@@ -1435,7 +1455,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
executeInDocker($this->deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'),
|
executeInDocker($this->deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
executeInDocker($this->deployment_uuid, "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git ls-remote {$this->fullRepoUrl} {$local_branch}"),
|
executeInDocker($this->deployment_uuid, "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git ls-remote {$this->fullRepoUrl} {$lsRemoteRef}"),
|
||||||
'hidden' => true,
|
'hidden' => true,
|
||||||
'save' => 'git_commit_sha',
|
'save' => 'git_commit_sha',
|
||||||
]
|
]
|
||||||
@@ -1443,7 +1463,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
} else {
|
} else {
|
||||||
$this->execute_remote_command(
|
$this->execute_remote_command(
|
||||||
[
|
[
|
||||||
executeInDocker($this->deployment_uuid, "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git ls-remote {$this->fullRepoUrl} {$local_branch}"),
|
executeInDocker($this->deployment_uuid, "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git ls-remote {$this->fullRepoUrl} {$lsRemoteRef}"),
|
||||||
'hidden' => true,
|
'hidden' => true,
|
||||||
'save' => 'git_commit_sha',
|
'save' => 'git_commit_sha',
|
||||||
],
|
],
|
||||||
|
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Models\TeamInvitation;
|
use App\Models\TeamInvitation;
|
||||||
|
use App\Models\User;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||||
@@ -30,6 +31,7 @@ class CleanupInstanceStuffsJob implements ShouldBeEncrypted, ShouldBeUnique, Sho
|
|||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$this->cleanupInvitationLink();
|
$this->cleanupInvitationLink();
|
||||||
|
$this->cleanupExpiredEmailChangeRequests();
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Log::error('CleanupInstanceStuffsJob failed with error: '.$e->getMessage());
|
Log::error('CleanupInstanceStuffsJob failed with error: '.$e->getMessage());
|
||||||
}
|
}
|
||||||
@@ -42,4 +44,15 @@ class CleanupInstanceStuffsJob implements ShouldBeEncrypted, ShouldBeUnique, Sho
|
|||||||
$item->isValid();
|
$item->isValid();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function cleanupExpiredEmailChangeRequests()
|
||||||
|
{
|
||||||
|
User::whereNotNull('email_change_code_expires_at')
|
||||||
|
->where('email_change_code_expires_at', '<', now())
|
||||||
|
->update([
|
||||||
|
'pending_email' => null,
|
||||||
|
'email_change_code' => null,
|
||||||
|
'email_change_code_expires_at' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -11,7 +11,7 @@ use Illuminate\Foundation\Bus\Dispatchable;
|
|||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
class ContainerStatusJob implements ShouldBeEncrypted, ShouldQueue
|
class DEPRECATEDContainerStatusJob implements ShouldBeEncrypted, ShouldQueue
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
@@ -12,7 +12,7 @@ use Illuminate\Foundation\Bus\Dispatchable;
|
|||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
class ServerCheckNewJob implements ShouldBeEncrypted, ShouldQueue
|
class DEPRECATEDServerCheckNewJob implements ShouldBeEncrypted, ShouldQueue
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
162
app/Jobs/DEPRECATEDServerResourceManager.php
Normal file
162
app/Jobs/DEPRECATEDServerResourceManager.php
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\InstanceSettings;
|
||||||
|
use App\Models\Server;
|
||||||
|
use App\Models\Team;
|
||||||
|
use Cron\CronExpression;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\Middleware\WithoutOverlapping;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class DEPRECATEDServerResourceManager implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The time when this job execution started.
|
||||||
|
*/
|
||||||
|
private ?Carbon $executionTime = null;
|
||||||
|
|
||||||
|
private InstanceSettings $settings;
|
||||||
|
|
||||||
|
private string $instanceTimezone;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new job instance.
|
||||||
|
*/
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->onQueue('high');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the middleware the job should pass through.
|
||||||
|
*/
|
||||||
|
public function middleware(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
(new WithoutOverlapping('server-resource-manager'))
|
||||||
|
->releaseAfter(60),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
// Freeze the execution time at the start of the job
|
||||||
|
$this->executionTime = Carbon::now();
|
||||||
|
|
||||||
|
$this->settings = instanceSettings();
|
||||||
|
$this->instanceTimezone = $this->settings->instance_timezone ?: config('app.timezone');
|
||||||
|
|
||||||
|
if (validate_timezone($this->instanceTimezone) === false) {
|
||||||
|
$this->instanceTimezone = config('app.timezone');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process server checks - don't let failures stop the job
|
||||||
|
try {
|
||||||
|
$this->processServerChecks();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::channel('scheduled-errors')->error('Failed to process server checks', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'trace' => $e->getTraceAsString(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function processServerChecks(): void
|
||||||
|
{
|
||||||
|
$servers = $this->getServers();
|
||||||
|
|
||||||
|
foreach ($servers as $server) {
|
||||||
|
try {
|
||||||
|
$this->processServer($server);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::channel('scheduled-errors')->error('Error processing server', [
|
||||||
|
'server_id' => $server->id,
|
||||||
|
'server_name' => $server->name,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getServers()
|
||||||
|
{
|
||||||
|
$allServers = Server::where('ip', '!=', '1.2.3.4');
|
||||||
|
|
||||||
|
if (isCloud()) {
|
||||||
|
$servers = $allServers->whereRelation('team.subscription', 'stripe_invoice_paid', true)->get();
|
||||||
|
$own = Team::find(0)->servers;
|
||||||
|
|
||||||
|
return $servers->merge($own);
|
||||||
|
} else {
|
||||||
|
return $allServers->get();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function processServer(Server $server): void
|
||||||
|
{
|
||||||
|
$serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone);
|
||||||
|
if (validate_timezone($serverTimezone) === false) {
|
||||||
|
$serverTimezone = config('app.timezone');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sentinel check
|
||||||
|
$lastSentinelUpdate = $server->sentinel_updated_at;
|
||||||
|
if (Carbon::parse($lastSentinelUpdate)->isBefore($this->executionTime->subSeconds($server->waitBeforeDoingSshCheck()))) {
|
||||||
|
// Dispatch ServerCheckJob if due
|
||||||
|
$checkFrequency = isCloud() ? '*/5 * * * *' : '* * * * *'; // Every 5 min for cloud, every minute for self-hosted
|
||||||
|
if ($this->shouldRunNow($checkFrequency, $serverTimezone)) {
|
||||||
|
ServerCheckJob::dispatch($server);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch ServerStorageCheckJob if due
|
||||||
|
$serverDiskUsageCheckFrequency = data_get($server->settings, 'server_disk_usage_check_frequency', '0 * * * *');
|
||||||
|
if (isset(VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency])) {
|
||||||
|
$serverDiskUsageCheckFrequency = VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency];
|
||||||
|
}
|
||||||
|
if ($this->shouldRunNow($serverDiskUsageCheckFrequency, $serverTimezone)) {
|
||||||
|
ServerStorageCheckJob::dispatch($server);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch DockerCleanupJob if due
|
||||||
|
$dockerCleanupFrequency = data_get($server->settings, 'docker_cleanup_frequency', '0 * * * *');
|
||||||
|
if (isset(VALID_CRON_STRINGS[$dockerCleanupFrequency])) {
|
||||||
|
$dockerCleanupFrequency = VALID_CRON_STRINGS[$dockerCleanupFrequency];
|
||||||
|
}
|
||||||
|
if ($this->shouldRunNow($dockerCleanupFrequency, $serverTimezone)) {
|
||||||
|
DockerCleanupJob::dispatch($server, false, $server->settings->delete_unused_volumes, $server->settings->delete_unused_networks);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch ServerPatchCheckJob if due (weekly)
|
||||||
|
if ($this->shouldRunNow('0 0 * * 0', $serverTimezone)) { // Weekly on Sunday at midnight
|
||||||
|
ServerPatchCheckJob::dispatch($server);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch Sentinel restart if due (daily for Sentinel-enabled servers)
|
||||||
|
if ($server->isSentinelEnabled() && $this->shouldRunNow('0 0 * * *', $serverTimezone)) {
|
||||||
|
dispatch(function () use ($server) {
|
||||||
|
$server->restartContainer('coolify-sentinel');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function shouldRunNow(string $frequency, string $timezone): bool
|
||||||
|
{
|
||||||
|
$cron = new CronExpression($frequency);
|
||||||
|
|
||||||
|
// Use the frozen execution time, not the current time
|
||||||
|
$baseTime = $this->executionTime ?? Carbon::now();
|
||||||
|
$executionTime = $baseTime->copy()->setTimezone($timezone);
|
||||||
|
|
||||||
|
return $cron->isDue($executionTime);
|
||||||
|
}
|
||||||
|
}
|
@@ -23,6 +23,8 @@ use Illuminate\Foundation\Bus\Dispatchable;
|
|||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
use Throwable;
|
||||||
|
use Visus\Cuid2\Cuid2;
|
||||||
|
|
||||||
class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||||
{
|
{
|
||||||
@@ -60,9 +62,16 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
|
|
||||||
public ?S3Storage $s3 = null;
|
public ?S3Storage $s3 = null;
|
||||||
|
|
||||||
|
public $timeout = 3600;
|
||||||
|
|
||||||
|
public string $backup_log_uuid;
|
||||||
|
|
||||||
public function __construct(public ScheduledDatabaseBackup $backup)
|
public function __construct(public ScheduledDatabaseBackup $backup)
|
||||||
{
|
{
|
||||||
$this->onQueue('high');
|
$this->onQueue('high');
|
||||||
|
$this->timeout = $backup->timeout;
|
||||||
|
|
||||||
|
$this->backup_log_uuid = (string) new Cuid2;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function handle(): void
|
public function handle(): void
|
||||||
@@ -219,12 +228,8 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
$this->mongo_root_username = str($rootUsername)->after('MONGO_INITDB_ROOT_USERNAME=')->value();
|
$this->mongo_root_username = str($rootUsername)->after('MONGO_INITDB_ROOT_USERNAME=')->value();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
\Log::info('MongoDB credentials extracted from environment', [
|
|
||||||
'has_username' => filled($this->mongo_root_username),
|
|
||||||
'has_password' => filled($this->mongo_root_password),
|
|
||||||
]);
|
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
\Log::warning('Failed to extract MongoDB environment variables', ['error' => $e->getMessage()]);
|
|
||||||
// Continue without env vars - will be handled in backup_standalone_mongodb method
|
// Continue without env vars - will be handled in backup_standalone_mongodb method
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -288,6 +293,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
}
|
}
|
||||||
$this->backup_location = $this->backup_dir.$this->backup_file;
|
$this->backup_location = $this->backup_dir.$this->backup_file;
|
||||||
$this->backup_log = ScheduledDatabaseBackupExecution::create([
|
$this->backup_log = ScheduledDatabaseBackupExecution::create([
|
||||||
|
'uuid' => $this->backup_log_uuid,
|
||||||
'database_name' => $database,
|
'database_name' => $database,
|
||||||
'filename' => $this->backup_location,
|
'filename' => $this->backup_location,
|
||||||
'scheduled_database_backup_id' => $this->backup->id,
|
'scheduled_database_backup_id' => $this->backup->id,
|
||||||
@@ -307,6 +313,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
$this->backup_file = "/mongo-dump-$databaseName-".Carbon::now()->timestamp.'.tar.gz';
|
$this->backup_file = "/mongo-dump-$databaseName-".Carbon::now()->timestamp.'.tar.gz';
|
||||||
$this->backup_location = $this->backup_dir.$this->backup_file;
|
$this->backup_location = $this->backup_dir.$this->backup_file;
|
||||||
$this->backup_log = ScheduledDatabaseBackupExecution::create([
|
$this->backup_log = ScheduledDatabaseBackupExecution::create([
|
||||||
|
'uuid' => $this->backup_log_uuid,
|
||||||
'database_name' => $databaseName,
|
'database_name' => $databaseName,
|
||||||
'filename' => $this->backup_location,
|
'filename' => $this->backup_location,
|
||||||
'scheduled_database_backup_id' => $this->backup->id,
|
'scheduled_database_backup_id' => $this->backup->id,
|
||||||
@@ -319,6 +326,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
}
|
}
|
||||||
$this->backup_location = $this->backup_dir.$this->backup_file;
|
$this->backup_location = $this->backup_dir.$this->backup_file;
|
||||||
$this->backup_log = ScheduledDatabaseBackupExecution::create([
|
$this->backup_log = ScheduledDatabaseBackupExecution::create([
|
||||||
|
'uuid' => $this->backup_log_uuid,
|
||||||
'database_name' => $database,
|
'database_name' => $database,
|
||||||
'filename' => $this->backup_location,
|
'filename' => $this->backup_location,
|
||||||
'scheduled_database_backup_id' => $this->backup->id,
|
'scheduled_database_backup_id' => $this->backup->id,
|
||||||
@@ -331,6 +339,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
}
|
}
|
||||||
$this->backup_location = $this->backup_dir.$this->backup_file;
|
$this->backup_location = $this->backup_dir.$this->backup_file;
|
||||||
$this->backup_log = ScheduledDatabaseBackupExecution::create([
|
$this->backup_log = ScheduledDatabaseBackupExecution::create([
|
||||||
|
'uuid' => $this->backup_log_uuid,
|
||||||
'database_name' => $database,
|
'database_name' => $database,
|
||||||
'filename' => $this->backup_location,
|
'filename' => $this->backup_location,
|
||||||
'scheduled_database_backup_id' => $this->backup->id,
|
'scheduled_database_backup_id' => $this->backup->id,
|
||||||
@@ -342,6 +351,12 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
$size = $this->calculate_size();
|
$size = $this->calculate_size();
|
||||||
if ($this->backup->save_s3) {
|
if ($this->backup->save_s3) {
|
||||||
$this->upload_to_s3();
|
$this->upload_to_s3();
|
||||||
|
|
||||||
|
// If local backup is disabled, delete the local file immediately after S3 upload
|
||||||
|
if ($this->backup->disable_local_backup) {
|
||||||
|
deleteBackupsLocally($this->backup_location, $this->server);
|
||||||
|
$this->add_to_backup_output('Local backup file deleted after S3 upload (disable_local_backup enabled).');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->team->notify(new BackupSuccess($this->backup, $this->database, $database));
|
$this->team->notify(new BackupSuccess($this->backup, $this->database, $database));
|
||||||
@@ -574,4 +589,18 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
|
|
||||||
return "{$helperImage}:{$latestVersion}";
|
return "{$helperImage}:{$latestVersion}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function failed(?Throwable $exception): void
|
||||||
|
{
|
||||||
|
$log = ScheduledDatabaseBackupExecution::where('uuid', $this->backup_log_uuid)->first();
|
||||||
|
|
||||||
|
if ($log) {
|
||||||
|
$log->update([
|
||||||
|
'status' => 'failed',
|
||||||
|
'message' => 'Job failed: '.($exception?->getMessage() ?? 'Unknown error'),
|
||||||
|
'size' => 0,
|
||||||
|
'filename' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -8,6 +8,7 @@ use App\Actions\Server\CleanupDocker;
|
|||||||
use App\Actions\Service\DeleteService;
|
use App\Actions\Service\DeleteService;
|
||||||
use App\Actions\Service\StopService;
|
use App\Actions\Service\StopService;
|
||||||
use App\Models\Application;
|
use App\Models\Application;
|
||||||
|
use App\Models\ApplicationPreview;
|
||||||
use App\Models\Service;
|
use App\Models\Service;
|
||||||
use App\Models\StandaloneClickhouse;
|
use App\Models\StandaloneClickhouse;
|
||||||
use App\Models\StandaloneDragonfly;
|
use App\Models\StandaloneDragonfly;
|
||||||
@@ -30,11 +31,11 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource,
|
public Application|ApplicationPreview|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource,
|
||||||
public bool $deleteConfigurations = true,
|
|
||||||
public bool $deleteVolumes = true,
|
public bool $deleteVolumes = true,
|
||||||
public bool $dockerCleanup = true,
|
public bool $deleteConnectedNetworks = true,
|
||||||
public bool $deleteConnectedNetworks = true
|
public bool $deleteConfigurations = true,
|
||||||
|
public bool $dockerCleanup = true
|
||||||
) {
|
) {
|
||||||
$this->onQueue('high');
|
$this->onQueue('high');
|
||||||
}
|
}
|
||||||
@@ -42,9 +43,16 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
public function handle()
|
public function handle()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
// Handle ApplicationPreview instances separately
|
||||||
|
if ($this->resource instanceof ApplicationPreview) {
|
||||||
|
$this->deleteApplicationPreview();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
switch ($this->resource->type()) {
|
switch ($this->resource->type()) {
|
||||||
case 'application':
|
case 'application':
|
||||||
StopApplication::run($this->resource, previewDeployments: true);
|
StopApplication::run($this->resource, previewDeployments: true, dockerCleanup: $this->dockerCleanup);
|
||||||
break;
|
break;
|
||||||
case 'standalone-postgresql':
|
case 'standalone-postgresql':
|
||||||
case 'standalone-redis':
|
case 'standalone-redis':
|
||||||
@@ -54,11 +62,11 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
case 'standalone-keydb':
|
case 'standalone-keydb':
|
||||||
case 'standalone-dragonfly':
|
case 'standalone-dragonfly':
|
||||||
case 'standalone-clickhouse':
|
case 'standalone-clickhouse':
|
||||||
StopDatabase::run($this->resource, true);
|
StopDatabase::run($this->resource, dockerCleanup: $this->dockerCleanup);
|
||||||
break;
|
break;
|
||||||
case 'service':
|
case 'service':
|
||||||
StopService::run($this->resource, true);
|
StopService::run($this->resource, $this->deleteConnectedNetworks, $this->dockerCleanup);
|
||||||
DeleteService::run($this->resource, $this->deleteConfigurations, $this->deleteVolumes, $this->dockerCleanup, $this->deleteConnectedNetworks);
|
DeleteService::run($this->resource, $this->deleteVolumes, $this->deleteConnectedNetworks, $this->deleteConfigurations, $this->dockerCleanup);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -70,7 +78,7 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
$this->resource->deleteVolumes();
|
$this->resource->deleteVolumes();
|
||||||
$this->resource->persistentStorages()->delete();
|
$this->resource->persistentStorages()->delete();
|
||||||
}
|
}
|
||||||
$this->resource->fileStorages()->delete();
|
$this->resource->fileStorages()->delete(); // these are file mounts which should probably have their own flag
|
||||||
|
|
||||||
$isDatabase = $this->resource instanceof StandalonePostgresql
|
$isDatabase = $this->resource instanceof StandalonePostgresql
|
||||||
|| $this->resource instanceof StandaloneRedis
|
|| $this->resource instanceof StandaloneRedis
|
||||||
@@ -98,10 +106,61 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
if ($this->dockerCleanup) {
|
if ($this->dockerCleanup) {
|
||||||
$server = data_get($this->resource, 'server') ?? data_get($this->resource, 'destination.server');
|
$server = data_get($this->resource, 'server') ?? data_get($this->resource, 'destination.server');
|
||||||
if ($server) {
|
if ($server) {
|
||||||
CleanupDocker::dispatch($server, true);
|
CleanupDocker::dispatch($server, false, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Artisan::queue('cleanup:stucked-resources');
|
Artisan::queue('cleanup:stucked-resources');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function deleteApplicationPreview()
|
||||||
|
{
|
||||||
|
$application = $this->resource->application;
|
||||||
|
$server = $application->destination->server;
|
||||||
|
$pull_request_id = $this->resource->pull_request_id;
|
||||||
|
|
||||||
|
// Ensure the preview is soft deleted (may already be done in Livewire component)
|
||||||
|
if (! $this->resource->trashed()) {
|
||||||
|
$this->resource->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ($server->isSwarm()) {
|
||||||
|
instant_remote_process(["docker stack rm {$application->uuid}-{$pull_request_id}"], $server);
|
||||||
|
} else {
|
||||||
|
$containers = getCurrentApplicationContainerStatus($server, $application->id, $pull_request_id)->toArray();
|
||||||
|
$this->stopPreviewContainers($containers, $server);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Log the error but don't fail the job
|
||||||
|
ray('Error stopping preview containers: '.$e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finally, force delete to trigger resource cleanup
|
||||||
|
$this->resource->forceDelete();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function stopPreviewContainers(array $containers, $server, int $timeout = 30)
|
||||||
|
{
|
||||||
|
if (empty($containers)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$containerNames = [];
|
||||||
|
foreach ($containers as $container) {
|
||||||
|
$containerNames[] = str_replace('/', '', $container['Names']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$containerList = implode(' ', array_map('escapeshellarg', $containerNames));
|
||||||
|
$commands = [
|
||||||
|
"docker stop --time=$timeout $containerList",
|
||||||
|
"docker rm -f $containerList",
|
||||||
|
];
|
||||||
|
|
||||||
|
instant_remote_process(
|
||||||
|
command: $commands,
|
||||||
|
server: $server,
|
||||||
|
throwError: false
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -34,7 +34,12 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
return [(new WithoutOverlapping('docker-cleanup-'.$this->server->uuid))->expireAfter(600)->dontRelease()];
|
return [(new WithoutOverlapping('docker-cleanup-'.$this->server->uuid))->expireAfter(600)->dontRelease()];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function __construct(public Server $server, public bool $manualCleanup = false) {}
|
public function __construct(
|
||||||
|
public Server $server,
|
||||||
|
public bool $manualCleanup = false,
|
||||||
|
public bool $deleteUnusedVolumes = false,
|
||||||
|
public bool $deleteUnusedNetworks = false
|
||||||
|
) {}
|
||||||
|
|
||||||
public function handle(): void
|
public function handle(): void
|
||||||
{
|
{
|
||||||
@@ -50,7 +55,11 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
$this->usageBefore = $this->server->getDiskUsage();
|
$this->usageBefore = $this->server->getDiskUsage();
|
||||||
|
|
||||||
if ($this->manualCleanup || $this->server->settings->force_docker_cleanup) {
|
if ($this->manualCleanup || $this->server->settings->force_docker_cleanup) {
|
||||||
$cleanup_log = CleanupDocker::run(server: $this->server);
|
$cleanup_log = CleanupDocker::run(
|
||||||
|
server: $this->server,
|
||||||
|
deleteUnusedVolumes: $this->deleteUnusedVolumes,
|
||||||
|
deleteUnusedNetworks: $this->deleteUnusedNetworks
|
||||||
|
);
|
||||||
$usageAfter = $this->server->getDiskUsage();
|
$usageAfter = $this->server->getDiskUsage();
|
||||||
$message = ($this->manualCleanup ? 'Manual' : 'Forced').' Docker cleanup job executed successfully. Disk usage before: '.$this->usageBefore.'%, Disk usage after: '.$usageAfter.'%.';
|
$message = ($this->manualCleanup ? 'Manual' : 'Forced').' Docker cleanup job executed successfully. Disk usage before: '.$this->usageBefore.'%, Disk usage after: '.$usageAfter.'%.';
|
||||||
|
|
||||||
@@ -67,7 +76,11 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (str($this->usageBefore)->isEmpty() || $this->usageBefore === null || $this->usageBefore === 0) {
|
if (str($this->usageBefore)->isEmpty() || $this->usageBefore === null || $this->usageBefore === 0) {
|
||||||
$cleanup_log = CleanupDocker::run(server: $this->server);
|
$cleanup_log = CleanupDocker::run(
|
||||||
|
server: $this->server,
|
||||||
|
deleteUnusedVolumes: $this->deleteUnusedVolumes,
|
||||||
|
deleteUnusedNetworks: $this->deleteUnusedNetworks
|
||||||
|
);
|
||||||
$message = 'Docker cleanup job executed successfully, but no disk usage could be determined.';
|
$message = 'Docker cleanup job executed successfully, but no disk usage could be determined.';
|
||||||
|
|
||||||
$this->execution_log->update([
|
$this->execution_log->update([
|
||||||
@@ -81,7 +94,11 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($this->usageBefore >= $this->server->settings->docker_cleanup_threshold) {
|
if ($this->usageBefore >= $this->server->settings->docker_cleanup_threshold) {
|
||||||
$cleanup_log = CleanupDocker::run(server: $this->server);
|
$cleanup_log = CleanupDocker::run(
|
||||||
|
server: $this->server,
|
||||||
|
deleteUnusedVolumes: $this->deleteUnusedVolumes,
|
||||||
|
deleteUnusedNetworks: $this->deleteUnusedNetworks
|
||||||
|
);
|
||||||
$usageAfter = $this->server->getDiskUsage();
|
$usageAfter = $this->server->getDiskUsage();
|
||||||
$diskSaved = $this->usageBefore - $usageAfter;
|
$diskSaved = $this->usageBefore - $usageAfter;
|
||||||
|
|
||||||
|
110
app/Jobs/PullChangelogFromGitHub.php
Normal file
110
app/Jobs/PullChangelogFromGitHub.php
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
|
class PullChangelogFromGitHub implements ShouldBeEncrypted, ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public $timeout = 30;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->onQueue('high');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$response = Http::retry(3, 1000)
|
||||||
|
->timeout(30)
|
||||||
|
->get('https://api.github.com/repos/coollabsio/coolify/releases?per_page=10');
|
||||||
|
|
||||||
|
if ($response->successful()) {
|
||||||
|
$releases = $response->json();
|
||||||
|
$changelog = $this->transformReleasesToChangelog($releases);
|
||||||
|
|
||||||
|
// Group entries by month and save them
|
||||||
|
$this->saveChangelogEntries($changelog);
|
||||||
|
} else {
|
||||||
|
send_internal_notification('PullChangelogFromGitHub failed with: '.$response->status().' '.$response->body());
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
send_internal_notification('PullChangelogFromGitHub failed with: '.$e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function transformReleasesToChangelog(array $releases): array
|
||||||
|
{
|
||||||
|
$entries = [];
|
||||||
|
|
||||||
|
foreach ($releases as $release) {
|
||||||
|
// Skip drafts and pre-releases if desired
|
||||||
|
if ($release['draft']) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$publishedAt = Carbon::parse($release['published_at']);
|
||||||
|
|
||||||
|
$entry = [
|
||||||
|
'tag_name' => $release['tag_name'],
|
||||||
|
'title' => $release['name'] ?: $release['tag_name'],
|
||||||
|
'content' => $release['body'] ?: 'No release notes available.',
|
||||||
|
'published_at' => $publishedAt->toISOString(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$entries[] = $entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function saveChangelogEntries(array $entries): void
|
||||||
|
{
|
||||||
|
// Create changelogs directory if it doesn't exist
|
||||||
|
$changelogsDir = base_path('changelogs');
|
||||||
|
if (! File::exists($changelogsDir)) {
|
||||||
|
File::makeDirectory($changelogsDir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group entries by year-month
|
||||||
|
$groupedEntries = [];
|
||||||
|
foreach ($entries as $entry) {
|
||||||
|
$date = Carbon::parse($entry['published_at']);
|
||||||
|
$monthKey = $date->format('Y-m');
|
||||||
|
|
||||||
|
if (! isset($groupedEntries[$monthKey])) {
|
||||||
|
$groupedEntries[$monthKey] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$groupedEntries[$monthKey][] = $entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save each month's entries to separate files
|
||||||
|
foreach ($groupedEntries as $month => $monthEntries) {
|
||||||
|
// Sort entries by published date (newest first)
|
||||||
|
usort($monthEntries, function ($a, $b) {
|
||||||
|
return Carbon::parse($b['published_at'])->timestamp - Carbon::parse($a['published_at'])->timestamp;
|
||||||
|
});
|
||||||
|
|
||||||
|
$monthData = [
|
||||||
|
'entries' => $monthEntries,
|
||||||
|
'last_updated' => now()->toISOString(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$filePath = base_path("changelogs/{$month}.json");
|
||||||
|
File::put($filePath, json_encode($monthData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
@@ -31,7 +31,7 @@ class PullTemplatesFromCDN implements ShouldBeEncrypted, ShouldQueue
|
|||||||
$response = Http::retry(3, 1000)->get(config('constants.services.official'));
|
$response = Http::retry(3, 1000)->get(config('constants.services.official'));
|
||||||
if ($response->successful()) {
|
if ($response->successful()) {
|
||||||
$services = $response->json();
|
$services = $response->json();
|
||||||
File::put(base_path('templates/service-templates.json'), json_encode($services));
|
File::put(base_path('templates/'.config('constants.services.file_name')), json_encode($services));
|
||||||
} else {
|
} else {
|
||||||
send_internal_notification('PullTemplatesAndVersions failed with: '.$response->status().' '.$response->body());
|
send_internal_notification('PullTemplatesAndVersions failed with: '.$response->status().' '.$response->body());
|
||||||
}
|
}
|
||||||
|
@@ -21,8 +21,9 @@ use Illuminate\Queue\InteractsWithQueue;
|
|||||||
use Illuminate\Queue\Middleware\WithoutOverlapping;
|
use Illuminate\Queue\Middleware\WithoutOverlapping;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
use Laravel\Horizon\Contracts\Silenced;
|
||||||
|
|
||||||
class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue
|
class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
313
app/Jobs/ScheduledJobManager.php
Normal file
313
app/Jobs/ScheduledJobManager.php
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\ScheduledDatabaseBackup;
|
||||||
|
use App\Models\ScheduledTask;
|
||||||
|
use App\Models\Server;
|
||||||
|
use App\Models\Team;
|
||||||
|
use Cron\CronExpression;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\Middleware\WithoutOverlapping;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class ScheduledJobManager implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The time when this job execution started.
|
||||||
|
* Used to ensure all scheduled items are evaluated against the same point in time.
|
||||||
|
*/
|
||||||
|
private ?Carbon $executionTime = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new job instance.
|
||||||
|
*/
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->onQueue($this->determineQueue());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function determineQueue(): string
|
||||||
|
{
|
||||||
|
$preferredQueue = 'crons';
|
||||||
|
$fallbackQueue = 'high';
|
||||||
|
|
||||||
|
$configuredQueues = explode(',', env('HORIZON_QUEUES', 'high,default'));
|
||||||
|
|
||||||
|
return in_array($preferredQueue, $configuredQueues) ? $preferredQueue : $fallbackQueue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the middleware the job should pass through.
|
||||||
|
*/
|
||||||
|
public function middleware(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
(new WithoutOverlapping('scheduled-job-manager'))
|
||||||
|
->releaseAfter(60), // Release the lock after 60 seconds if job fails
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
// Freeze the execution time at the start of the job
|
||||||
|
$this->executionTime = Carbon::now();
|
||||||
|
|
||||||
|
// Process backups - don't let failures stop task processing
|
||||||
|
try {
|
||||||
|
$this->processScheduledBackups();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::channel('scheduled-errors')->error('Failed to process scheduled backups', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'trace' => $e->getTraceAsString(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process tasks - don't let failures stop the job manager
|
||||||
|
try {
|
||||||
|
$this->processScheduledTasks();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::channel('scheduled-errors')->error('Failed to process scheduled tasks', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'trace' => $e->getTraceAsString(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process Docker cleanups - don't let failures stop the job manager
|
||||||
|
try {
|
||||||
|
$this->processDockerCleanups();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::channel('scheduled-errors')->error('Failed to process docker cleanups', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'trace' => $e->getTraceAsString(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function processScheduledBackups(): void
|
||||||
|
{
|
||||||
|
$backups = ScheduledDatabaseBackup::with(['database'])
|
||||||
|
->where('enabled', true)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($backups as $backup) {
|
||||||
|
try {
|
||||||
|
// Apply the same filtering logic as the original
|
||||||
|
if (! $this->shouldProcessBackup($backup)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$server = $backup->server();
|
||||||
|
$serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone'));
|
||||||
|
|
||||||
|
if (validate_timezone($serverTimezone) === false) {
|
||||||
|
$serverTimezone = config('app.timezone');
|
||||||
|
}
|
||||||
|
|
||||||
|
$frequency = $backup->frequency;
|
||||||
|
if (isset(VALID_CRON_STRINGS[$frequency])) {
|
||||||
|
$frequency = VALID_CRON_STRINGS[$frequency];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->shouldRunNow($frequency, $serverTimezone)) {
|
||||||
|
DatabaseBackupJob::dispatch($backup);
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::channel('scheduled-errors')->error('Error processing backup', [
|
||||||
|
'backup_id' => $backup->id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function processScheduledTasks(): void
|
||||||
|
{
|
||||||
|
$tasks = ScheduledTask::with(['service', 'application'])
|
||||||
|
->where('enabled', true)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($tasks as $task) {
|
||||||
|
try {
|
||||||
|
if (! $this->shouldProcessTask($task)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$server = $task->server();
|
||||||
|
$serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone'));
|
||||||
|
|
||||||
|
if (validate_timezone($serverTimezone) === false) {
|
||||||
|
$serverTimezone = config('app.timezone');
|
||||||
|
}
|
||||||
|
|
||||||
|
$frequency = $task->frequency;
|
||||||
|
if (isset(VALID_CRON_STRINGS[$frequency])) {
|
||||||
|
$frequency = VALID_CRON_STRINGS[$frequency];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->shouldRunNow($frequency, $serverTimezone)) {
|
||||||
|
ScheduledTaskJob::dispatch($task);
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::channel('scheduled-errors')->error('Error processing task', [
|
||||||
|
'task_id' => $task->id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function shouldProcessBackup(ScheduledDatabaseBackup $backup): bool
|
||||||
|
{
|
||||||
|
if (blank(data_get($backup, 'database'))) {
|
||||||
|
$backup->delete();
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$server = $backup->server();
|
||||||
|
if (blank($server)) {
|
||||||
|
$backup->delete();
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($server->isFunctional() === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function shouldProcessTask(ScheduledTask $task): bool
|
||||||
|
{
|
||||||
|
$service = $task->service;
|
||||||
|
$application = $task->application;
|
||||||
|
|
||||||
|
$server = $task->server();
|
||||||
|
if (blank($server)) {
|
||||||
|
$task->delete();
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($server->isFunctional() === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $service && ! $application) {
|
||||||
|
$task->delete();
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($application && str($application->status)->contains('running') === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($service && str($service->status)->contains('running') === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function shouldRunNow(string $frequency, string $timezone): bool
|
||||||
|
{
|
||||||
|
$cron = new CronExpression($frequency);
|
||||||
|
|
||||||
|
// Use the frozen execution time, not the current time
|
||||||
|
// Fallback to current time if execution time is not set (shouldn't happen)
|
||||||
|
$baseTime = $this->executionTime ?? Carbon::now();
|
||||||
|
$executionTime = $baseTime->copy()->setTimezone($timezone);
|
||||||
|
|
||||||
|
return $cron->isDue($executionTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function processDockerCleanups(): void
|
||||||
|
{
|
||||||
|
// Get all servers that need cleanup checks
|
||||||
|
$servers = $this->getServersForCleanup();
|
||||||
|
|
||||||
|
foreach ($servers as $server) {
|
||||||
|
try {
|
||||||
|
if (! $this->shouldProcessDockerCleanup($server)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone'));
|
||||||
|
if (validate_timezone($serverTimezone) === false) {
|
||||||
|
$serverTimezone = config('app.timezone');
|
||||||
|
}
|
||||||
|
|
||||||
|
$frequency = data_get($server->settings, 'docker_cleanup_frequency', '0 * * * *');
|
||||||
|
if (isset(VALID_CRON_STRINGS[$frequency])) {
|
||||||
|
$frequency = VALID_CRON_STRINGS[$frequency];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the frozen execution time for consistent evaluation
|
||||||
|
if ($this->shouldRunNow($frequency, $serverTimezone)) {
|
||||||
|
DockerCleanupJob::dispatch(
|
||||||
|
$server,
|
||||||
|
false,
|
||||||
|
$server->settings->delete_unused_volumes,
|
||||||
|
$server->settings->delete_unused_networks
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::channel('scheduled-errors')->error('Error processing docker cleanup', [
|
||||||
|
'server_id' => $server->id,
|
||||||
|
'server_name' => $server->name,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getServersForCleanup(): Collection
|
||||||
|
{
|
||||||
|
$query = Server::with('settings')
|
||||||
|
->where('ip', '!=', '1.2.3.4');
|
||||||
|
|
||||||
|
if (isCloud()) {
|
||||||
|
$servers = $query->whereRelation('team.subscription', 'stripe_invoice_paid', true)->get();
|
||||||
|
$own = Team::find(0)->servers()->with('settings')->get();
|
||||||
|
|
||||||
|
return $servers->merge($own);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function shouldProcessDockerCleanup(Server $server): bool
|
||||||
|
{
|
||||||
|
if (! $server->isFunctional()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In cloud, check subscription status (except team 0)
|
||||||
|
if (isCloud() && $server->team_id !== 0) {
|
||||||
|
if (data_get($server->team->subscription, 'stripe_invoice_paid', false) === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
153
app/Jobs/ServerConnectionCheckJob.php
Normal file
153
app/Jobs/ServerConnectionCheckJob.php
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\Server;
|
||||||
|
use App\Services\ConfigurationRepository;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\Middleware\WithoutOverlapping;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public $tries = 1;
|
||||||
|
|
||||||
|
public $timeout = 30;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public Server $server,
|
||||||
|
public bool $disableMux = true
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function middleware(): array
|
||||||
|
{
|
||||||
|
return [(new WithoutOverlapping('server-connection-check-'.$this->server->uuid))->expireAfter(45)->dontRelease()];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function disableSshMux(): void
|
||||||
|
{
|
||||||
|
$configRepository = app(ConfigurationRepository::class);
|
||||||
|
$configRepository->disableSshMux();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle()
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// Check if server is disabled
|
||||||
|
if ($this->server->settings->force_disabled) {
|
||||||
|
$this->server->settings->update([
|
||||||
|
'is_reachable' => false,
|
||||||
|
'is_usable' => false,
|
||||||
|
]);
|
||||||
|
Log::debug('ServerConnectionCheck: Server is disabled', [
|
||||||
|
'server_id' => $this->server->id,
|
||||||
|
'server_name' => $this->server->name,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Temporarily disable mux if requested
|
||||||
|
if ($this->disableMux) {
|
||||||
|
$this->disableSshMux();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check basic connectivity first
|
||||||
|
$isReachable = $this->checkConnection();
|
||||||
|
|
||||||
|
if (! $isReachable) {
|
||||||
|
$this->server->settings->update([
|
||||||
|
'is_reachable' => false,
|
||||||
|
'is_usable' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Log::warning('ServerConnectionCheck: Server not reachable', [
|
||||||
|
'server_id' => $this->server->id,
|
||||||
|
'server_name' => $this->server->name,
|
||||||
|
'server_ip' => $this->server->ip,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server is reachable, check if Docker is available
|
||||||
|
// $isUsable = $this->checkDockerAvailability();
|
||||||
|
|
||||||
|
$this->server->settings->update([
|
||||||
|
'is_reachable' => true,
|
||||||
|
'is_usable' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->server->settings->update([
|
||||||
|
'is_reachable' => false,
|
||||||
|
'is_usable' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkConnection(): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// Use instant_remote_process with a simple command
|
||||||
|
// This will automatically handle mux, sudo, IPv6, Cloudflare tunnel, etc.
|
||||||
|
$output = instant_remote_process_with_timeout(
|
||||||
|
['ls -la /'],
|
||||||
|
$this->server,
|
||||||
|
false // don't throw error
|
||||||
|
);
|
||||||
|
|
||||||
|
return $output !== null;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::debug('ServerConnectionCheck: Connection check failed', [
|
||||||
|
'server_id' => $this->server->id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkDockerAvailability(): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// Use instant_remote_process to check Docker
|
||||||
|
// The function will automatically handle sudo for non-root users
|
||||||
|
$output = instant_remote_process_with_timeout(
|
||||||
|
['docker version --format json'],
|
||||||
|
$this->server,
|
||||||
|
false // don't throw error
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($output === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse the JSON output to ensure Docker is really working
|
||||||
|
$output = trim($output);
|
||||||
|
if (! empty($output)) {
|
||||||
|
$dockerInfo = json_decode($output, true);
|
||||||
|
|
||||||
|
return isset($dockerInfo['Server']['Version']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::debug('ServerConnectionCheck: Docker check failed', [
|
||||||
|
'server_id' => $this->server->id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
170
app/Jobs/ServerManagerJob.php
Normal file
170
app/Jobs/ServerManagerJob.php
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\InstanceSettings;
|
||||||
|
use App\Models\Server;
|
||||||
|
use App\Models\Team;
|
||||||
|
use Cron\CronExpression;
|
||||||
|
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\Carbon;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class ServerManagerJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The time when this job execution started.
|
||||||
|
*/
|
||||||
|
private ?Carbon $executionTime = null;
|
||||||
|
|
||||||
|
private InstanceSettings $settings;
|
||||||
|
|
||||||
|
private string $instanceTimezone;
|
||||||
|
|
||||||
|
private string $checkFrequency = '* * * * *';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new job instance.
|
||||||
|
*/
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->onQueue('high');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
// Freeze the execution time at the start of the job
|
||||||
|
$this->executionTime = Carbon::now();
|
||||||
|
if (isCloud()) {
|
||||||
|
$this->checkFrequency = '*/5 * * * *';
|
||||||
|
}
|
||||||
|
$this->settings = instanceSettings();
|
||||||
|
$this->instanceTimezone = $this->settings->instance_timezone ?: config('app.timezone');
|
||||||
|
|
||||||
|
if (validate_timezone($this->instanceTimezone) === false) {
|
||||||
|
$this->instanceTimezone = config('app.timezone');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all servers to process
|
||||||
|
$servers = $this->getServers();
|
||||||
|
|
||||||
|
// Dispatch ServerConnectionCheck for all servers efficiently
|
||||||
|
$this->dispatchConnectionChecks($servers);
|
||||||
|
|
||||||
|
// Process server-specific scheduled tasks
|
||||||
|
$this->processScheduledTasks($servers);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getServers(): Collection
|
||||||
|
{
|
||||||
|
$allServers = Server::where('ip', '!=', '1.2.3.4');
|
||||||
|
|
||||||
|
if (isCloud()) {
|
||||||
|
$servers = $allServers->whereRelation('team.subscription', 'stripe_invoice_paid', true)->get();
|
||||||
|
$own = Team::find(0)->servers;
|
||||||
|
|
||||||
|
return $servers->merge($own);
|
||||||
|
} else {
|
||||||
|
return $allServers->get();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function dispatchConnectionChecks(Collection $servers): void
|
||||||
|
{
|
||||||
|
|
||||||
|
if ($this->shouldRunNow($this->checkFrequency)) {
|
||||||
|
$servers->each(function (Server $server) {
|
||||||
|
try {
|
||||||
|
ServerConnectionCheckJob::dispatch($server);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::channel('scheduled-errors')->error('Failed to dispatch ServerConnectionCheck', [
|
||||||
|
'server_id' => $server->id,
|
||||||
|
'server_name' => $server->name,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function processScheduledTasks(Collection $servers): void
|
||||||
|
{
|
||||||
|
foreach ($servers as $server) {
|
||||||
|
try {
|
||||||
|
$this->processServerTasks($server);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::channel('scheduled-errors')->error('Error processing server tasks', [
|
||||||
|
'server_id' => $server->id,
|
||||||
|
'server_name' => $server->name,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function processServerTasks(Server $server): void
|
||||||
|
{
|
||||||
|
// Check if we should run sentinel-based checks
|
||||||
|
$lastSentinelUpdate = $server->sentinel_updated_at;
|
||||||
|
$waitTime = $server->waitBeforeDoingSshCheck();
|
||||||
|
$sentinelOutOfSync = Carbon::parse($lastSentinelUpdate)->isBefore($this->executionTime->subSeconds($waitTime));
|
||||||
|
|
||||||
|
if ($sentinelOutOfSync) {
|
||||||
|
// Dispatch jobs if Sentinel is out of sync
|
||||||
|
if ($this->shouldRunNow($this->checkFrequency)) {
|
||||||
|
ServerCheckJob::dispatch($server);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch ServerStorageCheckJob if due
|
||||||
|
$serverDiskUsageCheckFrequency = data_get($server->settings, 'server_disk_usage_check_frequency', '0 * * * *');
|
||||||
|
if (isset(VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency])) {
|
||||||
|
$serverDiskUsageCheckFrequency = VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency];
|
||||||
|
}
|
||||||
|
$shouldRunStorageCheck = $this->shouldRunNow($serverDiskUsageCheckFrequency);
|
||||||
|
|
||||||
|
if ($shouldRunStorageCheck) {
|
||||||
|
ServerStorageCheckJob::dispatch($server);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone);
|
||||||
|
if (validate_timezone($serverTimezone) === false) {
|
||||||
|
$serverTimezone = config('app.timezone');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch ServerPatchCheckJob if due (weekly)
|
||||||
|
$shouldRunPatchCheck = $this->shouldRunNow('0 0 * * 0', $serverTimezone);
|
||||||
|
|
||||||
|
if ($shouldRunPatchCheck) { // Weekly on Sunday at midnight
|
||||||
|
ServerPatchCheckJob::dispatch($server);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch Sentinel restart if due (daily for Sentinel-enabled servers)
|
||||||
|
$isSentinelEnabled = $server->isSentinelEnabled();
|
||||||
|
$shouldRestartSentinel = $isSentinelEnabled && $this->shouldRunNow('0 0 * * *', $serverTimezone);
|
||||||
|
|
||||||
|
if ($shouldRestartSentinel) {
|
||||||
|
dispatch(function () use ($server) {
|
||||||
|
$server->restartContainer('coolify-sentinel');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function shouldRunNow(string $frequency, ?string $timezone = null): bool
|
||||||
|
{
|
||||||
|
$cron = new CronExpression($frequency);
|
||||||
|
|
||||||
|
// Use the frozen execution time, not the current time
|
||||||
|
$baseTime = $this->executionTime ?? Carbon::now();
|
||||||
|
$executionTime = $baseTime->copy()->setTimezone($timezone ?? config('app.timezone'));
|
||||||
|
|
||||||
|
return $cron->isDue($executionTime);
|
||||||
|
}
|
||||||
|
}
|
@@ -11,8 +11,9 @@ use Illuminate\Foundation\Bus\Dispatchable;
|
|||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
use Illuminate\Support\Facades\RateLimiter;
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Laravel\Horizon\Contracts\Silenced;
|
||||||
|
|
||||||
class ServerStorageCheckJob implements ShouldBeEncrypted, ShouldQueue
|
class ServerStorageCheckJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
133
app/Jobs/UpdateStripeCustomerEmailJob.php
Normal file
133
app/Jobs/UpdateStripeCustomerEmailJob.php
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\Team;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Stripe\Stripe;
|
||||||
|
|
||||||
|
class UpdateStripeCustomerEmailJob implements ShouldBeEncrypted, ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public $tries = 3;
|
||||||
|
|
||||||
|
public $backoff = [10, 30, 60];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private Team $team,
|
||||||
|
private int $userId,
|
||||||
|
private string $newEmail,
|
||||||
|
private string $oldEmail
|
||||||
|
) {
|
||||||
|
$this->onQueue('high');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if (! isCloud() || ! $this->team->subscription) {
|
||||||
|
Log::info('Skipping Stripe email update - not cloud or no subscription', [
|
||||||
|
'team_id' => $this->team->id,
|
||||||
|
'user_id' => $this->userId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the user changing email is a team owner
|
||||||
|
$isOwner = $this->team->members()
|
||||||
|
->wherePivot('role', 'owner')
|
||||||
|
->where('users.id', $this->userId)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if (! $isOwner) {
|
||||||
|
Log::info('Skipping Stripe email update - user is not team owner', [
|
||||||
|
'team_id' => $this->team->id,
|
||||||
|
'user_id' => $this->userId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current Stripe customer email to verify it matches the user's old email
|
||||||
|
$stripe_customer_id = data_get($this->team, 'subscription.stripe_customer_id');
|
||||||
|
if (! $stripe_customer_id) {
|
||||||
|
Log::info('Skipping Stripe email update - no Stripe customer ID', [
|
||||||
|
'team_id' => $this->team->id,
|
||||||
|
'user_id' => $this->userId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Stripe::setApiKey(config('subscription.stripe_api_key'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stripeCustomer = \Stripe\Customer::retrieve($stripe_customer_id);
|
||||||
|
$currentStripeEmail = $stripeCustomer->email;
|
||||||
|
|
||||||
|
// Only update if the current Stripe email matches the user's old email
|
||||||
|
if (strtolower($currentStripeEmail) !== strtolower($this->oldEmail)) {
|
||||||
|
Log::info('Skipping Stripe email update - Stripe customer email does not match user old email', [
|
||||||
|
'team_id' => $this->team->id,
|
||||||
|
'user_id' => $this->userId,
|
||||||
|
'stripe_email' => $currentStripeEmail,
|
||||||
|
'user_old_email' => $this->oldEmail,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Stripe customer email
|
||||||
|
\Stripe\Customer::update($stripe_customer_id, ['email' => $this->newEmail]);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Failed to retrieve or update Stripe customer', [
|
||||||
|
'team_id' => $this->team->id,
|
||||||
|
'user_id' => $this->userId,
|
||||||
|
'stripe_customer_id' => $stripe_customer_id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::info('Successfully updated Stripe customer email', [
|
||||||
|
'team_id' => $this->team->id,
|
||||||
|
'user_id' => $this->userId,
|
||||||
|
'old_email' => $this->oldEmail,
|
||||||
|
'new_email' => $this->newEmail,
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Failed to update Stripe customer email', [
|
||||||
|
'team_id' => $this->team->id,
|
||||||
|
'user_id' => $this->userId,
|
||||||
|
'old_email' => $this->oldEmail,
|
||||||
|
'new_email' => $this->newEmail,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'attempt' => $this->attempts(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Re-throw to trigger retry
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function failed(\Throwable $exception): void
|
||||||
|
{
|
||||||
|
Log::error('Permanently failed to update Stripe customer email after all retries', [
|
||||||
|
'team_id' => $this->team->id,
|
||||||
|
'user_id' => $this->userId,
|
||||||
|
'old_email' => $this->oldEmail,
|
||||||
|
'new_email' => $this->newEmail,
|
||||||
|
'error' => $exception->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Livewire;
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Models\Application;
|
||||||
use App\Models\ApplicationDeploymentQueue;
|
use App\Models\ApplicationDeploymentQueue;
|
||||||
use App\Models\PrivateKey;
|
use App\Models\PrivateKey;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
@@ -30,6 +31,12 @@ class Dashboard extends Component
|
|||||||
|
|
||||||
public function cleanupQueue()
|
public function cleanupQueue()
|
||||||
{
|
{
|
||||||
|
try {
|
||||||
|
$this->authorize('cleanupDeploymentQueue', Application::class);
|
||||||
|
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||||
|
return handleError($e, $this);
|
||||||
|
}
|
||||||
|
|
||||||
Artisan::queue('cleanup:deployment-queue', [
|
Artisan::queue('cleanup:deployment-queue', [
|
||||||
'--team-id' => currentTeam()->id,
|
'--team-id' => currentTeam()->id,
|
||||||
]);
|
]);
|
||||||
|
@@ -5,6 +5,7 @@ namespace App\Livewire\Destination\New;
|
|||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use App\Models\StandaloneDocker;
|
use App\Models\StandaloneDocker;
|
||||||
use App\Models\SwarmDocker;
|
use App\Models\SwarmDocker;
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Livewire\Attributes\Locked;
|
use Livewire\Attributes\Locked;
|
||||||
use Livewire\Attributes\Validate;
|
use Livewire\Attributes\Validate;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
@@ -12,6 +13,8 @@ use Visus\Cuid2\Cuid2;
|
|||||||
|
|
||||||
class Docker extends Component
|
class Docker extends Component
|
||||||
{
|
{
|
||||||
|
use AuthorizesRequests;
|
||||||
|
|
||||||
#[Locked]
|
#[Locked]
|
||||||
public $servers;
|
public $servers;
|
||||||
|
|
||||||
@@ -67,6 +70,7 @@ class Docker extends Component
|
|||||||
public function submit()
|
public function submit()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
$this->authorize('create', StandaloneDocker::class);
|
||||||
$this->validate();
|
$this->validate();
|
||||||
if ($this->isSwarm) {
|
if ($this->isSwarm) {
|
||||||
$found = $this->selectedServer->swarmDockers()->where('network', $this->network)->first();
|
$found = $this->selectedServer->swarmDockers()->where('network', $this->network)->first();
|
||||||
|
@@ -5,12 +5,15 @@ namespace App\Livewire\Destination;
|
|||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use App\Models\StandaloneDocker;
|
use App\Models\StandaloneDocker;
|
||||||
use App\Models\SwarmDocker;
|
use App\Models\SwarmDocker;
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Livewire\Attributes\Locked;
|
use Livewire\Attributes\Locked;
|
||||||
use Livewire\Attributes\Validate;
|
use Livewire\Attributes\Validate;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class Show extends Component
|
class Show extends Component
|
||||||
{
|
{
|
||||||
|
use AuthorizesRequests;
|
||||||
|
|
||||||
#[Locked]
|
#[Locked]
|
||||||
public $destination;
|
public $destination;
|
||||||
|
|
||||||
@@ -63,6 +66,8 @@ class Show extends Component
|
|||||||
public function submit()
|
public function submit()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
$this->authorize('update', $this->destination);
|
||||||
|
|
||||||
$this->syncData(true);
|
$this->syncData(true);
|
||||||
$this->dispatch('success', 'Destination saved.');
|
$this->dispatch('success', 'Destination saved.');
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
@@ -73,6 +78,8 @@ class Show extends Component
|
|||||||
public function delete()
|
public function delete()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
$this->authorize('delete', $this->destination);
|
||||||
|
|
||||||
if ($this->destination->getMorphClass() === \App\Models\StandaloneDocker::class) {
|
if ($this->destination->getMorphClass() === \App\Models\StandaloneDocker::class) {
|
||||||
if ($this->destination->attachedTo()) {
|
if ($this->destination->attachedTo()) {
|
||||||
return $this->dispatch('error', 'You must delete all resources before deleting this destination.');
|
return $this->dispatch('error', 'You must delete all resources before deleting this destination.');
|
||||||
|
@@ -5,11 +5,14 @@ namespace App\Livewire\Notifications;
|
|||||||
use App\Models\DiscordNotificationSettings;
|
use App\Models\DiscordNotificationSettings;
|
||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
use App\Notifications\Test;
|
use App\Notifications\Test;
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Livewire\Attributes\Validate;
|
use Livewire\Attributes\Validate;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class Discord extends Component
|
class Discord extends Component
|
||||||
{
|
{
|
||||||
|
use AuthorizesRequests;
|
||||||
|
|
||||||
public Team $team;
|
public Team $team;
|
||||||
|
|
||||||
public DiscordNotificationSettings $settings;
|
public DiscordNotificationSettings $settings;
|
||||||
@@ -67,6 +70,7 @@ class Discord extends Component
|
|||||||
try {
|
try {
|
||||||
$this->team = auth()->user()->currentTeam();
|
$this->team = auth()->user()->currentTeam();
|
||||||
$this->settings = $this->team->discordNotificationSettings;
|
$this->settings = $this->team->discordNotificationSettings;
|
||||||
|
$this->authorize('view', $this->settings);
|
||||||
$this->syncData();
|
$this->syncData();
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return handleError($e, $this);
|
return handleError($e, $this);
|
||||||
@@ -77,6 +81,7 @@ class Discord extends Component
|
|||||||
{
|
{
|
||||||
if ($toModel) {
|
if ($toModel) {
|
||||||
$this->validate();
|
$this->validate();
|
||||||
|
$this->authorize('update', $this->settings);
|
||||||
$this->settings->discord_enabled = $this->discordEnabled;
|
$this->settings->discord_enabled = $this->discordEnabled;
|
||||||
$this->settings->discord_webhook_url = $this->discordWebhookUrl;
|
$this->settings->discord_webhook_url = $this->discordWebhookUrl;
|
||||||
|
|
||||||
@@ -182,6 +187,7 @@ class Discord extends Component
|
|||||||
public function sendTestNotification()
|
public function sendTestNotification()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
$this->authorize('sendTest', $this->settings);
|
||||||
$this->team->notify(new Test(channel: 'discord'));
|
$this->team->notify(new Test(channel: 'discord'));
|
||||||
$this->dispatch('success', 'Test notification sent.');
|
$this->dispatch('success', 'Test notification sent.');
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
@@ -5,6 +5,7 @@ namespace App\Livewire\Notifications;
|
|||||||
use App\Models\EmailNotificationSettings;
|
use App\Models\EmailNotificationSettings;
|
||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
use App\Notifications\Test;
|
use App\Notifications\Test;
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Illuminate\Support\Facades\RateLimiter;
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
use Livewire\Attributes\Locked;
|
use Livewire\Attributes\Locked;
|
||||||
use Livewire\Attributes\Validate;
|
use Livewire\Attributes\Validate;
|
||||||
@@ -12,6 +13,8 @@ use Livewire\Component;
|
|||||||
|
|
||||||
class Email extends Component
|
class Email extends Component
|
||||||
{
|
{
|
||||||
|
use AuthorizesRequests;
|
||||||
|
|
||||||
protected $listeners = ['refresh' => '$refresh'];
|
protected $listeners = ['refresh' => '$refresh'];
|
||||||
|
|
||||||
#[Locked]
|
#[Locked]
|
||||||
@@ -110,6 +113,7 @@ class Email extends Component
|
|||||||
$this->team = auth()->user()->currentTeam();
|
$this->team = auth()->user()->currentTeam();
|
||||||
$this->emails = auth()->user()->email;
|
$this->emails = auth()->user()->email;
|
||||||
$this->settings = $this->team->emailNotificationSettings;
|
$this->settings = $this->team->emailNotificationSettings;
|
||||||
|
$this->authorize('view', $this->settings);
|
||||||
$this->syncData();
|
$this->syncData();
|
||||||
$this->testEmailAddress = auth()->user()->email;
|
$this->testEmailAddress = auth()->user()->email;
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
@@ -121,6 +125,7 @@ class Email extends Component
|
|||||||
{
|
{
|
||||||
if ($toModel) {
|
if ($toModel) {
|
||||||
$this->validate();
|
$this->validate();
|
||||||
|
$this->authorize('update', $this->settings);
|
||||||
$this->settings->smtp_enabled = $this->smtpEnabled;
|
$this->settings->smtp_enabled = $this->smtpEnabled;
|
||||||
$this->settings->smtp_from_address = $this->smtpFromAddress;
|
$this->settings->smtp_from_address = $this->smtpFromAddress;
|
||||||
$this->settings->smtp_from_name = $this->smtpFromName;
|
$this->settings->smtp_from_name = $this->smtpFromName;
|
||||||
@@ -311,6 +316,7 @@ class Email extends Component
|
|||||||
public function sendTestEmail()
|
public function sendTestEmail()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
$this->authorize('sendTest', $this->settings);
|
||||||
$this->validate([
|
$this->validate([
|
||||||
'testEmailAddress' => 'required|email',
|
'testEmailAddress' => 'required|email',
|
||||||
], [
|
], [
|
||||||
@@ -338,6 +344,7 @@ class Email extends Component
|
|||||||
|
|
||||||
public function copyFromInstanceSettings()
|
public function copyFromInstanceSettings()
|
||||||
{
|
{
|
||||||
|
$this->authorize('update', $this->settings);
|
||||||
$settings = instanceSettings();
|
$settings = instanceSettings();
|
||||||
$this->smtpFromAddress = $settings->smtp_from_address;
|
$this->smtpFromAddress = $settings->smtp_from_address;
|
||||||
$this->smtpFromName = $settings->smtp_from_name;
|
$this->smtpFromName = $settings->smtp_from_name;
|
||||||
|
@@ -5,12 +5,15 @@ namespace App\Livewire\Notifications;
|
|||||||
use App\Models\PushoverNotificationSettings;
|
use App\Models\PushoverNotificationSettings;
|
||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
use App\Notifications\Test;
|
use App\Notifications\Test;
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Livewire\Attributes\Locked;
|
use Livewire\Attributes\Locked;
|
||||||
use Livewire\Attributes\Validate;
|
use Livewire\Attributes\Validate;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class Pushover extends Component
|
class Pushover extends Component
|
||||||
{
|
{
|
||||||
|
use AuthorizesRequests;
|
||||||
|
|
||||||
protected $listeners = ['refresh' => '$refresh'];
|
protected $listeners = ['refresh' => '$refresh'];
|
||||||
|
|
||||||
#[Locked]
|
#[Locked]
|
||||||
@@ -72,6 +75,7 @@ class Pushover extends Component
|
|||||||
try {
|
try {
|
||||||
$this->team = auth()->user()->currentTeam();
|
$this->team = auth()->user()->currentTeam();
|
||||||
$this->settings = $this->team->pushoverNotificationSettings;
|
$this->settings = $this->team->pushoverNotificationSettings;
|
||||||
|
$this->authorize('view', $this->settings);
|
||||||
$this->syncData();
|
$this->syncData();
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return handleError($e, $this);
|
return handleError($e, $this);
|
||||||
@@ -82,6 +86,7 @@ class Pushover extends Component
|
|||||||
{
|
{
|
||||||
if ($toModel) {
|
if ($toModel) {
|
||||||
$this->validate();
|
$this->validate();
|
||||||
|
$this->authorize('update', $this->settings);
|
||||||
$this->settings->pushover_enabled = $this->pushoverEnabled;
|
$this->settings->pushover_enabled = $this->pushoverEnabled;
|
||||||
$this->settings->pushover_user_key = $this->pushoverUserKey;
|
$this->settings->pushover_user_key = $this->pushoverUserKey;
|
||||||
$this->settings->pushover_api_token = $this->pushoverApiToken;
|
$this->settings->pushover_api_token = $this->pushoverApiToken;
|
||||||
@@ -175,6 +180,7 @@ class Pushover extends Component
|
|||||||
public function sendTestNotification()
|
public function sendTestNotification()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
$this->authorize('sendTest', $this->settings);
|
||||||
$this->team->notify(new Test(channel: 'pushover'));
|
$this->team->notify(new Test(channel: 'pushover'));
|
||||||
$this->dispatch('success', 'Test notification sent.');
|
$this->dispatch('success', 'Test notification sent.');
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
@@ -5,12 +5,15 @@ namespace App\Livewire\Notifications;
|
|||||||
use App\Models\SlackNotificationSettings;
|
use App\Models\SlackNotificationSettings;
|
||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
use App\Notifications\Test;
|
use App\Notifications\Test;
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Livewire\Attributes\Locked;
|
use Livewire\Attributes\Locked;
|
||||||
use Livewire\Attributes\Validate;
|
use Livewire\Attributes\Validate;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class Slack extends Component
|
class Slack extends Component
|
||||||
{
|
{
|
||||||
|
use AuthorizesRequests;
|
||||||
|
|
||||||
protected $listeners = ['refresh' => '$refresh'];
|
protected $listeners = ['refresh' => '$refresh'];
|
||||||
|
|
||||||
#[Locked]
|
#[Locked]
|
||||||
@@ -69,6 +72,7 @@ class Slack extends Component
|
|||||||
try {
|
try {
|
||||||
$this->team = auth()->user()->currentTeam();
|
$this->team = auth()->user()->currentTeam();
|
||||||
$this->settings = $this->team->slackNotificationSettings;
|
$this->settings = $this->team->slackNotificationSettings;
|
||||||
|
$this->authorize('view', $this->settings);
|
||||||
$this->syncData();
|
$this->syncData();
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return handleError($e, $this);
|
return handleError($e, $this);
|
||||||
@@ -79,6 +83,7 @@ class Slack extends Component
|
|||||||
{
|
{
|
||||||
if ($toModel) {
|
if ($toModel) {
|
||||||
$this->validate();
|
$this->validate();
|
||||||
|
$this->authorize('update', $this->settings);
|
||||||
$this->settings->slack_enabled = $this->slackEnabled;
|
$this->settings->slack_enabled = $this->slackEnabled;
|
||||||
$this->settings->slack_webhook_url = $this->slackWebhookUrl;
|
$this->settings->slack_webhook_url = $this->slackWebhookUrl;
|
||||||
|
|
||||||
@@ -168,6 +173,7 @@ class Slack extends Component
|
|||||||
public function sendTestNotification()
|
public function sendTestNotification()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
$this->authorize('sendTest', $this->settings);
|
||||||
$this->team->notify(new Test(channel: 'slack'));
|
$this->team->notify(new Test(channel: 'slack'));
|
||||||
$this->dispatch('success', 'Test notification sent.');
|
$this->dispatch('success', 'Test notification sent.');
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
@@ -5,12 +5,15 @@ namespace App\Livewire\Notifications;
|
|||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
use App\Models\TelegramNotificationSettings;
|
use App\Models\TelegramNotificationSettings;
|
||||||
use App\Notifications\Test;
|
use App\Notifications\Test;
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Livewire\Attributes\Locked;
|
use Livewire\Attributes\Locked;
|
||||||
use Livewire\Attributes\Validate;
|
use Livewire\Attributes\Validate;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class Telegram extends Component
|
class Telegram extends Component
|
||||||
{
|
{
|
||||||
|
use AuthorizesRequests;
|
||||||
|
|
||||||
protected $listeners = ['refresh' => '$refresh'];
|
protected $listeners = ['refresh' => '$refresh'];
|
||||||
|
|
||||||
#[Locked]
|
#[Locked]
|
||||||
@@ -111,6 +114,7 @@ class Telegram extends Component
|
|||||||
try {
|
try {
|
||||||
$this->team = auth()->user()->currentTeam();
|
$this->team = auth()->user()->currentTeam();
|
||||||
$this->settings = $this->team->telegramNotificationSettings;
|
$this->settings = $this->team->telegramNotificationSettings;
|
||||||
|
$this->authorize('view', $this->settings);
|
||||||
$this->syncData();
|
$this->syncData();
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return handleError($e, $this);
|
return handleError($e, $this);
|
||||||
@@ -121,6 +125,7 @@ class Telegram extends Component
|
|||||||
{
|
{
|
||||||
if ($toModel) {
|
if ($toModel) {
|
||||||
$this->validate();
|
$this->validate();
|
||||||
|
$this->authorize('update', $this->settings);
|
||||||
$this->settings->telegram_enabled = $this->telegramEnabled;
|
$this->settings->telegram_enabled = $this->telegramEnabled;
|
||||||
$this->settings->telegram_token = $this->telegramToken;
|
$this->settings->telegram_token = $this->telegramToken;
|
||||||
$this->settings->telegram_chat_id = $this->telegramChatId;
|
$this->settings->telegram_chat_id = $this->telegramChatId;
|
||||||
@@ -241,6 +246,7 @@ class Telegram extends Component
|
|||||||
public function sendTestNotification()
|
public function sendTestNotification()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
$this->authorize('sendTest', $this->settings);
|
||||||
$this->team->notify(new Test(channel: 'telegram'));
|
$this->team->notify(new Test(channel: 'telegram'));
|
||||||
$this->dispatch('success', 'Test notification sent.');
|
$this->dispatch('success', 'Test notification sent.');
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
@@ -4,6 +4,7 @@ namespace App\Livewire\Profile;
|
|||||||
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
use Illuminate\Validation\Rules\Password;
|
use Illuminate\Validation\Rules\Password;
|
||||||
use Livewire\Attributes\Validate;
|
use Livewire\Attributes\Validate;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
@@ -23,11 +24,25 @@ class Index extends Component
|
|||||||
#[Validate('required')]
|
#[Validate('required')]
|
||||||
public string $name;
|
public string $name;
|
||||||
|
|
||||||
|
public string $new_email = '';
|
||||||
|
|
||||||
|
public string $email_verification_code = '';
|
||||||
|
|
||||||
|
public bool $show_email_change = false;
|
||||||
|
|
||||||
|
public bool $show_verification = false;
|
||||||
|
|
||||||
public function mount()
|
public function mount()
|
||||||
{
|
{
|
||||||
$this->userId = Auth::id();
|
$this->userId = Auth::id();
|
||||||
$this->name = Auth::user()->name;
|
$this->name = Auth::user()->name;
|
||||||
$this->email = Auth::user()->email;
|
$this->email = Auth::user()->email;
|
||||||
|
|
||||||
|
// Check if there's a pending email change
|
||||||
|
if (Auth::user()->hasEmailChangeRequest()) {
|
||||||
|
$this->new_email = Auth::user()->pending_email;
|
||||||
|
$this->show_verification = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function submit()
|
public function submit()
|
||||||
@@ -46,6 +61,180 @@ class Index extends Component
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function requestEmailChange()
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// For self-hosted, check if email is enabled
|
||||||
|
if (! isCloud()) {
|
||||||
|
$settings = instanceSettings();
|
||||||
|
if (! $settings->smtp_enabled && ! $settings->resend_enabled) {
|
||||||
|
$this->dispatch('error', 'Email functionality is not configured. Please contact your administrator.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->validate([
|
||||||
|
'new_email' => ['required', 'email', 'unique:users,email'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Skip rate limiting in development mode
|
||||||
|
if (! isDev()) {
|
||||||
|
// Rate limit by current user's email (1 request per 2 minutes)
|
||||||
|
$userEmailKey = 'email-change:user:'.Auth::id();
|
||||||
|
if (! RateLimiter::attempt($userEmailKey, 1, function () {}, 120)) {
|
||||||
|
$seconds = RateLimiter::availableIn($userEmailKey);
|
||||||
|
$this->dispatch('error', 'Too many requests. Please wait '.$seconds.' seconds before trying again.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limit by new email address (3 requests per hour per email)
|
||||||
|
$newEmailKey = 'email-change:email:'.md5(strtolower($this->new_email));
|
||||||
|
if (! RateLimiter::attempt($newEmailKey, 3, function () {}, 3600)) {
|
||||||
|
$this->dispatch('error', 'This email address has received too many verification requests. Please try again later.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional rate limit by IP address (5 requests per hour)
|
||||||
|
$ipKey = 'email-change:ip:'.request()->ip();
|
||||||
|
if (! RateLimiter::attempt($ipKey, 5, function () {}, 3600)) {
|
||||||
|
$this->dispatch('error', 'Too many requests from your IP address. Please try again later.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Auth::user()->requestEmailChange($this->new_email);
|
||||||
|
|
||||||
|
$this->show_email_change = false;
|
||||||
|
$this->show_verification = true;
|
||||||
|
|
||||||
|
$this->dispatch('success', 'Verification code sent to '.$this->new_email);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return handleError($e, $this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verifyEmailChange()
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->validate([
|
||||||
|
'email_verification_code' => ['required', 'string', 'size:6'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Skip rate limiting in development mode
|
||||||
|
if (! isDev()) {
|
||||||
|
// Rate limit verification attempts (5 attempts per 10 minutes)
|
||||||
|
$verifyKey = 'email-verify:user:'.Auth::id();
|
||||||
|
if (! RateLimiter::attempt($verifyKey, 5, function () {}, 600)) {
|
||||||
|
$seconds = RateLimiter::availableIn($verifyKey);
|
||||||
|
$minutes = ceil($seconds / 60);
|
||||||
|
$this->dispatch('error', 'Too many verification attempts. Please wait '.$minutes.' minutes before trying again.');
|
||||||
|
|
||||||
|
// If too many failed attempts, clear the email change request for security
|
||||||
|
if (RateLimiter::attempts($verifyKey) >= 10) {
|
||||||
|
Auth::user()->clearEmailChangeRequest();
|
||||||
|
$this->new_email = '';
|
||||||
|
$this->email_verification_code = '';
|
||||||
|
$this->show_verification = false;
|
||||||
|
$this->dispatch('error', 'Email change request cancelled due to too many failed attempts. Please start over.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! Auth::user()->isEmailChangeCodeValid($this->email_verification_code)) {
|
||||||
|
$this->dispatch('error', 'Invalid or expired verification code.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Auth::user()->confirmEmailChange($this->email_verification_code)) {
|
||||||
|
// Clear rate limiters on successful verification (only in production)
|
||||||
|
if (! isDev()) {
|
||||||
|
$verifyKey = 'email-verify:user:'.Auth::id();
|
||||||
|
RateLimiter::clear($verifyKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->email = Auth::user()->email;
|
||||||
|
$this->new_email = '';
|
||||||
|
$this->email_verification_code = '';
|
||||||
|
$this->show_verification = false;
|
||||||
|
|
||||||
|
$this->dispatch('success', 'Email address updated successfully.');
|
||||||
|
} else {
|
||||||
|
$this->dispatch('error', 'Failed to update email address.');
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return handleError($e, $this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resendVerificationCode()
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// Check if there's a pending request
|
||||||
|
if (! Auth::user()->hasEmailChangeRequest()) {
|
||||||
|
$this->dispatch('error', 'No pending email change request.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if enough time has passed (at least half of the expiry time)
|
||||||
|
$expiryMinutes = config('constants.email_change.verification_code_expiry_minutes', 10);
|
||||||
|
$halfExpiryMinutes = $expiryMinutes / 2;
|
||||||
|
$codeExpiry = Auth::user()->email_change_code_expires_at;
|
||||||
|
$timeSinceCreated = $codeExpiry->subMinutes($expiryMinutes)->diffInMinutes(now());
|
||||||
|
|
||||||
|
if ($timeSinceCreated < $halfExpiryMinutes) {
|
||||||
|
$minutesToWait = ceil($halfExpiryMinutes - $timeSinceCreated);
|
||||||
|
$this->dispatch('error', 'Please wait '.$minutesToWait.' more minutes before requesting a new code.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pendingEmail = Auth::user()->pending_email;
|
||||||
|
|
||||||
|
// Skip rate limiting in development mode
|
||||||
|
if (! isDev()) {
|
||||||
|
// Rate limit by email address
|
||||||
|
$newEmailKey = 'email-change:email:'.md5(strtolower($pendingEmail));
|
||||||
|
if (! RateLimiter::attempt($newEmailKey, 3, function () {}, 3600)) {
|
||||||
|
$this->dispatch('error', 'This email address has received too many verification requests. Please try again later.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate and send new code
|
||||||
|
Auth::user()->requestEmailChange($pendingEmail);
|
||||||
|
|
||||||
|
$this->dispatch('success', 'New verification code sent to '.$pendingEmail);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return handleError($e, $this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancelEmailChange()
|
||||||
|
{
|
||||||
|
Auth::user()->clearEmailChangeRequest();
|
||||||
|
$this->new_email = '';
|
||||||
|
$this->email_verification_code = '';
|
||||||
|
$this->show_email_change = false;
|
||||||
|
$this->show_verification = false;
|
||||||
|
|
||||||
|
$this->dispatch('success', 'Email change request cancelled.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function showEmailChangeForm()
|
||||||
|
{
|
||||||
|
$this->show_email_change = true;
|
||||||
|
$this->new_email = '';
|
||||||
|
}
|
||||||
|
|
||||||
public function resetPassword()
|
public function resetPassword()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
@@ -3,18 +3,29 @@
|
|||||||
namespace App\Livewire\Project;
|
namespace App\Livewire\Project;
|
||||||
|
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use Livewire\Attributes\Validate;
|
use App\Support\ValidationPatterns;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Visus\Cuid2\Cuid2;
|
use Visus\Cuid2\Cuid2;
|
||||||
|
|
||||||
class AddEmpty extends Component
|
class AddEmpty extends Component
|
||||||
{
|
{
|
||||||
#[Validate(['required', 'string', 'min:3'])]
|
|
||||||
public string $name;
|
public string $name;
|
||||||
|
|
||||||
#[Validate(['nullable', 'string'])]
|
|
||||||
public string $description = '';
|
public string $description = '';
|
||||||
|
|
||||||
|
protected function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ValidationPatterns::nameRules(),
|
||||||
|
'description' => ValidationPatterns::descriptionRules(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function messages(): array
|
||||||
|
{
|
||||||
|
return ValidationPatterns::combinedMessages();
|
||||||
|
}
|
||||||
|
|
||||||
public function submit()
|
public function submit()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
@@ -3,11 +3,14 @@
|
|||||||
namespace App\Livewire\Project\Application;
|
namespace App\Livewire\Project\Application;
|
||||||
|
|
||||||
use App\Models\Application;
|
use App\Models\Application;
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Livewire\Attributes\Validate;
|
use Livewire\Attributes\Validate;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class Advanced extends Component
|
class Advanced extends Component
|
||||||
{
|
{
|
||||||
|
use AuthorizesRequests;
|
||||||
|
|
||||||
public Application $application;
|
public Application $application;
|
||||||
|
|
||||||
#[Validate(['boolean'])]
|
#[Validate(['boolean'])]
|
||||||
@@ -19,6 +22,9 @@ class Advanced extends Component
|
|||||||
#[Validate(['boolean'])]
|
#[Validate(['boolean'])]
|
||||||
public bool $isGitLfsEnabled = false;
|
public bool $isGitLfsEnabled = false;
|
||||||
|
|
||||||
|
#[Validate(['boolean'])]
|
||||||
|
public bool $isGitShallowCloneEnabled = false;
|
||||||
|
|
||||||
#[Validate(['boolean'])]
|
#[Validate(['boolean'])]
|
||||||
public bool $isPreviewDeploymentsEnabled = false;
|
public bool $isPreviewDeploymentsEnabled = false;
|
||||||
|
|
||||||
@@ -83,6 +89,7 @@ class Advanced extends Component
|
|||||||
$this->application->settings->is_force_https_enabled = $this->isForceHttpsEnabled;
|
$this->application->settings->is_force_https_enabled = $this->isForceHttpsEnabled;
|
||||||
$this->application->settings->is_git_submodules_enabled = $this->isGitSubmodulesEnabled;
|
$this->application->settings->is_git_submodules_enabled = $this->isGitSubmodulesEnabled;
|
||||||
$this->application->settings->is_git_lfs_enabled = $this->isGitLfsEnabled;
|
$this->application->settings->is_git_lfs_enabled = $this->isGitLfsEnabled;
|
||||||
|
$this->application->settings->is_git_shallow_clone_enabled = $this->isGitShallowCloneEnabled;
|
||||||
$this->application->settings->is_preview_deployments_enabled = $this->isPreviewDeploymentsEnabled;
|
$this->application->settings->is_preview_deployments_enabled = $this->isPreviewDeploymentsEnabled;
|
||||||
$this->application->settings->is_auto_deploy_enabled = $this->isAutoDeployEnabled;
|
$this->application->settings->is_auto_deploy_enabled = $this->isAutoDeployEnabled;
|
||||||
$this->application->settings->is_log_drain_enabled = $this->isLogDrainEnabled;
|
$this->application->settings->is_log_drain_enabled = $this->isLogDrainEnabled;
|
||||||
@@ -108,6 +115,7 @@ class Advanced extends Component
|
|||||||
|
|
||||||
$this->isGitSubmodulesEnabled = $this->application->settings->is_git_submodules_enabled;
|
$this->isGitSubmodulesEnabled = $this->application->settings->is_git_submodules_enabled;
|
||||||
$this->isGitLfsEnabled = $this->application->settings->is_git_lfs_enabled;
|
$this->isGitLfsEnabled = $this->application->settings->is_git_lfs_enabled;
|
||||||
|
$this->isGitShallowCloneEnabled = $this->application->settings->is_git_shallow_clone_enabled ?? false;
|
||||||
$this->isPreviewDeploymentsEnabled = $this->application->settings->is_preview_deployments_enabled;
|
$this->isPreviewDeploymentsEnabled = $this->application->settings->is_preview_deployments_enabled;
|
||||||
$this->isAutoDeployEnabled = $this->application->settings->is_auto_deploy_enabled;
|
$this->isAutoDeployEnabled = $this->application->settings->is_auto_deploy_enabled;
|
||||||
$this->isGpuEnabled = $this->application->settings->is_gpu_enabled;
|
$this->isGpuEnabled = $this->application->settings->is_gpu_enabled;
|
||||||
@@ -137,6 +145,7 @@ class Advanced extends Component
|
|||||||
public function instantSave()
|
public function instantSave()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
$this->authorize('update', $this->application);
|
||||||
$reset = false;
|
$reset = false;
|
||||||
if ($this->isLogDrainEnabled) {
|
if ($this->isLogDrainEnabled) {
|
||||||
if (! $this->application->destination->server->isLogDrainEnabled()) {
|
if (! $this->application->destination->server->isLogDrainEnabled()) {
|
||||||
@@ -175,6 +184,7 @@ class Advanced extends Component
|
|||||||
public function submit()
|
public function submit()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
$this->authorize('update', $this->application);
|
||||||
if ($this->gpuCount && $this->gpuDeviceIds) {
|
if ($this->gpuCount && $this->gpuDeviceIds) {
|
||||||
$this->dispatch('error', 'You cannot set both GPU count and GPU device IDs.');
|
$this->dispatch('error', 'You cannot set both GPU count and GPU device IDs.');
|
||||||
$this->gpuCount = null;
|
$this->gpuCount = null;
|
||||||
@@ -192,33 +202,39 @@ class Advanced extends Component
|
|||||||
|
|
||||||
public function saveCustomName()
|
public function saveCustomName()
|
||||||
{
|
{
|
||||||
if (str($this->customInternalName)->isNotEmpty()) {
|
try {
|
||||||
$this->customInternalName = str($this->customInternalName)->slug()->value();
|
$this->authorize('update', $this->application);
|
||||||
} else {
|
|
||||||
$this->customInternalName = null;
|
if (str($this->customInternalName)->isNotEmpty()) {
|
||||||
}
|
$this->customInternalName = str($this->customInternalName)->slug()->value();
|
||||||
if (is_null($this->customInternalName)) {
|
} else {
|
||||||
|
$this->customInternalName = null;
|
||||||
|
}
|
||||||
|
if (is_null($this->customInternalName)) {
|
||||||
|
$this->syncData(true);
|
||||||
|
$this->dispatch('success', 'Custom name saved.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$customInternalName = $this->customInternalName;
|
||||||
|
$server = $this->application->destination->server;
|
||||||
|
$allApplications = $server->applications();
|
||||||
|
|
||||||
|
$foundSameInternalName = $allApplications->filter(function ($application) {
|
||||||
|
return $application->id !== $this->application->id && $application->settings->custom_internal_name === $this->customInternalName;
|
||||||
|
});
|
||||||
|
if ($foundSameInternalName->isNotEmpty()) {
|
||||||
|
$this->dispatch('error', 'This custom container name is already in use by another application on this server.');
|
||||||
|
$this->customInternalName = $customInternalName;
|
||||||
|
$this->syncData(true);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
$this->syncData(true);
|
$this->syncData(true);
|
||||||
$this->dispatch('success', 'Custom name saved.');
|
$this->dispatch('success', 'Custom name saved.');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
return;
|
return handleError($e, $this);
|
||||||
}
|
}
|
||||||
$customInternalName = $this->customInternalName;
|
|
||||||
$server = $this->application->destination->server;
|
|
||||||
$allApplications = $server->applications();
|
|
||||||
|
|
||||||
$foundSameInternalName = $allApplications->filter(function ($application) {
|
|
||||||
return $application->id !== $this->application->id && $application->settings->custom_internal_name === $this->customInternalName;
|
|
||||||
});
|
|
||||||
if ($foundSameInternalName->isNotEmpty()) {
|
|
||||||
$this->dispatch('error', 'This custom container name is already in use by another application on this server.');
|
|
||||||
$this->customInternalName = $customInternalName;
|
|
||||||
$this->syncData(true);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$this->syncData(true);
|
|
||||||
$this->dispatch('success', 'Custom name saved.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
|
@@ -18,11 +18,13 @@ class Index extends Component
|
|||||||
|
|
||||||
public int $skip = 0;
|
public int $skip = 0;
|
||||||
|
|
||||||
public int $default_take = 10;
|
public int $defaultTake = 10;
|
||||||
|
|
||||||
public bool $show_next = false;
|
public bool $showNext = false;
|
||||||
|
|
||||||
public bool $show_prev = false;
|
public bool $showPrev = false;
|
||||||
|
|
||||||
|
public int $currentPage = 1;
|
||||||
|
|
||||||
public ?string $pull_request_id = null;
|
public ?string $pull_request_id = null;
|
||||||
|
|
||||||
@@ -51,68 +53,111 @@ class Index extends Component
|
|||||||
if (! $application) {
|
if (! $application) {
|
||||||
return redirect()->route('dashboard');
|
return redirect()->route('dashboard');
|
||||||
}
|
}
|
||||||
['deployments' => $deployments, 'count' => $count] = $application->deployments(0, $this->default_take);
|
// Validate pull request ID from URL parameters
|
||||||
|
if ($this->pull_request_id !== null && $this->pull_request_id !== '') {
|
||||||
|
if (! is_numeric($this->pull_request_id) || (float) $this->pull_request_id <= 0 || (float) $this->pull_request_id != (int) $this->pull_request_id) {
|
||||||
|
$this->pull_request_id = null;
|
||||||
|
$this->dispatch('error', 'Invalid Pull Request ID in URL. Filter cleared.');
|
||||||
|
} else {
|
||||||
|
// Ensure it's stored as a string representation of a positive integer
|
||||||
|
$this->pull_request_id = (string) (int) $this->pull_request_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
['deployments' => $deployments, 'count' => $count] = $application->deployments(0, $this->defaultTake, $this->pull_request_id);
|
||||||
$this->application = $application;
|
$this->application = $application;
|
||||||
$this->deployments = $deployments;
|
$this->deployments = $deployments;
|
||||||
$this->deployments_count = $count;
|
$this->deployments_count = $count;
|
||||||
$this->current_url = url()->current();
|
$this->current_url = url()->current();
|
||||||
$this->show_pull_request_only();
|
$this->updateCurrentPage();
|
||||||
$this->show_more();
|
$this->showMore();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function show_pull_request_only()
|
private function showMore()
|
||||||
{
|
|
||||||
if ($this->pull_request_id) {
|
|
||||||
$this->deployments = $this->deployments->where('pull_request_id', $this->pull_request_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function show_more()
|
|
||||||
{
|
{
|
||||||
if ($this->deployments->count() !== 0) {
|
if ($this->deployments->count() !== 0) {
|
||||||
$this->show_next = true;
|
$this->showNext = true;
|
||||||
if ($this->deployments->count() < $this->default_take) {
|
if ($this->deployments->count() < $this->defaultTake) {
|
||||||
$this->show_next = false;
|
$this->showNext = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function reload_deployments()
|
public function reloadDeployments()
|
||||||
{
|
{
|
||||||
$this->load_deployments();
|
$this->loadDeployments();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function previous_page(?int $take = null)
|
public function previousPage(?int $take = null)
|
||||||
{
|
{
|
||||||
if ($take) {
|
if ($take) {
|
||||||
$this->skip = $this->skip - $take;
|
$this->skip = $this->skip - $take;
|
||||||
}
|
}
|
||||||
$this->skip = $this->skip - $this->default_take;
|
$this->skip = $this->skip - $this->defaultTake;
|
||||||
if ($this->skip < 0) {
|
if ($this->skip < 0) {
|
||||||
$this->show_prev = false;
|
$this->showPrev = false;
|
||||||
$this->skip = 0;
|
$this->skip = 0;
|
||||||
}
|
}
|
||||||
$this->load_deployments();
|
$this->updateCurrentPage();
|
||||||
|
$this->loadDeployments();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function next_page(?int $take = null)
|
public function nextPage(?int $take = null)
|
||||||
{
|
{
|
||||||
if ($take) {
|
if ($take) {
|
||||||
$this->skip = $this->skip + $take;
|
$this->skip = $this->skip + $take;
|
||||||
}
|
}
|
||||||
$this->show_prev = true;
|
$this->showPrev = true;
|
||||||
$this->load_deployments();
|
$this->updateCurrentPage();
|
||||||
|
$this->loadDeployments();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function load_deployments()
|
public function loadDeployments()
|
||||||
{
|
{
|
||||||
['deployments' => $deployments, 'count' => $count] = $this->application->deployments($this->skip, $this->default_take);
|
['deployments' => $deployments, 'count' => $count] = $this->application->deployments($this->skip, $this->defaultTake, $this->pull_request_id);
|
||||||
$this->deployments = $deployments;
|
$this->deployments = $deployments;
|
||||||
$this->deployments_count = $count;
|
$this->deployments_count = $count;
|
||||||
$this->show_pull_request_only();
|
$this->showMore();
|
||||||
$this->show_more();
|
}
|
||||||
|
|
||||||
|
public function updatedPullRequestId($value)
|
||||||
|
{
|
||||||
|
// Sanitize and validate the pull request ID
|
||||||
|
if ($value !== null && $value !== '') {
|
||||||
|
// Check if it's numeric and positive
|
||||||
|
if (! is_numeric($value) || (float) $value <= 0 || (float) $value != (int) $value) {
|
||||||
|
$this->pull_request_id = null;
|
||||||
|
$this->dispatch('error', 'Invalid Pull Request ID. Please enter a valid positive number.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Ensure it's stored as a string representation of a positive integer
|
||||||
|
$this->pull_request_id = (string) (int) $value;
|
||||||
|
} else {
|
||||||
|
$this->pull_request_id = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset pagination when filter changes
|
||||||
|
$this->skip = 0;
|
||||||
|
$this->showPrev = false;
|
||||||
|
$this->updateCurrentPage();
|
||||||
|
$this->loadDeployments();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearFilter()
|
||||||
|
{
|
||||||
|
$this->pull_request_id = null;
|
||||||
|
$this->skip = 0;
|
||||||
|
$this->showPrev = false;
|
||||||
|
$this->updateCurrentPage();
|
||||||
|
$this->loadDeployments();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function updateCurrentPage()
|
||||||
|
{
|
||||||
|
$this->currentPage = intval($this->skip / $this->defaultTake) + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
|
@@ -4,6 +4,8 @@ namespace App\Livewire\Project\Application;
|
|||||||
|
|
||||||
use App\Actions\Application\GenerateConfig;
|
use App\Actions\Application\GenerateConfig;
|
||||||
use App\Models\Application;
|
use App\Models\Application;
|
||||||
|
use App\Support\ValidationPatterns;
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Spatie\Url\Url;
|
use Spatie\Url\Url;
|
||||||
@@ -11,6 +13,8 @@ use Visus\Cuid2\Cuid2;
|
|||||||
|
|
||||||
class General extends Component
|
class General extends Component
|
||||||
{
|
{
|
||||||
|
use AuthorizesRequests;
|
||||||
|
|
||||||
public string $applicationId;
|
public string $applicationId;
|
||||||
|
|
||||||
public Application $application;
|
public Application $application;
|
||||||
@@ -52,52 +56,89 @@ class General extends Component
|
|||||||
'configurationChanged' => '$refresh',
|
'configurationChanged' => '$refresh',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $rules = [
|
protected function rules(): array
|
||||||
'application.name' => 'required',
|
{
|
||||||
'application.description' => 'nullable',
|
return [
|
||||||
'application.fqdn' => 'nullable',
|
'application.name' => ValidationPatterns::nameRules(),
|
||||||
'application.git_repository' => 'required',
|
'application.description' => ValidationPatterns::descriptionRules(),
|
||||||
'application.git_branch' => 'required',
|
'application.fqdn' => 'nullable',
|
||||||
'application.git_commit_sha' => 'nullable',
|
'application.git_repository' => 'required',
|
||||||
'application.install_command' => 'nullable',
|
'application.git_branch' => 'required',
|
||||||
'application.build_command' => 'nullable',
|
'application.git_commit_sha' => 'nullable',
|
||||||
'application.start_command' => 'nullable',
|
'application.install_command' => 'nullable',
|
||||||
'application.build_pack' => 'required',
|
'application.build_command' => 'nullable',
|
||||||
'application.static_image' => 'required',
|
'application.start_command' => 'nullable',
|
||||||
'application.base_directory' => 'required',
|
'application.build_pack' => 'required',
|
||||||
'application.publish_directory' => 'nullable',
|
'application.static_image' => 'required',
|
||||||
'application.ports_exposes' => 'required',
|
'application.base_directory' => 'required',
|
||||||
'application.ports_mappings' => 'nullable',
|
'application.publish_directory' => 'nullable',
|
||||||
'application.custom_network_aliases' => 'nullable',
|
'application.ports_exposes' => 'required',
|
||||||
'application.dockerfile' => 'nullable',
|
'application.ports_mappings' => 'nullable',
|
||||||
'application.docker_registry_image_name' => 'nullable',
|
'application.custom_network_aliases' => 'nullable',
|
||||||
'application.docker_registry_image_tag' => 'nullable',
|
'application.dockerfile' => 'nullable',
|
||||||
'application.dockerfile_location' => 'nullable',
|
'application.docker_registry_image_name' => 'nullable',
|
||||||
'application.docker_compose_location' => 'nullable',
|
'application.docker_registry_image_tag' => 'nullable',
|
||||||
'application.docker_compose' => 'nullable',
|
'application.dockerfile_location' => 'nullable',
|
||||||
'application.docker_compose_raw' => 'nullable',
|
'application.docker_compose_location' => 'nullable',
|
||||||
'application.dockerfile_target_build' => 'nullable',
|
'application.docker_compose' => 'nullable',
|
||||||
'application.docker_compose_custom_start_command' => 'nullable',
|
'application.docker_compose_raw' => 'nullable',
|
||||||
'application.docker_compose_custom_build_command' => 'nullable',
|
'application.dockerfile_target_build' => 'nullable',
|
||||||
'application.custom_labels' => 'nullable',
|
'application.docker_compose_custom_start_command' => 'nullable',
|
||||||
'application.custom_docker_run_options' => 'nullable',
|
'application.docker_compose_custom_build_command' => 'nullable',
|
||||||
'application.pre_deployment_command' => 'nullable',
|
'application.custom_labels' => 'nullable',
|
||||||
'application.pre_deployment_command_container' => 'nullable',
|
'application.custom_docker_run_options' => 'nullable',
|
||||||
'application.post_deployment_command' => 'nullable',
|
'application.pre_deployment_command' => 'nullable',
|
||||||
'application.post_deployment_command_container' => 'nullable',
|
'application.pre_deployment_command_container' => 'nullable',
|
||||||
'application.custom_nginx_configuration' => 'nullable',
|
'application.post_deployment_command' => 'nullable',
|
||||||
'application.settings.is_static' => 'boolean|required',
|
'application.post_deployment_command_container' => 'nullable',
|
||||||
'application.settings.is_spa' => 'boolean|required',
|
'application.custom_nginx_configuration' => 'nullable',
|
||||||
'application.settings.is_build_server_enabled' => 'boolean|required',
|
'application.settings.is_static' => 'boolean|required',
|
||||||
'application.settings.is_container_label_escape_enabled' => 'boolean|required',
|
'application.settings.is_spa' => 'boolean|required',
|
||||||
'application.settings.is_container_label_readonly_enabled' => 'boolean|required',
|
'application.settings.is_build_server_enabled' => 'boolean|required',
|
||||||
'application.settings.is_preserve_repository_enabled' => 'boolean|required',
|
'application.settings.is_container_label_escape_enabled' => 'boolean|required',
|
||||||
'application.is_http_basic_auth_enabled' => 'boolean|required',
|
'application.settings.is_container_label_readonly_enabled' => 'boolean|required',
|
||||||
'application.http_basic_auth_username' => 'string|nullable',
|
'application.settings.is_preserve_repository_enabled' => 'boolean|required',
|
||||||
'application.http_basic_auth_password' => 'string|nullable',
|
'application.is_http_basic_auth_enabled' => 'boolean|required',
|
||||||
'application.watch_paths' => 'nullable',
|
'application.http_basic_auth_username' => 'string|nullable',
|
||||||
'application.redirect' => 'string|required',
|
'application.http_basic_auth_password' => 'string|nullable',
|
||||||
];
|
'application.watch_paths' => 'nullable',
|
||||||
|
'application.redirect' => 'string|required',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function messages(): array
|
||||||
|
{
|
||||||
|
return array_merge(
|
||||||
|
ValidationPatterns::combinedMessages(),
|
||||||
|
[
|
||||||
|
'application.name.required' => 'The Name field is required.',
|
||||||
|
'application.name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
|
||||||
|
'application.description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
|
||||||
|
'application.git_repository.required' => 'The Git Repository field is required.',
|
||||||
|
'application.git_branch.required' => 'The Git Branch field is required.',
|
||||||
|
'application.build_pack.required' => 'The Build Pack field is required.',
|
||||||
|
'application.static_image.required' => 'The Static Image field is required.',
|
||||||
|
'application.base_directory.required' => 'The Base Directory field is required.',
|
||||||
|
'application.ports_exposes.required' => 'The Exposed Ports field is required.',
|
||||||
|
'application.settings.is_static.required' => 'The Static setting is required.',
|
||||||
|
'application.settings.is_static.boolean' => 'The Static setting must be true or false.',
|
||||||
|
'application.settings.is_spa.required' => 'The SPA setting is required.',
|
||||||
|
'application.settings.is_spa.boolean' => 'The SPA setting must be true or false.',
|
||||||
|
'application.settings.is_build_server_enabled.required' => 'The Build Server setting is required.',
|
||||||
|
'application.settings.is_build_server_enabled.boolean' => 'The Build Server setting must be true or false.',
|
||||||
|
'application.settings.is_container_label_escape_enabled.required' => 'The Container Label Escape setting is required.',
|
||||||
|
'application.settings.is_container_label_escape_enabled.boolean' => 'The Container Label Escape setting must be true or false.',
|
||||||
|
'application.settings.is_container_label_readonly_enabled.required' => 'The Container Label Readonly setting is required.',
|
||||||
|
'application.settings.is_container_label_readonly_enabled.boolean' => 'The Container Label Readonly setting must be true or false.',
|
||||||
|
'application.settings.is_preserve_repository_enabled.required' => 'The Preserve Repository setting is required.',
|
||||||
|
'application.settings.is_preserve_repository_enabled.boolean' => 'The Preserve Repository setting must be true or false.',
|
||||||
|
'application.is_http_basic_auth_enabled.required' => 'The HTTP Basic Auth setting is required.',
|
||||||
|
'application.is_http_basic_auth_enabled.boolean' => 'The HTTP Basic Auth setting must be true or false.',
|
||||||
|
'application.redirect.required' => 'The Redirect setting is required.',
|
||||||
|
'application.redirect.string' => 'The Redirect setting must be a string.',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
protected $validationAttributes = [
|
protected $validationAttributes = [
|
||||||
'application.name' => 'name',
|
'application.name' => 'name',
|
||||||
@@ -152,23 +193,50 @@ class General extends Component
|
|||||||
$this->dispatch('error', $e->getMessage());
|
$this->dispatch('error', $e->getMessage());
|
||||||
}
|
}
|
||||||
if ($this->application->build_pack === 'dockercompose') {
|
if ($this->application->build_pack === 'dockercompose') {
|
||||||
$this->application->fqdn = null;
|
// Only update if user has permission
|
||||||
$this->application->settings->save();
|
try {
|
||||||
|
$this->authorize('update', $this->application);
|
||||||
|
$this->application->fqdn = null;
|
||||||
|
$this->application->settings->save();
|
||||||
|
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||||
|
// User doesn't have update permission, just continue without saving
|
||||||
|
}
|
||||||
}
|
}
|
||||||
$this->parsedServiceDomains = $this->application->docker_compose_domains ? json_decode($this->application->docker_compose_domains, true) : [];
|
$this->parsedServiceDomains = $this->application->docker_compose_domains ? json_decode($this->application->docker_compose_domains, true) : [];
|
||||||
|
// Convert service names with dots to use underscores for HTML form binding
|
||||||
|
$sanitizedDomains = [];
|
||||||
|
foreach ($this->parsedServiceDomains as $serviceName => $domain) {
|
||||||
|
$sanitizedKey = str($serviceName)->slug('_')->toString();
|
||||||
|
$sanitizedDomains[$sanitizedKey] = $domain;
|
||||||
|
}
|
||||||
|
$this->parsedServiceDomains = $sanitizedDomains;
|
||||||
|
|
||||||
$this->ports_exposes = $this->application->ports_exposes;
|
$this->ports_exposes = $this->application->ports_exposes;
|
||||||
$this->is_preserve_repository_enabled = $this->application->settings->is_preserve_repository_enabled;
|
$this->is_preserve_repository_enabled = $this->application->settings->is_preserve_repository_enabled;
|
||||||
$this->is_container_label_escape_enabled = $this->application->settings->is_container_label_escape_enabled;
|
$this->is_container_label_escape_enabled = $this->application->settings->is_container_label_escape_enabled;
|
||||||
$this->customLabels = $this->application->parseContainerLabels();
|
$this->customLabels = $this->application->parseContainerLabels();
|
||||||
if (! $this->customLabels && $this->application->destination->server->proxyType() !== 'NONE' && $this->application->settings->is_container_label_readonly_enabled === true) {
|
if (! $this->customLabels && $this->application->destination->server->proxyType() !== 'NONE' && $this->application->settings->is_container_label_readonly_enabled === true) {
|
||||||
$this->customLabels = str(implode('|coolify|', generateLabelsApplication($this->application)))->replace('|coolify|', "\n");
|
// Only update custom labels if user has permission
|
||||||
$this->application->custom_labels = base64_encode($this->customLabels);
|
try {
|
||||||
$this->application->save();
|
$this->authorize('update', $this->application);
|
||||||
|
$this->customLabels = str(implode('|coolify|', generateLabelsApplication($this->application)))->replace('|coolify|', "\n");
|
||||||
|
$this->application->custom_labels = base64_encode($this->customLabels);
|
||||||
|
$this->application->save();
|
||||||
|
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||||
|
// User doesn't have update permission, just use existing labels
|
||||||
|
// $this->customLabels = str(implode('|coolify|', generateLabelsApplication($this->application)))->replace('|coolify|', "\n");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
$this->initialDockerComposeLocation = $this->application->docker_compose_location;
|
$this->initialDockerComposeLocation = $this->application->docker_compose_location;
|
||||||
if ($this->application->build_pack === 'dockercompose' && ! $this->application->docker_compose_raw) {
|
if ($this->application->build_pack === 'dockercompose' && ! $this->application->docker_compose_raw) {
|
||||||
$this->initLoadingCompose = true;
|
// Only load compose file if user has update permission
|
||||||
$this->dispatch('info', 'Loading docker compose file.');
|
try {
|
||||||
|
$this->authorize('update', $this->application);
|
||||||
|
$this->initLoadingCompose = true;
|
||||||
|
$this->dispatch('info', 'Loading docker compose file.');
|
||||||
|
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||||
|
// User doesn't have update permission, skip loading compose file
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (str($this->application->status)->startsWith('running') && is_null($this->application->config_hash)) {
|
if (str($this->application->status)->startsWith('running') && is_null($this->application->config_hash)) {
|
||||||
@@ -178,53 +246,67 @@ class General extends Component
|
|||||||
|
|
||||||
public function instantSave()
|
public function instantSave()
|
||||||
{
|
{
|
||||||
if ($this->application->settings->isDirty('is_spa')) {
|
try {
|
||||||
$this->generateNginxConfiguration($this->application->settings->is_spa ? 'spa' : 'static');
|
$this->authorize('update', $this->application);
|
||||||
}
|
|
||||||
if ($this->application->isDirty('is_http_basic_auth_enabled')) {
|
|
||||||
$this->application->save();
|
|
||||||
}
|
|
||||||
$this->application->settings->save();
|
|
||||||
$this->dispatch('success', 'Settings saved.');
|
|
||||||
$this->application->refresh();
|
|
||||||
|
|
||||||
// If port_exposes changed, reset default labels
|
if ($this->application->settings->isDirty('is_spa')) {
|
||||||
if ($this->ports_exposes !== $this->application->ports_exposes || $this->is_container_label_escape_enabled !== $this->application->settings->is_container_label_escape_enabled) {
|
$this->generateNginxConfiguration($this->application->settings->is_spa ? 'spa' : 'static');
|
||||||
$this->resetDefaultLabels(false);
|
|
||||||
}
|
|
||||||
if ($this->is_preserve_repository_enabled !== $this->application->settings->is_preserve_repository_enabled) {
|
|
||||||
if ($this->application->settings->is_preserve_repository_enabled === false) {
|
|
||||||
$this->application->fileStorages->each(function ($storage) {
|
|
||||||
$storage->is_based_on_git = $this->application->settings->is_preserve_repository_enabled;
|
|
||||||
$storage->save();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
if ($this->application->isDirty('is_http_basic_auth_enabled')) {
|
||||||
if ($this->application->settings->is_container_label_readonly_enabled) {
|
$this->application->save();
|
||||||
$this->resetDefaultLabels(false);
|
}
|
||||||
}
|
$this->application->settings->save();
|
||||||
|
$this->dispatch('success', 'Settings saved.');
|
||||||
|
$this->application->refresh();
|
||||||
|
|
||||||
|
// If port_exposes changed, reset default labels
|
||||||
|
if ($this->ports_exposes !== $this->application->ports_exposes || $this->is_container_label_escape_enabled !== $this->application->settings->is_container_label_escape_enabled) {
|
||||||
|
$this->resetDefaultLabels(false);
|
||||||
|
}
|
||||||
|
if ($this->is_preserve_repository_enabled !== $this->application->settings->is_preserve_repository_enabled) {
|
||||||
|
if ($this->application->settings->is_preserve_repository_enabled === false) {
|
||||||
|
$this->application->fileStorages->each(function ($storage) {
|
||||||
|
$storage->is_based_on_git = $this->application->settings->is_preserve_repository_enabled;
|
||||||
|
$storage->save();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($this->application->settings->is_container_label_readonly_enabled) {
|
||||||
|
$this->resetDefaultLabels(false);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return handleError($e, $this);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function loadComposeFile($isInit = false)
|
public function loadComposeFile($isInit = false, $showToast = true)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
$this->authorize('update', $this->application);
|
||||||
|
|
||||||
if ($isInit && $this->application->docker_compose_raw) {
|
if ($isInit && $this->application->docker_compose_raw) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Must reload the application to get the latest database changes
|
|
||||||
// Why? Not sure, but it works.
|
|
||||||
// $this->application->refresh();
|
|
||||||
|
|
||||||
['parsedServices' => $this->parsedServices, 'initialDockerComposeLocation' => $this->initialDockerComposeLocation] = $this->application->loadComposeFile($isInit);
|
['parsedServices' => $this->parsedServices, 'initialDockerComposeLocation' => $this->initialDockerComposeLocation] = $this->application->loadComposeFile($isInit);
|
||||||
if (is_null($this->parsedServices)) {
|
if (is_null($this->parsedServices)) {
|
||||||
$this->dispatch('error', 'Failed to parse your docker-compose file. Please check the syntax and try again.');
|
$showToast && $this->dispatch('error', 'Failed to parse your docker-compose file. Please check the syntax and try again.');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$this->application->parse();
|
|
||||||
$this->dispatch('success', 'Docker compose file loaded.');
|
// Refresh parsedServiceDomains to reflect any changes in docker_compose_domains
|
||||||
|
$this->application->refresh();
|
||||||
|
$this->parsedServiceDomains = $this->application->docker_compose_domains ? json_decode($this->application->docker_compose_domains, true) : [];
|
||||||
|
// Convert service names with dots to use underscores for HTML form binding
|
||||||
|
$sanitizedDomains = [];
|
||||||
|
foreach ($this->parsedServiceDomains as $serviceName => $domain) {
|
||||||
|
$sanitizedKey = str($serviceName)->slug('_')->toString();
|
||||||
|
$sanitizedDomains[$sanitizedKey] = $domain;
|
||||||
|
}
|
||||||
|
$this->parsedServiceDomains = $sanitizedDomains;
|
||||||
|
|
||||||
|
$showToast && $this->dispatch('success', 'Docker compose file loaded.');
|
||||||
$this->dispatch('compose_loaded');
|
$this->dispatch('compose_loaded');
|
||||||
$this->dispatch('refreshStorages');
|
$this->dispatch('refreshStorages');
|
||||||
$this->dispatch('refreshEnvs');
|
$this->dispatch('refreshEnvs');
|
||||||
@@ -240,17 +322,41 @@ class General extends Component
|
|||||||
|
|
||||||
public function generateDomain(string $serviceName)
|
public function generateDomain(string $serviceName)
|
||||||
{
|
{
|
||||||
$uuid = new Cuid2;
|
try {
|
||||||
$domain = generateFqdn($this->application->destination->server, $uuid);
|
$this->authorize('update', $this->application);
|
||||||
$this->parsedServiceDomains[$serviceName]['domain'] = $domain;
|
|
||||||
$this->application->docker_compose_domains = json_encode($this->parsedServiceDomains);
|
|
||||||
$this->application->save();
|
|
||||||
$this->dispatch('success', 'Domain generated.');
|
|
||||||
if ($this->application->build_pack === 'dockercompose') {
|
|
||||||
$this->loadComposeFile();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $domain;
|
$uuid = new Cuid2;
|
||||||
|
$domain = generateUrl(server: $this->application->destination->server, random: $uuid);
|
||||||
|
$sanitizedKey = str($serviceName)->slug('_')->toString();
|
||||||
|
$this->parsedServiceDomains[$sanitizedKey]['domain'] = $domain;
|
||||||
|
|
||||||
|
// Convert back to original service names for storage
|
||||||
|
$originalDomains = [];
|
||||||
|
foreach ($this->parsedServiceDomains as $key => $value) {
|
||||||
|
// Find the original service name by checking parsed services
|
||||||
|
$originalServiceName = $key;
|
||||||
|
if (isset($this->parsedServices['services'])) {
|
||||||
|
foreach ($this->parsedServices['services'] as $originalName => $service) {
|
||||||
|
if (str($originalName)->slug('_')->toString() === $key) {
|
||||||
|
$originalServiceName = $originalName;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$originalDomains[$originalServiceName] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->application->docker_compose_domains = json_encode($originalDomains);
|
||||||
|
$this->application->save();
|
||||||
|
$this->dispatch('success', 'Domain generated.');
|
||||||
|
if ($this->application->build_pack === 'dockercompose') {
|
||||||
|
$this->loadComposeFile(showToast: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $domain;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return handleError($e, $this);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updatedApplicationBaseDirectory()
|
public function updatedApplicationBaseDirectory()
|
||||||
@@ -269,6 +375,16 @@ class General extends Component
|
|||||||
|
|
||||||
public function updatedApplicationBuildPack()
|
public function updatedApplicationBuildPack()
|
||||||
{
|
{
|
||||||
|
// Check if user has permission to update
|
||||||
|
try {
|
||||||
|
$this->authorize('update', $this->application);
|
||||||
|
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||||
|
// User doesn't have permission, revert the change and return
|
||||||
|
$this->application->refresh();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->application->build_pack !== 'nixpacks') {
|
if ($this->application->build_pack !== 'nixpacks') {
|
||||||
$this->application->settings->is_static = false;
|
$this->application->settings->is_static = false;
|
||||||
$this->application->settings->save();
|
$this->application->settings->save();
|
||||||
@@ -277,8 +393,26 @@ class General extends Component
|
|||||||
$this->resetDefaultLabels(false);
|
$this->resetDefaultLabels(false);
|
||||||
}
|
}
|
||||||
if ($this->application->build_pack === 'dockercompose') {
|
if ($this->application->build_pack === 'dockercompose') {
|
||||||
$this->application->fqdn = null;
|
// Only update if user has permission
|
||||||
$this->application->settings->save();
|
try {
|
||||||
|
$this->authorize('update', $this->application);
|
||||||
|
$this->application->fqdn = null;
|
||||||
|
$this->application->settings->save();
|
||||||
|
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||||
|
// User doesn't have update permission, just continue without saving
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Clear Docker Compose specific data when switching away from dockercompose
|
||||||
|
if ($this->application->getOriginal('build_pack') === 'dockercompose') {
|
||||||
|
$this->application->docker_compose_domains = null;
|
||||||
|
$this->application->docker_compose_raw = null;
|
||||||
|
|
||||||
|
// Remove SERVICE_FQDN_* and SERVICE_URL_* environment variables
|
||||||
|
$this->application->environment_variables()->where('key', 'LIKE', 'SERVICE_FQDN_%')->delete();
|
||||||
|
$this->application->environment_variables()->where('key', 'LIKE', 'SERVICE_URL_%')->delete();
|
||||||
|
$this->application->environment_variables_preview()->where('key', 'LIKE', 'SERVICE_FQDN_%')->delete();
|
||||||
|
$this->application->environment_variables_preview()->where('key', 'LIKE', 'SERVICE_URL_%')->delete();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if ($this->application->build_pack === 'static') {
|
if ($this->application->build_pack === 'static') {
|
||||||
$this->application->ports_exposes = $this->ports_exposes = 80;
|
$this->application->ports_exposes = $this->ports_exposes = 80;
|
||||||
@@ -291,21 +425,33 @@ class General extends Component
|
|||||||
|
|
||||||
public function getWildcardDomain()
|
public function getWildcardDomain()
|
||||||
{
|
{
|
||||||
$server = data_get($this->application, 'destination.server');
|
try {
|
||||||
if ($server) {
|
$this->authorize('update', $this->application);
|
||||||
$fqdn = generateFqdn($server, $this->application->uuid);
|
|
||||||
$this->application->fqdn = $fqdn;
|
$server = data_get($this->application, 'destination.server');
|
||||||
$this->application->save();
|
if ($server) {
|
||||||
$this->resetDefaultLabels();
|
$fqdn = generateFqdn(server: $server, random: $this->application->uuid, parserVersion: $this->application->compose_parsing_version);
|
||||||
$this->dispatch('success', 'Wildcard domain generated.');
|
$this->application->fqdn = $fqdn;
|
||||||
|
$this->application->save();
|
||||||
|
$this->resetDefaultLabels();
|
||||||
|
$this->dispatch('success', 'Wildcard domain generated.');
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return handleError($e, $this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function generateNginxConfiguration($type = 'static')
|
public function generateNginxConfiguration($type = 'static')
|
||||||
{
|
{
|
||||||
$this->application->custom_nginx_configuration = defaultNginxConfiguration($type);
|
try {
|
||||||
$this->application->save();
|
$this->authorize('update', $this->application);
|
||||||
$this->dispatch('success', 'Nginx configuration generated.');
|
|
||||||
|
$this->application->custom_nginx_configuration = defaultNginxConfiguration($type);
|
||||||
|
$this->application->save();
|
||||||
|
$this->dispatch('success', 'Nginx configuration generated.');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return handleError($e, $this);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function resetDefaultLabels($manualReset = false)
|
public function resetDefaultLabels($manualReset = false)
|
||||||
@@ -320,7 +466,7 @@ class General extends Component
|
|||||||
$this->application->custom_labels = base64_encode($this->customLabels);
|
$this->application->custom_labels = base64_encode($this->customLabels);
|
||||||
$this->application->save();
|
$this->application->save();
|
||||||
if ($this->application->build_pack === 'dockercompose') {
|
if ($this->application->build_pack === 'dockercompose') {
|
||||||
$this->loadComposeFile();
|
$this->loadComposeFile(showToast: false);
|
||||||
}
|
}
|
||||||
$this->dispatch('configurationChanged');
|
$this->dispatch('configurationChanged');
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
@@ -347,6 +493,8 @@ class General extends Component
|
|||||||
|
|
||||||
public function setRedirect()
|
public function setRedirect()
|
||||||
{
|
{
|
||||||
|
$this->authorize('update', $this->application);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$has_www = collect($this->application->fqdns)->filter(fn ($fqdn) => str($fqdn)->contains('www.'))->count();
|
$has_www = collect($this->application->fqdns)->filter(fn ($fqdn) => str($fqdn)->contains('www.'))->count();
|
||||||
if ($has_www === 0 && $this->application->redirect === 'www') {
|
if ($has_www === 0 && $this->application->redirect === 'www') {
|
||||||
@@ -365,6 +513,7 @@ class General extends Component
|
|||||||
public function submit($showToaster = true)
|
public function submit($showToaster = true)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
$this->authorize('update', $this->application);
|
||||||
$this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim();
|
$this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim();
|
||||||
$this->application->fqdn = str($this->application->fqdn)->replaceStart(',', '')->trim();
|
$this->application->fqdn = str($this->application->fqdn)->replaceStart(',', '')->trim();
|
||||||
$this->application->fqdn = str($this->application->fqdn)->trim()->explode(',')->map(function ($domain) {
|
$this->application->fqdn = str($this->application->fqdn)->trim()->explode(',')->map(function ($domain) {
|
||||||
@@ -397,7 +546,7 @@ class General extends Component
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($this->application->build_pack === 'dockercompose' && $this->initialDockerComposeLocation !== $this->application->docker_compose_location) {
|
if ($this->application->build_pack === 'dockercompose' && $this->initialDockerComposeLocation !== $this->application->docker_compose_location) {
|
||||||
$compose_return = $this->loadComposeFile();
|
$compose_return = $this->loadComposeFile(showToast: false);
|
||||||
if ($compose_return instanceof \Livewire\Features\SupportEvents\Event) {
|
if ($compose_return instanceof \Livewire\Features\SupportEvents\Event) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -430,17 +579,17 @@ class General extends Component
|
|||||||
}
|
}
|
||||||
if ($this->application->build_pack === 'dockercompose') {
|
if ($this->application->build_pack === 'dockercompose') {
|
||||||
$this->application->docker_compose_domains = json_encode($this->parsedServiceDomains);
|
$this->application->docker_compose_domains = json_encode($this->parsedServiceDomains);
|
||||||
|
|
||||||
foreach ($this->parsedServiceDomains as $serviceName => $service) {
|
|
||||||
$domain = data_get($service, 'domain');
|
|
||||||
if ($domain) {
|
|
||||||
if (! validate_dns_entry($domain, $this->application->destination->server)) {
|
|
||||||
$showToaster && $this->dispatch('error', 'Validating DNS failed.', "Make sure you have added the DNS records correctly.<br><br>$domain->{$this->application->destination->server->ip}<br><br>Check this <a target='_blank' class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/dns-configuration'>documentation</a> for further help.");
|
|
||||||
}
|
|
||||||
check_domain_usage(resource: $this->application);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ($this->application->isDirty('docker_compose_domains')) {
|
if ($this->application->isDirty('docker_compose_domains')) {
|
||||||
|
foreach ($this->parsedServiceDomains as $service) {
|
||||||
|
$domain = data_get($service, 'domain');
|
||||||
|
if ($domain) {
|
||||||
|
if (! validate_dns_entry($domain, $this->application->destination->server)) {
|
||||||
|
$showToaster && $this->dispatch('error', 'Validating DNS failed.', "Make sure you have added the DNS records correctly.<br><br>$domain->{$this->application->destination->server->ip}<br><br>Check this <a target='_blank' class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/dns-configuration'>documentation</a> for further help.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check_domain_usage(resource: $this->application);
|
||||||
|
$this->application->save();
|
||||||
$this->resetDefaultLabels();
|
$this->resetDefaultLabels();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -471,4 +620,75 @@ class General extends Component
|
|||||||
'Content-Disposition' => 'attachment; filename='.$fileName,
|
'Content-Disposition' => 'attachment; filename='.$fileName,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function updateServiceEnvironmentVariables()
|
||||||
|
{
|
||||||
|
$domains = collect(json_decode($this->application->docker_compose_domains, true)) ?? collect([]);
|
||||||
|
|
||||||
|
foreach ($domains as $serviceName => $service) {
|
||||||
|
$serviceNameFormatted = str($serviceName)->upper()->replace('-', '_');
|
||||||
|
$domain = data_get($service, 'domain');
|
||||||
|
// Delete SERVICE_FQDN_ and SERVICE_URL_ variables if domain is removed
|
||||||
|
$this->application->environment_variables()->where('resourceable_type', Application::class)
|
||||||
|
->where('resourceable_id', $this->application->id)
|
||||||
|
->where('key', 'LIKE', "SERVICE_FQDN_{$serviceNameFormatted}%")
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
$this->application->environment_variables()->where('resourceable_type', Application::class)
|
||||||
|
->where('resourceable_id', $this->application->id)
|
||||||
|
->where('key', 'LIKE', "SERVICE_URL_{$serviceNameFormatted}%")
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
if ($domain) {
|
||||||
|
// Create or update SERVICE_FQDN_ and SERVICE_URL_ variables
|
||||||
|
$fqdn = Url::fromString($domain);
|
||||||
|
$port = $fqdn->getPort();
|
||||||
|
$path = $fqdn->getPath();
|
||||||
|
$urlValue = $fqdn->getScheme().'://'.$fqdn->getHost();
|
||||||
|
if ($path !== '/') {
|
||||||
|
$urlValue = $urlValue.$path;
|
||||||
|
}
|
||||||
|
$fqdnValue = str($domain)->after('://');
|
||||||
|
if ($path !== '/') {
|
||||||
|
$fqdnValue = $fqdnValue.$path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create/update SERVICE_FQDN_
|
||||||
|
$this->application->environment_variables()->updateOrCreate([
|
||||||
|
'key' => "SERVICE_FQDN_{$serviceNameFormatted}",
|
||||||
|
], [
|
||||||
|
'value' => $fqdnValue,
|
||||||
|
'is_build_time' => false,
|
||||||
|
'is_preview' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create/update SERVICE_URL_
|
||||||
|
$this->application->environment_variables()->updateOrCreate([
|
||||||
|
'key' => "SERVICE_URL_{$serviceNameFormatted}",
|
||||||
|
], [
|
||||||
|
'value' => $urlValue,
|
||||||
|
'is_build_time' => false,
|
||||||
|
'is_preview' => false,
|
||||||
|
]);
|
||||||
|
// Create/update port-specific variables if port exists
|
||||||
|
if (filled($port)) {
|
||||||
|
$this->application->environment_variables()->updateOrCreate([
|
||||||
|
'key' => "SERVICE_FQDN_{$serviceNameFormatted}_{$port}",
|
||||||
|
], [
|
||||||
|
'value' => $fqdnValue,
|
||||||
|
'is_build_time' => false,
|
||||||
|
'is_preview' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->application->environment_variables()->updateOrCreate([
|
||||||
|
'key' => "SERVICE_URL_{$serviceNameFormatted}_{$port}",
|
||||||
|
], [
|
||||||
|
'value' => $urlValue,
|
||||||
|
'is_build_time' => false,
|
||||||
|
'is_preview' => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -5,11 +5,14 @@ namespace App\Livewire\Project\Application;
|
|||||||
use App\Actions\Application\StopApplication;
|
use App\Actions\Application\StopApplication;
|
||||||
use App\Actions\Docker\GetContainersStatus;
|
use App\Actions\Docker\GetContainersStatus;
|
||||||
use App\Models\Application;
|
use App\Models\Application;
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Visus\Cuid2\Cuid2;
|
use Visus\Cuid2\Cuid2;
|
||||||
|
|
||||||
class Heading extends Component
|
class Heading extends Component
|
||||||
{
|
{
|
||||||
|
use AuthorizesRequests;
|
||||||
|
|
||||||
public Application $application;
|
public Application $application;
|
||||||
|
|
||||||
public ?string $lastDeploymentInfo = null;
|
public ?string $lastDeploymentInfo = null;
|
||||||
@@ -57,11 +60,15 @@ class Heading extends Component
|
|||||||
|
|
||||||
public function force_deploy_without_cache()
|
public function force_deploy_without_cache()
|
||||||
{
|
{
|
||||||
|
$this->authorize('deploy', $this->application);
|
||||||
|
|
||||||
$this->deploy(force_rebuild: true);
|
$this->deploy(force_rebuild: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function deploy(bool $force_rebuild = false)
|
public function deploy(bool $force_rebuild = false)
|
||||||
{
|
{
|
||||||
|
$this->authorize('deploy', $this->application);
|
||||||
|
|
||||||
if ($this->application->build_pack === 'dockercompose' && is_null($this->application->docker_compose_raw)) {
|
if ($this->application->build_pack === 'dockercompose' && is_null($this->application->docker_compose_raw)) {
|
||||||
$this->dispatch('error', 'Failed to deploy', 'Please load a Compose file first.');
|
$this->dispatch('error', 'Failed to deploy', 'Please load a Compose file first.');
|
||||||
|
|
||||||
@@ -110,12 +117,16 @@ class Heading extends Component
|
|||||||
|
|
||||||
public function stop()
|
public function stop()
|
||||||
{
|
{
|
||||||
|
$this->authorize('deploy', $this->application);
|
||||||
|
|
||||||
$this->dispatch('info', 'Gracefully stopping application.<br/>It could take a while depending on the application.');
|
$this->dispatch('info', 'Gracefully stopping application.<br/>It could take a while depending on the application.');
|
||||||
StopApplication::dispatch($this->application, false, $this->docker_cleanup);
|
StopApplication::dispatch($this->application, false, $this->docker_cleanup);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function restart()
|
public function restart()
|
||||||
{
|
{
|
||||||
|
$this->authorize('deploy', $this->application);
|
||||||
|
|
||||||
if ($this->application->additional_servers->count() > 0 && str($this->application->docker_registry_image_name)->isEmpty()) {
|
if ($this->application->additional_servers->count() > 0 && str($this->application->docker_registry_image_name)->isEmpty()) {
|
||||||
$this->dispatch('error', 'Failed to deploy', 'Before deploying to multiple servers, you must first set a Docker image in the General tab.<br>More information here: <a target="_blank" class="underline" href="https://coolify.io/docs/knowledge-base/server/multiple-servers">documentation</a>');
|
$this->dispatch('error', 'Failed to deploy', 'Before deploying to multiple servers, you must first set a Docker image in the General tab.<br>More information here: <a target="_blank" class="underline" href="https://coolify.io/docs/knowledge-base/server/multiple-servers">documentation</a>');
|
||||||
|
|
||||||
|
@@ -3,12 +3,15 @@
|
|||||||
namespace App\Livewire\Project\Application\Preview;
|
namespace App\Livewire\Project\Application\Preview;
|
||||||
|
|
||||||
use App\Models\Application;
|
use App\Models\Application;
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Livewire\Attributes\Validate;
|
use Livewire\Attributes\Validate;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Spatie\Url\Url;
|
use Spatie\Url\Url;
|
||||||
|
|
||||||
class Form extends Component
|
class Form extends Component
|
||||||
{
|
{
|
||||||
|
use AuthorizesRequests;
|
||||||
|
|
||||||
public Application $application;
|
public Application $application;
|
||||||
|
|
||||||
#[Validate('required')]
|
#[Validate('required')]
|
||||||
@@ -27,6 +30,7 @@ class Form extends Component
|
|||||||
public function submit()
|
public function submit()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
$this->authorize('update', $this->application);
|
||||||
$this->resetErrorBag();
|
$this->resetErrorBag();
|
||||||
$this->validate();
|
$this->validate();
|
||||||
$this->application->preview_url_template = str_replace(' ', '', $this->previewUrlTemplate);
|
$this->application->preview_url_template = str_replace(' ', '', $this->previewUrlTemplate);
|
||||||
@@ -41,6 +45,7 @@ class Form extends Component
|
|||||||
public function resetToDefault()
|
public function resetToDefault()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
$this->authorize('update', $this->application);
|
||||||
$this->application->preview_url_template = '{{pr_id}}.{{domain}}';
|
$this->application->preview_url_template = '{{pr_id}}.{{domain}}';
|
||||||
$this->previewUrlTemplate = $this->application->preview_url_template;
|
$this->previewUrlTemplate = $this->application->preview_url_template;
|
||||||
$this->application->save();
|
$this->application->save();
|
||||||
|
@@ -3,14 +3,18 @@
|
|||||||
namespace App\Livewire\Project\Application;
|
namespace App\Livewire\Project\Application;
|
||||||
|
|
||||||
use App\Actions\Docker\GetContainersStatus;
|
use App\Actions\Docker\GetContainersStatus;
|
||||||
|
use App\Jobs\DeleteResourceJob;
|
||||||
use App\Models\Application;
|
use App\Models\Application;
|
||||||
use App\Models\ApplicationPreview;
|
use App\Models\ApplicationPreview;
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Visus\Cuid2\Cuid2;
|
use Visus\Cuid2\Cuid2;
|
||||||
|
|
||||||
class Previews extends Component
|
class Previews extends Component
|
||||||
{
|
{
|
||||||
|
use AuthorizesRequests;
|
||||||
|
|
||||||
public Application $application;
|
public Application $application;
|
||||||
|
|
||||||
public string $deployment_uuid;
|
public string $deployment_uuid;
|
||||||
@@ -34,6 +38,7 @@ class Previews extends Component
|
|||||||
public function load_prs()
|
public function load_prs()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
$this->authorize('update', $this->application);
|
||||||
['rate_limit_remaining' => $rate_limit_remaining, 'data' => $data] = githubApi(source: $this->application->source, endpoint: "/repos/{$this->application->git_repository}/pulls");
|
['rate_limit_remaining' => $rate_limit_remaining, 'data' => $data] = githubApi(source: $this->application->source, endpoint: "/repos/{$this->application->git_repository}/pulls");
|
||||||
$this->rate_limit_remaining = $rate_limit_remaining;
|
$this->rate_limit_remaining = $rate_limit_remaining;
|
||||||
$this->pull_requests = $data->sortBy('number')->values();
|
$this->pull_requests = $data->sortBy('number')->values();
|
||||||
@@ -47,6 +52,7 @@ class Previews extends Component
|
|||||||
public function save_preview($preview_id)
|
public function save_preview($preview_id)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
$this->authorize('update', $this->application);
|
||||||
$success = true;
|
$success = true;
|
||||||
$preview = $this->application->previews->find($preview_id);
|
$preview = $this->application->previews->find($preview_id);
|
||||||
if (data_get_str($preview, 'fqdn')->isNotEmpty()) {
|
if (data_get_str($preview, 'fqdn')->isNotEmpty()) {
|
||||||
@@ -72,29 +78,36 @@ class Previews extends Component
|
|||||||
|
|
||||||
public function generate_preview($preview_id)
|
public function generate_preview($preview_id)
|
||||||
{
|
{
|
||||||
$preview = $this->application->previews->find($preview_id);
|
try {
|
||||||
if (! $preview) {
|
$this->authorize('update', $this->application);
|
||||||
$this->dispatch('error', 'Preview not found.');
|
|
||||||
|
|
||||||
return;
|
$preview = $this->application->previews->find($preview_id);
|
||||||
}
|
if (! $preview) {
|
||||||
if ($this->application->build_pack === 'dockercompose') {
|
$this->dispatch('error', 'Preview not found.');
|
||||||
$preview->generate_preview_fqdn_compose();
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($this->application->build_pack === 'dockercompose') {
|
||||||
|
$preview->generate_preview_fqdn_compose();
|
||||||
|
$this->application->refresh();
|
||||||
|
$this->dispatch('success', 'Domain generated.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$preview->generate_preview_fqdn();
|
||||||
$this->application->refresh();
|
$this->application->refresh();
|
||||||
|
$this->dispatch('update_links');
|
||||||
$this->dispatch('success', 'Domain generated.');
|
$this->dispatch('success', 'Domain generated.');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
return;
|
return handleError($e, $this);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->application->generate_preview_fqdn($preview->pull_request_id);
|
|
||||||
$this->application->refresh();
|
|
||||||
$this->dispatch('update_links');
|
|
||||||
$this->dispatch('success', 'Domain generated.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function add(int $pull_request_id, ?string $pull_request_html_url = null)
|
public function add(int $pull_request_id, ?string $pull_request_html_url = null)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
$this->authorize('update', $this->application);
|
||||||
if ($this->application->build_pack === 'dockercompose') {
|
if ($this->application->build_pack === 'dockercompose') {
|
||||||
$this->setDeploymentUuid();
|
$this->setDeploymentUuid();
|
||||||
$found = ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', $pull_request_id)->first();
|
$found = ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||||
@@ -118,7 +131,7 @@ class Previews extends Component
|
|||||||
'pull_request_html_url' => $pull_request_html_url,
|
'pull_request_html_url' => $pull_request_html_url,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
$this->application->generate_preview_fqdn($pull_request_id);
|
$found->generate_preview_fqdn();
|
||||||
$this->application->refresh();
|
$this->application->refresh();
|
||||||
$this->dispatch('update_links');
|
$this->dispatch('update_links');
|
||||||
$this->dispatch('success', 'Preview added.');
|
$this->dispatch('success', 'Preview added.');
|
||||||
@@ -130,17 +143,23 @@ class Previews extends Component
|
|||||||
|
|
||||||
public function force_deploy_without_cache(int $pull_request_id, ?string $pull_request_html_url = null)
|
public function force_deploy_without_cache(int $pull_request_id, ?string $pull_request_html_url = null)
|
||||||
{
|
{
|
||||||
|
$this->authorize('deploy', $this->application);
|
||||||
|
|
||||||
$this->deploy($pull_request_id, $pull_request_html_url, force_rebuild: true);
|
$this->deploy($pull_request_id, $pull_request_html_url, force_rebuild: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function add_and_deploy(int $pull_request_id, ?string $pull_request_html_url = null)
|
public function add_and_deploy(int $pull_request_id, ?string $pull_request_html_url = null)
|
||||||
{
|
{
|
||||||
|
$this->authorize('deploy', $this->application);
|
||||||
|
|
||||||
$this->add($pull_request_id, $pull_request_html_url);
|
$this->add($pull_request_id, $pull_request_html_url);
|
||||||
$this->deploy($pull_request_id, $pull_request_html_url);
|
$this->deploy($pull_request_id, $pull_request_html_url);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function deploy(int $pull_request_id, ?string $pull_request_html_url = null, bool $force_rebuild = false)
|
public function deploy(int $pull_request_id, ?string $pull_request_html_url = null, bool $force_rebuild = false)
|
||||||
{
|
{
|
||||||
|
$this->authorize('deploy', $this->application);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->setDeploymentUuid();
|
$this->setDeploymentUuid();
|
||||||
$found = ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', $pull_request_id)->first();
|
$found = ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||||
@@ -183,6 +202,8 @@ class Previews extends Component
|
|||||||
|
|
||||||
public function stop(int $pull_request_id)
|
public function stop(int $pull_request_id)
|
||||||
{
|
{
|
||||||
|
$this->authorize('deploy', $this->application);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$server = $this->application->destination->server;
|
$server = $this->application->destination->server;
|
||||||
|
|
||||||
@@ -205,48 +226,29 @@ class Previews extends Component
|
|||||||
public function delete(int $pull_request_id)
|
public function delete(int $pull_request_id)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$server = $this->application->destination->server;
|
$this->authorize('delete', $this->application);
|
||||||
|
$preview = ApplicationPreview::where('application_id', $this->application->id)
|
||||||
|
->where('pull_request_id', $pull_request_id)
|
||||||
|
->first();
|
||||||
|
|
||||||
if ($this->application->destination->server->isSwarm()) {
|
if (! $preview) {
|
||||||
instant_remote_process(["docker stack rm {$this->application->uuid}-{$pull_request_id}"], $server);
|
$this->dispatch('error', 'Preview not found.');
|
||||||
} else {
|
|
||||||
$containers = getCurrentApplicationContainerStatus($server, $this->application->id, $pull_request_id)->toArray();
|
return;
|
||||||
$this->stopContainers($containers, $server);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ApplicationPreview::where('application_id', $this->application->id)
|
// Soft delete immediately for instant UI feedback
|
||||||
->where('pull_request_id', $pull_request_id)
|
$preview->delete();
|
||||||
->first()
|
|
||||||
->delete();
|
|
||||||
|
|
||||||
$this->application->refresh();
|
// Dispatch the job for async cleanup (container stopping + force delete)
|
||||||
|
DeleteResourceJob::dispatch($preview);
|
||||||
|
|
||||||
|
// Refresh the application and its previews relationship to reflect the soft delete
|
||||||
|
$this->application->load('previews');
|
||||||
$this->dispatch('update_links');
|
$this->dispatch('update_links');
|
||||||
$this->dispatch('success', 'Preview deleted.');
|
$this->dispatch('success', 'Preview deletion started. It may take a few moments to complete.');
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return handleError($e, $this);
|
return handleError($e, $this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function stopContainers(array $containers, $server, int $timeout = 30)
|
|
||||||
{
|
|
||||||
if (empty($containers)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$containerNames = [];
|
|
||||||
foreach ($containers as $container) {
|
|
||||||
$containerNames[] = str_replace('/', '', $container['Names']);
|
|
||||||
}
|
|
||||||
|
|
||||||
$containerList = implode(' ', array_map('escapeshellarg', $containerNames));
|
|
||||||
$commands = [
|
|
||||||
"docker stop --time=$timeout $containerList",
|
|
||||||
"docker rm -f $containerList",
|
|
||||||
];
|
|
||||||
|
|
||||||
instant_remote_process(
|
|
||||||
command: $commands,
|
|
||||||
server: $server,
|
|
||||||
throwError: false
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@@ -3,12 +3,15 @@
|
|||||||
namespace App\Livewire\Project\Application;
|
namespace App\Livewire\Project\Application;
|
||||||
|
|
||||||
use App\Models\ApplicationPreview;
|
use App\Models\ApplicationPreview;
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Spatie\Url\Url;
|
use Spatie\Url\Url;
|
||||||
use Visus\Cuid2\Cuid2;
|
use Visus\Cuid2\Cuid2;
|
||||||
|
|
||||||
class PreviewsCompose extends Component
|
class PreviewsCompose extends Component
|
||||||
{
|
{
|
||||||
|
use AuthorizesRequests;
|
||||||
|
|
||||||
public $service;
|
public $service;
|
||||||
|
|
||||||
public $serviceName;
|
public $serviceName;
|
||||||
@@ -22,40 +25,71 @@ class PreviewsCompose extends Component
|
|||||||
|
|
||||||
public function save()
|
public function save()
|
||||||
{
|
{
|
||||||
$domain = data_get($this->service, 'domain');
|
try {
|
||||||
$docker_compose_domains = data_get($this->preview, 'docker_compose_domains');
|
$this->authorize('update', $this->preview->application);
|
||||||
$docker_compose_domains = json_decode($docker_compose_domains, true);
|
|
||||||
$docker_compose_domains[$this->serviceName]['domain'] = $domain;
|
$domain = data_get($this->service, 'domain');
|
||||||
$this->preview->docker_compose_domains = json_encode($docker_compose_domains);
|
$docker_compose_domains = data_get($this->preview, 'docker_compose_domains');
|
||||||
$this->preview->save();
|
$docker_compose_domains = json_decode($docker_compose_domains, true);
|
||||||
$this->dispatch('update_links');
|
$docker_compose_domains[$this->serviceName]['domain'] = $domain;
|
||||||
$this->dispatch('success', 'Domain saved.');
|
$this->preview->docker_compose_domains = json_encode($docker_compose_domains);
|
||||||
|
$this->preview->save();
|
||||||
|
$this->dispatch('update_links');
|
||||||
|
$this->dispatch('success', 'Domain saved.');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return handleError($e, $this);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function generate()
|
public function generate()
|
||||||
{
|
{
|
||||||
$domains = collect(json_decode($this->preview->application->docker_compose_domains)) ?? collect();
|
try {
|
||||||
$domain = $domains->first(function ($_, $key) {
|
$this->authorize('update', $this->preview->application);
|
||||||
return $key === $this->serviceName;
|
|
||||||
});
|
$domains = collect(json_decode($this->preview->application->docker_compose_domains)) ?? collect();
|
||||||
if ($domain) {
|
$domain = $domains->first(function ($_, $key) {
|
||||||
$domain = data_get($domain, 'domain');
|
return $key === $this->serviceName;
|
||||||
$url = Url::fromString($domain);
|
});
|
||||||
$template = $this->preview->application->preview_url_template;
|
|
||||||
$host = $url->getHost();
|
$domain_string = data_get($domain, 'domain');
|
||||||
$schema = $url->getScheme();
|
|
||||||
$random = new Cuid2;
|
// If no domain is set in the main application, generate a default domain
|
||||||
$preview_fqdn = str_replace('{{random}}', $random, $template);
|
if (empty($domain_string)) {
|
||||||
$preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
|
$server = $this->preview->application->destination->server;
|
||||||
$preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn);
|
$template = $this->preview->application->preview_url_template;
|
||||||
$preview_fqdn = "$schema://$preview_fqdn";
|
$random = new Cuid2;
|
||||||
|
|
||||||
|
// Generate a unique domain like main app services do
|
||||||
|
$generated_fqdn = generateFqdn(server: $server, random: $random, parserVersion: $this->preview->application->compose_parsing_version);
|
||||||
|
|
||||||
|
$preview_fqdn = str_replace('{{random}}', $random, $template);
|
||||||
|
$preview_fqdn = str_replace('{{domain}}', str($generated_fqdn)->after('://'), $preview_fqdn);
|
||||||
|
$preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn);
|
||||||
|
$preview_fqdn = str($generated_fqdn)->before('://').'://'.$preview_fqdn;
|
||||||
|
} else {
|
||||||
|
// Use the existing domain from the main application
|
||||||
|
$url = Url::fromString($domain_string);
|
||||||
|
$template = $this->preview->application->preview_url_template;
|
||||||
|
$host = $url->getHost();
|
||||||
|
$schema = $url->getScheme();
|
||||||
|
$random = new Cuid2;
|
||||||
|
$preview_fqdn = str_replace('{{random}}', $random, $template);
|
||||||
|
$preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
|
||||||
|
$preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn);
|
||||||
|
$preview_fqdn = "$schema://$preview_fqdn";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the generated domain
|
||||||
$docker_compose_domains = data_get($this->preview, 'docker_compose_domains');
|
$docker_compose_domains = data_get($this->preview, 'docker_compose_domains');
|
||||||
$docker_compose_domains = json_decode($docker_compose_domains, true);
|
$docker_compose_domains = json_decode($docker_compose_domains, true);
|
||||||
$docker_compose_domains[$this->serviceName]['domain'] = $this->service->domain = $preview_fqdn;
|
$docker_compose_domains[$this->serviceName]['domain'] = $this->service->domain = $preview_fqdn;
|
||||||
$this->preview->docker_compose_domains = json_encode($docker_compose_domains);
|
$this->preview->docker_compose_domains = json_encode($docker_compose_domains);
|
||||||
$this->preview->save();
|
$this->preview->save();
|
||||||
|
|
||||||
|
$this->dispatch('update_links');
|
||||||
|
$this->dispatch('success', 'Domain generated.');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return handleError($e, $this);
|
||||||
}
|
}
|
||||||
$this->dispatch('update_links');
|
|
||||||
$this->dispatch('success', 'Domain generated.');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -3,11 +3,14 @@
|
|||||||
namespace App\Livewire\Project\Application;
|
namespace App\Livewire\Project\Application;
|
||||||
|
|
||||||
use App\Models\Application;
|
use App\Models\Application;
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Visus\Cuid2\Cuid2;
|
use Visus\Cuid2\Cuid2;
|
||||||
|
|
||||||
class Rollback extends Component
|
class Rollback extends Component
|
||||||
{
|
{
|
||||||
|
use AuthorizesRequests;
|
||||||
|
|
||||||
public Application $application;
|
public Application $application;
|
||||||
|
|
||||||
public $images = [];
|
public $images = [];
|
||||||
@@ -23,6 +26,8 @@ class Rollback extends Component
|
|||||||
|
|
||||||
public function rollbackImage($commit)
|
public function rollbackImage($commit)
|
||||||
{
|
{
|
||||||
|
$this->authorize('deploy', $this->application);
|
||||||
|
|
||||||
$deployment_uuid = new Cuid2;
|
$deployment_uuid = new Cuid2;
|
||||||
|
|
||||||
queue_application_deployment(
|
queue_application_deployment(
|
||||||
@@ -43,6 +48,8 @@ class Rollback extends Component
|
|||||||
|
|
||||||
public function loadImages($showToast = false)
|
public function loadImages($showToast = false)
|
||||||
{
|
{
|
||||||
|
$this->authorize('view', $this->application);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$image = $this->application->docker_registry_image_name ?? $this->application->uuid;
|
$image = $this->application->docker_registry_image_name ?? $this->application->uuid;
|
||||||
if ($this->application->destination->server->isFunctional()) {
|
if ($this->application->destination->server->isFunctional()) {
|
||||||
|
@@ -4,12 +4,15 @@ namespace App\Livewire\Project\Application;
|
|||||||
|
|
||||||
use App\Models\Application;
|
use App\Models\Application;
|
||||||
use App\Models\PrivateKey;
|
use App\Models\PrivateKey;
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Livewire\Attributes\Locked;
|
use Livewire\Attributes\Locked;
|
||||||
use Livewire\Attributes\Validate;
|
use Livewire\Attributes\Validate;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class Source extends Component
|
class Source extends Component
|
||||||
{
|
{
|
||||||
|
use AuthorizesRequests;
|
||||||
|
|
||||||
public Application $application;
|
public Application $application;
|
||||||
|
|
||||||
#[Locked]
|
#[Locked]
|
||||||
@@ -81,6 +84,7 @@ class Source extends Component
|
|||||||
public function setPrivateKey(int $privateKeyId)
|
public function setPrivateKey(int $privateKeyId)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
$this->authorize('update', $this->application);
|
||||||
$this->privateKeyId = $privateKeyId;
|
$this->privateKeyId = $privateKeyId;
|
||||||
$this->syncData(true);
|
$this->syncData(true);
|
||||||
$this->getPrivateKeys();
|
$this->getPrivateKeys();
|
||||||
@@ -94,7 +98,9 @@ class Source extends Component
|
|||||||
|
|
||||||
public function submit()
|
public function submit()
|
||||||
{
|
{
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
$this->authorize('update', $this->application);
|
||||||
if (str($this->gitCommitSha)->isEmpty()) {
|
if (str($this->gitCommitSha)->isEmpty()) {
|
||||||
$this->gitCommitSha = 'HEAD';
|
$this->gitCommitSha = 'HEAD';
|
||||||
}
|
}
|
||||||
@@ -107,7 +113,9 @@ class Source extends Component
|
|||||||
|
|
||||||
public function changeSource($sourceId, $sourceType)
|
public function changeSource($sourceId, $sourceType)
|
||||||
{
|
{
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
$this->authorize('update', $this->application);
|
||||||
$this->application->update([
|
$this->application->update([
|
||||||
'source_id' => $sourceId,
|
'source_id' => $sourceId,
|
||||||
'source_type' => $sourceType,
|
'source_type' => $sourceType,
|
||||||
|
@@ -11,6 +11,7 @@ use App\Jobs\VolumeCloneJob;
|
|||||||
use App\Models\Environment;
|
use App\Models\Environment;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
|
use App\Support\ValidationPatterns;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Visus\Cuid2\Cuid2;
|
use Visus\Cuid2\Cuid2;
|
||||||
|
|
||||||
@@ -42,11 +43,14 @@ class CloneMe extends Component
|
|||||||
|
|
||||||
public bool $cloneVolumeData = false;
|
public bool $cloneVolumeData = false;
|
||||||
|
|
||||||
protected $messages = [
|
protected function messages(): array
|
||||||
'selectedServer' => 'Please select a server.',
|
{
|
||||||
'selectedDestination' => 'Please select a server & destination.',
|
return array_merge([
|
||||||
'newName' => 'Please enter a name for the new project or environment.',
|
'selectedServer' => 'Please select a server.',
|
||||||
];
|
'selectedDestination' => 'Please select a server & destination.',
|
||||||
|
'newName.required' => 'Please enter a name for the new project or environment.',
|
||||||
|
], ValidationPatterns::nameMessages());
|
||||||
|
}
|
||||||
|
|
||||||
public function mount($project_uuid)
|
public function mount($project_uuid)
|
||||||
{
|
{
|
||||||
@@ -90,7 +94,7 @@ class CloneMe extends Component
|
|||||||
try {
|
try {
|
||||||
$this->validate([
|
$this->validate([
|
||||||
'selectedDestination' => 'required',
|
'selectedDestination' => 'required',
|
||||||
'newName' => 'required',
|
'newName' => ValidationPatterns::nameRules(),
|
||||||
]);
|
]);
|
||||||
if ($type === 'project') {
|
if ($type === 'project') {
|
||||||
$foundProject = Project::where('name', $this->newName)->first();
|
$foundProject = Project::where('name', $this->newName)->first();
|
||||||
@@ -129,7 +133,7 @@ class CloneMe extends Component
|
|||||||
$uuid = (string) new Cuid2;
|
$uuid = (string) new Cuid2;
|
||||||
$url = $application->fqdn;
|
$url = $application->fqdn;
|
||||||
if ($this->server->proxyType() !== 'NONE' && $applicationSettings->is_container_label_readonly_enabled === true) {
|
if ($this->server->proxyType() !== 'NONE' && $applicationSettings->is_container_label_readonly_enabled === true) {
|
||||||
$url = generateFqdn($this->server, $uuid);
|
$url = generateFqdn(server: $this->server, random: $uuid, parserVersion: $application->compose_parsing_version);
|
||||||
}
|
}
|
||||||
|
|
||||||
$newApplication = $application->replicate([
|
$newApplication = $application->replicate([
|
||||||
@@ -454,7 +458,7 @@ class CloneMe extends Component
|
|||||||
|
|
||||||
if ($this->cloneVolumeData) {
|
if ($this->cloneVolumeData) {
|
||||||
try {
|
try {
|
||||||
StopService::dispatch($application, false, false);
|
StopService::dispatch($application);
|
||||||
$sourceVolume = $volume->name;
|
$sourceVolume = $volume->name;
|
||||||
$targetVolume = $newPersistentVolume->name;
|
$targetVolume = $newPersistentVolume->name;
|
||||||
$sourceServer = $application->service->destination->server;
|
$sourceServer = $application->service->destination->server;
|
||||||
@@ -508,7 +512,7 @@ class CloneMe extends Component
|
|||||||
|
|
||||||
if ($this->cloneVolumeData) {
|
if ($this->cloneVolumeData) {
|
||||||
try {
|
try {
|
||||||
StopService::dispatch($database->service, false, false);
|
StopService::dispatch($database->service);
|
||||||
$sourceVolume = $volume->name;
|
$sourceVolume = $volume->name;
|
||||||
$targetVolume = $newPersistentVolume->name;
|
$targetVolume = $newPersistentVolume->name;
|
||||||
$sourceServer = $database->service->destination->server;
|
$sourceServer = $database->service->destination->server;
|
||||||
|
@@ -5,6 +5,7 @@ namespace App\Livewire\Project\Database;
|
|||||||
use App\Models\InstanceSettings;
|
use App\Models\InstanceSettings;
|
||||||
use App\Models\ScheduledDatabaseBackup;
|
use App\Models\ScheduledDatabaseBackup;
|
||||||
use Exception;
|
use Exception;
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Livewire\Attributes\Locked;
|
use Livewire\Attributes\Locked;
|
||||||
@@ -14,6 +15,8 @@ use Spatie\Url\Url;
|
|||||||
|
|
||||||
class BackupEdit extends Component
|
class BackupEdit extends Component
|
||||||
{
|
{
|
||||||
|
use AuthorizesRequests;
|
||||||
|
|
||||||
public ScheduledDatabaseBackup $backup;
|
public ScheduledDatabaseBackup $backup;
|
||||||
|
|
||||||
#[Locked]
|
#[Locked]
|
||||||
@@ -64,6 +67,9 @@ class BackupEdit extends Component
|
|||||||
#[Validate(['required', 'boolean'])]
|
#[Validate(['required', 'boolean'])]
|
||||||
public bool $saveS3 = false;
|
public bool $saveS3 = false;
|
||||||
|
|
||||||
|
#[Validate(['required', 'boolean'])]
|
||||||
|
public bool $disableLocalBackup = false;
|
||||||
|
|
||||||
#[Validate(['nullable', 'integer'])]
|
#[Validate(['nullable', 'integer'])]
|
||||||
public ?int $s3StorageId = 1;
|
public ?int $s3StorageId = 1;
|
||||||
|
|
||||||
@@ -73,6 +79,9 @@ class BackupEdit extends Component
|
|||||||
#[Validate(['required', 'boolean'])]
|
#[Validate(['required', 'boolean'])]
|
||||||
public bool $dumpAll = false;
|
public bool $dumpAll = false;
|
||||||
|
|
||||||
|
#[Validate(['required', 'int', 'min:1', 'max:36000'])]
|
||||||
|
public int $timeout = 3600;
|
||||||
|
|
||||||
public function mount()
|
public function mount()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
@@ -95,9 +104,11 @@ class BackupEdit extends Component
|
|||||||
$this->backup->database_backup_retention_days_s3 = $this->databaseBackupRetentionDaysS3;
|
$this->backup->database_backup_retention_days_s3 = $this->databaseBackupRetentionDaysS3;
|
||||||
$this->backup->database_backup_retention_max_storage_s3 = $this->databaseBackupRetentionMaxStorageS3;
|
$this->backup->database_backup_retention_max_storage_s3 = $this->databaseBackupRetentionMaxStorageS3;
|
||||||
$this->backup->save_s3 = $this->saveS3;
|
$this->backup->save_s3 = $this->saveS3;
|
||||||
|
$this->backup->disable_local_backup = $this->disableLocalBackup;
|
||||||
$this->backup->s3_storage_id = $this->s3StorageId;
|
$this->backup->s3_storage_id = $this->s3StorageId;
|
||||||
$this->backup->databases_to_backup = $this->databasesToBackup;
|
$this->backup->databases_to_backup = $this->databasesToBackup;
|
||||||
$this->backup->dump_all = $this->dumpAll;
|
$this->backup->dump_all = $this->dumpAll;
|
||||||
|
$this->backup->timeout = $this->timeout;
|
||||||
$this->customValidate();
|
$this->customValidate();
|
||||||
$this->backup->save();
|
$this->backup->save();
|
||||||
} else {
|
} else {
|
||||||
@@ -111,14 +122,18 @@ class BackupEdit extends Component
|
|||||||
$this->databaseBackupRetentionDaysS3 = $this->backup->database_backup_retention_days_s3;
|
$this->databaseBackupRetentionDaysS3 = $this->backup->database_backup_retention_days_s3;
|
||||||
$this->databaseBackupRetentionMaxStorageS3 = $this->backup->database_backup_retention_max_storage_s3;
|
$this->databaseBackupRetentionMaxStorageS3 = $this->backup->database_backup_retention_max_storage_s3;
|
||||||
$this->saveS3 = $this->backup->save_s3;
|
$this->saveS3 = $this->backup->save_s3;
|
||||||
|
$this->disableLocalBackup = $this->backup->disable_local_backup ?? false;
|
||||||
$this->s3StorageId = $this->backup->s3_storage_id;
|
$this->s3StorageId = $this->backup->s3_storage_id;
|
||||||
$this->databasesToBackup = $this->backup->databases_to_backup;
|
$this->databasesToBackup = $this->backup->databases_to_backup;
|
||||||
$this->dumpAll = $this->backup->dump_all;
|
$this->dumpAll = $this->backup->dump_all;
|
||||||
|
$this->timeout = $this->backup->timeout;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete($password)
|
public function delete($password)
|
||||||
{
|
{
|
||||||
|
$this->authorize('manageBackups', $this->backup->database);
|
||||||
|
|
||||||
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
|
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
|
||||||
if (! Hash::check($password, Auth::user()->password)) {
|
if (! Hash::check($password, Auth::user()->password)) {
|
||||||
$this->addError('password', 'The provided password is incorrect.');
|
$this->addError('password', 'The provided password is incorrect.');
|
||||||
@@ -176,6 +191,8 @@ class BackupEdit extends Component
|
|||||||
public function instantSave()
|
public function instantSave()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
$this->authorize('manageBackups', $this->backup->database);
|
||||||
|
|
||||||
$this->syncData(true);
|
$this->syncData(true);
|
||||||
$this->dispatch('success', 'Backup updated successfully.');
|
$this->dispatch('success', 'Backup updated successfully.');
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
@@ -188,6 +205,12 @@ class BackupEdit extends Component
|
|||||||
if (! is_numeric($this->backup->s3_storage_id)) {
|
if (! is_numeric($this->backup->s3_storage_id)) {
|
||||||
$this->backup->s3_storage_id = null;
|
$this->backup->s3_storage_id = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate that disable_local_backup can only be true when S3 backup is enabled
|
||||||
|
if ($this->backup->disable_local_backup && ! $this->backup->save_s3) {
|
||||||
|
throw new \Exception('Local backup can only be disabled when S3 backup is enabled.');
|
||||||
|
}
|
||||||
|
|
||||||
$isValid = validate_cron_expression($this->backup->frequency);
|
$isValid = validate_cron_expression($this->backup->frequency);
|
||||||
if (! $isValid) {
|
if (! $isValid) {
|
||||||
throw new \Exception('Invalid Cron / Human expression');
|
throw new \Exception('Invalid Cron / Human expression');
|
||||||
@@ -198,6 +221,8 @@ class BackupEdit extends Component
|
|||||||
public function submit()
|
public function submit()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
$this->authorize('manageBackups', $this->backup->database);
|
||||||
|
|
||||||
$this->syncData(true);
|
$this->syncData(true);
|
||||||
$this->dispatch('success', 'Backup updated successfully.');
|
$this->dispatch('success', 'Backup updated successfully.');
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
@@ -4,6 +4,7 @@ namespace App\Livewire\Project\Database;
|
|||||||
|
|
||||||
use App\Models\InstanceSettings;
|
use App\Models\InstanceSettings;
|
||||||
use App\Models\ScheduledDatabaseBackup;
|
use App\Models\ScheduledDatabaseBackup;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
@@ -14,7 +15,19 @@ class BackupExecutions extends Component
|
|||||||
|
|
||||||
public $database;
|
public $database;
|
||||||
|
|
||||||
public $executions = [];
|
public ?Collection $executions;
|
||||||
|
|
||||||
|
public int $executions_count = 0;
|
||||||
|
|
||||||
|
public int $skip = 0;
|
||||||
|
|
||||||
|
public int $defaultTake = 10;
|
||||||
|
|
||||||
|
public bool $showNext = false;
|
||||||
|
|
||||||
|
public bool $showPrev = false;
|
||||||
|
|
||||||
|
public int $currentPage = 1;
|
||||||
|
|
||||||
public $setDeletableBackup;
|
public $setDeletableBackup;
|
||||||
|
|
||||||
@@ -40,6 +53,20 @@ class BackupExecutions extends Component
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function cleanupDeleted()
|
||||||
|
{
|
||||||
|
if ($this->backup) {
|
||||||
|
$deletedCount = $this->backup->executions()->where('local_storage_deleted', true)->count();
|
||||||
|
if ($deletedCount > 0) {
|
||||||
|
$this->backup->executions()->where('local_storage_deleted', true)->delete();
|
||||||
|
$this->refreshBackupExecutions();
|
||||||
|
$this->dispatch('success', "Cleaned up {$deletedCount} backup entries deleted from local storage.");
|
||||||
|
} else {
|
||||||
|
$this->dispatch('info', 'No backup entries found that are deleted from local storage.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function deleteBackup($executionId, $password)
|
public function deleteBackup($executionId, $password)
|
||||||
{
|
{
|
||||||
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
|
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
|
||||||
@@ -85,18 +112,74 @@ class BackupExecutions extends Component
|
|||||||
|
|
||||||
public function refreshBackupExecutions(): void
|
public function refreshBackupExecutions(): void
|
||||||
{
|
{
|
||||||
if ($this->backup && $this->backup->exists) {
|
$this->loadExecutions();
|
||||||
$this->executions = $this->backup->executions()->get()->toArray();
|
}
|
||||||
} else {
|
|
||||||
$this->executions = [];
|
public function reloadExecutions()
|
||||||
|
{
|
||||||
|
$this->loadExecutions();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function previousPage(?int $take = null)
|
||||||
|
{
|
||||||
|
if ($take) {
|
||||||
|
$this->skip = $this->skip - $take;
|
||||||
}
|
}
|
||||||
|
$this->skip = $this->skip - $this->defaultTake;
|
||||||
|
if ($this->skip < 0) {
|
||||||
|
$this->showPrev = false;
|
||||||
|
$this->skip = 0;
|
||||||
|
}
|
||||||
|
$this->updateCurrentPage();
|
||||||
|
$this->loadExecutions();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function nextPage(?int $take = null)
|
||||||
|
{
|
||||||
|
if ($take) {
|
||||||
|
$this->skip = $this->skip + $take;
|
||||||
|
}
|
||||||
|
$this->showPrev = true;
|
||||||
|
$this->updateCurrentPage();
|
||||||
|
$this->loadExecutions();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loadExecutions()
|
||||||
|
{
|
||||||
|
if ($this->backup && $this->backup->exists) {
|
||||||
|
['executions' => $executions, 'count' => $count] = $this->backup->executionsPaginated($this->skip, $this->defaultTake);
|
||||||
|
$this->executions = $executions;
|
||||||
|
$this->executions_count = $count;
|
||||||
|
} else {
|
||||||
|
$this->executions = collect([]);
|
||||||
|
$this->executions_count = 0;
|
||||||
|
}
|
||||||
|
$this->showMore();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function showMore()
|
||||||
|
{
|
||||||
|
if ($this->executions->count() !== 0) {
|
||||||
|
$this->showNext = true;
|
||||||
|
if ($this->executions->count() < $this->defaultTake) {
|
||||||
|
$this->showNext = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function updateCurrentPage()
|
||||||
|
{
|
||||||
|
$this->currentPage = intval($this->skip / $this->defaultTake) + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function mount(ScheduledDatabaseBackup $backup)
|
public function mount(ScheduledDatabaseBackup $backup)
|
||||||
{
|
{
|
||||||
$this->backup = $backup;
|
$this->backup = $backup;
|
||||||
$this->database = $backup->database;
|
$this->database = $backup->database;
|
||||||
$this->refreshBackupExecutions();
|
$this->updateCurrentPage();
|
||||||
|
$this->loadExecutions();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function server()
|
public function server()
|
||||||
|
@@ -3,14 +3,19 @@
|
|||||||
namespace App\Livewire\Project\Database;
|
namespace App\Livewire\Project\Database;
|
||||||
|
|
||||||
use App\Jobs\DatabaseBackupJob;
|
use App\Jobs\DatabaseBackupJob;
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class BackupNow extends Component
|
class BackupNow extends Component
|
||||||
{
|
{
|
||||||
|
use AuthorizesRequests;
|
||||||
|
|
||||||
public $backup;
|
public $backup;
|
||||||
|
|
||||||
public function backupNow()
|
public function backupNow()
|
||||||
{
|
{
|
||||||
|
$this->authorize('manageBackups', $this->backup->database);
|
||||||
|
|
||||||
DatabaseBackupJob::dispatch($this->backup);
|
DatabaseBackupJob::dispatch($this->backup);
|
||||||
$this->dispatch('success', 'Backup queued. It will be available in a few minutes.');
|
$this->dispatch('success', 'Backup queued. It will be available in a few minutes.');
|
||||||
}
|
}
|
||||||
|
@@ -6,51 +6,42 @@ use App\Actions\Database\StartDatabaseProxy;
|
|||||||
use App\Actions\Database\StopDatabaseProxy;
|
use App\Actions\Database\StopDatabaseProxy;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use App\Models\StandaloneClickhouse;
|
use App\Models\StandaloneClickhouse;
|
||||||
|
use App\Support\ValidationPatterns;
|
||||||
use Exception;
|
use Exception;
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Livewire\Attributes\Validate;
|
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class General extends Component
|
class General extends Component
|
||||||
{
|
{
|
||||||
|
use AuthorizesRequests;
|
||||||
|
|
||||||
public Server $server;
|
public Server $server;
|
||||||
|
|
||||||
public StandaloneClickhouse $database;
|
public StandaloneClickhouse $database;
|
||||||
|
|
||||||
#[Validate(['required', 'string'])]
|
|
||||||
public string $name;
|
public string $name;
|
||||||
|
|
||||||
#[Validate(['nullable', 'string'])]
|
|
||||||
public ?string $description = null;
|
public ?string $description = null;
|
||||||
|
|
||||||
#[Validate(['required', 'string'])]
|
|
||||||
public string $clickhouseAdminUser;
|
public string $clickhouseAdminUser;
|
||||||
|
|
||||||
#[Validate(['required', 'string'])]
|
|
||||||
public string $clickhouseAdminPassword;
|
public string $clickhouseAdminPassword;
|
||||||
|
|
||||||
#[Validate(['required', 'string'])]
|
|
||||||
public string $image;
|
public string $image;
|
||||||
|
|
||||||
#[Validate(['nullable', 'string'])]
|
|
||||||
public ?string $portsMappings = null;
|
public ?string $portsMappings = null;
|
||||||
|
|
||||||
#[Validate(['nullable', 'boolean'])]
|
|
||||||
public ?bool $isPublic = null;
|
public ?bool $isPublic = null;
|
||||||
|
|
||||||
#[Validate(['nullable', 'integer'])]
|
|
||||||
public ?int $publicPort = null;
|
public ?int $publicPort = null;
|
||||||
|
|
||||||
#[Validate(['nullable', 'string'])]
|
|
||||||
public ?string $customDockerRunOptions = null;
|
public ?string $customDockerRunOptions = null;
|
||||||
|
|
||||||
#[Validate(['nullable', 'string'])]
|
|
||||||
public ?string $dbUrl = null;
|
public ?string $dbUrl = null;
|
||||||
|
|
||||||
#[Validate(['nullable', 'string'])]
|
|
||||||
public ?string $dbUrlPublic = null;
|
public ?string $dbUrlPublic = null;
|
||||||
|
|
||||||
#[Validate(['nullable', 'boolean'])]
|
|
||||||
public bool $isLogDrainEnabled = false;
|
public bool $isLogDrainEnabled = false;
|
||||||
|
|
||||||
public function getListeners()
|
public function getListeners()
|
||||||
@@ -72,6 +63,40 @@ class General extends Component
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ValidationPatterns::nameRules(),
|
||||||
|
'description' => ValidationPatterns::descriptionRules(),
|
||||||
|
'clickhouseAdminUser' => 'required|string',
|
||||||
|
'clickhouseAdminPassword' => 'required|string',
|
||||||
|
'image' => 'required|string',
|
||||||
|
'portsMappings' => 'nullable|string',
|
||||||
|
'isPublic' => 'nullable|boolean',
|
||||||
|
'publicPort' => 'nullable|integer',
|
||||||
|
'customDockerRunOptions' => 'nullable|string',
|
||||||
|
'dbUrl' => 'nullable|string',
|
||||||
|
'dbUrlPublic' => 'nullable|string',
|
||||||
|
'isLogDrainEnabled' => 'nullable|boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function messages(): array
|
||||||
|
{
|
||||||
|
return array_merge(
|
||||||
|
ValidationPatterns::combinedMessages(),
|
||||||
|
[
|
||||||
|
'clickhouseAdminUser.required' => 'The Admin User field is required.',
|
||||||
|
'clickhouseAdminUser.string' => 'The Admin User must be a string.',
|
||||||
|
'clickhouseAdminPassword.required' => 'The Admin Password field is required.',
|
||||||
|
'clickhouseAdminPassword.string' => 'The Admin Password must be a string.',
|
||||||
|
'image.required' => 'The Docker Image field is required.',
|
||||||
|
'image.string' => 'The Docker Image must be a string.',
|
||||||
|
'publicPort.integer' => 'The Public Port must be an integer.',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function syncData(bool $toModel = false)
|
public function syncData(bool $toModel = false)
|
||||||
{
|
{
|
||||||
if ($toModel) {
|
if ($toModel) {
|
||||||
@@ -109,6 +134,8 @@ class General extends Component
|
|||||||
public function instantSaveAdvanced()
|
public function instantSaveAdvanced()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
$this->authorize('update', $this->database);
|
||||||
|
|
||||||
if (! $this->server->isLogDrainEnabled()) {
|
if (! $this->server->isLogDrainEnabled()) {
|
||||||
$this->isLogDrainEnabled = false;
|
$this->isLogDrainEnabled = false;
|
||||||
$this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.');
|
$this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.');
|
||||||
@@ -127,6 +154,8 @@ class General extends Component
|
|||||||
public function instantSave()
|
public function instantSave()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
$this->authorize('update', $this->database);
|
||||||
|
|
||||||
if ($this->isPublic && ! $this->publicPort) {
|
if ($this->isPublic && ! $this->publicPort) {
|
||||||
$this->dispatch('error', 'Public port is required.');
|
$this->dispatch('error', 'Public port is required.');
|
||||||
$this->isPublic = false;
|
$this->isPublic = false;
|
||||||
@@ -164,6 +193,8 @@ class General extends Component
|
|||||||
public function submit()
|
public function submit()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
$this->authorize('update', $this->database);
|
||||||
|
|
||||||
if (str($this->publicPort)->isEmpty()) {
|
if (str($this->publicPort)->isEmpty()) {
|
||||||
$this->publicPort = null;
|
$this->publicPort = null;
|
||||||
}
|
}
|
||||||
|
@@ -26,27 +26,38 @@ class Configuration extends Component
|
|||||||
|
|
||||||
public function mount()
|
public function mount()
|
||||||
{
|
{
|
||||||
$this->currentRoute = request()->route()->getName();
|
try {
|
||||||
|
$this->currentRoute = request()->route()->getName();
|
||||||
|
|
||||||
$project = currentTeam()
|
$project = currentTeam()
|
||||||
->projects()
|
->projects()
|
||||||
->select('id', 'uuid', 'team_id')
|
->select('id', 'uuid', 'team_id')
|
||||||
->where('uuid', request()->route('project_uuid'))
|
->where('uuid', request()->route('project_uuid'))
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
$environment = $project->environments()
|
$environment = $project->environments()
|
||||||
->select('id', 'name', 'project_id', 'uuid')
|
->select('id', 'name', 'project_id', 'uuid')
|
||||||
->where('uuid', request()->route('environment_uuid'))
|
->where('uuid', request()->route('environment_uuid'))
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
$database = $environment->databases()
|
$database = $environment->databases()
|
||||||
->where('uuid', request()->route('database_uuid'))
|
->where('uuid', request()->route('database_uuid'))
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
|
||||||
$this->database = $database;
|
$this->database = $database;
|
||||||
$this->project = $project;
|
$this->project = $project;
|
||||||
$this->environment = $environment;
|
$this->environment = $environment;
|
||||||
if (str($this->database->status)->startsWith('running') && is_null($this->database->config_hash)) {
|
if (str($this->database->status)->startsWith('running') && is_null($this->database->config_hash)) {
|
||||||
$this->database->isConfigurationChanged(true);
|
$this->database->isConfigurationChanged(true);
|
||||||
$this->dispatch('configurationChanged');
|
$this->dispatch('configurationChanged');
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
if ($e instanceof \Illuminate\Auth\Access\AuthorizationException) {
|
||||||
|
return redirect()->route('dashboard');
|
||||||
|
}
|
||||||
|
if ($e instanceof \Illuminate\Support\ItemNotFoundException) {
|
||||||
|
return redirect()->route('dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
return handleError($e, $this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Livewire\Project\Database;
|
namespace App\Livewire\Project\Database;
|
||||||
|
|
||||||
use App\Models\ScheduledDatabaseBackup;
|
use App\Models\ScheduledDatabaseBackup;
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Livewire\Attributes\Locked;
|
use Livewire\Attributes\Locked;
|
||||||
use Livewire\Attributes\Validate;
|
use Livewire\Attributes\Validate;
|
||||||
@@ -10,6 +11,8 @@ use Livewire\Component;
|
|||||||
|
|
||||||
class CreateScheduledBackup extends Component
|
class CreateScheduledBackup extends Component
|
||||||
{
|
{
|
||||||
|
use AuthorizesRequests;
|
||||||
|
|
||||||
#[Validate(['required', 'string'])]
|
#[Validate(['required', 'string'])]
|
||||||
public $frequency;
|
public $frequency;
|
||||||
|
|
||||||
@@ -41,6 +44,8 @@ class CreateScheduledBackup extends Component
|
|||||||
public function submit()
|
public function submit()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
$this->authorize('manageBackups', $this->database);
|
||||||
|
|
||||||
$this->validate();
|
$this->validate();
|
||||||
|
|
||||||
$isValid = validate_cron_expression($this->frequency);
|
$isValid = validate_cron_expression($this->frequency);
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user