diff --git a/.claude/agents/project-manager-backlog.md b/.claude/agents/project-manager-backlog.md deleted file mode 100644 index 1cc6ad612..000000000 --- a/.claude/agents/project-manager-backlog.md +++ /dev/null @@ -1,193 +0,0 @@ ---- -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: 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." 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. 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." The user has a complex set of features that need to be broken down into proper atomic tasks following backlog.md structure. 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." The user needs task review, so use the project-manager-backlog agent to ensure compliance with project guidelines. -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- - .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. diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 000000000..8c6715a15 --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "laravel-boost": { + "command": "php", + "args": [ + "artisan", + "boost:mcp" + ] + } + } +} \ No newline at end of file diff --git a/.cursor/rules/backlog-guildlines.md b/.cursor/rules/backlog-guildlines.md deleted file mode 100644 index ea95eb0b5..000000000 --- a/.cursor/rules/backlog-guildlines.md +++ /dev/null @@ -1,398 +0,0 @@ - -# === 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 === diff --git a/.cursor/rules/laravel-boost.mdc b/.cursor/rules/laravel-boost.mdc new file mode 100644 index 000000000..005ede849 --- /dev/null +++ b/.cursor/rules/laravel-boost.mdc @@ -0,0 +1,405 @@ +--- +alwaysApply: true +--- +<laravel-boost-guidelines> +=== foundation rules === + +# Laravel Boost Guidelines + +The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications. + +## Foundational Context +This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. + +- php - 8.4.7 +- laravel/fortify (FORTIFY) - v1 +- laravel/framework (LARAVEL) - v12 +- laravel/horizon (HORIZON) - v5 +- laravel/prompts (PROMPTS) - v0 +- laravel/sanctum (SANCTUM) - v4 +- laravel/socialite (SOCIALITE) - v5 +- livewire/livewire (LIVEWIRE) - v3 +- laravel/dusk (DUSK) - v8 +- laravel/pint (PINT) - v1 +- laravel/telescope (TELESCOPE) - v5 +- pestphp/pest (PEST) - v3 +- phpunit/phpunit (PHPUNIT) - v11 +- rector/rector (RECTOR) - v2 +- laravel-echo (ECHO) - v2 +- tailwindcss (TAILWINDCSS) - v4 +- vue (VUE) - v3 + + +## Conventions +- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming. +- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. +- Check for existing components to reuse before writing a new one. + +## Verification Scripts +- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important. + +## Application Structure & Architecture +- Stick to existing directory structure - don't create new base folders without approval. +- Do not change the application's dependencies without approval. + +## Frontend Bundling +- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them. + +## Replies +- Be concise in your explanations - focus on what's important rather than explaining obvious details. + +## Documentation Files +- You must only create documentation files if explicitly requested by the user. + + +=== boost rules === + +## Laravel Boost +- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them. + +## Artisan +- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters. + +## URLs +- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port. + +## Tinker / Debugging +- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly. +- Use the `database-query` tool when you only need to read from the database. + +## Reading Browser Logs With the `browser-logs` Tool +- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost. +- Only recent browser logs will be useful - ignore old logs. + +## Searching Documentation (Critically Important) +- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages. +- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc. +- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches. +- Search the documentation before making code changes to ensure we are taking the correct approach. +- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`. +- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`. + +### Available Search Syntax +- You can and should pass multiple queries at once. The most relevant results will be returned first. + +1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth' +2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit" +3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order +4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit" +5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms + + +=== php rules === + +## PHP + +- Always use curly braces for control structures, even if it has one line. + +### Constructors +- Use PHP 8 constructor property promotion in `__construct()`. + - <code-snippet>public function __construct(public GitHub $github) { }</code-snippet> +- Do not allow empty `__construct()` methods with zero parameters. + +### Type Declarations +- Always use explicit return type declarations for methods and functions. +- Use appropriate PHP type hints for method parameters. + +<code-snippet name="Explicit Return Types and Method Params" lang="php"> +protected function isAccessible(User $user, ?string $path = null): bool +{ + ... +} +</code-snippet> + +## Comments +- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on. + +## PHPDoc Blocks +- Add useful array shape type definitions for arrays when appropriate. + +## Enums +- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. + + +=== laravel/core rules === + +## Do Things the Laravel Way + +- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool. +- If you're creating a generic PHP class, use `artisan make:class`. +- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. + +### Database +- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins. +- Use Eloquent models and relationships before suggesting raw database queries +- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them. +- Generate code that prevents N+1 query problems by using eager loading. +- Use Laravel's query builder for very complex database operations. + +### Model Creation +- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`. + +### APIs & Eloquent Resources +- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. + +### Controllers & Validation +- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages. +- Check sibling Form Requests to see if the application uses array or string based validation rules. + +### Queues +- Use queued jobs for time-consuming operations with the `ShouldQueue` interface. + +### Authentication & Authorization +- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.). + +### URL Generation +- When generating links to other pages, prefer named routes and the `route()` function. + +### Configuration +- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`. + +### Testing +- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. +- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. +- When creating tests, make use of `php artisan make:test [options] <name>` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. + +### Vite Error +- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`. + + +=== laravel/v12 rules === + +## Laravel 12 + +- Use the `search-docs` tool to get version specific documentation. +- This project upgraded from Laravel 10 without migrating to the new streamlined Laravel file structure. +- This is **perfectly fine** and recommended by Laravel. Follow the existing structure from Laravel 10. We do not to need migrate to the new Laravel structure unless the user explicitly requests that. + +### Laravel 10 Structure +- Middleware typically lives in `app/Http/Middleware/` and service providers in `app/Providers/`. +- There is no `bootstrap/app.php` application configuration in a Laravel 10 structure: + - Middleware registration happens in `app/Http/Kernel.php` + - Exception handling is in `app/Exceptions/Handler.php` + - Console commands and schedule register in `app/Console/Kernel.php` + - Rate limits likely exist in `RouteServiceProvider` or `app/Http/Kernel.php` + +### Database +- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. +- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. + +### Models +- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. + + +=== livewire/core rules === + +## Livewire Core +- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests. +- Use the `php artisan make:livewire [Posts\\CreatePost]` artisan command to create new components +- State should live on the server, with the UI reflecting it. +- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions. + +## Livewire Best Practices +- Livewire components require a single root element. +- Use `wire:loading` and `wire:dirty` for delightful loading states. +- Add `wire:key` in loops: + + ```blade + @foreach ($items as $item) + <div wire:key="item-{{ $item->id }}"> + {{ $item->name }} + </div> + @endforeach + ``` + +- Prefer lifecycle hooks like `mount()`, `updatedFoo()`) for initialization and reactive side effects: + +<code-snippet name="Lifecycle hook examples" lang="php"> + public function mount(User $user) { $this->user = $user; } + public function updatedSearch() { $this->resetPage(); } +</code-snippet> + + +## Testing Livewire + +<code-snippet name="Example Livewire component test" lang="php"> + Livewire::test(Counter::class) + ->assertSet('count', 0) + ->call('increment') + ->assertSet('count', 1) + ->assertSee(1) + ->assertStatus(200); +</code-snippet> + + + <code-snippet name="Testing a Livewire component exists within a page" lang="php"> + $this->get('/posts/create') + ->assertSeeLivewire(CreatePost::class); + </code-snippet> + + +=== livewire/v3 rules === + +## Livewire 3 + +### Key Changes From Livewire 2 +- These things changed in Livewire 2, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions. + - Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default. + - Components now use the `App\Livewire` namespace (not `App\Http\Livewire`). + - Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`). + - Use the `components.layouts.app` view as the typical layout path (not `layouts.app`). + +### New Directives +- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples. + +### Alpine +- Alpine is now included with Livewire, don't manually include Alpine.js. +- Plugins included with Alpine: persist, intersect, collapse, and focus. + +### Lifecycle Hooks +- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring: + +<code-snippet name="livewire:load example" lang="js"> +document.addEventListener('livewire:init', function () { + Livewire.hook('request', ({ fail }) => { + if (fail && fail.status === 419) { + alert('Your session expired'); + } + }); + + Livewire.hook('message.failed', (message, component) => { + console.error(message); + }); +}); +</code-snippet> + + +=== pint/core rules === + +## Laravel Pint Code Formatter + +- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style. +- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues. + + +=== pest/core rules === + +## Pest + +### Testing +- If you need to verify a feature is working, write or update a Unit / Feature test. + +### Pest Tests +- All tests must be written using Pest. Use `php artisan make:test --pest <name>`. +- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application. +- Tests should test all of the happy paths, failure paths, and weird paths. +- Tests live in the `tests/Feature` and `tests/Unit` directories. +- Pest tests look and behave like this: +<code-snippet name="Basic Pest Test Example" lang="php"> +it('is true', function () { + expect(true)->toBeTrue(); +}); +</code-snippet> + +### Running Tests +- Run the minimal number of tests using an appropriate filter before finalizing code edits. +- To run all tests: `php artisan test`. +- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`. +- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file). +- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing. + +### Pest Assertions +- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.: +<code-snippet name="Pest Example Asserting postJson Response" lang="php"> +it('returns all', function () { + $response = $this->postJson('/api/docs', []); + + $response->assertSuccessful(); +}); +</code-snippet> + +### Mocking +- Mocking can be very helpful when appropriate. +- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do. +- You can also create partial mocks using the same import or self method. + +### Datasets +- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules. + +<code-snippet name="Pest Dataset Example" lang="php"> +it('has emails', function (string $email) { + expect($email)->not->toBeEmpty(); +})->with([ + 'james' => 'james@laravel.com', + 'taylor' => 'taylor@laravel.com', +]); +</code-snippet> + + +=== tailwindcss/core rules === + +## Tailwind Core + +- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own. +- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..) +- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically +- You can use the `search-docs` tool to get exact examples from the official documentation when needed. + +### Spacing +- When listing items, use gap utilities for spacing, don't use margins. + + <code-snippet name="Valid Flex Gap Spacing Example" lang="html"> + <div class="flex gap-8"> + <div>Superior</div> + <div>Michigan</div> + <div>Erie</div> + </div> + </code-snippet> + + +### Dark Mode +- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`. + + +=== tailwindcss/v4 rules === + +## Tailwind 4 + +- Always use Tailwind CSS v4 - do not use the deprecated utilities. +- `corePlugins` is not supported in Tailwind v4. +- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3: + +<code-snippet name="Tailwind v4 Import Tailwind Diff" lang="diff" + - @tailwind base; + - @tailwind components; + - @tailwind utilities; + + @import "tailwindcss"; +</code-snippet> + + +### Replaced Utilities +- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement. +- Opacity values are still numeric. + +| Deprecated | Replacement | +|------------+--------------| +| bg-opacity-* | bg-black/* | +| text-opacity-* | text-black/* | +| border-opacity-* | border-black/* | +| divide-opacity-* | divide-black/* | +| ring-opacity-* | ring-black/* | +| placeholder-opacity-* | placeholder-black/* | +| flex-shrink-* | shrink-* | +| flex-grow-* | grow-* | +| overflow-ellipsis | text-ellipsis | +| decoration-slice | box-decoration-slice | +| decoration-clone | box-decoration-clone | + + +=== tests rules === + +## Test Enforcement + +- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. +- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter. +</laravel-boost-guidelines> \ No newline at end of file diff --git a/.cursor/rules/testing-patterns.mdc b/.cursor/rules/testing-patterns.mdc index 010b76544..a0e64dbae 100644 --- a/.cursor/rules/testing-patterns.mdc +++ b/.cursor/rules/testing-patterns.mdc @@ -9,6 +9,8 @@ alwaysApply: false Coolify employs **comprehensive testing strategies** using modern PHP testing frameworks to ensure reliability of deployment operations, infrastructure management, and user interactions. +!Important: Always run tests inside `coolify` container. + ## Testing Framework Stack ### Core Testing Tools diff --git a/.github/workflows/coolify-production-build.yml b/.github/workflows/coolify-production-build.yml index 9286fdbb0..cd1f002b8 100644 --- a/.github/workflows/coolify-production-build.yml +++ b/.github/workflows/coolify-production-build.yml @@ -13,7 +13,6 @@ on: - docker/testing-host/Dockerfile - templates/** - CHANGELOG.md - - backlog/** env: GITHUB_REGISTRY: ghcr.io diff --git a/.github/workflows/coolify-staging-build.yml b/.github/workflows/coolify-staging-build.yml index 390eab000..09b1e9421 100644 --- a/.github/workflows/coolify-staging-build.yml +++ b/.github/workflows/coolify-staging-build.yml @@ -16,7 +16,6 @@ on: - docker/testing-host/Dockerfile - templates/** - CHANGELOG.md - - backlog/** env: GITHUB_REGISTRY: ghcr.io diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 000000000..8c6715a15 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "laravel-boost": { + "command": "php", + "args": [ + "artisan", + "boost:mcp" + ] + } + } +} \ No newline at end of file diff --git a/.phpactor.json b/.phpactor.json new file mode 100644 index 000000000..4d42bbbc5 --- /dev/null +++ b/.phpactor.json @@ -0,0 +1,4 @@ +{ + "$schema": "/phpactor.schema.json", + "language_server_phpstan.enabled": true +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 661029f98..4360a7c49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,150 @@ All notable changes to this project will be documented in this file. ## [unreleased] +### 📚 Documentation + +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- *(docker)* Add a blank line for improved readability in Dockerfile + +## [4.0.0-beta.428] - 2025-09-15 + +### 🚀 Features + +- *(deployment)* Enhance deployment status reporting with detailed information on active deployments and team members + +### 🐛 Bug Fixes + +- *(application)* Improve watch paths handling by trimming and filtering empty paths to prevent unnecessary triggers + +### 🚜 Refactor + +- *(deployment)* Streamline environment variable handling for dockercompose and improve sorting of runtime variables +- *(remoteProcess)* Remove command log comments for file transfers to simplify code +- *(remoteProcess)* Remove file transfer handling from remote_process and instant_remote_process functions to simplify code +- *(deployment)* Update environment file paths in docker compose commands to use working directory for improved consistency + +### 📚 Documentation + +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- *(constants)* Update realtime_version from 1.0.10 to 1.0.11 +- *(versions)* Increment coolify version to 4.0.0-beta.428 and update realtime_version to 1.0.10 + +## [4.0.0-beta.427] - 2025-09-15 + +### 🚀 Features + +- *(command)* Add option to sync GitHub releases to BunnyCDN and refactor sync logic +- *(ui)* Display current version in settings dropdown and update UI accordingly +- *(settings)* Add option to restrict PR deployments to repository members and contributors +- *(command)* Implement SSH command retry logic with exponential backoff and logging for better error handling +- *(ssh)* Add Sentry tracking for SSH retry events to enhance error monitoring +- *(exceptions)* Introduce NonReportableException to handle known errors and update Handler for selective reporting +- *(sudo-helper)* Add helper functions for command parsing and ownership management with sudo +- *(dev-command)* Dispatch CheckHelperImageJob during instance initialization to enhance setup process +- *(ssh-multiplexing)* Enhance multiplexed connection management with health checks and metadata caching +- *(ssh-multiplexing)* Add connection age metadata handling to improve multiplexed connection management +- *(database-backup)* Enhance error handling and output management in DatabaseBackupJob +- *(application)* Display parsing version in development mode and clean up domain conflict modal markup +- *(deployment)* Add SERVICE_NAME variables for service discovery +- *(storages)* Add method to retrieve the first storage ID for improved stability in storage display +- *(environment)* Add 'is_literal' attribute to environment variable for enhanced configuration options +- *(pre-commit)* Automate generation of service templates and OpenAPI documentation during pre-commit hook +- *(execute-container)* Enhance container command form with auto-connect feature for single container scenarios +- *(environment)* Introduce 'is_buildtime_only' attribute to environment variables for improved build-time configuration +- *(templates)* Add n8n service with PostgreSQL and worker support for enhanced workflow automation +- *(user-management)* Implement user deletion command with phased resource and subscription cancellation, including dry run option +- *(sentinel)* Add support for custom Docker images in StartSentinel and related methods +- *(sentinel)* Add slide-over for viewing Sentinel logs and custom Docker image input for development +- *(executions)* Add 'Load All' button to view all logs and implement loadAllLogs method for complete log retrieval +- *(auth)* Enhance user login flow to handle team invitations, attaching users to invited teams upon first login and maintaining personal team logic for regular logins +- *(laravel-boost)* Add Laravel Boost guidelines and MCP server configuration to enhance development experience + +### 🐛 Bug Fixes + +- *(ui)* Transactional email settings link on members page (#6491) +- *(api)* Add custom labels generation for applications with readonly container label setting enabled +- *(ui)* Add cursor pointer to upgrade button for better user interaction +- *(templates)* Update SECRET_KEY environment variable in getoutline.yaml to use SERVICE_HEX_32_OUTLINE +- *(command)* Enhance database deletion command to support multiple database types +- *(command)* Enhance cleanup process for stuck application previews by adding force delete for trashed records +- *(user)* Ensure email attributes are stored in lowercase for consistency and prevent case-related issues +- *(webhook)* Replace delete with forceDelete for application previews to ensure immediate removal +- *(ssh)* Introduce SshRetryHandler and SshRetryable trait for enhanced SSH command retry logic with exponential backoff and error handling +- Appwrite template - 500 errors, missing env vars etc. +- *(LocalFileVolume)* Add missing directory creation command for workdir in saveStorageOnServer method +- *(ScheduledTaskJob)* Replace generic Exception with NonReportableException for better error handling +- *(web-routes)* Enhance backup response messages to clarify local and S3 availability +- *(proxy)* Replace CheckConfiguration with GetProxyConfiguration and SaveConfiguration with SaveProxyConfiguration for improved clarity and consistency in proxy management +- *(private-key)* Implement transaction handling and error verification for private key storage operations +- *(deployment)* Add COOLIFY_* environment variables to Nixpacks build context for enhanced deployment configuration +- *(application)* Add functionality to stop and remove Docker containers on server +- *(templates)* Update 'compose' configuration for Appwrite service to enhance compatibility and streamline deployment +- *(security)* Update contact email for reporting vulnerabilities to enhance privacy +- *(feedback)* Update feedback email address to improve communication with users +- *(security)* Update contact email for vulnerability reports to improve security communication +- *(navbar)* Restrict subscription link visibility to admin users in cloud environment +- *(docker)* Enhance container status aggregation for multi-container applications, including exclusion handling based on docker-compose configuration + +### 🚜 Refactor + +- *(jobs)* Pull github changelogs from cdn instead of github +- *(command)* Streamline database deletion process to handle multiple database types and improve user experience +- *(command)* Improve database collection logic for deletion command by using unique identifiers and enhancing user experience +- *(command)* Remove InitChangelog command as it is no longer needed +- *(command)* Streamline Init command by removing unnecessary options and enhancing error handling for various operations +- *(webhook)* Replace direct forceDelete calls with DeleteResourceJob dispatch for application previews +- *(command)* Replace forceDelete calls with DeleteResourceJob dispatch for all stuck resources in cleanup process +- *(command)* Simplify SSH command retry logic by removing unnecessary logging and improving delay calculation +- *(ssh)* Enhance error handling in SSH command execution and improve connection validation logging +- *(backlog)* Remove outdated guidelines and project manager agent files to streamline task management documentation +- *(error-handling)* Remove ray debugging statements from CheckUpdates and shared helper functions to clean up error reporting +- *(file-transfer)* Replace base64 encoding with direct file transfer method across multiple database actions for improved clarity and efficiency +- *(remoteProcess)* Remove debugging statement from transfer_file_to_server function to clean up code +- *(dns-validation)* Rename DNS validation functions for consistency and clarity, and remove unused code +- *(file-transfer)* Replace base64 encoding with direct file transfer method in various components for improved clarity and efficiency +- *(private-key)* Remove debugging statement from storeInFileSystem method for cleaner code +- *(github-webhook)* Restructure application processing by grouping applications by server for improved deployment handling +- *(deployment)* Enhance queuing logic to support concurrent deployments by including pull request ID in checks +- *(remoteProcess)* Remove debugging statement from transfer_file_to_container function for cleaner code +- *(deployment)* Streamline next deployment queuing logic by repositioning queue_next_deployment call +- *(deployment)* Add validation for pull request existence in deployment process to enhance error handling +- *(database)* Remove volume_configuration_dir and streamline configuration directory usage in MongoDB and PostgreSQL handlers +- *(application-source)* Improve layout and accessibility of Git repository links in the application source view +- *(models)* Remove 'is_readonly' attribute from multiple database models for consistency +- *(webhook)* Remove Webhook model and related logic; add migrations to drop webhooks and kubernetes tables +- *(clone)* Consolidate application cloning logic into a dedicated function for improved maintainability and readability +- *(clone)* Integrate preview cloning logic directly into application cloning function for improved clarity and maintainability +- *(application)* Enhance environment variable retrieval in configuration change check for improved accuracy +- *(clone)* Enhance application cloning by separating production and preview environment variable handling +- *(deployment)* Add environment variable copying logic to Docker build commands for pull requests +- *(environment)* Standardize service name formatting by replacing '-' and '.' with '_' in environment variable keys +- *(deployment)* Update environment file handling in Docker commands to use '/artifacts/' path and streamline variable management +- *(openapi)* Remove 'is_build_time' attribute from environment variable definitions to streamline configuration +- *(environment)* Remove 'is_build_time' attribute from environment variable handling across the application to simplify configuration +- *(environment)* Streamline environment variable handling by replacing sorting methods with direct property access and enhancing query ordering for improved performance +- *(stripe-jobs)* Comment out internal notification calls and add subscription status verification before sending failure notifications + +### 📚 Documentation + +- Update changelog +- *(testing-patterns)* Add important note to always run tests inside the `coolify` container for clarity + +### ⚙️ Miscellaneous Tasks + +- Update coolify version to 4.0.0-beta.427 and nightly version to 4.0.0-beta.428 +- Use main value then fallback to service_ values +- Remove webhooks table cleanup +- *(cleanup)* Remove deprecated ServerCheck and related job classes to streamline codebase +- *(versions)* Update sentinel version from 0.0.15 to 0.0.16 in versions.json files + +## [4.0.0-beta.426] - 2025-08-28 + ### 🚜 Refactor - *(policy)* Simplify ServiceDatabasePolicy methods to always return true and add manageBackups method diff --git a/CLAUDE.md b/CLAUDE.md index 87409c260..22e762182 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -248,402 +248,411 @@ For more detailed guidelines and patterns, refer to the `.cursor/rules/` directo - [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 GUIDELINES START === -# Instructions for the usage of Backlog.md CLI Tool +<laravel-boost-guidelines> +=== foundation rules === -## What is Backlog.md? +# Laravel Boost Guidelines -**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. +The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications. -### Core Capabilities +## Foundational Context +This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. -✅ **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 +- php - 8.4.7 +- laravel/fortify (FORTIFY) - v1 +- laravel/framework (LARAVEL) - v12 +- laravel/horizon (HORIZON) - v5 +- laravel/prompts (PROMPTS) - v0 +- laravel/sanctum (SANCTUM) - v4 +- laravel/socialite (SOCIALITE) - v5 +- livewire/livewire (LIVEWIRE) - v3 +- laravel/dusk (DUSK) - v8 +- laravel/pint (PINT) - v1 +- laravel/telescope (TELESCOPE) - v5 +- pestphp/pest (PEST) - v3 +- phpunit/phpunit (PHPUNIT) - v11 +- rector/rector (RECTOR) - v2 +- laravel-echo (ECHO) - v2 +- tailwindcss (TAILWINDCSS) - v4 +- vue (VUE) - v3 -### 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 +## Conventions +- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming. +- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. +- Check for existing components to reuse before writing a new one. -### Key Understanding +## Verification Scripts +- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important. -- **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 - ---- +## Application Structure & Architecture +- Stick to existing directory structure - don't create new base folders without approval. +- Do not change the application's dependencies without approval. -# ⚠️ CRITICAL: NEVER EDIT TASK FILES DIRECTLY +## Frontend Bundling +- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them. -**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 +## Replies +- Be concise in your explanations - focus on what's important rather than explaining obvious details. -**Why?** Direct file editing breaks metadata synchronization, Git tracking, and task relationships. +## Documentation Files +- You must only create documentation files if explicitly requested by the user. ---- -## 1. Source of Truth & File Structure +=== boost rules === -### 📖 **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/`** +## Laravel Boost +- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them. -### 🔧 **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 +## Artisan +- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters. ---- +## URLs +- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port. -## 2. Common Mistakes to Avoid +## Tinker / Debugging +- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly. +- Use the `database-query` tool when you only need to read from the database. -### ❌ **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 -``` +## Reading Browser Logs With the `browser-logs` Tool +- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost. +- Only recent browser logs will be useful - ignore old logs. -### ✅ **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 -``` +## Searching Documentation (Critically Important) +- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages. +- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc. +- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches. +- Search the documentation before making code changes to ensure we are taking the correct approach. +- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`. +- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`. ---- +### Available Search Syntax +- You can and should pass multiple queries at once. The most relevant results will be returned first. -## 3. Understanding Task Format (Read-Only Reference) +1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth' +2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit" +3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order +4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit" +5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms -⚠️ **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 +=== php rules === -```markdown ---- -id: task-42 -title: Add GraphQL resolver -status: To Do -assignee: [@sara] -labels: [backend, api] ---- +## PHP -## Description -Brief explanation of the task purpose. +- Always use curly braces for control structures, even if it has one line. -## 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. -``` +### Constructors +- Use PHP 8 constructor property promotion in `__construct()`. + - <code-snippet>public function __construct(public GitHub $github) { }</code-snippet> +- Do not allow empty `__construct()` methods with zero parameters. -### How to Modify Each Section +### Type Declarations +- Always use explicit return type declarations for methods and functions. +- Use appropriate PHP type hints for method parameters. -| 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. +<code-snippet name="Explicit Return Types and Method Params" lang="php"> +protected function isAccessible(User $user, ?string $path = null): bool +{ + ... +} +</code-snippet> -### 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:** +## Comments +- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on. -⚠️ **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` ✅ +## PHPDoc Blocks +- Add useful array shape type definitions for arrays when appropriate. -```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 +## Enums +- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. -# 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 +=== laravel/core rules === -# 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) -``` +## Do Things the Laravel Way -**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 +- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool. +- If you're creating a generic PHP class, use `artisan make:class`. +- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. -Good Examples: -- "User can successfully log in with valid credentials" -- "System processes 1000 requests per second without errors" +### Database +- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins. +- Use Eloquent models and relationships before suggesting raw database queries +- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them. +- Generate code that prevents N+1 query problems by using eager loading. +- Use Laravel's query builder for very complex database operations. -Bad Example (Implementation Step): -- "Add a new function handleLogin() in auth.ts" +### Model Creation +- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`. -### Task Breakdown Strategy +### APIs & Eloquent Resources +- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. -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 +### Controllers & Validation +- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages. +- Check sibling Form Requests to see if the application uses array or string based validation rules. -### Task Requirements +### Queues +- Use queued jobs for time-consuming operations with the `ShouldQueue` interface. -- 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 +### Authentication & Authorization +- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.). ---- +### URL Generation +- When generating links to other pages, prefer named routes and the `route()` function. -## 5. Implementing Tasks +### Configuration +- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`. -### 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" -``` +### Testing +- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. +- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. +- When creating tests, make use of `php artisan make:test [options] <name>` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. -### 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" -``` +### Vite Error +- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`. -**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. +=== laravel/v12 rules === -**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"` +## Laravel 12 ---- - -## 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 +- Use the `search-docs` tool to get version specific documentation. +- This project upgraded from Laravel 10 without migrating to the new streamlined Laravel file structure. +- This is **perfectly fine** and recommended by Laravel. Follow the existing structure from Laravel 10. We do not to need migrate to the new Laravel structure unless the user explicitly requests that. -⚠️ **NEVER mark a task as Done without completing ALL items above** +### Laravel 10 Structure +- Middleware typically lives in `app/Http/Middleware/` and service providers in `app/Providers/`. +- There is no `bootstrap/app.php` application configuration in a Laravel 10 structure: + - Middleware registration happens in `app/Http/Kernel.php` + - Exception handling is in `app/Exceptions/Handler.php` + - Console commands and schedule register in `app/Console/Kernel.php` + - Rate limits likely exist in `RouteServiceProvider` or `app/Http/Kernel.php` ---- +### Database +- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. +- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. -## 8. Quick Reference: DO vs DON'T +### Models +- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. -### 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 | +=== livewire/core rules === ---- +## Livewire Core +- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests. +- Use the `php artisan make:livewire [Posts\\CreatePost]` artisan command to create new components +- State should live on the server, with the UI reflecting it. +- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions. -## 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` | +## Livewire Best Practices +- Livewire components require a single root element. +- Use `wire:loading` and `wire:dirty` for delightful loading states. +- Add `wire:key` in loops: -### 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` | + ```blade + @foreach ($items as $item) + <div wire:key="item-{{ $item->id }}"> + {{ $item->name }} + </div> + @endforeach + ``` -### 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 === +- Prefer lifecycle hooks like `mount()`, `updatedFoo()`) for initialization and reactive side effects: +<code-snippet name="Lifecycle hook examples" lang="php"> + public function mount(User $user) { $this->user = $user; } + public function updatedSearch() { $this->resetPage(); } +</code-snippet> + + +## Testing Livewire + +<code-snippet name="Example Livewire component test" lang="php"> + Livewire::test(Counter::class) + ->assertSet('count', 0) + ->call('increment') + ->assertSet('count', 1) + ->assertSee(1) + ->assertStatus(200); +</code-snippet> + + + <code-snippet name="Testing a Livewire component exists within a page" lang="php"> + $this->get('/posts/create') + ->assertSeeLivewire(CreatePost::class); + </code-snippet> + + +=== livewire/v3 rules === + +## Livewire 3 + +### Key Changes From Livewire 2 +- These things changed in Livewire 2, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions. + - Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default. + - Components now use the `App\Livewire` namespace (not `App\Http\Livewire`). + - Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`). + - Use the `components.layouts.app` view as the typical layout path (not `layouts.app`). + +### New Directives +- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples. + +### Alpine +- Alpine is now included with Livewire, don't manually include Alpine.js. +- Plugins included with Alpine: persist, intersect, collapse, and focus. + +### Lifecycle Hooks +- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring: + +<code-snippet name="livewire:load example" lang="js"> +document.addEventListener('livewire:init', function () { + Livewire.hook('request', ({ fail }) => { + if (fail && fail.status === 419) { + alert('Your session expired'); + } + }); + + Livewire.hook('message.failed', (message, component) => { + console.error(message); + }); +}); +</code-snippet> + + +=== pint/core rules === + +## Laravel Pint Code Formatter + +- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style. +- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues. + + +=== pest/core rules === + +## Pest + +### Testing +- If you need to verify a feature is working, write or update a Unit / Feature test. + +### Pest Tests +- All tests must be written using Pest. Use `php artisan make:test --pest <name>`. +- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application. +- Tests should test all of the happy paths, failure paths, and weird paths. +- Tests live in the `tests/Feature` and `tests/Unit` directories. +- Pest tests look and behave like this: +<code-snippet name="Basic Pest Test Example" lang="php"> +it('is true', function () { + expect(true)->toBeTrue(); +}); +</code-snippet> + +### Running Tests +- Run the minimal number of tests using an appropriate filter before finalizing code edits. +- To run all tests: `php artisan test`. +- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`. +- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file). +- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing. + +### Pest Assertions +- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.: +<code-snippet name="Pest Example Asserting postJson Response" lang="php"> +it('returns all', function () { + $response = $this->postJson('/api/docs', []); + + $response->assertSuccessful(); +}); +</code-snippet> + +### Mocking +- Mocking can be very helpful when appropriate. +- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do. +- You can also create partial mocks using the same import or self method. + +### Datasets +- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules. + +<code-snippet name="Pest Dataset Example" lang="php"> +it('has emails', function (string $email) { + expect($email)->not->toBeEmpty(); +})->with([ + 'james' => 'james@laravel.com', + 'taylor' => 'taylor@laravel.com', +]); +</code-snippet> + + +=== tailwindcss/core rules === + +## Tailwind Core + +- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own. +- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..) +- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically +- You can use the `search-docs` tool to get exact examples from the official documentation when needed. + +### Spacing +- When listing items, use gap utilities for spacing, don't use margins. + + <code-snippet name="Valid Flex Gap Spacing Example" lang="html"> + <div class="flex gap-8"> + <div>Superior</div> + <div>Michigan</div> + <div>Erie</div> + </div> + </code-snippet> + + +### Dark Mode +- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`. + + +=== tailwindcss/v4 rules === + +## Tailwind 4 + +- Always use Tailwind CSS v4 - do not use the deprecated utilities. +- `corePlugins` is not supported in Tailwind v4. +- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3: + +<code-snippet name="Tailwind v4 Import Tailwind Diff" lang="diff" + - @tailwind base; + - @tailwind components; + - @tailwind utilities; + + @import "tailwindcss"; +</code-snippet> + + +### Replaced Utilities +- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement. +- Opacity values are still numeric. + +| Deprecated | Replacement | +|------------+--------------| +| bg-opacity-* | bg-black/* | +| text-opacity-* | text-black/* | +| border-opacity-* | border-black/* | +| divide-opacity-* | divide-black/* | +| ring-opacity-* | ring-black/* | +| placeholder-opacity-* | placeholder-black/* | +| flex-shrink-* | shrink-* | +| flex-grow-* | grow-* | +| overflow-ellipsis | text-ellipsis | +| decoration-slice | box-decoration-slice | +| decoration-clone | box-decoration-clone | + + +=== tests rules === + +## Test Enforcement + +- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. +- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter. +</laravel-boost-guidelines> + + +Random other things you should remember: +- App\Models\Application::team must return a relationship instance., always use team() \ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md index 0711bf5b5..e491737ef 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -18,7 +18,7 @@ We take security seriously. Security updates are released as soon as possible af If you discover a security vulnerability, please follow these steps: 1. **DO NOT** disclose the vulnerability publicly. -2. Send a detailed report to: `hi@coollabs.io`. +2. Send a detailed report to: `security@coollabs.io`. 3. Include in your report: - A description of the vulnerability - Steps to reproduce the issue diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php index 4314ccd2f..38d46b3c1 100644 --- a/app/Actions/Database/StartPostgresql.php +++ b/app/Actions/Database/StartPostgresql.php @@ -229,6 +229,8 @@ class StartPostgresql } $this->commands[] = "echo 'Database started.'"; + ray($this->commands); + return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged'); } diff --git a/app/Actions/Docker/GetContainersStatus.php b/app/Actions/Docker/GetContainersStatus.php index c3268ec07..f5d5f82b6 100644 --- a/app/Actions/Docker/GetContainersStatus.php +++ b/app/Actions/Docker/GetContainersStatus.php @@ -26,6 +26,8 @@ class GetContainersStatus public $server; + protected ?Collection $applicationContainerStatuses; + public function handle(Server $server, ?Collection $containers = null, ?Collection $containerReplicates = null) { $this->containers = $containers; @@ -94,7 +96,11 @@ class GetContainersStatus } $containerStatus = data_get($container, 'State.Status'); $containerHealth = data_get($container, 'State.Health.Status', 'unhealthy'); - $containerStatus = "$containerStatus ($containerHealth)"; + if ($containerStatus === 'restarting') { + $containerStatus = "restarting ($containerHealth)"; + } else { + $containerStatus = "$containerStatus ($containerHealth)"; + } $labels = Arr::undot(format_docker_labels_to_json($labels)); $applicationId = data_get($labels, 'coolify.applicationId'); if ($applicationId) { @@ -119,11 +125,16 @@ class GetContainersStatus $application = $this->applications->where('id', $applicationId)->first(); if ($application) { $foundApplications[] = $application->id; - $statusFromDb = $application->status; - if ($statusFromDb !== $containerStatus) { - $application->update(['status' => $containerStatus]); - } else { - $application->update(['last_online_at' => now()]); + // Store container status for aggregation + if (! isset($this->applicationContainerStatuses)) { + $this->applicationContainerStatuses = collect(); + } + if (! $this->applicationContainerStatuses->has($applicationId)) { + $this->applicationContainerStatuses->put($applicationId, collect()); + } + $containerName = data_get($labels, 'com.docker.compose.service'); + if ($containerName) { + $this->applicationContainerStatuses->get($applicationId)->put($containerName, $containerStatus); } } else { // Notify user that this container should not be there. @@ -320,6 +331,97 @@ class GetContainersStatus } // $this->server->team?->notify(new ContainerStopped($containerName, $this->server, $url)); } + + // Aggregate multi-container application statuses + if (isset($this->applicationContainerStatuses) && $this->applicationContainerStatuses->isNotEmpty()) { + foreach ($this->applicationContainerStatuses as $applicationId => $containerStatuses) { + $application = $this->applications->where('id', $applicationId)->first(); + if (! $application) { + continue; + } + + $aggregatedStatus = $this->aggregateApplicationStatus($application, $containerStatuses); + if ($aggregatedStatus) { + $statusFromDb = $application->status; + if ($statusFromDb !== $aggregatedStatus) { + $application->update(['status' => $aggregatedStatus]); + } else { + $application->update(['last_online_at' => now()]); + } + } + } + } + ServiceChecked::dispatch($this->server->team->id); } + + private function aggregateApplicationStatus($application, Collection $containerStatuses): ?string + { + // Parse docker compose to check for excluded containers + $dockerComposeRaw = data_get($application, 'docker_compose_raw'); + $excludedContainers = collect(); + + if ($dockerComposeRaw) { + try { + $dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw); + $services = data_get($dockerCompose, 'services', []); + + foreach ($services as $serviceName => $serviceConfig) { + // Check if container should be excluded + $excludeFromHc = data_get($serviceConfig, 'exclude_from_hc', false); + $restartPolicy = data_get($serviceConfig, 'restart', 'always'); + + if ($excludeFromHc || $restartPolicy === 'no') { + $excludedContainers->push($serviceName); + } + } + } catch (\Exception $e) { + // If we can't parse, treat all containers as included + } + } + + // Filter out excluded containers + $relevantStatuses = $containerStatuses->filter(function ($status, $containerName) use ($excludedContainers) { + return ! $excludedContainers->contains($containerName); + }); + + // If all containers are excluded, don't update status + if ($relevantStatuses->isEmpty()) { + return null; + } + + $hasRunning = false; + $hasRestarting = false; + $hasUnhealthy = false; + $hasExited = false; + + foreach ($relevantStatuses as $status) { + if (str($status)->contains('restarting')) { + $hasRestarting = true; + } elseif (str($status)->contains('running')) { + $hasRunning = true; + if (str($status)->contains('unhealthy')) { + $hasUnhealthy = true; + } + } elseif (str($status)->contains('exited')) { + $hasExited = true; + $hasUnhealthy = true; + } + } + + if ($hasRestarting) { + return 'degraded (unhealthy)'; + } + + if ($hasRunning && $hasExited) { + return 'degraded (unhealthy)'; + } + + if ($hasRunning) { + return $hasUnhealthy ? 'running (unhealthy)' : 'running (healthy)'; + } + + // All containers are exited + return 'exited (unhealthy)'; + } } diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index ea2befd3a..9f97dd0d4 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -40,7 +40,7 @@ class CreateNewUser implements CreatesNewUsers $user = User::create([ 'id' => 0, 'name' => $input['name'], - 'email' => strtolower($input['email']), + 'email' => $input['email'], 'password' => Hash::make($input['password']), ]); $team = $user->teams()->first(); @@ -52,7 +52,7 @@ class CreateNewUser implements CreatesNewUsers } else { $user = User::create([ 'name' => $input['name'], - 'email' => strtolower($input['email']), + 'email' => $input['email'], 'password' => Hash::make($input['password']), ]); $team = $user->teams()->first(); diff --git a/app/Actions/Proxy/CheckConfiguration.php b/app/Actions/Proxy/CheckConfiguration.php deleted file mode 100644 index b2d1eb787..000000000 --- a/app/Actions/Proxy/CheckConfiguration.php +++ /dev/null @@ -1,36 +0,0 @@ -<?php - -namespace App\Actions\Proxy; - -use App\Models\Server; -use App\Services\ProxyDashboardCacheService; -use Lorisleiva\Actions\Concerns\AsAction; - -class CheckConfiguration -{ - use AsAction; - - public function handle(Server $server, bool $reset = false) - { - $proxyType = $server->proxyType(); - if ($proxyType === 'NONE') { - return 'OK'; - } - $proxy_path = $server->proxyPath(); - $payload = [ - "mkdir -p $proxy_path", - "cat $proxy_path/docker-compose.yml", - ]; - $proxy_configuration = instant_remote_process($payload, $server, false); - if ($reset || ! $proxy_configuration || is_null($proxy_configuration)) { - $proxy_configuration = str(generate_default_proxy_configuration($server))->trim()->value(); - } - if (! $proxy_configuration || is_null($proxy_configuration)) { - throw new \Exception('Could not generate proxy configuration'); - } - - ProxyDashboardCacheService::isTraefikDashboardAvailableFromConfiguration($server, $proxy_configuration); - - return $proxy_configuration; - } -} diff --git a/app/Actions/Proxy/CheckProxy.php b/app/Actions/Proxy/CheckProxy.php index a06e547c5..99537e606 100644 --- a/app/Actions/Proxy/CheckProxy.php +++ b/app/Actions/Proxy/CheckProxy.php @@ -70,7 +70,7 @@ class CheckProxy try { if ($server->proxyType() !== ProxyTypes::NONE->value) { - $proxyCompose = CheckConfiguration::run($server); + $proxyCompose = GetProxyConfiguration::run($server); if (isset($proxyCompose)) { $yaml = Yaml::parse($proxyCompose); $configPorts = []; diff --git a/app/Actions/Proxy/GetProxyConfiguration.php b/app/Actions/Proxy/GetProxyConfiguration.php new file mode 100644 index 000000000..3bf91c281 --- /dev/null +++ b/app/Actions/Proxy/GetProxyConfiguration.php @@ -0,0 +1,47 @@ +<?php + +namespace App\Actions\Proxy; + +use App\Models\Server; +use App\Services\ProxyDashboardCacheService; +use Lorisleiva\Actions\Concerns\AsAction; + +class GetProxyConfiguration +{ + use AsAction; + + public function handle(Server $server, bool $forceRegenerate = false): string + { + $proxyType = $server->proxyType(); + if ($proxyType === 'NONE') { + return 'OK'; + } + + $proxy_path = $server->proxyPath(); + $proxy_configuration = null; + + // If not forcing regeneration, try to read existing configuration + if (! $forceRegenerate) { + $payload = [ + "mkdir -p $proxy_path", + "cat $proxy_path/docker-compose.yml 2>/dev/null", + ]; + $proxy_configuration = instant_remote_process($payload, $server, false); + } + + // Generate default configuration if: + // 1. Force regenerate is requested + // 2. Configuration file doesn't exist or is empty + if ($forceRegenerate || empty(trim($proxy_configuration ?? ''))) { + $proxy_configuration = str(generate_default_proxy_configuration($server))->trim()->value(); + } + + if (empty($proxy_configuration)) { + throw new \Exception('Could not get or generate proxy configuration'); + } + + ProxyDashboardCacheService::isTraefikDashboardAvailableFromConfiguration($server, $proxy_configuration); + + return $proxy_configuration; + } +} diff --git a/app/Actions/Proxy/SaveConfiguration.php b/app/Actions/Proxy/SaveProxyConfiguration.php similarity index 59% rename from app/Actions/Proxy/SaveConfiguration.php rename to app/Actions/Proxy/SaveProxyConfiguration.php index f2de2b3f5..53fbecce2 100644 --- a/app/Actions/Proxy/SaveConfiguration.php +++ b/app/Actions/Proxy/SaveProxyConfiguration.php @@ -5,22 +5,21 @@ namespace App\Actions\Proxy; use App\Models\Server; use Lorisleiva\Actions\Concerns\AsAction; -class SaveConfiguration +class SaveProxyConfiguration { use AsAction; - public function handle(Server $server, ?string $proxy_settings = null) + public function handle(Server $server, string $configuration): void { - if (is_null($proxy_settings)) { - $proxy_settings = CheckConfiguration::run($server, true); - } $proxy_path = $server->proxyPath(); - $docker_compose_yml_base64 = base64_encode($proxy_settings); + $docker_compose_yml_base64 = base64_encode($configuration); + // Update the saved settings hash $server->proxy->last_saved_settings = str($docker_compose_yml_base64)->pipe('md5')->value; $server->save(); - return instant_remote_process([ + // Transfer the configuration file to the server + instant_remote_process([ "mkdir -p $proxy_path", "echo '$docker_compose_yml_base64' | base64 -d | tee $proxy_path/docker-compose.yml > /dev/null", ], $server); diff --git a/app/Actions/Proxy/StartProxy.php b/app/Actions/Proxy/StartProxy.php index e7c020ff6..ecfb13d0b 100644 --- a/app/Actions/Proxy/StartProxy.php +++ b/app/Actions/Proxy/StartProxy.php @@ -21,11 +21,11 @@ class StartProxy } $commands = collect([]); $proxy_path = $server->proxyPath(); - $configuration = CheckConfiguration::run($server); + $configuration = GetProxyConfiguration::run($server); if (! $configuration) { throw new \Exception('Configuration is not synced'); } - SaveConfiguration::run($server, $configuration); + SaveProxyConfiguration::run($server, $configuration); $docker_compose_yml_base64 = base64_encode($configuration); $server->proxy->last_applied_settings = str($docker_compose_yml_base64)->pipe('md5')->value(); $server->save(); diff --git a/app/Actions/Server/CheckUpdates.php b/app/Actions/Server/CheckUpdates.php index a8b1be11d..6823dfb92 100644 --- a/app/Actions/Server/CheckUpdates.php +++ b/app/Actions/Server/CheckUpdates.php @@ -102,7 +102,6 @@ class CheckUpdates ]; } } catch (\Throwable $e) { - ray('Error:', $e->getMessage()); return [ 'osId' => $osId, diff --git a/app/Actions/Server/ServerCheck.php b/app/Actions/Server/ServerCheck.php deleted file mode 100644 index 6ac87f1f0..000000000 --- a/app/Actions/Server/ServerCheck.php +++ /dev/null @@ -1,268 +0,0 @@ -<?php - -namespace App\Actions\Server; - -use App\Actions\Database\StartDatabaseProxy; -use App\Actions\Proxy\CheckProxy; -use App\Actions\Proxy\StartProxy; -use App\Jobs\CheckAndStartSentinelJob; -use App\Jobs\ServerStorageCheckJob; -use App\Models\Application; -use App\Models\ApplicationPreview; -use App\Models\Server; -use App\Models\Service; -use App\Models\ServiceApplication; -use App\Models\ServiceDatabase; -use App\Notifications\Container\ContainerRestarted; -use Illuminate\Support\Arr; -use Lorisleiva\Actions\Concerns\AsAction; - -class ServerCheck -{ - use AsAction; - - public Server $server; - - public bool $isSentinel = false; - - public $containers; - - public $databases; - - public function handle(Server $server, $data = null) - { - $this->server = $server; - try { - if ($this->server->isFunctional() === false) { - return 'Server is not functional.'; - } - - if (! $this->server->isSwarmWorker() && ! $this->server->isBuildServer()) { - - if (isset($data)) { - $data = collect($data); - - $this->server->sentinelHeartbeat(); - - $this->containers = collect(data_get($data, 'containers')); - - $filesystemUsageRoot = data_get($data, 'filesystem_usage_root.used_percentage'); - ServerStorageCheckJob::dispatch($this->server, $filesystemUsageRoot); - - $containerReplicates = null; - $this->isSentinel = true; - } else { - ['containers' => $this->containers, 'containerReplicates' => $containerReplicates] = $this->server->getContainers(); - // ServerStorageCheckJob::dispatch($this->server); - } - - if (is_null($this->containers)) { - return 'No containers found.'; - } - - if (isset($containerReplicates)) { - foreach ($containerReplicates as $containerReplica) { - $name = data_get($containerReplica, 'Name'); - $this->containers = $this->containers->map(function ($container) use ($name, $containerReplica) { - if (data_get($container, 'Spec.Name') === $name) { - $replicas = data_get($containerReplica, 'Replicas'); - $running = str($replicas)->explode('/')[0]; - $total = str($replicas)->explode('/')[1]; - if ($running === $total) { - data_set($container, 'State.Status', 'running'); - data_set($container, 'State.Health.Status', 'healthy'); - } else { - data_set($container, 'State.Status', 'starting'); - data_set($container, 'State.Health.Status', 'unhealthy'); - } - } - - return $container; - }); - } - } - $this->checkContainers(); - - if ($this->server->isSentinelEnabled() && $this->isSentinel === false) { - CheckAndStartSentinelJob::dispatch($this->server); - } - - if ($this->server->isLogDrainEnabled()) { - $this->checkLogDrainContainer(); - } - - if ($this->server->proxySet() && ! $this->server->proxy->force_stop) { - $foundProxyContainer = $this->containers->filter(function ($value, $key) { - if ($this->server->isSwarm()) { - return data_get($value, 'Spec.Name') === 'coolify-proxy_traefik'; - } else { - return data_get($value, 'Name') === '/coolify-proxy'; - } - })->first(); - $proxyStatus = data_get($foundProxyContainer, 'State.Status', 'exited'); - if (! $foundProxyContainer || $proxyStatus !== 'running') { - try { - $shouldStart = CheckProxy::run($this->server); - if ($shouldStart) { - StartProxy::run($this->server, async: false); - $this->server->team?->notify(new ContainerRestarted('coolify-proxy', $this->server)); - } - } catch (\Throwable $e) { - } - } else { - $this->server->proxy->status = data_get($foundProxyContainer, 'State.Status'); - $this->server->save(); - $connectProxyToDockerNetworks = connectProxyToNetworks($this->server); - instant_remote_process($connectProxyToDockerNetworks, $this->server, false); - } - } - } - } catch (\Throwable $e) { - return handleError($e); - } - } - - private function checkLogDrainContainer() - { - $foundLogDrainContainer = $this->containers->filter(function ($value, $key) { - return data_get($value, 'Name') === '/coolify-log-drain'; - })->first(); - if ($foundLogDrainContainer) { - $status = data_get($foundLogDrainContainer, 'State.Status'); - if ($status !== 'running') { - StartLogDrain::dispatch($this->server); - } - } else { - StartLogDrain::dispatch($this->server); - } - } - - private function checkContainers() - { - foreach ($this->containers as $container) { - if ($this->isSentinel) { - $labels = Arr::undot(data_get($container, 'labels')); - } else { - if ($this->server->isSwarm()) { - $labels = Arr::undot(data_get($container, 'Spec.Labels')); - } else { - $labels = Arr::undot(data_get($container, 'Config.Labels')); - } - } - $managed = data_get($labels, 'coolify.managed'); - if (! $managed) { - continue; - } - $uuid = data_get($labels, 'coolify.name'); - if (! $uuid) { - $uuid = data_get($labels, 'com.docker.compose.service'); - } - - if ($this->isSentinel) { - $containerStatus = data_get($container, 'state'); - $containerHealth = data_get($container, 'health_status'); - } else { - $containerStatus = data_get($container, 'State.Status'); - $containerHealth = data_get($container, 'State.Health.Status', 'unhealthy'); - } - $containerStatus = "$containerStatus ($containerHealth)"; - - $applicationId = data_get($labels, 'coolify.applicationId'); - $serviceId = data_get($labels, 'coolify.serviceId'); - $databaseId = data_get($labels, 'coolify.databaseId'); - $pullRequestId = data_get($labels, 'coolify.pullRequestId'); - - if ($applicationId) { - // Application - if ($pullRequestId != 0) { - if (str($applicationId)->contains('-')) { - $applicationId = str($applicationId)->before('-'); - } - $preview = ApplicationPreview::where('application_id', $applicationId)->where('pull_request_id', $pullRequestId)->first(); - if ($preview) { - $preview->update(['status' => $containerStatus]); - } - } else { - $application = Application::where('id', $applicationId)->first(); - if ($application) { - $application->update([ - 'status' => $containerStatus, - 'last_online_at' => now(), - ]); - } - } - } elseif (isset($serviceId)) { - // Service - $subType = data_get($labels, 'coolify.service.subType'); - $subId = data_get($labels, 'coolify.service.subId'); - $service = Service::where('id', $serviceId)->first(); - if (! $service) { - continue; - } - if ($subType === 'application') { - $service = ServiceApplication::where('id', $subId)->first(); - } else { - $service = ServiceDatabase::where('id', $subId)->first(); - } - if ($service) { - $service->update([ - 'status' => $containerStatus, - 'last_online_at' => now(), - ]); - if ($subType === 'database') { - $isPublic = data_get($service, 'is_public'); - if ($isPublic) { - $foundTcpProxy = $this->containers->filter(function ($value, $key) use ($uuid) { - if ($this->isSentinel) { - return data_get($value, 'name') === $uuid.'-proxy'; - } else { - - if ($this->server->isSwarm()) { - return data_get($value, 'Spec.Name') === "coolify-proxy_$uuid"; - } else { - return data_get($value, 'Name') === "/$uuid-proxy"; - } - } - })->first(); - if (! $foundTcpProxy) { - StartDatabaseProxy::run($service); - } - } - } - } - } else { - // Database - if (is_null($this->databases)) { - $this->databases = $this->server->databases(); - } - $database = $this->databases->where('uuid', $uuid)->first(); - if ($database) { - $database->update([ - 'status' => $containerStatus, - 'last_online_at' => now(), - ]); - - $isPublic = data_get($database, 'is_public'); - if ($isPublic) { - $foundTcpProxy = $this->containers->filter(function ($value, $key) use ($uuid) { - if ($this->isSentinel) { - return data_get($value, 'name') === $uuid.'-proxy'; - } else { - if ($this->server->isSwarm()) { - return data_get($value, 'Spec.Name') === "coolify-proxy_$uuid"; - } else { - - return data_get($value, 'Name') === "/$uuid-proxy"; - } - } - })->first(); - if (! $foundTcpProxy) { - StartDatabaseProxy::run($database); - // $this->server->team?->notify(new ContainerRestarted("TCP Proxy for database", $this->server)); - } - } - } - } - } - } -} diff --git a/app/Actions/Server/StartSentinel.php b/app/Actions/Server/StartSentinel.php index dd1a7ed53..1f248aec1 100644 --- a/app/Actions/Server/StartSentinel.php +++ b/app/Actions/Server/StartSentinel.php @@ -10,7 +10,7 @@ class StartSentinel { use AsAction; - public function handle(Server $server, bool $restart = false, ?string $latestVersion = null) + public function handle(Server $server, bool $restart = false, ?string $latestVersion = null, ?string $customImage = null) { if ($server->isSwarm() || $server->isBuildServer()) { return; @@ -44,7 +44,9 @@ class StartSentinel ]; if (isDev()) { // data_set($environments, 'DEBUG', 'true'); - // $image = 'sentinel'; + if ($customImage && ! empty($customImage)) { + $image = $customImage; + } $mountDir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/sentinel'; } $dockerEnvironments = '-e "'.implode('" -e "', array_map(fn ($key, $value) => "$key=$value", array_keys($environments), $environments)).'"'; diff --git a/app/Actions/Shared/ComplexStatusCheck.php b/app/Actions/Shared/ComplexStatusCheck.php index 5a7ba6637..e06136e3c 100644 --- a/app/Actions/Shared/ComplexStatusCheck.php +++ b/app/Actions/Shared/ComplexStatusCheck.php @@ -26,22 +26,22 @@ class ComplexStatusCheck continue; } } - $container = instant_remote_process(["docker container inspect $(docker container ls -q --filter 'label=coolify.applicationId={$application->id}' --filter 'label=coolify.pullRequestId=0') --format '{{json .}}'"], $server, false); - $container = format_docker_command_output_to_json($container); - if ($container->count() === 1) { - $container = $container->first(); - $containerStatus = data_get($container, 'State.Status'); - $containerHealth = data_get($container, 'State.Health.Status', 'unhealthy'); + $containers = instant_remote_process(["docker container inspect $(docker container ls -q --filter 'label=coolify.applicationId={$application->id}' --filter 'label=coolify.pullRequestId=0') --format '{{json .}}'"], $server, false); + $containers = format_docker_command_output_to_json($containers); + + if ($containers->count() > 0) { + $statusToSet = $this->aggregateContainerStatuses($application, $containers); + if ($is_main_server) { $statusFromDb = $application->status; - if ($statusFromDb !== $containerStatus) { - $application->update(['status' => "$containerStatus:$containerHealth"]); + if ($statusFromDb !== $statusToSet) { + $application->update(['status' => $statusToSet]); } } else { $additional_server = $application->additional_servers()->wherePivot('server_id', $server->id); $statusFromDb = $additional_server->first()->pivot->status; - if ($statusFromDb !== $containerStatus) { - $additional_server->updateExistingPivot($server->id, ['status' => "$containerStatus:$containerHealth"]); + if ($statusFromDb !== $statusToSet) { + $additional_server->updateExistingPivot($server->id, ['status' => $statusToSet]); } } } else { @@ -57,4 +57,78 @@ class ComplexStatusCheck } } } + + private function aggregateContainerStatuses($application, $containers) + { + $dockerComposeRaw = data_get($application, 'docker_compose_raw'); + $excludedContainers = collect(); + + if ($dockerComposeRaw) { + try { + $dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw); + $services = data_get($dockerCompose, 'services', []); + + foreach ($services as $serviceName => $serviceConfig) { + $excludeFromHc = data_get($serviceConfig, 'exclude_from_hc', false); + $restartPolicy = data_get($serviceConfig, 'restart', 'always'); + + if ($excludeFromHc || $restartPolicy === 'no') { + $excludedContainers->push($serviceName); + } + } + } catch (\Exception $e) { + // If we can't parse, treat all containers as included + } + } + + $hasRunning = false; + $hasRestarting = false; + $hasUnhealthy = false; + $hasExited = false; + $relevantContainerCount = 0; + + foreach ($containers as $container) { + $labels = data_get($container, 'Config.Labels', []); + $serviceName = data_get($labels, 'com.docker.compose.service'); + + if ($serviceName && $excludedContainers->contains($serviceName)) { + continue; + } + + $relevantContainerCount++; + $containerStatus = data_get($container, 'State.Status'); + $containerHealth = data_get($container, 'State.Health.Status', 'unhealthy'); + + if ($containerStatus === 'restarting') { + $hasRestarting = true; + $hasUnhealthy = true; + } elseif ($containerStatus === 'running') { + $hasRunning = true; + if ($containerHealth === 'unhealthy') { + $hasUnhealthy = true; + } + } elseif ($containerStatus === 'exited') { + $hasExited = true; + $hasUnhealthy = true; + } + } + + if ($relevantContainerCount === 0) { + return 'running:healthy'; + } + + if ($hasRestarting) { + return 'degraded:unhealthy'; + } + + if ($hasRunning && $hasExited) { + return 'degraded:unhealthy'; + } + + if ($hasRunning) { + return $hasUnhealthy ? 'running:unhealthy' : 'running:healthy'; + } + + return 'exited:unhealthy'; + } } diff --git a/app/Actions/Stripe/CancelSubscription.php b/app/Actions/Stripe/CancelSubscription.php new file mode 100644 index 000000000..859aec6f6 --- /dev/null +++ b/app/Actions/Stripe/CancelSubscription.php @@ -0,0 +1,151 @@ +<?php + +namespace App\Actions\Stripe; + +use App\Models\Subscription; +use App\Models\User; +use Illuminate\Support\Collection; +use Stripe\StripeClient; + +class CancelSubscription +{ + private User $user; + + private bool $isDryRun; + + private ?StripeClient $stripe = null; + + public function __construct(User $user, bool $isDryRun = false) + { + $this->user = $user; + $this->isDryRun = $isDryRun; + + if (! $isDryRun && isCloud()) { + $this->stripe = new StripeClient(config('subscription.stripe_api_key')); + } + } + + public function getSubscriptionsPreview(): Collection + { + $subscriptions = collect(); + + // Get all teams the user belongs to + $teams = $this->user->teams; + + foreach ($teams as $team) { + // Only include subscriptions from teams where user is owner + $userRole = $team->pivot->role; + if ($userRole === 'owner' && $team->subscription) { + $subscription = $team->subscription; + + // Only include active subscriptions + if ($subscription->stripe_subscription_id && + $subscription->stripe_invoice_paid) { + $subscriptions->push($subscription); + } + } + } + + return $subscriptions; + } + + public function execute(): array + { + if ($this->isDryRun) { + return [ + 'cancelled' => 0, + 'failed' => 0, + 'errors' => [], + ]; + } + + $cancelledCount = 0; + $failedCount = 0; + $errors = []; + + $subscriptions = $this->getSubscriptionsPreview(); + + foreach ($subscriptions as $subscription) { + try { + $this->cancelSingleSubscription($subscription); + $cancelledCount++; + } catch (\Exception $e) { + $failedCount++; + $errorMessage = "Failed to cancel subscription {$subscription->stripe_subscription_id}: ".$e->getMessage(); + $errors[] = $errorMessage; + \Log::error($errorMessage); + } + } + + return [ + 'cancelled' => $cancelledCount, + 'failed' => $failedCount, + 'errors' => $errors, + ]; + } + + private function cancelSingleSubscription(Subscription $subscription): void + { + if (! $this->stripe) { + throw new \Exception('Stripe client not initialized'); + } + + $subscriptionId = $subscription->stripe_subscription_id; + + // Cancel the subscription immediately (not at period end) + $this->stripe->subscriptions->cancel($subscriptionId, []); + + // Update local database + $subscription->update([ + 'stripe_cancel_at_period_end' => false, + 'stripe_invoice_paid' => false, + 'stripe_trial_already_ended' => false, + 'stripe_past_due' => false, + 'stripe_feedback' => 'User account deleted', + 'stripe_comment' => 'Subscription cancelled due to user account deletion at '.now()->toDateTimeString(), + ]); + + // Call the team's subscription ended method to handle cleanup + if ($subscription->team) { + $subscription->team->subscriptionEnded(); + } + + \Log::info("Cancelled Stripe subscription: {$subscriptionId} for team: {$subscription->team->name}"); + } + + /** + * Cancel a single subscription by ID (helper method for external use) + */ + public static function cancelById(string $subscriptionId): bool + { + try { + if (! isCloud()) { + return false; + } + + $stripe = new StripeClient(config('subscription.stripe_api_key')); + $stripe->subscriptions->cancel($subscriptionId, []); + + // Update local record if exists + $subscription = Subscription::where('stripe_subscription_id', $subscriptionId)->first(); + if ($subscription) { + $subscription->update([ + 'stripe_cancel_at_period_end' => false, + 'stripe_invoice_paid' => false, + 'stripe_trial_already_ended' => false, + 'stripe_past_due' => false, + ]); + + if ($subscription->team) { + $subscription->team->subscriptionEnded(); + } + } + + return true; + } catch (\Exception $e) { + \Log::error("Failed to cancel subscription {$subscriptionId}: ".$e->getMessage()); + + return false; + } + } +} diff --git a/app/Actions/User/DeleteUserResources.php b/app/Actions/User/DeleteUserResources.php new file mode 100644 index 000000000..7b2e7318d --- /dev/null +++ b/app/Actions/User/DeleteUserResources.php @@ -0,0 +1,125 @@ +<?php + +namespace App\Actions\User; + +use App\Models\User; +use Illuminate\Support\Collection; + +class DeleteUserResources +{ + private User $user; + + private bool $isDryRun; + + public function __construct(User $user, bool $isDryRun = false) + { + $this->user = $user; + $this->isDryRun = $isDryRun; + } + + public function getResourcesPreview(): array + { + $applications = collect(); + $databases = collect(); + $services = collect(); + + // Get all teams the user belongs to + $teams = $this->user->teams; + + foreach ($teams as $team) { + // Get all servers for this team + $servers = $team->servers; + + foreach ($servers as $server) { + // Get applications + $serverApplications = $server->applications; + $applications = $applications->merge($serverApplications); + + // Get databases + $serverDatabases = $this->getAllDatabasesForServer($server); + $databases = $databases->merge($serverDatabases); + + // Get services + $serverServices = $server->services; + $services = $services->merge($serverServices); + } + } + + return [ + 'applications' => $applications->unique('id'), + 'databases' => $databases->unique('id'), + 'services' => $services->unique('id'), + ]; + } + + public function execute(): array + { + if ($this->isDryRun) { + return [ + 'applications' => 0, + 'databases' => 0, + 'services' => 0, + ]; + } + + $deletedCounts = [ + 'applications' => 0, + 'databases' => 0, + 'services' => 0, + ]; + + $resources = $this->getResourcesPreview(); + + // Delete applications + foreach ($resources['applications'] as $application) { + try { + $application->forceDelete(); + $deletedCounts['applications']++; + } catch (\Exception $e) { + \Log::error("Failed to delete application {$application->id}: ".$e->getMessage()); + throw $e; // Re-throw to trigger rollback + } + } + + // Delete databases + foreach ($resources['databases'] as $database) { + try { + $database->forceDelete(); + $deletedCounts['databases']++; + } catch (\Exception $e) { + \Log::error("Failed to delete database {$database->id}: ".$e->getMessage()); + throw $e; // Re-throw to trigger rollback + } + } + + // Delete services + foreach ($resources['services'] as $service) { + try { + $service->forceDelete(); + $deletedCounts['services']++; + } catch (\Exception $e) { + \Log::error("Failed to delete service {$service->id}: ".$e->getMessage()); + throw $e; // Re-throw to trigger rollback + } + } + + return $deletedCounts; + } + + private function getAllDatabasesForServer($server): Collection + { + $databases = collect(); + + // Get all standalone database types + $databases = $databases->merge($server->postgresqls); + $databases = $databases->merge($server->mysqls); + $databases = $databases->merge($server->mariadbs); + $databases = $databases->merge($server->mongodbs); + $databases = $databases->merge($server->redis); + $databases = $databases->merge($server->keydbs); + $databases = $databases->merge($server->dragonflies); + $databases = $databases->merge($server->clickhouses); + + return $databases; + } +} diff --git a/app/Actions/User/DeleteUserServers.php b/app/Actions/User/DeleteUserServers.php new file mode 100644 index 000000000..d8caae54d --- /dev/null +++ b/app/Actions/User/DeleteUserServers.php @@ -0,0 +1,77 @@ +<?php + +namespace App\Actions\User; + +use App\Models\Server; +use App\Models\User; +use Illuminate\Support\Collection; + +class DeleteUserServers +{ + private User $user; + + private bool $isDryRun; + + public function __construct(User $user, bool $isDryRun = false) + { + $this->user = $user; + $this->isDryRun = $isDryRun; + } + + public function getServersPreview(): Collection + { + $servers = collect(); + + // Get all teams the user belongs to + $teams = $this->user->teams; + + foreach ($teams as $team) { + // Only include servers from teams where user is owner or admin + $userRole = $team->pivot->role; + if ($userRole === 'owner' || $userRole === 'admin') { + $teamServers = $team->servers; + $servers = $servers->merge($teamServers); + } + } + + // Return unique servers (in case same server is in multiple teams) + return $servers->unique('id'); + } + + public function execute(): array + { + if ($this->isDryRun) { + return [ + 'servers' => 0, + ]; + } + + $deletedCount = 0; + + $servers = $this->getServersPreview(); + + foreach ($servers as $server) { + try { + // Skip the default server (ID 0) which is the Coolify host + if ($server->id === 0) { + \Log::info('Skipping deletion of Coolify host server (ID: 0)'); + + continue; + } + + // The Server model's forceDeleting event will handle cleanup of: + // - destinations + // - settings + $server->forceDelete(); + $deletedCount++; + } catch (\Exception $e) { + \Log::error("Failed to delete server {$server->id}: ".$e->getMessage()); + throw $e; // Re-throw to trigger rollback + } + } + + return [ + 'servers' => $deletedCount, + ]; + } +} diff --git a/app/Actions/User/DeleteUserTeams.php b/app/Actions/User/DeleteUserTeams.php new file mode 100644 index 000000000..d572db9e7 --- /dev/null +++ b/app/Actions/User/DeleteUserTeams.php @@ -0,0 +1,202 @@ +<?php + +namespace App\Actions\User; + +use App\Models\Team; +use App\Models\User; + +class DeleteUserTeams +{ + private User $user; + + private bool $isDryRun; + + public function __construct(User $user, bool $isDryRun = false) + { + $this->user = $user; + $this->isDryRun = $isDryRun; + } + + public function getTeamsPreview(): array + { + $teamsToDelete = collect(); + $teamsToTransfer = collect(); + $teamsToLeave = collect(); + $edgeCases = collect(); + + $teams = $this->user->teams; + + foreach ($teams as $team) { + // Skip root team (ID 0) + if ($team->id === 0) { + continue; + } + + $userRole = $team->pivot->role; + $memberCount = $team->members->count(); + + if ($memberCount === 1) { + // User is alone in the team - delete it + $teamsToDelete->push($team); + } elseif ($userRole === 'owner') { + // Check if there are other owners + $otherOwners = $team->members + ->where('id', '!=', $this->user->id) + ->filter(function ($member) { + return $member->pivot->role === 'owner'; + }); + + if ($otherOwners->isNotEmpty()) { + // There are other owners, but check if this user is paying for the subscription + if ($this->isUserPayingForTeamSubscription($team)) { + // User is paying for the subscription - this is an edge case + $edgeCases->push([ + 'team' => $team, + 'reason' => 'User is paying for the team\'s Stripe subscription but there are other owners. The subscription needs to be cancelled or transferred to another owner\'s payment method.', + ]); + } else { + // There are other owners and user is not paying, just remove this user + $teamsToLeave->push($team); + } + } else { + // User is the only owner, check for replacement + $newOwner = $this->findNewOwner($team); + if ($newOwner) { + $teamsToTransfer->push([ + 'team' => $team, + 'new_owner' => $newOwner, + ]); + } else { + // No suitable replacement found - this is an edge case + $edgeCases->push([ + 'team' => $team, + 'reason' => 'No suitable owner replacement found. Team has only regular members without admin privileges.', + ]); + } + } + } else { + // User is just a member - remove them from the team + $teamsToLeave->push($team); + } + } + + return [ + 'to_delete' => $teamsToDelete, + 'to_transfer' => $teamsToTransfer, + 'to_leave' => $teamsToLeave, + 'edge_cases' => $edgeCases, + ]; + } + + public function execute(): array + { + if ($this->isDryRun) { + return [ + 'deleted' => 0, + 'transferred' => 0, + 'left' => 0, + ]; + } + + $counts = [ + 'deleted' => 0, + 'transferred' => 0, + 'left' => 0, + ]; + + $preview = $this->getTeamsPreview(); + + // Check for edge cases - should not happen here as we check earlier, but be safe + if ($preview['edge_cases']->isNotEmpty()) { + throw new \Exception('Edge cases detected during execution. This should not happen.'); + } + + // Delete teams where user is alone + foreach ($preview['to_delete'] as $team) { + try { + // The Team model's deleting event will handle cleanup of: + // - private keys + // - sources + // - tags + // - environment variables + // - s3 storages + // - notification settings + $team->delete(); + $counts['deleted']++; + } catch (\Exception $e) { + \Log::error("Failed to delete team {$team->id}: ".$e->getMessage()); + throw $e; // Re-throw to trigger rollback + } + } + + // Transfer ownership for teams where user is owner but not alone + foreach ($preview['to_transfer'] as $item) { + try { + $team = $item['team']; + $newOwner = $item['new_owner']; + + // Update the new owner's role to owner + $team->members()->updateExistingPivot($newOwner->id, ['role' => 'owner']); + + // Remove the current user from the team + $team->members()->detach($this->user->id); + + $counts['transferred']++; + } catch (\Exception $e) { + \Log::error("Failed to transfer ownership of team {$item['team']->id}: ".$e->getMessage()); + throw $e; // Re-throw to trigger rollback + } + } + + // Remove user from teams where they're just a member + foreach ($preview['to_leave'] as $team) { + try { + $team->members()->detach($this->user->id); + $counts['left']++; + } catch (\Exception $e) { + \Log::error("Failed to remove user from team {$team->id}: ".$e->getMessage()); + throw $e; // Re-throw to trigger rollback + } + } + + return $counts; + } + + private function findNewOwner(Team $team): ?User + { + // Only look for admins as potential new owners + // We don't promote regular members automatically + $otherAdmin = $team->members + ->where('id', '!=', $this->user->id) + ->filter(function ($member) { + return $member->pivot->role === 'admin'; + }) + ->first(); + + return $otherAdmin; + } + + private function isUserPayingForTeamSubscription(Team $team): bool + { + if (! $team->subscription || ! $team->subscription->stripe_customer_id) { + return false; + } + + // In Stripe, we need to check if the customer email matches the user's email + // This would require a Stripe API call to get customer details + // For now, we'll check if the subscription was created by this user + + // Alternative approach: Check if user is the one who initiated the subscription + // We could store this information when the subscription is created + // For safety, we'll assume if there's an active subscription and multiple owners, + // we should treat it as an edge case that needs manual review + + if ($team->subscription->stripe_subscription_id && + $team->subscription->stripe_invoice_paid) { + // Active subscription exists - we should be cautious + return true; + } + + return false; + } +} diff --git a/app/Console/Commands/CleanupDatabase.php b/app/Console/Commands/CleanupDatabase.php index 2ccb76529..347ea9419 100644 --- a/app/Console/Commands/CleanupDatabase.php +++ b/app/Console/Commands/CleanupDatabase.php @@ -64,13 +64,5 @@ class CleanupDatabase extends Command if ($this->option('yes')) { $scheduled_task_executions->delete(); } - - // Cleanup webhooks table - $webhooks = DB::table('webhooks')->where('created_at', '<', now()->subDays($keep_days)); - $count = $webhooks->count(); - echo "Delete $count entries from webhooks.\n"; - if ($this->option('yes')) { - $webhooks->delete(); - } } } diff --git a/app/Console/Commands/CleanupStuckedResources.php b/app/Console/Commands/CleanupStuckedResources.php index 81824675b..ce2d6d598 100644 --- a/app/Console/Commands/CleanupStuckedResources.php +++ b/app/Console/Commands/CleanupStuckedResources.php @@ -3,6 +3,7 @@ namespace App\Console\Commands; use App\Jobs\CleanupHelperContainersJob; +use App\Jobs\DeleteResourceJob; use App\Models\Application; use App\Models\ApplicationDeploymentQueue; use App\Models\ApplicationPreview; @@ -72,7 +73,7 @@ class CleanupStuckedResources extends Command $applications = Application::withTrashed()->whereNotNull('deleted_at')->get(); foreach ($applications as $application) { echo "Deleting stuck application: {$application->name}\n"; - $application->forceDelete(); + DeleteResourceJob::dispatch($application); } } catch (\Throwable $e) { echo "Error in cleaning stuck application: {$e->getMessage()}\n"; @@ -82,26 +83,35 @@ class CleanupStuckedResources extends Command foreach ($applicationsPreviews as $applicationPreview) { if (! data_get($applicationPreview, 'application')) { echo "Deleting stuck application preview: {$applicationPreview->uuid}\n"; - $applicationPreview->delete(); + DeleteResourceJob::dispatch($applicationPreview); } } } catch (\Throwable $e) { echo "Error in cleaning stuck application: {$e->getMessage()}\n"; } + try { + $applicationsPreviews = ApplicationPreview::withTrashed()->whereNotNull('deleted_at')->get(); + foreach ($applicationsPreviews as $applicationPreview) { + echo "Deleting stuck application preview: {$applicationPreview->fqdn}\n"; + DeleteResourceJob::dispatch($applicationPreview); + } + } catch (\Throwable $e) { + echo "Error in cleaning stuck application: {$e->getMessage()}\n"; + } try { $postgresqls = StandalonePostgresql::withTrashed()->whereNotNull('deleted_at')->get(); foreach ($postgresqls as $postgresql) { echo "Deleting stuck postgresql: {$postgresql->name}\n"; - $postgresql->forceDelete(); + DeleteResourceJob::dispatch($postgresql); } } catch (\Throwable $e) { echo "Error in cleaning stuck postgresql: {$e->getMessage()}\n"; } try { - $redis = StandaloneRedis::withTrashed()->whereNotNull('deleted_at')->get(); - foreach ($redis as $redis) { + $rediss = StandaloneRedis::withTrashed()->whereNotNull('deleted_at')->get(); + foreach ($rediss as $redis) { echo "Deleting stuck redis: {$redis->name}\n"; - $redis->forceDelete(); + DeleteResourceJob::dispatch($redis); } } catch (\Throwable $e) { echo "Error in cleaning stuck redis: {$e->getMessage()}\n"; @@ -110,7 +120,7 @@ class CleanupStuckedResources extends Command $keydbs = StandaloneKeydb::withTrashed()->whereNotNull('deleted_at')->get(); foreach ($keydbs as $keydb) { echo "Deleting stuck keydb: {$keydb->name}\n"; - $keydb->forceDelete(); + DeleteResourceJob::dispatch($keydb); } } catch (\Throwable $e) { echo "Error in cleaning stuck keydb: {$e->getMessage()}\n"; @@ -119,7 +129,7 @@ class CleanupStuckedResources extends Command $dragonflies = StandaloneDragonfly::withTrashed()->whereNotNull('deleted_at')->get(); foreach ($dragonflies as $dragonfly) { echo "Deleting stuck dragonfly: {$dragonfly->name}\n"; - $dragonfly->forceDelete(); + DeleteResourceJob::dispatch($dragonfly); } } catch (\Throwable $e) { echo "Error in cleaning stuck dragonfly: {$e->getMessage()}\n"; @@ -128,7 +138,7 @@ class CleanupStuckedResources extends Command $clickhouses = StandaloneClickhouse::withTrashed()->whereNotNull('deleted_at')->get(); foreach ($clickhouses as $clickhouse) { echo "Deleting stuck clickhouse: {$clickhouse->name}\n"; - $clickhouse->forceDelete(); + DeleteResourceJob::dispatch($clickhouse); } } catch (\Throwable $e) { echo "Error in cleaning stuck clickhouse: {$e->getMessage()}\n"; @@ -137,7 +147,7 @@ class CleanupStuckedResources extends Command $mongodbs = StandaloneMongodb::withTrashed()->whereNotNull('deleted_at')->get(); foreach ($mongodbs as $mongodb) { echo "Deleting stuck mongodb: {$mongodb->name}\n"; - $mongodb->forceDelete(); + DeleteResourceJob::dispatch($mongodb); } } catch (\Throwable $e) { echo "Error in cleaning stuck mongodb: {$e->getMessage()}\n"; @@ -146,7 +156,7 @@ class CleanupStuckedResources extends Command $mysqls = StandaloneMysql::withTrashed()->whereNotNull('deleted_at')->get(); foreach ($mysqls as $mysql) { echo "Deleting stuck mysql: {$mysql->name}\n"; - $mysql->forceDelete(); + DeleteResourceJob::dispatch($mysql); } } catch (\Throwable $e) { echo "Error in cleaning stuck mysql: {$e->getMessage()}\n"; @@ -155,7 +165,7 @@ class CleanupStuckedResources extends Command $mariadbs = StandaloneMariadb::withTrashed()->whereNotNull('deleted_at')->get(); foreach ($mariadbs as $mariadb) { echo "Deleting stuck mariadb: {$mariadb->name}\n"; - $mariadb->forceDelete(); + DeleteResourceJob::dispatch($mariadb); } } catch (\Throwable $e) { echo "Error in cleaning stuck mariadb: {$e->getMessage()}\n"; @@ -164,7 +174,7 @@ class CleanupStuckedResources extends Command $services = Service::withTrashed()->whereNotNull('deleted_at')->get(); foreach ($services as $service) { echo "Deleting stuck service: {$service->name}\n"; - $service->forceDelete(); + DeleteResourceJob::dispatch($service); } } catch (\Throwable $e) { echo "Error in cleaning stuck service: {$e->getMessage()}\n"; @@ -217,19 +227,19 @@ class CleanupStuckedResources extends Command foreach ($applications as $application) { if (! data_get($application, 'environment')) { echo 'Application without environment: '.$application->name.'\n'; - $application->forceDelete(); + DeleteResourceJob::dispatch($application); continue; } if (! $application->destination()) { echo 'Application without destination: '.$application->name.'\n'; - $application->forceDelete(); + DeleteResourceJob::dispatch($application); continue; } if (! data_get($application, 'destination.server')) { echo 'Application without server: '.$application->name.'\n'; - $application->forceDelete(); + DeleteResourceJob::dispatch($application); continue; } @@ -242,19 +252,19 @@ class CleanupStuckedResources extends Command foreach ($postgresqls as $postgresql) { if (! data_get($postgresql, 'environment')) { echo 'Postgresql without environment: '.$postgresql->name.'\n'; - $postgresql->forceDelete(); + DeleteResourceJob::dispatch($postgresql); continue; } if (! $postgresql->destination()) { echo 'Postgresql without destination: '.$postgresql->name.'\n'; - $postgresql->forceDelete(); + DeleteResourceJob::dispatch($postgresql); continue; } if (! data_get($postgresql, 'destination.server')) { echo 'Postgresql without server: '.$postgresql->name.'\n'; - $postgresql->forceDelete(); + DeleteResourceJob::dispatch($postgresql); continue; } @@ -267,19 +277,19 @@ class CleanupStuckedResources extends Command foreach ($redis as $redis) { if (! data_get($redis, 'environment')) { echo 'Redis without environment: '.$redis->name.'\n'; - $redis->forceDelete(); + DeleteResourceJob::dispatch($redis); continue; } if (! $redis->destination()) { echo 'Redis without destination: '.$redis->name.'\n'; - $redis->forceDelete(); + DeleteResourceJob::dispatch($redis); continue; } if (! data_get($redis, 'destination.server')) { echo 'Redis without server: '.$redis->name.'\n'; - $redis->forceDelete(); + DeleteResourceJob::dispatch($redis); continue; } @@ -293,19 +303,19 @@ class CleanupStuckedResources extends Command foreach ($mongodbs as $mongodb) { if (! data_get($mongodb, 'environment')) { echo 'Mongodb without environment: '.$mongodb->name.'\n'; - $mongodb->forceDelete(); + DeleteResourceJob::dispatch($mongodb); continue; } if (! $mongodb->destination()) { echo 'Mongodb without destination: '.$mongodb->name.'\n'; - $mongodb->forceDelete(); + DeleteResourceJob::dispatch($mongodb); continue; } if (! data_get($mongodb, 'destination.server')) { echo 'Mongodb without server: '.$mongodb->name.'\n'; - $mongodb->forceDelete(); + DeleteResourceJob::dispatch($mongodb); continue; } @@ -319,19 +329,19 @@ class CleanupStuckedResources extends Command foreach ($mysqls as $mysql) { if (! data_get($mysql, 'environment')) { echo 'Mysql without environment: '.$mysql->name.'\n'; - $mysql->forceDelete(); + DeleteResourceJob::dispatch($mysql); continue; } if (! $mysql->destination()) { echo 'Mysql without destination: '.$mysql->name.'\n'; - $mysql->forceDelete(); + DeleteResourceJob::dispatch($mysql); continue; } if (! data_get($mysql, 'destination.server')) { echo 'Mysql without server: '.$mysql->name.'\n'; - $mysql->forceDelete(); + DeleteResourceJob::dispatch($mysql); continue; } @@ -345,19 +355,19 @@ class CleanupStuckedResources extends Command foreach ($mariadbs as $mariadb) { if (! data_get($mariadb, 'environment')) { echo 'Mariadb without environment: '.$mariadb->name.'\n'; - $mariadb->forceDelete(); + DeleteResourceJob::dispatch($mariadb); continue; } if (! $mariadb->destination()) { echo 'Mariadb without destination: '.$mariadb->name.'\n'; - $mariadb->forceDelete(); + DeleteResourceJob::dispatch($mariadb); continue; } if (! data_get($mariadb, 'destination.server')) { echo 'Mariadb without server: '.$mariadb->name.'\n'; - $mariadb->forceDelete(); + DeleteResourceJob::dispatch($mariadb); continue; } @@ -371,19 +381,19 @@ class CleanupStuckedResources extends Command foreach ($services as $service) { if (! data_get($service, 'environment')) { echo 'Service without environment: '.$service->name.'\n'; - $service->forceDelete(); + DeleteResourceJob::dispatch($service); continue; } if (! $service->destination()) { echo 'Service without destination: '.$service->name.'\n'; - $service->forceDelete(); + DeleteResourceJob::dispatch($service); continue; } if (! data_get($service, 'server')) { echo 'Service without server: '.$service->name.'\n'; - $service->forceDelete(); + DeleteResourceJob::dispatch($service); continue; } @@ -396,7 +406,7 @@ class CleanupStuckedResources extends Command foreach ($serviceApplications as $service) { if (! data_get($service, 'service')) { echo 'ServiceApplication without service: '.$service->name.'\n'; - $service->forceDelete(); + DeleteResourceJob::dispatch($service); continue; } @@ -409,7 +419,7 @@ class CleanupStuckedResources extends Command foreach ($serviceDatabases as $service) { if (! data_get($service, 'service')) { echo 'ServiceDatabase without service: '.$service->name.'\n'; - $service->forceDelete(); + DeleteResourceJob::dispatch($service); continue; } diff --git a/app/Console/Commands/CloudDeleteUser.php b/app/Console/Commands/CloudDeleteUser.php new file mode 100644 index 000000000..6928eb97b --- /dev/null +++ b/app/Console/Commands/CloudDeleteUser.php @@ -0,0 +1,722 @@ +<?php + +namespace App\Console\Commands; + +use App\Actions\Stripe\CancelSubscription; +use App\Actions\User\DeleteUserResources; +use App\Actions\User\DeleteUserServers; +use App\Actions\User\DeleteUserTeams; +use App\Models\User; +use Illuminate\Console\Command; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; + +class CloudDeleteUser extends Command +{ + protected $signature = 'cloud:delete-user {email} + {--dry-run : Preview what will be deleted without actually deleting} + {--skip-stripe : Skip Stripe subscription cancellation} + {--skip-resources : Skip resource deletion}'; + + protected $description = 'Delete a user from the cloud instance with phase-by-phase confirmation'; + + private bool $isDryRun = false; + + private bool $skipStripe = false; + + private bool $skipResources = false; + + private User $user; + + public function handle() + { + if (! isCloud()) { + $this->error('This command is only available on cloud instances.'); + + return 1; + } + + $email = $this->argument('email'); + $this->isDryRun = $this->option('dry-run'); + $this->skipStripe = $this->option('skip-stripe'); + $this->skipResources = $this->option('skip-resources'); + + if ($this->isDryRun) { + $this->info('🔍 DRY RUN MODE - No data will be deleted'); + $this->newLine(); + } + + try { + $this->user = User::whereEmail($email)->firstOrFail(); + } catch (\Exception $e) { + $this->error("User with email '{$email}' not found."); + + return 1; + } + + $this->logAction("Starting user deletion process for: {$email}"); + + // Phase 1: Show User Overview (outside transaction) + if (! $this->showUserOverview()) { + $this->info('User deletion cancelled.'); + + return 0; + } + + // If not dry run, wrap everything in a transaction + if (! $this->isDryRun) { + try { + DB::beginTransaction(); + + // Phase 2: Delete Resources + if (! $this->skipResources) { + if (! $this->deleteResources()) { + DB::rollBack(); + $this->error('User deletion failed at resource deletion phase. All changes rolled back.'); + + return 1; + } + } + + // Phase 3: Delete Servers + if (! $this->deleteServers()) { + DB::rollBack(); + $this->error('User deletion failed at server deletion phase. All changes rolled back.'); + + return 1; + } + + // Phase 4: Handle Teams + if (! $this->handleTeams()) { + DB::rollBack(); + $this->error('User deletion failed at team handling phase. All changes rolled back.'); + + return 1; + } + + // Phase 5: Cancel Stripe Subscriptions + if (! $this->skipStripe && isCloud()) { + if (! $this->cancelStripeSubscriptions()) { + DB::rollBack(); + $this->error('User deletion failed at Stripe cancellation phase. All changes rolled back.'); + + return 1; + } + } + + // Phase 6: Delete User Profile + if (! $this->deleteUserProfile()) { + DB::rollBack(); + $this->error('User deletion failed at final phase. All changes rolled back.'); + + return 1; + } + + // Commit the transaction + DB::commit(); + + $this->newLine(); + $this->info('✅ User deletion completed successfully!'); + $this->logAction("User deletion completed for: {$email}"); + + } catch (\Exception $e) { + DB::rollBack(); + $this->error('An error occurred during user deletion: '.$e->getMessage()); + $this->logAction("User deletion failed for {$email}: ".$e->getMessage()); + + return 1; + } + } else { + // Dry run mode - just run through the phases without transaction + // Phase 2: Delete Resources + if (! $this->skipResources) { + if (! $this->deleteResources()) { + $this->info('User deletion would be cancelled at resource deletion phase.'); + + return 0; + } + } + + // Phase 3: Delete Servers + if (! $this->deleteServers()) { + $this->info('User deletion would be cancelled at server deletion phase.'); + + return 0; + } + + // Phase 4: Handle Teams + if (! $this->handleTeams()) { + $this->info('User deletion would be cancelled at team handling phase.'); + + return 0; + } + + // Phase 5: Cancel Stripe Subscriptions + if (! $this->skipStripe && isCloud()) { + if (! $this->cancelStripeSubscriptions()) { + $this->info('User deletion would be cancelled at Stripe cancellation phase.'); + + return 0; + } + } + + // Phase 6: Delete User Profile + if (! $this->deleteUserProfile()) { + $this->info('User deletion would be cancelled at final phase.'); + + return 0; + } + + $this->newLine(); + $this->info('✅ DRY RUN completed successfully! No data was deleted.'); + } + + return 0; + } + + private function showUserOverview(): bool + { + $this->info('═══════════════════════════════════════'); + $this->info('PHASE 1: USER OVERVIEW'); + $this->info('═══════════════════════════════════════'); + $this->newLine(); + + $teams = $this->user->teams; + $ownedTeams = $teams->filter(fn ($team) => $team->pivot->role === 'owner'); + $memberTeams = $teams->filter(fn ($team) => $team->pivot->role !== 'owner'); + + // Collect all servers from all teams + $allServers = collect(); + $allApplications = collect(); + $allDatabases = collect(); + $allServices = collect(); + $activeSubscriptions = collect(); + + foreach ($teams as $team) { + $servers = $team->servers; + $allServers = $allServers->merge($servers); + + foreach ($servers as $server) { + $resources = $server->definedResources(); + foreach ($resources as $resource) { + if ($resource instanceof \App\Models\Application) { + $allApplications->push($resource); + } elseif ($resource instanceof \App\Models\Service) { + $allServices->push($resource); + } else { + $allDatabases->push($resource); + } + } + } + + if ($team->subscription && $team->subscription->stripe_subscription_id) { + $activeSubscriptions->push($team->subscription); + } + } + + $this->table( + ['Property', 'Value'], + [ + ['User', $this->user->email], + ['User ID', $this->user->id], + ['Created', $this->user->created_at->format('Y-m-d H:i:s')], + ['Last Login', $this->user->updated_at->format('Y-m-d H:i:s')], + ['Teams (Total)', $teams->count()], + ['Teams (Owner)', $ownedTeams->count()], + ['Teams (Member)', $memberTeams->count()], + ['Servers', $allServers->unique('id')->count()], + ['Applications', $allApplications->count()], + ['Databases', $allDatabases->count()], + ['Services', $allServices->count()], + ['Active Stripe Subscriptions', $activeSubscriptions->count()], + ] + ); + + $this->newLine(); + + $this->warn('⚠️ WARNING: This will permanently delete the user and all associated data!'); + $this->newLine(); + + if (! $this->confirm('Do you want to continue with the deletion process?', false)) { + return false; + } + + return true; + } + + private function deleteResources(): bool + { + $this->newLine(); + $this->info('═══════════════════════════════════════'); + $this->info('PHASE 2: DELETE RESOURCES'); + $this->info('═══════════════════════════════════════'); + $this->newLine(); + + $action = new DeleteUserResources($this->user, $this->isDryRun); + $resources = $action->getResourcesPreview(); + + if ($resources['applications']->isEmpty() && + $resources['databases']->isEmpty() && + $resources['services']->isEmpty()) { + $this->info('No resources to delete.'); + + return true; + } + + $this->info('Resources to be deleted:'); + $this->newLine(); + + if ($resources['applications']->isNotEmpty()) { + $this->warn("Applications to be deleted ({$resources['applications']->count()}):"); + $this->table( + ['Name', 'UUID', 'Server', 'Status'], + $resources['applications']->map(function ($app) { + return [ + $app->name, + $app->uuid, + $app->destination->server->name, + $app->status ?? 'unknown', + ]; + })->toArray() + ); + $this->newLine(); + } + + if ($resources['databases']->isNotEmpty()) { + $this->warn("Databases to be deleted ({$resources['databases']->count()}):"); + $this->table( + ['Name', 'Type', 'UUID', 'Server'], + $resources['databases']->map(function ($db) { + return [ + $db->name, + class_basename($db), + $db->uuid, + $db->destination->server->name, + ]; + })->toArray() + ); + $this->newLine(); + } + + if ($resources['services']->isNotEmpty()) { + $this->warn("Services to be deleted ({$resources['services']->count()}):"); + $this->table( + ['Name', 'UUID', 'Server'], + $resources['services']->map(function ($service) { + return [ + $service->name, + $service->uuid, + $service->server->name, + ]; + })->toArray() + ); + $this->newLine(); + } + + $this->error('⚠️ THIS ACTION CANNOT BE UNDONE!'); + if (! $this->confirm('Are you sure you want to delete all these resources?', false)) { + return false; + } + + if (! $this->isDryRun) { + $this->info('Deleting resources...'); + $result = $action->execute(); + $this->info("Deleted: {$result['applications']} applications, {$result['databases']} databases, {$result['services']} services"); + $this->logAction("Deleted resources for user {$this->user->email}: {$result['applications']} apps, {$result['databases']} databases, {$result['services']} services"); + } + + return true; + } + + private function deleteServers(): bool + { + $this->newLine(); + $this->info('═══════════════════════════════════════'); + $this->info('PHASE 3: DELETE SERVERS'); + $this->info('═══════════════════════════════════════'); + $this->newLine(); + + $action = new DeleteUserServers($this->user, $this->isDryRun); + $servers = $action->getServersPreview(); + + if ($servers->isEmpty()) { + $this->info('No servers to delete.'); + + return true; + } + + $this->warn("Servers to be deleted ({$servers->count()}):"); + $this->table( + ['ID', 'Name', 'IP', 'Description', 'Resources Count'], + $servers->map(function ($server) { + $resourceCount = $server->definedResources()->count(); + + return [ + $server->id, + $server->name, + $server->ip, + $server->description ?? '-', + $resourceCount, + ]; + })->toArray() + ); + $this->newLine(); + + $this->error('⚠️ WARNING: Deleting servers will remove all server configurations!'); + if (! $this->confirm('Are you sure you want to delete all these servers?', false)) { + return false; + } + + if (! $this->isDryRun) { + $this->info('Deleting servers...'); + $result = $action->execute(); + $this->info("Deleted {$result['servers']} servers"); + $this->logAction("Deleted {$result['servers']} servers for user {$this->user->email}"); + } + + return true; + } + + private function handleTeams(): bool + { + $this->newLine(); + $this->info('═══════════════════════════════════════'); + $this->info('PHASE 4: HANDLE TEAMS'); + $this->info('═══════════════════════════════════════'); + $this->newLine(); + + $action = new DeleteUserTeams($this->user, $this->isDryRun); + $preview = $action->getTeamsPreview(); + + // Check for edge cases first - EXIT IMMEDIATELY if found + if ($preview['edge_cases']->isNotEmpty()) { + $this->error('═══════════════════════════════════════'); + $this->error('⚠️ EDGE CASES DETECTED - CANNOT PROCEED'); + $this->error('═══════════════════════════════════════'); + $this->newLine(); + + foreach ($preview['edge_cases'] as $edgeCase) { + $team = $edgeCase['team']; + $reason = $edgeCase['reason']; + $this->error("Team: {$team->name} (ID: {$team->id})"); + $this->error("Issue: {$reason}"); + + // Show team members for context + $this->info('Current members:'); + foreach ($team->members as $member) { + $role = $member->pivot->role; + $this->line(" - {$member->name} ({$member->email}) - Role: {$role}"); + } + + // Check for active resources + $resourceCount = 0; + foreach ($team->servers as $server) { + $resources = $server->definedResources(); + $resourceCount += $resources->count(); + } + + if ($resourceCount > 0) { + $this->warn(" ⚠️ This team has {$resourceCount} active resources!"); + } + + // Show subscription details if relevant + if ($team->subscription && $team->subscription->stripe_subscription_id) { + $this->warn(' ⚠️ Active Stripe subscription details:'); + $this->warn(" Subscription ID: {$team->subscription->stripe_subscription_id}"); + $this->warn(" Customer ID: {$team->subscription->stripe_customer_id}"); + + // Show other owners who could potentially take over + $otherOwners = $team->members + ->where('id', '!=', $this->user->id) + ->filter(function ($member) { + return $member->pivot->role === 'owner'; + }); + + if ($otherOwners->isNotEmpty()) { + $this->info(' Other owners who could take over billing:'); + foreach ($otherOwners as $owner) { + $this->line(" - {$owner->name} ({$owner->email})"); + } + } + } + + $this->newLine(); + } + + $this->error('Please resolve these issues manually before retrying:'); + + // Check if any edge case involves subscription payment issues + $hasSubscriptionIssue = $preview['edge_cases']->contains(function ($edgeCase) { + return str_contains($edgeCase['reason'], 'Stripe subscription'); + }); + + if ($hasSubscriptionIssue) { + $this->info('For teams with subscription payment issues:'); + $this->info('1. Cancel the subscription through Stripe dashboard, OR'); + $this->info('2. Transfer the subscription to another owner\'s payment method, OR'); + $this->info('3. Have the other owner create a new subscription after cancelling this one'); + $this->newLine(); + } + + $hasNoOwnerReplacement = $preview['edge_cases']->contains(function ($edgeCase) { + return str_contains($edgeCase['reason'], 'No suitable owner replacement'); + }); + + if ($hasNoOwnerReplacement) { + $this->info('For teams with no suitable owner replacement:'); + $this->info('1. Assign an admin role to a trusted member, OR'); + $this->info('2. Transfer team resources to another team, OR'); + $this->info('3. Delete the team manually if no longer needed'); + $this->newLine(); + } + + $this->error('USER DELETION ABORTED DUE TO EDGE CASES'); + $this->logAction("User deletion aborted for {$this->user->email}: Edge cases in team handling"); + + // Exit immediately - don't proceed with deletion + if (! $this->isDryRun) { + DB::rollBack(); + } + exit(1); + } + + if ($preview['to_delete']->isEmpty() && + $preview['to_transfer']->isEmpty() && + $preview['to_leave']->isEmpty()) { + $this->info('No team changes needed.'); + + return true; + } + + if ($preview['to_delete']->isNotEmpty()) { + $this->warn('Teams to be DELETED (user is the only member):'); + $this->table( + ['ID', 'Name', 'Resources', 'Subscription'], + $preview['to_delete']->map(function ($team) { + $resourceCount = 0; + foreach ($team->servers as $server) { + $resourceCount += $server->definedResources()->count(); + } + $hasSubscription = $team->subscription && $team->subscription->stripe_subscription_id + ? '⚠️ YES - '.$team->subscription->stripe_subscription_id + : 'No'; + + return [ + $team->id, + $team->name, + $resourceCount, + $hasSubscription, + ]; + })->toArray() + ); + $this->newLine(); + } + + if ($preview['to_transfer']->isNotEmpty()) { + $this->warn('Teams where ownership will be TRANSFERRED:'); + $this->table( + ['Team ID', 'Team Name', 'New Owner', 'New Owner Email'], + $preview['to_transfer']->map(function ($item) { + return [ + $item['team']->id, + $item['team']->name, + $item['new_owner']->name, + $item['new_owner']->email, + ]; + })->toArray() + ); + $this->newLine(); + } + + if ($preview['to_leave']->isNotEmpty()) { + $this->warn('Teams where user will be REMOVED (other owners/admins exist):'); + $userId = $this->user->id; + $this->table( + ['ID', 'Name', 'User Role', 'Other Members'], + $preview['to_leave']->map(function ($team) use ($userId) { + $userRole = $team->members->where('id', $userId)->first()->pivot->role; + $otherMembers = $team->members->count() - 1; + + return [ + $team->id, + $team->name, + $userRole, + $otherMembers, + ]; + })->toArray() + ); + $this->newLine(); + } + + $this->error('⚠️ WARNING: Team changes affect access control and ownership!'); + if (! $this->confirm('Are you sure you want to proceed with these team changes?', false)) { + return false; + } + + if (! $this->isDryRun) { + $this->info('Processing team changes...'); + $result = $action->execute(); + $this->info("Teams deleted: {$result['deleted']}, ownership transferred: {$result['transferred']}, left: {$result['left']}"); + $this->logAction("Team changes for user {$this->user->email}: deleted {$result['deleted']}, transferred {$result['transferred']}, left {$result['left']}"); + } + + return true; + } + + private function cancelStripeSubscriptions(): bool + { + $this->newLine(); + $this->info('═══════════════════════════════════════'); + $this->info('PHASE 5: CANCEL STRIPE SUBSCRIPTIONS'); + $this->info('═══════════════════════════════════════'); + $this->newLine(); + + $action = new CancelSubscription($this->user, $this->isDryRun); + $subscriptions = $action->getSubscriptionsPreview(); + + if ($subscriptions->isEmpty()) { + $this->info('No Stripe subscriptions to cancel.'); + + return true; + } + + $this->info('Stripe subscriptions to cancel:'); + $this->newLine(); + + $totalMonthlyValue = 0; + foreach ($subscriptions as $subscription) { + $team = $subscription->team; + $planId = $subscription->stripe_plan_id; + + // Try to get the price from config + $monthlyValue = $this->getSubscriptionMonthlyValue($planId); + $totalMonthlyValue += $monthlyValue; + + $this->line(" - {$subscription->stripe_subscription_id} (Team: {$team->name})"); + if ($monthlyValue > 0) { + $this->line(" Monthly value: \${$monthlyValue}"); + } + if ($subscription->stripe_cancel_at_period_end) { + $this->line(' ⚠️ Already set to cancel at period end'); + } + } + + if ($totalMonthlyValue > 0) { + $this->newLine(); + $this->warn("Total monthly value: \${$totalMonthlyValue}"); + } + $this->newLine(); + + $this->error('⚠️ WARNING: Subscriptions will be cancelled IMMEDIATELY (not at period end)!'); + if (! $this->confirm('Are you sure you want to cancel all these subscriptions immediately?', false)) { + return false; + } + + if (! $this->isDryRun) { + $this->info('Cancelling subscriptions...'); + $result = $action->execute(); + $this->info("Cancelled {$result['cancelled']} subscriptions, {$result['failed']} failed"); + if ($result['failed'] > 0 && ! empty($result['errors'])) { + $this->error('Failed subscriptions:'); + foreach ($result['errors'] as $error) { + $this->error(" - {$error}"); + } + } + $this->logAction("Cancelled {$result['cancelled']} Stripe subscriptions for user {$this->user->email}"); + } + + return true; + } + + private function deleteUserProfile(): bool + { + $this->newLine(); + $this->info('═══════════════════════════════════════'); + $this->info('PHASE 6: DELETE USER PROFILE'); + $this->info('═══════════════════════════════════════'); + $this->newLine(); + + $this->warn('⚠️ FINAL STEP - This action is IRREVERSIBLE!'); + $this->newLine(); + + $this->info('User profile to be deleted:'); + $this->table( + ['Property', 'Value'], + [ + ['Email', $this->user->email], + ['Name', $this->user->name], + ['User ID', $this->user->id], + ['Created', $this->user->created_at->format('Y-m-d H:i:s')], + ['Email Verified', $this->user->email_verified_at ? 'Yes' : 'No'], + ['2FA Enabled', $this->user->two_factor_confirmed_at ? 'Yes' : 'No'], + ] + ); + + $this->newLine(); + + $this->warn("Type 'DELETE {$this->user->email}' to confirm final deletion:"); + $confirmation = $this->ask('Confirmation'); + + if ($confirmation !== "DELETE {$this->user->email}") { + $this->error('Confirmation text does not match. Deletion cancelled.'); + + return false; + } + + if (! $this->isDryRun) { + $this->info('Deleting user profile...'); + + try { + $this->user->delete(); + $this->info('User profile deleted successfully.'); + $this->logAction("User profile deleted: {$this->user->email}"); + } catch (\Exception $e) { + $this->error('Failed to delete user profile: '.$e->getMessage()); + $this->logAction("Failed to delete user profile {$this->user->email}: ".$e->getMessage()); + + return false; + } + } + + return true; + } + + private function getSubscriptionMonthlyValue(string $planId): int + { + // Map plan IDs to monthly values based on config + $subscriptionConfigs = config('subscription'); + + foreach ($subscriptionConfigs as $key => $value) { + if ($value === $planId && str_contains($key, 'stripe_price_id_')) { + // Extract price from key pattern: stripe_price_id_basic_monthly -> basic + $planType = str($key)->after('stripe_price_id_')->before('_')->toString(); + + // Map to known prices (you may need to adjust these based on your actual pricing) + return match ($planType) { + 'basic' => 29, + 'pro' => 49, + 'ultimate' => 99, + default => 0 + }; + } + } + + return 0; + } + + private function logAction(string $message): void + { + $logMessage = "[CloudDeleteUser] {$message}"; + + if ($this->isDryRun) { + $logMessage = "[DRY RUN] {$logMessage}"; + } + + Log::channel('single')->info($logMessage); + + // Also log to a dedicated user deletion log file + $logFile = storage_path('logs/user-deletions.log'); + $timestamp = now()->format('Y-m-d H:i:s'); + file_put_contents($logFile, "[{$timestamp}] {$logMessage}\n", FILE_APPEND | LOCK_EX); + } +} diff --git a/app/Console/Commands/Dev.php b/app/Console/Commands/Dev.php index a4cfde6f8..8f26d78ff 100644 --- a/app/Console/Commands/Dev.php +++ b/app/Console/Commands/Dev.php @@ -2,6 +2,7 @@ namespace App\Console\Commands; +use App\Jobs\CheckHelperImageJob; use App\Models\InstanceSettings; use Illuminate\Console\Command; use Illuminate\Support\Facades\Artisan; @@ -44,5 +45,6 @@ class Dev extends Command } else { echo "Instance already initialized.\n"; } + CheckHelperImageJob::dispatch(); } } diff --git a/app/Console/Commands/Init.php b/app/Console/Commands/Init.php index b85829256..6e8d18f61 100644 --- a/app/Console/Commands/Init.php +++ b/app/Console/Commands/Init.php @@ -5,9 +5,10 @@ namespace App\Console\Commands; use App\Enums\ActivityTypes; use App\Enums\ApplicationDeploymentStatus; use App\Jobs\CheckHelperImageJob; -use App\Jobs\PullChangelogFromGitHub; +use App\Jobs\PullChangelog; use App\Models\ApplicationDeploymentQueue; use App\Models\Environment; +use App\Models\InstanceSettings; use App\Models\ScheduledDatabaseBackup; use App\Models\Server; use App\Models\StandalonePostgresql; @@ -19,80 +20,18 @@ use Illuminate\Support\Facades\Http; class Init extends Command { - protected $signature = 'app:init {--force-cloud}'; + protected $signature = 'app:init'; protected $description = 'Cleanup instance related stuffs'; public $servers = null; + public InstanceSettings $settings; + public function handle() { - $this->optimize(); - - if (isCloud() && ! $this->option('force-cloud')) { - echo "Skipping init as we are on cloud and --force-cloud option is not set\n"; - - return; - } - - $this->servers = Server::all(); - if (! isCloud()) { - $this->sendAliveSignal(); - get_public_ips(); - } - - // Backward compatibility - $this->replaceSlashInEnvironmentName(); - $this->restoreCoolifyDbBackup(); - $this->updateUserEmails(); - // - $this->updateTraefikLabels(); - if (! isCloud() || $this->option('force-cloud')) { - $this->cleanupUnusedNetworkFromCoolifyProxy(); - } - - $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'); - - try { - $this->pullHelperImage(); - } catch (\Throwable $e) { - // - } - - if (isCloud()) { - try { - $this->cleanupInProgressApplicationDeployments(); - } catch (\Throwable $e) { - echo "Could not cleanup inprogress deployments: {$e->getMessage()}\n"; - } - - try { - $this->pullTemplatesFromCDN(); - } catch (\Throwable $e) { - 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; - } - - try { - $this->cleanupInProgressApplicationDeployments(); - } catch (\Throwable $e) { - echo "Could not cleanup inprogress deployments: {$e->getMessage()}\n"; - } + Artisan::call('optimize:clear'); + Artisan::call('optimize'); try { $this->pullTemplatesFromCDN(); @@ -105,20 +44,80 @@ class Init extends Command } catch (\Throwable $e) { echo "Could not changelogs from github: {$e->getMessage()}\n"; } + + try { + $this->pullHelperImage(); + } catch (\Throwable $e) { + echo "Error in pullHelperImage command: {$e->getMessage()}\n"; + } + + if (isCloud()) { + return; + } + + $this->settings = instanceSettings(); + $this->servers = Server::all(); + + $do_not_track = data_get($this->settings, 'do_not_track', true); + if ($do_not_track == false) { + $this->sendAliveSignal(); + } + get_public_ips(); + + // Backward compatibility + $this->replaceSlashInEnvironmentName(); + $this->restoreCoolifyDbBackup(); + $this->updateUserEmails(); + // + $this->updateTraefikLabels(); + $this->cleanupUnusedNetworkFromCoolifyProxy(); + + try { + $this->call('cleanup:redis'); + } catch (\Throwable $e) { + echo "Error in cleanup:redis command: {$e->getMessage()}\n"; + } + try { + $this->call('cleanup:names'); + } catch (\Throwable $e) { + echo "Error in cleanup:names command: {$e->getMessage()}\n"; + } + try { + $this->call('cleanup:stucked-resources'); + } catch (\Throwable $e) { + echo "Error in cleanup:stucked-resources command: {$e->getMessage()}\n"; + } + try { + $updatedCount = ApplicationDeploymentQueue::whereIn('status', [ + ApplicationDeploymentStatus::IN_PROGRESS->value, + ApplicationDeploymentStatus::QUEUED->value, + ])->update([ + 'status' => ApplicationDeploymentStatus::FAILED->value, + ]); + + if ($updatedCount > 0) { + echo "Marked {$updatedCount} stuck deployments as failed\n"; + } + } catch (\Throwable $e) { + echo "Could not cleanup inprogress deployments: {$e->getMessage()}\n"; + } + try { $localhost = $this->servers->where('id', 0)->first(); - $localhost->setupDynamicProxyConfiguration(); + if ($localhost) { + $localhost->setupDynamicProxyConfiguration(); + } } catch (\Throwable $e) { echo "Could not setup dynamic configuration: {$e->getMessage()}\n"; } - $settings = instanceSettings(); + if (! is_null(config('constants.coolify.autoupdate', null))) { if (config('constants.coolify.autoupdate') == true) { echo "Enabling auto-update\n"; - $settings->update(['is_auto_update_enabled' => true]); + $this->settings->update(['is_auto_update_enabled' => true]); } else { echo "Disabling auto-update\n"; - $settings->update(['is_auto_update_enabled' => false]); + $this->settings->update(['is_auto_update_enabled' => false]); } } } @@ -140,24 +139,18 @@ class Init extends Command private function pullChangelogFromGitHub() { try { - PullChangelogFromGitHub::dispatch(); + PullChangelog::dispatch(); echo "Changelog fetch initiated\n"; } catch (\Throwable $e) { echo "Could not fetch changelog from GitHub: {$e->getMessage()}\n"; } } - private function optimize() - { - Artisan::call('optimize:clear'); - Artisan::call('optimize'); - } - private function updateUserEmails() { try { User::whereRaw('email ~ \'[A-Z]\'')->get()->each(function (User $user) { - $user->update(['email' => strtolower($user->email)]); + $user->update(['email' => $user->email]); }); } catch (\Throwable $e) { echo "Error in updating user emails: {$e->getMessage()}\n"; @@ -173,27 +166,6 @@ class Init extends Command } } - private function cleanupUnnecessaryDynamicProxyConfiguration() - { - foreach ($this->servers as $server) { - try { - if (! $server->isFunctional()) { - continue; - } - if ($server->id === 0) { - continue; - } - $file = $server->proxyPath().'/dynamic/coolify.yaml'; - - return instant_remote_process([ - "rm -f $file", - ], $server, false); - } catch (\Throwable $e) { - echo "Error in cleaning up unnecessary dynamic proxy configuration: {$e->getMessage()}\n"; - } - } - } - private function cleanupUnusedNetworkFromCoolifyProxy() { foreach ($this->servers as $server) { @@ -263,13 +235,6 @@ class Init extends Command { $id = config('app.id'); $version = config('constants.coolify.version'); - $settings = instanceSettings(); - $do_not_track = data_get($settings, 'do_not_track'); - if ($do_not_track == true) { - echo "Do_not_track is enabled\n"; - - return; - } try { Http::get("https://undead.coolify.io/v4/alive?appId=$id&version=$version"); } catch (\Throwable $e) { @@ -277,23 +242,6 @@ class Init extends Command } } - private function cleanupInProgressApplicationDeployments() - { - // Cleanup any failed deployments - try { - if (isCloud()) { - return; - } - $queued_inprogress_deployments = ApplicationDeploymentQueue::whereIn('status', [ApplicationDeploymentStatus::IN_PROGRESS->value, ApplicationDeploymentStatus::QUEUED->value])->get(); - foreach ($queued_inprogress_deployments as $deployment) { - $deployment->status = ApplicationDeploymentStatus::FAILED->value; - $deployment->save(); - } - } catch (\Throwable $e) { - echo "Error: {$e->getMessage()}\n"; - } - } - private function replaceSlashInEnvironmentName() { if (version_compare('4.0.0-beta.298', config('constants.coolify.version'), '<=')) { diff --git a/app/Console/Commands/InitChangelog.php b/app/Console/Commands/InitChangelog.php deleted file mode 100644 index f9eb12f04..000000000 --- a/app/Console/Commands/InitChangelog.php +++ /dev/null @@ -1,98 +0,0 @@ -<?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; - } -} diff --git a/app/Console/Commands/ServicesDelete.php b/app/Console/Commands/ServicesDelete.php index b5a74166a..870cef3d9 100644 --- a/app/Console/Commands/ServicesDelete.php +++ b/app/Console/Commands/ServicesDelete.php @@ -6,7 +6,14 @@ use App\Jobs\DeleteResourceJob; use App\Models\Application; 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 Illuminate\Console\Command; use function Laravel\Prompts\confirm; @@ -103,19 +110,79 @@ class ServicesDelete extends Command private function deleteDatabase() { - $databases = StandalonePostgresql::all(); - if ($databases->count() === 0) { + // Collect all databases from all types with unique identifiers + $allDatabases = collect(); + $databaseOptions = collect(); + + // Add PostgreSQL databases + foreach (StandalonePostgresql::all() as $db) { + $key = "postgresql_{$db->id}"; + $allDatabases->put($key, $db); + $databaseOptions->put($key, "{$db->name} (PostgreSQL)"); + } + + // Add MySQL databases + foreach (StandaloneMysql::all() as $db) { + $key = "mysql_{$db->id}"; + $allDatabases->put($key, $db); + $databaseOptions->put($key, "{$db->name} (MySQL)"); + } + + // Add MariaDB databases + foreach (StandaloneMariadb::all() as $db) { + $key = "mariadb_{$db->id}"; + $allDatabases->put($key, $db); + $databaseOptions->put($key, "{$db->name} (MariaDB)"); + } + + // Add MongoDB databases + foreach (StandaloneMongodb::all() as $db) { + $key = "mongodb_{$db->id}"; + $allDatabases->put($key, $db); + $databaseOptions->put($key, "{$db->name} (MongoDB)"); + } + + // Add Redis databases + foreach (StandaloneRedis::all() as $db) { + $key = "redis_{$db->id}"; + $allDatabases->put($key, $db); + $databaseOptions->put($key, "{$db->name} (Redis)"); + } + + // Add KeyDB databases + foreach (StandaloneKeydb::all() as $db) { + $key = "keydb_{$db->id}"; + $allDatabases->put($key, $db); + $databaseOptions->put($key, "{$db->name} (KeyDB)"); + } + + // Add Dragonfly databases + foreach (StandaloneDragonfly::all() as $db) { + $key = "dragonfly_{$db->id}"; + $allDatabases->put($key, $db); + $databaseOptions->put($key, "{$db->name} (Dragonfly)"); + } + + // Add ClickHouse databases + foreach (StandaloneClickhouse::all() as $db) { + $key = "clickhouse_{$db->id}"; + $allDatabases->put($key, $db); + $databaseOptions->put($key, "{$db->name} (ClickHouse)"); + } + + if ($allDatabases->count() === 0) { $this->error('There are no databases to delete.'); return; } + $databasesToDelete = multiselect( 'What database do you want to delete?', - $databases->pluck('name', 'id')->sortKeys(), + $databaseOptions->sortKeys(), ); - foreach ($databasesToDelete as $database) { - $toDelete = $databases->where('id', $database)->first(); + foreach ($databasesToDelete as $databaseKey) { + $toDelete = $allDatabases->get($databaseKey); if ($toDelete) { $this->info($toDelete); $confirmed = confirm('Are you sure you want to delete all selected resources?'); diff --git a/app/Console/Commands/SyncBunny.php b/app/Console/Commands/SyncBunny.php index 6581bb587..b0cd24715 100644 --- a/app/Console/Commands/SyncBunny.php +++ b/app/Console/Commands/SyncBunny.php @@ -16,7 +16,7 @@ class SyncBunny extends Command * * @var string */ - protected $signature = 'sync:bunny {--templates} {--release} {--nightly}'; + protected $signature = 'sync:bunny {--templates} {--release} {--github-releases} {--nightly}'; /** * The console command description. @@ -25,6 +25,50 @@ class SyncBunny extends Command */ protected $description = 'Sync files to BunnyCDN'; + /** + * Fetch GitHub releases and sync to CDN + */ + private function syncGitHubReleases($parent_dir, $bunny_cdn_storage_name, $bunny_cdn_path, $bunny_cdn) + { + $this->info('Fetching releases from GitHub...'); + try { + $response = Http::timeout(30) + ->get('https://api.github.com/repos/coollabsio/coolify/releases', [ + 'per_page' => 30, // Fetch more releases for better changelog + ]); + + if ($response->successful()) { + $releases = $response->json(); + + // Save releases to a temporary file + $releases_file = "$parent_dir/releases.json"; + file_put_contents($releases_file, json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + // Upload to CDN + Http::pool(fn (Pool $pool) => [ + $pool->storage(fileName: $releases_file)->put("/$bunny_cdn_storage_name/$bunny_cdn_path/releases.json"), + $pool->purge("$bunny_cdn/coolify/releases.json"), + ]); + + // Clean up temporary file + unlink($releases_file); + + $this->info('releases.json uploaded & purged...'); + $this->info('Total releases synced: '.count($releases)); + + return true; + } else { + $this->error('Failed to fetch releases from GitHub: '.$response->status()); + + return false; + } + } catch (\Throwable $e) { + $this->error('Error fetching releases: '.$e->getMessage()); + + return false; + } + } + /** * Execute the console command. */ @@ -33,6 +77,7 @@ class SyncBunny extends Command $that = $this; $only_template = $this->option('templates'); $only_version = $this->option('release'); + $only_github_releases = $this->option('github-releases'); $nightly = $this->option('nightly'); $bunny_cdn = 'https://cdn.coollabs.io'; $bunny_cdn_path = 'coolify'; @@ -90,7 +135,7 @@ class SyncBunny extends Command $install_script_location = "$parent_dir/other/nightly/$install_script"; $versions_location = "$parent_dir/other/nightly/$versions"; } - if (! $only_template && ! $only_version) { + if (! $only_template && ! $only_version && ! $only_github_releases) { if ($nightly) { $this->info('About to sync files NIGHTLY (docker-compose.prod.yaml, upgrade.sh, install.sh, etc) to BunnyCDN.'); } else { @@ -128,12 +173,29 @@ class SyncBunny extends Command if (! $confirmed) { return; } + + // First sync GitHub releases + $this->info('Syncing GitHub releases first...'); + $this->syncGitHubReleases($parent_dir, $bunny_cdn_storage_name, $bunny_cdn_path, $bunny_cdn); + + // Then sync versions.json Http::pool(fn (Pool $pool) => [ $pool->storage(fileName: $versions_location)->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$versions"), $pool->purge("$bunny_cdn/$bunny_cdn_path/$versions"), ]); $this->info('versions.json uploaded & purged...'); + return; + } elseif ($only_github_releases) { + $this->info('About to sync GitHub releases to BunnyCDN.'); + $confirmed = confirm('Are you sure you want to sync GitHub releases?'); + if (! $confirmed) { + return; + } + + // Use the reusable function + $this->syncGitHubReleases($parent_dir, $bunny_cdn_storage_name, $bunny_cdn_path, $bunny_cdn); + return; } diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index c5c4d7e7f..c2ea27274 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -6,7 +6,7 @@ use App\Jobs\CheckAndStartSentinelJob; use App\Jobs\CheckForUpdatesJob; use App\Jobs\CheckHelperImageJob; use App\Jobs\CleanupInstanceStuffsJob; -use App\Jobs\PullChangelogFromGitHub; +use App\Jobs\PullChangelog; use App\Jobs\PullTemplatesFromCDN; use App\Jobs\RegenerateSslCertJob; use App\Jobs\ScheduledJobManager; @@ -68,7 +68,7 @@ class Kernel extends ConsoleKernel $this->scheduleInstance->command('cleanup:unreachable-servers')->daily()->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 PullChangelog)->cron($this->updateCheckFrequency)->timezone($this->instanceTimezone)->onOneServer(); $this->scheduleInstance->job(new CleanupInstanceStuffsJob)->everyTwoMinutes()->onOneServer(); $this->scheduleUpdates(); diff --git a/app/Events/ApplicationConfigurationChanged.php b/app/Events/ApplicationConfigurationChanged.php new file mode 100644 index 000000000..3dd532b19 --- /dev/null +++ b/app/Events/ApplicationConfigurationChanged.php @@ -0,0 +1,35 @@ +<?php + +namespace App\Events; + +use Illuminate\Broadcasting\InteractsWithSockets; +use Illuminate\Broadcasting\PrivateChannel; +use Illuminate\Contracts\Broadcasting\ShouldBroadcast; +use Illuminate\Foundation\Events\Dispatchable; +use Illuminate\Queue\SerializesModels; + +class ApplicationConfigurationChanged implements ShouldBroadcast +{ + use Dispatchable, InteractsWithSockets, SerializesModels; + + public ?int $teamId = null; + + public function __construct($teamId = null) + { + if (is_null($teamId) && auth()->check() && auth()->user()->currentTeam()) { + $teamId = auth()->user()->currentTeam()->id; + } + $this->teamId = $teamId; + } + + public function broadcastOn(): array + { + if (is_null($this->teamId)) { + return []; + } + + return [ + new PrivateChannel("team.{$this->teamId}"), + ]; + } +} diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index 275de57c0..3d731223d 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -29,6 +29,7 @@ class Handler extends ExceptionHandler */ protected $dontReport = [ ProcessException::class, + NonReportableException::class, ]; /** @@ -110,9 +111,14 @@ class Handler extends ExceptionHandler ); } ); + // Check for errors that should not be reported to Sentry if (str($e->getMessage())->contains('No space left on device')) { + // Log locally but don't send to Sentry + logger()->warning('Disk space error: '.$e->getMessage()); + return; } + Integration::captureUnhandledException($e); }); } diff --git a/app/Exceptions/NonReportableException.php b/app/Exceptions/NonReportableException.php new file mode 100644 index 000000000..4c4672127 --- /dev/null +++ b/app/Exceptions/NonReportableException.php @@ -0,0 +1,31 @@ +<?php + +namespace App\Exceptions; + +use Exception; + +/** + * Exception that should not be reported to Sentry or other error tracking services. + * Use this for known, expected errors that don't require external tracking. + */ +class NonReportableException extends Exception +{ + /** + * Create a new non-reportable exception instance. + * + * @param string $message + * @param int $code + */ + public function __construct($message = '', $code = 0, ?\Throwable $previous = null) + { + parent::__construct($message, $code, $previous); + } + + /** + * Create from another exception, preserving its message and stack trace. + */ + public static function fromException(\Throwable $exception): static + { + return new static($exception->getMessage(), $exception->getCode(), $exception); + } +} diff --git a/app/Helpers/SshMultiplexingHelper.php b/app/Helpers/SshMultiplexingHelper.php index 8caa2880a..f847f33cc 100644 --- a/app/Helpers/SshMultiplexingHelper.php +++ b/app/Helpers/SshMultiplexingHelper.php @@ -4,7 +4,9 @@ namespace App\Helpers; use App\Models\PrivateKey; use App\Models\Server; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Process; class SshMultiplexingHelper @@ -30,6 +32,7 @@ class SshMultiplexingHelper $sshConfig = self::serverSshConfiguration($server); $muxSocket = $sshConfig['muxFilename']; + // Check if connection exists $checkCommand = "ssh -O check -o ControlPath=$muxSocket "; if (data_get($server, 'settings.is_cloudflare_tunnel')) { $checkCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" '; @@ -41,6 +44,24 @@ class SshMultiplexingHelper return self::establishNewMultiplexedConnection($server); } + // Connection exists, ensure we have metadata for age tracking + if (self::getConnectionAge($server) === null) { + // Existing connection but no metadata, store current time as fallback + self::storeConnectionMetadata($server); + } + + // Connection exists, check if it needs refresh due to age + if (self::isConnectionExpired($server)) { + return self::refreshMultiplexedConnection($server); + } + + // Perform health check if enabled + if (config('constants.ssh.mux_health_check_enabled')) { + if (! self::isConnectionHealthy($server)) { + return self::refreshMultiplexedConnection($server); + } + } + return true; } @@ -65,6 +86,9 @@ class SshMultiplexingHelper return false; } + // Store connection metadata for tracking + self::storeConnectionMetadata($server); + return true; } @@ -79,6 +103,9 @@ class SshMultiplexingHelper } $closeCommand .= "{$server->user}@{$server->ip}"; Process::run($closeCommand); + + // Clear connection metadata from cache + self::clearConnectionMetadata($server); } public static function generateScpCommand(Server $server, string $source, string $dest) @@ -94,8 +121,18 @@ class SshMultiplexingHelper if ($server->isIpv6()) { $scp_command .= '-6 '; } - if (self::isMultiplexingEnabled() && self::ensureMultiplexedConnection($server)) { - $scp_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} "; + if (self::isMultiplexingEnabled()) { + try { + if (self::ensureMultiplexedConnection($server)) { + $scp_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} "; + } + } catch (\Exception $e) { + Log::warning('SSH multiplexing failed for SCP, falling back to non-multiplexed connection', [ + 'server' => $server->name ?? $server->ip, + 'error' => $e->getMessage(), + ]); + // Continue without multiplexing + } } if (data_get($server, 'settings.is_cloudflare_tunnel')) { @@ -130,8 +167,16 @@ class SshMultiplexingHelper $ssh_command = "timeout $timeout ssh "; - if (self::isMultiplexingEnabled() && self::ensureMultiplexedConnection($server)) { - $ssh_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} "; + $multiplexingSuccessful = false; + if (self::isMultiplexingEnabled()) { + try { + $multiplexingSuccessful = self::ensureMultiplexedConnection($server); + if ($multiplexingSuccessful) { + $ssh_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} "; + } + } catch (\Exception $e) { + // Continue without multiplexing + } } if (data_get($server, 'settings.is_cloudflare_tunnel')) { @@ -186,4 +231,81 @@ class SshMultiplexingHelper return $options; } + + /** + * Check if the multiplexed connection is healthy by running a test command + */ + public static function isConnectionHealthy(Server $server): bool + { + $sshConfig = self::serverSshConfiguration($server); + $muxSocket = $sshConfig['muxFilename']; + $healthCheckTimeout = config('constants.ssh.mux_health_check_timeout'); + + $healthCommand = "timeout $healthCheckTimeout ssh -o ControlMaster=auto -o ControlPath=$muxSocket "; + if (data_get($server, 'settings.is_cloudflare_tunnel')) { + $healthCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" '; + } + $healthCommand .= "{$server->user}@{$server->ip} 'echo \"health_check_ok\"'"; + + $process = Process::run($healthCommand); + $isHealthy = $process->exitCode() === 0 && str_contains($process->output(), 'health_check_ok'); + + return $isHealthy; + } + + /** + * Check if the connection has exceeded its maximum age + */ + public static function isConnectionExpired(Server $server): bool + { + $connectionAge = self::getConnectionAge($server); + $maxAge = config('constants.ssh.mux_max_age'); + + return $connectionAge !== null && $connectionAge > $maxAge; + } + + /** + * Get the age of the current connection in seconds + */ + public static function getConnectionAge(Server $server): ?int + { + $cacheKey = "ssh_mux_connection_time_{$server->uuid}"; + $connectionTime = Cache::get($cacheKey); + + if ($connectionTime === null) { + return null; + } + + return time() - $connectionTime; + } + + /** + * Refresh a multiplexed connection by closing and re-establishing it + */ + public static function refreshMultiplexedConnection(Server $server): bool + { + // Close existing connection + self::removeMuxFile($server); + + // Establish new connection + return self::establishNewMultiplexedConnection($server); + } + + /** + * Store connection metadata when a new connection is established + */ + private static function storeConnectionMetadata(Server $server): void + { + $cacheKey = "ssh_mux_connection_time_{$server->uuid}"; + Cache::put($cacheKey, time(), config('constants.ssh.mux_persist_time') + 300); // Cache slightly longer than persist time + } + + /** + * Clear connection metadata from cache + */ + private static function clearConnectionMetadata(Server $server): void + { + $cacheKey = "ssh_mux_connection_time_{$server->uuid}"; + Cache::forget($cacheKey); + } } diff --git a/app/Helpers/SshRetryHandler.php b/app/Helpers/SshRetryHandler.php new file mode 100644 index 000000000..aaaf4252a --- /dev/null +++ b/app/Helpers/SshRetryHandler.php @@ -0,0 +1,34 @@ +<?php + +namespace App\Helpers; + +use App\Traits\SshRetryable; + +/** + * Helper class to use SshRetryable trait in non-class contexts + */ +class SshRetryHandler +{ + use SshRetryable; + + /** + * Static method to get a singleton instance + */ + public static function instance(): self + { + static $instance = null; + if ($instance === null) { + $instance = new self; + } + + return $instance; + } + + /** + * Convenience static method for retry execution + */ + public static function retry(callable $callback, array $context = [], bool $throwError = true) + { + return self::instance()->executeWithSshRetry($callback, $context, $throwError); + } +} diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 7ef1c3506..cd640df17 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -2429,7 +2429,6 @@ class ApplicationsController extends Controller 'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'], 'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'], 'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'], - 'is_build_time' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in build time.'], 'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'], 'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'], 'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'], @@ -2470,7 +2469,7 @@ class ApplicationsController extends Controller )] public function update_env_by_uuid(Request $request) { - $allowedFields = ['key', 'value', 'is_preview', 'is_build_time', 'is_literal']; + $allowedFields = ['key', 'value', 'is_preview', 'is_literal']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -2495,7 +2494,6 @@ class ApplicationsController extends Controller 'key' => 'string|required', 'value' => 'string|nullable', 'is_preview' => 'boolean', - 'is_build_time' => 'boolean', 'is_literal' => 'boolean', 'is_multiline' => 'boolean', 'is_shown_once' => 'boolean', @@ -2516,16 +2514,12 @@ class ApplicationsController extends Controller ], 422); } $is_preview = $request->is_preview ?? false; - $is_build_time = $request->is_build_time ?? false; $is_literal = $request->is_literal ?? false; $key = str($request->key)->trim()->replace(' ', '_')->value; if ($is_preview) { $env = $application->environment_variables_preview->where('key', $key)->first(); if ($env) { $env->value = $request->value; - if ($env->is_build_time != $is_build_time) { - $env->is_build_time = $is_build_time; - } if ($env->is_literal != $is_literal) { $env->is_literal = $is_literal; } @@ -2538,6 +2532,12 @@ class ApplicationsController extends Controller if ($env->is_shown_once != $request->is_shown_once) { $env->is_shown_once = $request->is_shown_once; } + if ($request->has('is_runtime') && $env->is_runtime != $request->is_runtime) { + $env->is_runtime = $request->is_runtime; + } + if ($request->has('is_buildtime') && $env->is_buildtime != $request->is_buildtime) { + $env->is_buildtime = $request->is_buildtime; + } $env->save(); return response()->json($this->removeSensitiveData($env))->setStatusCode(201); @@ -2550,9 +2550,6 @@ class ApplicationsController extends Controller $env = $application->environment_variables->where('key', $key)->first(); if ($env) { $env->value = $request->value; - if ($env->is_build_time != $is_build_time) { - $env->is_build_time = $is_build_time; - } if ($env->is_literal != $is_literal) { $env->is_literal = $is_literal; } @@ -2565,6 +2562,12 @@ class ApplicationsController extends Controller if ($env->is_shown_once != $request->is_shown_once) { $env->is_shown_once = $request->is_shown_once; } + if ($request->has('is_runtime') && $env->is_runtime != $request->is_runtime) { + $env->is_runtime = $request->is_runtime; + } + if ($request->has('is_buildtime') && $env->is_buildtime != $request->is_buildtime) { + $env->is_buildtime = $request->is_buildtime; + } $env->save(); return response()->json($this->removeSensitiveData($env))->setStatusCode(201); @@ -2619,7 +2622,6 @@ class ApplicationsController extends Controller 'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'], 'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'], 'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'], - 'is_build_time' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in build time.'], 'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'], 'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'], 'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'], @@ -2690,7 +2692,7 @@ class ApplicationsController extends Controller ], 400); } $bulk_data = collect($bulk_data)->map(function ($item) { - return collect($item)->only(['key', 'value', 'is_preview', 'is_build_time', 'is_literal']); + return collect($item)->only(['key', 'value', 'is_preview', 'is_literal']); }); $returnedEnvs = collect(); foreach ($bulk_data as $item) { @@ -2698,7 +2700,6 @@ class ApplicationsController extends Controller 'key' => 'string|required', 'value' => 'string|nullable', 'is_preview' => 'boolean', - 'is_build_time' => 'boolean', 'is_literal' => 'boolean', 'is_multiline' => 'boolean', 'is_shown_once' => 'boolean', @@ -2710,7 +2711,6 @@ class ApplicationsController extends Controller ], 422); } $is_preview = $item->get('is_preview') ?? false; - $is_build_time = $item->get('is_build_time') ?? false; $is_literal = $item->get('is_literal') ?? false; $is_multi_line = $item->get('is_multiline') ?? false; $is_shown_once = $item->get('is_shown_once') ?? false; @@ -2719,9 +2719,7 @@ class ApplicationsController extends Controller $env = $application->environment_variables_preview->where('key', $key)->first(); if ($env) { $env->value = $item->get('value'); - if ($env->is_build_time != $is_build_time) { - $env->is_build_time = $is_build_time; - } + if ($env->is_literal != $is_literal) { $env->is_literal = $is_literal; } @@ -2731,16 +2729,23 @@ class ApplicationsController extends Controller if ($env->is_shown_once != $item->get('is_shown_once')) { $env->is_shown_once = $item->get('is_shown_once'); } + if ($item->has('is_runtime') && $env->is_runtime != $item->get('is_runtime')) { + $env->is_runtime = $item->get('is_runtime'); + } + if ($item->has('is_buildtime') && $env->is_buildtime != $item->get('is_buildtime')) { + $env->is_buildtime = $item->get('is_buildtime'); + } $env->save(); } else { $env = $application->environment_variables()->create([ 'key' => $item->get('key'), 'value' => $item->get('value'), 'is_preview' => $is_preview, - 'is_build_time' => $is_build_time, 'is_literal' => $is_literal, 'is_multiline' => $is_multi_line, 'is_shown_once' => $is_shown_once, + 'is_runtime' => $item->get('is_runtime', true), + 'is_buildtime' => $item->get('is_buildtime', true), 'resourceable_type' => get_class($application), 'resourceable_id' => $application->id, ]); @@ -2749,9 +2754,6 @@ class ApplicationsController extends Controller $env = $application->environment_variables->where('key', $key)->first(); if ($env) { $env->value = $item->get('value'); - if ($env->is_build_time != $is_build_time) { - $env->is_build_time = $is_build_time; - } if ($env->is_literal != $is_literal) { $env->is_literal = $is_literal; } @@ -2761,16 +2763,23 @@ class ApplicationsController extends Controller if ($env->is_shown_once != $item->get('is_shown_once')) { $env->is_shown_once = $item->get('is_shown_once'); } + if ($item->has('is_runtime') && $env->is_runtime != $item->get('is_runtime')) { + $env->is_runtime = $item->get('is_runtime'); + } + if ($item->has('is_buildtime') && $env->is_buildtime != $item->get('is_buildtime')) { + $env->is_buildtime = $item->get('is_buildtime'); + } $env->save(); } else { $env = $application->environment_variables()->create([ 'key' => $item->get('key'), 'value' => $item->get('value'), 'is_preview' => $is_preview, - 'is_build_time' => $is_build_time, 'is_literal' => $is_literal, 'is_multiline' => $is_multi_line, 'is_shown_once' => $is_shown_once, + 'is_runtime' => $item->get('is_runtime', true), + 'is_buildtime' => $item->get('is_buildtime', true), 'resourceable_type' => get_class($application), 'resourceable_id' => $application->id, ]); @@ -2814,7 +2823,6 @@ class ApplicationsController extends Controller 'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'], 'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'], 'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'], - 'is_build_time' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in build time.'], 'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'], 'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'], 'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'], @@ -2854,7 +2862,7 @@ class ApplicationsController extends Controller )] public function create_env(Request $request) { - $allowedFields = ['key', 'value', 'is_preview', 'is_build_time', 'is_literal']; + $allowedFields = ['key', 'value', 'is_preview', 'is_literal']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -2874,7 +2882,6 @@ class ApplicationsController extends Controller 'key' => 'string|required', 'value' => 'string|nullable', 'is_preview' => 'boolean', - 'is_build_time' => 'boolean', 'is_literal' => 'boolean', 'is_multiline' => 'boolean', 'is_shown_once' => 'boolean', @@ -2908,10 +2915,11 @@ class ApplicationsController extends Controller 'key' => $request->key, 'value' => $request->value, 'is_preview' => $request->is_preview ?? false, - 'is_build_time' => $request->is_build_time ?? false, 'is_literal' => $request->is_literal ?? false, 'is_multiline' => $request->is_multiline ?? false, 'is_shown_once' => $request->is_shown_once ?? false, + 'is_runtime' => $request->is_runtime ?? true, + 'is_buildtime' => $request->is_buildtime ?? true, 'resourceable_type' => get_class($application), 'resourceable_id' => $application->id, ]); @@ -2931,10 +2939,11 @@ class ApplicationsController extends Controller 'key' => $request->key, 'value' => $request->value, 'is_preview' => $request->is_preview ?? false, - 'is_build_time' => $request->is_build_time ?? false, 'is_literal' => $request->is_literal ?? false, 'is_multiline' => $request->is_multiline ?? false, 'is_shown_once' => $request->is_shown_once ?? false, + 'is_runtime' => $request->is_runtime ?? true, + 'is_buildtime' => $request->is_buildtime ?? true, 'resourceable_type' => get_class($application), 'resourceable_id' => $application->id, ]); diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php index b87420f72..c4d603392 100644 --- a/app/Http/Controllers/Api/DeployController.php +++ b/app/Http/Controllers/Api/DeployController.php @@ -225,6 +225,14 @@ class DeployController extends Controller foreach ($uuids as $uuid) { $resource = getResourceByUuid($uuid, $teamId); if ($resource) { + if ($pr !== 0) { + $preview = $resource->previews()->where('pull_request_id', $pr)->first(); + if (! $preview) { + $deployments->push(['message' => "Pull request {$pr} not found for this resource.", 'resource_uuid' => $uuid]); + + continue; + } + } ['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $this->deploy_resource($resource, $force, $pr); if ($deployment_uuid) { $deployments->push(['message' => $return_message, 'resource_uuid' => $uuid, 'deployment_uuid' => $deployment_uuid->toString()]); diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 162f637c5..e240e326e 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -353,7 +353,6 @@ class ServicesController extends Controller 'value' => $generatedValue, 'resourceable_id' => $service->id, 'resourceable_type' => $service->getMorphClass(), - 'is_build_time' => false, 'is_preview' => false, ]); }); @@ -919,7 +918,6 @@ class ServicesController extends Controller 'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'], 'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'], 'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'], - 'is_build_time' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in build time.'], 'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'], 'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'], 'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'], @@ -975,7 +973,6 @@ class ServicesController extends Controller $validator = customApiValidator($request->all(), [ 'key' => 'string|required', 'value' => 'string|nullable', - 'is_build_time' => 'boolean', 'is_literal' => 'boolean', 'is_multiline' => 'boolean', 'is_shown_once' => 'boolean', @@ -1039,7 +1036,6 @@ class ServicesController extends Controller 'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'], 'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'], 'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'], - 'is_build_time' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in build time.'], 'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'], 'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'], 'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'], @@ -1105,7 +1101,6 @@ class ServicesController extends Controller $validator = customApiValidator($item, [ 'key' => 'string|required', 'value' => 'string|nullable', - 'is_build_time' => 'boolean', 'is_literal' => 'boolean', 'is_multiline' => 'boolean', 'is_shown_once' => 'boolean', @@ -1161,7 +1156,6 @@ class ServicesController extends Controller 'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'], 'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'], 'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'], - 'is_build_time' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in build time.'], 'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'], 'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'], 'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'], @@ -1216,7 +1210,6 @@ class ServicesController extends Controller $validator = customApiValidator($request->all(), [ 'key' => 'string|required', 'value' => 'string|nullable', - 'is_build_time' => 'boolean', 'is_literal' => 'boolean', 'is_multiline' => 'boolean', 'is_shown_once' => 'boolean', diff --git a/app/Http/Controllers/Api/TeamController.php b/app/Http/Controllers/Api/TeamController.php index d4b24d8ab..e12d83542 100644 --- a/app/Http/Controllers/Api/TeamController.php +++ b/app/Http/Controllers/Api/TeamController.php @@ -179,6 +179,8 @@ class TeamController extends Controller $members = $team->members; $members->makeHidden([ 'pivot', + 'email_change_code', + 'email_change_code_expires_at', ]); return response()->json( @@ -264,6 +266,8 @@ class TeamController extends Controller $team = auth()->user()->currentTeam(); $team->members->makeHidden([ 'pivot', + 'email_change_code', + 'email_change_code_expires_at', ]); return response()->json( diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index 8872754e5..5ba9c08e7 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Webhook; use App\Enums\ProcessStatus; use App\Http\Controllers\Controller; use App\Jobs\ApplicationPullRequestUpdateJob; +use App\Jobs\DeleteResourceJob; use App\Jobs\GithubAppPermissionJob; use App\Models\Application; use App\Models\ApplicationPreview; @@ -78,6 +79,7 @@ class Github extends Controller $pull_request_html_url = data_get($payload, 'pull_request.html_url'); $branch = data_get($payload, 'pull_request.head.ref'); $base_branch = data_get($payload, 'pull_request.base.ref'); + $author_association = data_get($payload, 'pull_request.author_association'); } if (! $branch) { return response('Nothing to do. No branch found in the request.'); @@ -95,151 +97,168 @@ class Github extends Controller return response("Nothing to do. No applications found with branch '$base_branch'."); } } - foreach ($applications as $application) { - $webhook_secret = data_get($application, 'manual_webhook_secret_github'); - $hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret); - if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) { - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'failed', - 'message' => 'Invalid signature.', - ]); + $applicationsByServer = $applications->groupBy(function ($app) { + return $app->destination->server_id; + }); - continue; - } - $isFunctional = $application->destination->server->isFunctional(); - if (! $isFunctional) { - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'failed', - 'message' => 'Server is not functional.', - ]); + foreach ($applicationsByServer as $serverId => $serverApplications) { + foreach ($serverApplications as $application) { + $webhook_secret = data_get($application, 'manual_webhook_secret_github'); + $hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret); + if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'failed', + 'message' => 'Invalid signature.', + ]); - continue; - } - if ($x_github_event === 'push') { - if ($application->isDeployable()) { - $is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files); - if ($is_watch_path_triggered || is_null($application->watch_paths)) { - $deployment_uuid = new Cuid2; - $result = queue_application_deployment( - application: $application, - deployment_uuid: $deployment_uuid, - force_rebuild: false, - commit: data_get($payload, 'after', 'HEAD'), - is_webhook: true, - ); - if ($result['status'] === 'skipped') { - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'skipped', - 'message' => $result['message'], - ]); + continue; + } + $isFunctional = $application->destination->server->isFunctional(); + if (! $isFunctional) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'failed', + 'message' => 'Server is not functional.', + ]); + + continue; + } + if ($x_github_event === 'push') { + if ($application->isDeployable()) { + $is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files); + if ($is_watch_path_triggered || is_null($application->watch_paths)) { + $deployment_uuid = new Cuid2; + $result = queue_application_deployment( + application: $application, + deployment_uuid: $deployment_uuid, + force_rebuild: false, + commit: data_get($payload, 'after', 'HEAD'), + is_webhook: true, + ); + if ($result['status'] === 'skipped') { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'skipped', + 'message' => $result['message'], + ]); + } else { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'success', + 'message' => 'Deployment queued.', + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'deployment_uuid' => $result['deployment_uuid'], + ]); + } } else { + $paths = str($application->watch_paths)->explode("\n"); $return_payloads->push([ - 'application' => $application->name, - 'status' => 'success', - 'message' => 'Deployment queued.', + 'status' => 'failed', + 'message' => 'Changed files do not match watch paths. Ignoring deployment.', 'application_uuid' => $application->uuid, 'application_name' => $application->name, - 'deployment_uuid' => $result['deployment_uuid'], + 'details' => [ + 'changed_files' => $changed_files, + 'watch_paths' => $paths, + ], ]); } } else { - $paths = str($application->watch_paths)->explode("\n"); $return_payloads->push([ 'status' => 'failed', - 'message' => 'Changed files do not match watch paths. Ignoring deployment.', + 'message' => 'Deployments disabled.', 'application_uuid' => $application->uuid, 'application_name' => $application->name, - 'details' => [ - 'changed_files' => $changed_files, - 'watch_paths' => $paths, - ], ]); } - } else { - $return_payloads->push([ - 'status' => 'failed', - 'message' => 'Deployments disabled.', - 'application_uuid' => $application->uuid, - 'application_name' => $application->name, - ]); } - } - if ($x_github_event === 'pull_request') { - if ($action === 'opened' || $action === 'synchronize' || $action === 'reopened') { - if ($application->isPRDeployable()) { - $deployment_uuid = new Cuid2; - $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); - if (! $found) { - if ($application->build_pack === 'dockercompose') { - $pr_app = ApplicationPreview::create([ - 'git_type' => 'github', - 'application_id' => $application->id, - 'pull_request_id' => $pull_request_id, - 'pull_request_html_url' => $pull_request_html_url, - 'docker_compose_domains' => $application->docker_compose_domains, - ]); - $pr_app->generate_preview_fqdn_compose(); - } else { - $pr_app = ApplicationPreview::create([ - 'git_type' => 'github', - 'application_id' => $application->id, - 'pull_request_id' => $pull_request_id, - 'pull_request_html_url' => $pull_request_html_url, - ]); - $pr_app->generate_preview_fqdn(); - } - } + if ($x_github_event === 'pull_request') { + if ($action === 'opened' || $action === 'synchronize' || $action === 'reopened') { + if ($application->isPRDeployable()) { + // Check if PR deployments from public contributors are restricted + if (! $application->settings->is_pr_deployments_public_enabled) { + $trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR']; + if (! in_array($author_association, $trustedAssociations)) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'failed', + 'message' => 'PR deployments are restricted to repository members and contributors. Author association: '.$author_association, + ]); - $result = queue_application_deployment( - application: $application, - pull_request_id: $pull_request_id, - deployment_uuid: $deployment_uuid, - force_rebuild: false, - commit: data_get($payload, 'head.sha', 'HEAD'), - is_webhook: true, - git_type: 'github' - ); - if ($result['status'] === 'skipped') { + continue; + } + } + $deployment_uuid = new Cuid2; + $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); + if (! $found) { + if ($application->build_pack === 'dockercompose') { + $pr_app = ApplicationPreview::create([ + 'git_type' => 'github', + 'application_id' => $application->id, + 'pull_request_id' => $pull_request_id, + 'pull_request_html_url' => $pull_request_html_url, + 'docker_compose_domains' => $application->docker_compose_domains, + ]); + $pr_app->generate_preview_fqdn_compose(); + } else { + $pr_app = ApplicationPreview::create([ + 'git_type' => 'github', + 'application_id' => $application->id, + 'pull_request_id' => $pull_request_id, + 'pull_request_html_url' => $pull_request_html_url, + ]); + $pr_app->generate_preview_fqdn(); + } + } + + $result = queue_application_deployment( + application: $application, + pull_request_id: $pull_request_id, + deployment_uuid: $deployment_uuid, + force_rebuild: false, + commit: data_get($payload, 'head.sha', 'HEAD'), + is_webhook: true, + git_type: 'github' + ); + if ($result['status'] === 'skipped') { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'skipped', + 'message' => $result['message'], + ]); + } else { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'success', + 'message' => 'Preview deployment queued.', + ]); + } + } else { $return_payloads->push([ 'application' => $application->name, - 'status' => 'skipped', - 'message' => $result['message'], + 'status' => 'failed', + 'message' => 'Preview deployments disabled.', + ]); + } + } + if ($action === 'closed') { + $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); + if ($found) { + DeleteResourceJob::dispatch($found); + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'success', + 'message' => 'Preview deployment closed.', ]); } else { $return_payloads->push([ 'application' => $application->name, - 'status' => 'success', - 'message' => 'Preview deployment queued.', + 'status' => 'failed', + 'message' => 'No preview deployment found.', ]); } - } else { - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'failed', - 'message' => 'Preview deployments disabled.', - ]); - } - } - if ($action === 'closed') { - $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); - if ($found) { - $found->delete(); - $container_name = generateApplicationContainerName($application, $pull_request_id); - instant_remote_process(["docker rm -f $container_name"], $application->destination->server); - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'success', - 'message' => 'Preview deployment closed.', - ]); - } else { - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'failed', - 'message' => 'No preview deployment found.', - ]); } } } @@ -327,6 +346,7 @@ class Github extends Controller $pull_request_html_url = data_get($payload, 'pull_request.html_url'); $branch = data_get($payload, 'pull_request.head.ref'); $base_branch = data_get($payload, 'pull_request.base.ref'); + $author_association = data_get($payload, 'pull_request.author_association'); } if (! $id || ! $branch) { return response('Nothing to do. No id or branch found.'); @@ -344,127 +364,147 @@ class Github extends Controller return response("Nothing to do. No applications found with branch '$base_branch'."); } } - foreach ($applications as $application) { - $isFunctional = $application->destination->server->isFunctional(); - if (! $isFunctional) { - $return_payloads->push([ - 'status' => 'failed', - 'message' => 'Server is not functional.', - 'application_uuid' => $application->uuid, - 'application_name' => $application->name, - ]); + $applicationsByServer = $applications->groupBy(function ($app) { + return $app->destination->server_id; + }); - continue; - } - if ($x_github_event === 'push') { - if ($application->isDeployable()) { - $is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files); - if ($is_watch_path_triggered || is_null($application->watch_paths)) { - $deployment_uuid = new Cuid2; - $result = queue_application_deployment( - application: $application, - deployment_uuid: $deployment_uuid, - commit: data_get($payload, 'after', 'HEAD'), - force_rebuild: false, - is_webhook: true, - ); - $return_payloads->push([ - 'status' => $result['status'], - 'message' => $result['message'], - 'application_uuid' => $application->uuid, - 'application_name' => $application->name, - 'deployment_uuid' => $result['deployment_uuid'], - ]); - } else { - $paths = str($application->watch_paths)->explode("\n"); - $return_payloads->push([ - 'status' => 'failed', - 'message' => 'Changed files do not match watch paths. Ignoring deployment.', - 'application_uuid' => $application->uuid, - 'application_name' => $application->name, - 'details' => [ - 'changed_files' => $changed_files, - 'watch_paths' => $paths, - ], - ]); - } - } else { + foreach ($applicationsByServer as $serverId => $serverApplications) { + foreach ($serverApplications as $application) { + $isFunctional = $application->destination->server->isFunctional(); + if (! $isFunctional) { $return_payloads->push([ 'status' => 'failed', - 'message' => 'Deployments disabled.', + 'message' => 'Server is not functional.', 'application_uuid' => $application->uuid, 'application_name' => $application->name, ]); + + continue; } - } - if ($x_github_event === 'pull_request') { - if ($action === 'opened' || $action === 'synchronize' || $action === 'reopened') { - if ($application->isPRDeployable()) { - $deployment_uuid = new Cuid2; - $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); - if (! $found) { - ApplicationPreview::create([ - 'git_type' => 'github', - 'application_id' => $application->id, - 'pull_request_id' => $pull_request_id, - 'pull_request_html_url' => $pull_request_html_url, + if ($x_github_event === 'push') { + if ($application->isDeployable()) { + $is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files); + if ($is_watch_path_triggered || is_null($application->watch_paths)) { + $deployment_uuid = new Cuid2; + $result = queue_application_deployment( + application: $application, + deployment_uuid: $deployment_uuid, + commit: data_get($payload, 'after', 'HEAD'), + force_rebuild: false, + is_webhook: true, + ); + $return_payloads->push([ + 'status' => $result['status'], + 'message' => $result['message'], + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'deployment_uuid' => $result['deployment_uuid'], + ]); + } else { + $paths = str($application->watch_paths)->explode("\n"); + $return_payloads->push([ + 'status' => 'failed', + 'message' => 'Changed files do not match watch paths. Ignoring deployment.', + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'details' => [ + 'changed_files' => $changed_files, + 'watch_paths' => $paths, + ], ]); } - $result = queue_application_deployment( - application: $application, - pull_request_id: $pull_request_id, - deployment_uuid: $deployment_uuid, - force_rebuild: false, - commit: data_get($payload, 'head.sha', 'HEAD'), - is_webhook: true, - git_type: 'github' - ); - if ($result['status'] === 'skipped') { + } else { + $return_payloads->push([ + 'status' => 'failed', + 'message' => 'Deployments disabled.', + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + ]); + } + } + if ($x_github_event === 'pull_request') { + if ($action === 'opened' || $action === 'synchronize' || $action === 'reopened') { + if ($application->isPRDeployable()) { + // Check if PR deployments from public contributors are restricted + if (! $application->settings->is_pr_deployments_public_enabled) { + $trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR']; + if (! in_array($author_association, $trustedAssociations)) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'failed', + 'message' => 'PR deployments are restricted to repository members and contributors. Author association: '.$author_association, + ]); + + continue; + } + } + $deployment_uuid = new Cuid2; + $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); + if (! $found) { + ApplicationPreview::create([ + 'git_type' => 'github', + 'application_id' => $application->id, + 'pull_request_id' => $pull_request_id, + 'pull_request_html_url' => $pull_request_html_url, + ]); + } + $result = queue_application_deployment( + application: $application, + pull_request_id: $pull_request_id, + deployment_uuid: $deployment_uuid, + force_rebuild: false, + commit: data_get($payload, 'head.sha', 'HEAD'), + is_webhook: true, + git_type: 'github' + ); + if ($result['status'] === 'skipped') { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'skipped', + 'message' => $result['message'], + ]); + } else { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'success', + 'message' => 'Preview deployment queued.', + ]); + } + } else { $return_payloads->push([ 'application' => $application->name, - 'status' => 'skipped', - 'message' => $result['message'], + 'status' => 'failed', + 'message' => 'Preview deployments disabled.', + ]); + } + } + if ($action === 'closed' || $action === 'close') { + $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); + if ($found) { + $containers = getCurrentApplicationContainerStatus($application->destination->server, $application->id, $pull_request_id); + if ($containers->isNotEmpty()) { + $containers->each(function ($container) use ($application) { + $container_name = data_get($container, 'Names'); + instant_remote_process(["docker rm -f $container_name"], $application->destination->server); + }); + } + + ApplicationPullRequestUpdateJob::dispatchSync(application: $application, preview: $found, status: ProcessStatus::CLOSED); + + DeleteResourceJob::dispatch($found); + + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'success', + 'message' => 'Preview deployment closed.', ]); } else { $return_payloads->push([ 'application' => $application->name, - 'status' => 'success', - 'message' => 'Preview deployment queued.', + 'status' => 'failed', + 'message' => 'No preview deployment found.', ]); } - } else { - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'failed', - 'message' => 'Preview deployments disabled.', - ]); - } - } - if ($action === 'closed' || $action === 'close') { - $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); - if ($found) { - $containers = getCurrentApplicationContainerStatus($application->destination->server, $application->id, $pull_request_id); - if ($containers->isNotEmpty()) { - $containers->each(function ($container) use ($application) { - $container_name = data_get($container, 'Names'); - instant_remote_process(["docker rm -f $container_name"], $application->destination->server); - }); - } - - ApplicationPullRequestUpdateJob::dispatchSync(application: $application, preview: $found, status: ProcessStatus::CLOSED); - $found->delete(); - - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'success', - 'message' => 'Preview deployment closed.', - ]); - } else { - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'failed', - 'message' => 'No preview deployment found.', - ]); } } } diff --git a/app/Http/Controllers/Webhook/Stripe.php b/app/Http/Controllers/Webhook/Stripe.php index 83ba16699..ae50aac42 100644 --- a/app/Http/Controllers/Webhook/Stripe.php +++ b/app/Http/Controllers/Webhook/Stripe.php @@ -4,15 +4,12 @@ namespace App\Http\Controllers\Webhook; use App\Http\Controllers\Controller; use App\Jobs\StripeProcessJob; -use App\Models\Webhook; use Exception; use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; class Stripe extends Controller { - protected $webhook; - public function events(Request $request) { try { @@ -40,19 +37,10 @@ class Stripe extends Controller return response('Webhook received. Cool cool cool cool cool.', 200); } - $this->webhook = Webhook::create([ - 'type' => 'stripe', - 'payload' => $request->getContent(), - ]); StripeProcessJob::dispatch($event); return response('Webhook received. Cool cool cool cool cool.', 200); } catch (Exception $e) { - $this->webhook->update([ - 'status' => 'failed', - 'failure_reason' => $e->getMessage(), - ]); - return response($e->getMessage(), 400); } } diff --git a/app/Http/Middleware/ApiAllowed.php b/app/Http/Middleware/ApiAllowed.php index dd85c3521..21441a117 100644 --- a/app/Http/Middleware/ApiAllowed.php +++ b/app/Http/Middleware/ApiAllowed.php @@ -28,7 +28,7 @@ class ApiAllowed $allowedIps = array_map('trim', $allowedIps); $allowedIps = array_filter($allowedIps); // Remove empty entries - if (! empty($allowedIps) && ! check_ip_against_allowlist($request->ip(), $allowedIps)) { + if (! empty($allowedIps) && ! checkIPAgainstAllowlist($request->ip(), $allowedIps)) { return response()->json(['success' => true, 'message' => 'You are not allowed to access the API.'], 403); } } diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 9037fa3e5..7fa12757e 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -5,6 +5,7 @@ namespace App\Jobs; use App\Actions\Docker\GetContainersStatus; use App\Enums\ApplicationDeploymentStatus; use App\Enums\ProcessStatus; +use App\Events\ApplicationConfigurationChanged; use App\Events\ServiceStatusChanged; use App\Models\Application; use App\Models\ApplicationDeploymentQueue; @@ -147,6 +148,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private Collection $saved_outputs; + private ?string $secrets_hash_key = null; + private ?string $full_healthcheck_url = null; private string $serverUser = 'root'; @@ -167,6 +170,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private bool $preserveRepository = false; + private bool $dockerBuildkitSupported = false; + + private bool $skip_build = false; + + private Collection|string $build_secrets; + public function tags() { // Do not remove this one, it needs to properly identify which worker is running the job @@ -183,6 +192,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->application = Application::find($this->application_deployment_queue->application_id); $this->build_pack = data_get($this->application, 'build_pack'); $this->build_args = collect([]); + $this->build_secrets = ''; $this->deployment_uuid = $this->application_deployment_queue->deployment_uuid; $this->pull_request_id = $this->application_deployment_queue->pull_request_id; @@ -221,7 +231,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue if ($this->pull_request_id === 0) { $this->container_name = $this->application->settings->custom_internal_name; } else { - $this->container_name = "{$this->application->settings->custom_internal_name}-pr-{$this->pull_request_id}"; + $this->container_name = addPreviewDeploymentSuffix($this->application->settings->custom_internal_name, $this->pull_request_id); } } @@ -250,6 +260,14 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue public function handle(): void { + // Check if deployment was cancelled before we even started + $this->application_deployment_queue->refresh(); + if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) { + $this->application_deployment_queue->addLogEntry('Deployment was cancelled before starting.'); + + return; + } + $this->application_deployment_queue->update([ 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value, 'horizon_job_worker' => gethostname(), @@ -263,7 +281,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue try { // Make sure the private key is stored in the filesystem $this->server->privateKey->storeInFileSystem(); - // Generate custom host<->ip mapping $allContainers = instant_remote_process(["docker network inspect {$this->destination->network} -f '{{json .Containers}}' "], $this->server); @@ -319,6 +336,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->build_server = $this->server; $this->original_server = $this->server; } + $this->detectBuildKitCapabilities(); $this->decide_what_to_do(); } catch (Exception $e) { if ($this->pull_request_id !== 0 && $this->application->is_github_based()) { @@ -336,6 +354,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } else { $this->write_deployment_configurations(); } + $this->application_deployment_queue->addLogEntry("Gracefully shutting down build container: {$this->deployment_uuid}"); $this->graceful_shutdown_container($this->deployment_uuid); @@ -343,6 +362,80 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } } + private function detectBuildKitCapabilities(): void + { + // If build secrets are not enabled, skip detection and use traditional args + if (! $this->application->settings->use_build_secrets) { + $this->dockerBuildkitSupported = false; + + return; + } + + $serverToCheck = $this->use_build_server ? $this->build_server : $this->server; + $serverName = $this->use_build_server ? "build server ({$serverToCheck->name})" : "deployment server ({$serverToCheck->name})"; + + try { + $dockerVersion = instant_remote_process( + ["docker version --format '{{.Server.Version}}'"], + $serverToCheck + ); + + $versionParts = explode('.', $dockerVersion); + $majorVersion = (int) $versionParts[0]; + $minorVersion = (int) ($versionParts[1] ?? 0); + + if ($majorVersion < 18 || ($majorVersion == 18 && $minorVersion < 9)) { + $this->dockerBuildkitSupported = false; + $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not support BuildKit (requires 18.09+). Build secrets feature disabled."); + + return; + } + + $buildkitEnabled = instant_remote_process( + ["docker buildx version >/dev/null 2>&1 && echo 'available' || echo 'not-available'"], + $serverToCheck + ); + + if (trim($buildkitEnabled) !== 'available') { + $buildkitTest = instant_remote_process( + ["DOCKER_BUILDKIT=1 docker build --help 2>&1 | grep -q 'secret' && echo 'supported' || echo 'not-supported'"], + $serverToCheck + ); + + if (trim($buildkitTest) === 'supported') { + $this->dockerBuildkitSupported = true; + $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit secrets support detected on {$serverName}."); + $this->application_deployment_queue->addLogEntry('Build secrets are enabled and will be used for enhanced security.'); + } else { + $this->dockerBuildkitSupported = false; + $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not have BuildKit secrets support."); + $this->application_deployment_queue->addLogEntry('Build secrets feature is enabled but not supported. Using traditional build arguments.'); + } + } else { + // Buildx is available, which means BuildKit is available + // Now specifically test for secrets support + $secretsTest = instant_remote_process( + ["docker build --help 2>&1 | grep -q 'secret' && echo 'supported' || echo 'not-supported'"], + $serverToCheck + ); + + if (trim($secretsTest) === 'supported') { + $this->dockerBuildkitSupported = true; + $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit and Buildx detected on {$serverName}."); + $this->application_deployment_queue->addLogEntry('Build secrets are enabled and will be used for enhanced security.'); + } else { + $this->dockerBuildkitSupported = false; + $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with Buildx on {$serverName}, but secrets not supported."); + $this->application_deployment_queue->addLogEntry('Build secrets feature is enabled but not supported. Using traditional build arguments.'); + } + } + } catch (\Exception $e) { + $this->dockerBuildkitSupported = false; + $this->application_deployment_queue->addLogEntry("Could not detect BuildKit capabilities on {$serverName}: {$e->getMessage()}"); + $this->application_deployment_queue->addLogEntry('Build secrets feature is enabled but detection failed. Using traditional build arguments.'); + } + } + private function decide_what_to_do() { if ($this->restart_only) { @@ -471,14 +564,23 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } $this->generate_image_names(); $this->cleanup_git(); + + $this->generate_build_env_variables(); + $this->application->loadComposeFile(isInit: false); if ($this->application->settings->is_raw_compose_deployment_enabled) { $this->application->oldRawParser(); $yaml = $composeFile = $this->application->docker_compose_raw; - $this->save_environment_variables(); + $this->generate_runtime_environment_variables(); + + // For raw compose, we cannot automatically add secrets configuration + // User must define it manually in their docker-compose file + if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported && ! empty($this->build_secrets)) { + $this->application_deployment_queue->addLogEntry('Build secrets are configured. Ensure your docker-compose file includes build.secrets configuration for services that need them.'); + } } else { $composeFile = $this->application->parse(pull_request_id: $this->pull_request_id, preview_id: data_get($this->preview, 'id')); - $this->save_environment_variables(); + $this->generate_runtime_environment_variables(); if (filled($this->env_filename)) { $services = collect(data_get($composeFile, 'services', [])); $services = $services->map(function ($service, $name) { @@ -494,6 +596,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue return; } + + // Add build secrets to compose file if enabled and BuildKit is supported + if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported && ! empty($this->build_secrets)) { + $composeFile = $this->add_build_secrets_to_compose($composeFile); + } + $yaml = Yaml::dump(convertToArray($composeFile), 10); } $this->docker_compose_base64 = base64_encode($yaml); @@ -501,16 +609,28 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue executeInDocker($this->deployment_uuid, "echo '{$this->docker_compose_base64}' | base64 -d | tee {$this->workdir}{$this->docker_compose_location} > /dev/null"), 'hidden' => true, ]); + + // Modify Dockerfiles for ARGs and build secrets + $this->modify_dockerfiles_for_compose($composeFile); // Build new container to limit downtime. $this->application_deployment_queue->addLogEntry('Pulling & building required images.'); if ($this->docker_compose_custom_build_command) { + // Prepend DOCKER_BUILDKIT=1 if BuildKit is supported + $build_command = $this->docker_compose_custom_build_command; + if ($this->dockerBuildkitSupported) { + $build_command = "DOCKER_BUILDKIT=1 {$build_command}"; + } $this->execute_remote_command( - [executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$this->docker_compose_custom_build_command}"), 'hidden' => true], + [executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$build_command}"), 'hidden' => true], ); } else { $command = "{$this->coolify_variables} docker compose"; - if ($this->env_filename) { + // Prepend DOCKER_BUILDKIT=1 if BuildKit is supported + if ($this->dockerBuildkitSupported) { + $command = "DOCKER_BUILDKIT=1 {$command}"; + } + if (filled($this->env_filename)) { $command .= " --env-file {$this->workdir}/{$this->env_filename}"; } if ($this->force_rebuild) { @@ -518,6 +638,13 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } else { $command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} build --pull"; } + + if (! $this->application->settings->use_build_secrets && $this->build_args instanceof \Illuminate\Support\Collection && $this->build_args->isNotEmpty()) { + $build_args_string = $this->build_args->implode(' '); + $command .= " {$build_args_string}"; + $this->application_deployment_queue->addLogEntry('Adding build arguments to Docker Compose build command.'); + } + $this->execute_remote_command( [executeInDocker($this->deployment_uuid, $command), 'hidden' => true], ); @@ -556,7 +683,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->docker_compose_location = '/docker-compose.yaml'; $command = "{$this->coolify_variables} docker compose"; - if ($this->env_filename) { + if (filled($this->env_filename)) { $command .= " --env-file {$server_workdir}/{$this->env_filename}"; } $command .= " --project-directory {$server_workdir} -f {$server_workdir}{$this->docker_compose_location} up -d"; @@ -573,7 +700,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } else { $command = "{$this->coolify_variables} docker compose"; if ($this->preserveRepository) { - if ($this->env_filename) { + if (filled($this->env_filename)) { $command .= " --env-file {$server_workdir}/{$this->env_filename}"; } $command .= " --project-name {$this->application->uuid} --project-directory {$server_workdir} -f {$server_workdir}{$this->docker_compose_location} up -d"; @@ -583,7 +710,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue ['command' => $command, 'hidden' => true], ); } else { - if ($this->env_filename) { + if (filled($this->env_filename)) { $command .= " --env-file {$this->workdir}/{$this->env_filename}"; } $command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} up -d"; @@ -647,6 +774,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->generate_compose_file(); $this->generate_build_env_variables(); $this->build_image(); + + // For Nixpacks, save runtime environment variables AFTER the build + // to prevent them from being accessible during the build process + $this->save_runtime_environment_variables(); $this->push_to_docker_registry(); $this->rolling_update(); } @@ -669,7 +800,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->clone_repository(); $this->cleanup_git(); $this->generate_compose_file(); - $this->build_image(); + $this->build_static_image(); $this->push_to_docker_registry(); $this->rolling_update(); } @@ -712,8 +843,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue if ($this->pull_request_id === 0) { $composeFileName = "$mainDir/docker-compose.yaml"; } else { - $composeFileName = "$mainDir/docker-compose-pr-{$this->pull_request_id}.yaml"; - $this->docker_compose_location = "/docker-compose-pr-{$this->pull_request_id}.yaml"; + $composeFileName = "$mainDir/".addPreviewDeploymentSuffix('docker-compose', $this->pull_request_id).'.yaml'; + $this->docker_compose_location = '/'.addPreviewDeploymentSuffix('docker-compose', $this->pull_request_id).'.yaml'; } $this->execute_remote_command( [ @@ -840,18 +971,17 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue { if (str($this->saved_outputs->get('local_image_found'))->isNotEmpty()) { if ($this->is_this_additional_server) { + $this->skip_build = true; $this->application_deployment_queue->addLogEntry("Image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped."); $this->generate_compose_file(); $this->push_to_docker_registry(); $this->rolling_update(); - if ($this->restart_only) { - $this->post_deployment(); - } return true; } if (! $this->application->isConfigurationChanged()) { $this->application_deployment_queue->addLogEntry("No configuration changed & image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped."); + $this->skip_build = true; $this->generate_compose_file(); $this->push_to_docker_registry(); $this->rolling_update(); @@ -892,7 +1022,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } } - private function save_environment_variables() + private function generate_runtime_environment_variables() { $envs = collect([]); $sort = $this->application->settings->is_env_sorting_enabled; @@ -905,10 +1035,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } if ($this->build_pack === 'dockercompose') { $sorted_environment_variables = $sorted_environment_variables->filter(function ($env) { - return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_'); + return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_') && ! str($env->key)->startsWith('SERVICE_NAME_'); }); $sorted_environment_variables_preview = $sorted_environment_variables_preview->filter(function ($env) { - return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_'); + return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_') && ! str($env->key)->startsWith('SERVICE_NAME_'); }); } $ports = $this->application->main_port(); @@ -919,20 +1049,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue if ($this->pull_request_id === 0) { $this->env_filename = '.env'; - foreach ($sorted_environment_variables as $env) { - $envs->push($env->key.'='.$env->real_value); - } - // Add PORT if not exists, use the first port as default - if ($this->build_pack !== 'dockercompose') { - if ($this->application->environment_variables->where('key', 'PORT')->isEmpty()) { - $envs->push("PORT={$ports[0]}"); - } - } - // Add HOST if not exists - if ($this->application->environment_variables->where('key', 'HOST')->isEmpty()) { - $envs->push('HOST=0.0.0.0'); - } - + // Generate SERVICE_ variables first for dockercompose if ($this->build_pack === 'dockercompose') { $domains = collect(json_decode($this->application->docker_compose_domains)) ?? collect([]); @@ -949,23 +1066,50 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.$coolifyFqdn); } } + + // Generate SERVICE_NAME for dockercompose services from processed compose + if ($this->application->settings->is_raw_compose_deployment_enabled) { + $dockerCompose = Yaml::parse($this->application->docker_compose_raw); + } else { + $dockerCompose = Yaml::parse($this->application->docker_compose); + } + $services = data_get($dockerCompose, 'services', []); + foreach ($services as $serviceName => $_) { + $envs->push('SERVICE_NAME_'.str($serviceName)->upper().'='.$serviceName); + } } - } else { - $this->env_filename = ".env-pr-$this->pull_request_id"; - foreach ($sorted_environment_variables_preview as $env) { + + // Filter runtime variables (only include variables that are available at runtime) + $runtime_environment_variables = $sorted_environment_variables->filter(function ($env) { + return $env->is_runtime; + }); + + // Sort runtime environment variables: those referencing SERVICE_ variables come after others + $runtime_environment_variables = $runtime_environment_variables->sortBy(function ($env) { + if (str($env->value)->startsWith('$SERVICE_') || str($env->value)->contains('${SERVICE_')) { + return 2; + } + + return 1; + }); + + foreach ($runtime_environment_variables as $env) { $envs->push($env->key.'='.$env->real_value); } // Add PORT if not exists, use the first port as default if ($this->build_pack !== 'dockercompose') { - if ($this->application->environment_variables_preview->where('key', 'PORT')->isEmpty()) { + if ($this->application->environment_variables->where('key', 'PORT')->isEmpty()) { $envs->push("PORT={$ports[0]}"); } } // Add HOST if not exists - if ($this->application->environment_variables_preview->where('key', 'HOST')->isEmpty()) { + if ($this->application->environment_variables->where('key', 'HOST')->isEmpty()) { $envs->push('HOST=0.0.0.0'); } + } else { + $this->env_filename = '.env'; + // Generate SERVICE_ variables first for dockercompose preview if ($this->build_pack === 'dockercompose') { $domains = collect(json_decode(data_get($this->preview, 'docker_compose_domains'))) ?? collect([]); @@ -982,44 +1126,121 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.$coolifyFqdn); } } + + // Generate SERVICE_NAME for dockercompose services + $rawDockerCompose = Yaml::parse($this->application->docker_compose_raw); + $rawServices = data_get($rawDockerCompose, 'services', []); + foreach ($rawServices as $rawServiceName => $_) { + $envs->push('SERVICE_NAME_'.str($rawServiceName)->upper().'='.addPreviewDeploymentSuffix($rawServiceName, $this->pull_request_id)); + } + } + + // Filter runtime variables for preview (only include variables that are available at runtime) + $runtime_environment_variables_preview = $sorted_environment_variables_preview->filter(function ($env) { + return $env->is_runtime; + }); + + // Sort runtime environment variables: those referencing SERVICE_ variables come after others + $runtime_environment_variables_preview = $runtime_environment_variables_preview->sortBy(function ($env) { + if (str($env->value)->startsWith('$SERVICE_') || str($env->value)->contains('${SERVICE_')) { + return 2; + } + + return 1; + }); + + foreach ($runtime_environment_variables_preview as $env) { + $envs->push($env->key.'='.$env->real_value); + } + // Add PORT if not exists, use the first port as default + if ($this->build_pack !== 'dockercompose') { + if ($this->application->environment_variables_preview->where('key', 'PORT')->isEmpty()) { + $envs->push("PORT={$ports[0]}"); + } + } + // Add HOST if not exists + if ($this->application->environment_variables_preview->where('key', 'HOST')->isEmpty()) { + $envs->push('HOST=0.0.0.0'); } } if ($envs->isEmpty()) { - $this->env_filename = null; - if ($this->use_build_server) { - $this->server = $this->original_server; - $this->execute_remote_command( - [ - 'command' => "rm -f $this->configuration_dir/{$this->env_filename}", - 'hidden' => true, - 'ignore_errors' => true, - ] - ); - $this->server = $this->build_server; - $this->execute_remote_command( - [ - 'command' => "rm -f $this->configuration_dir/{$this->env_filename}", - 'hidden' => true, - 'ignore_errors' => true, - ] - ); - } else { - $this->execute_remote_command( - [ - 'command' => "rm -f $this->configuration_dir/{$this->env_filename}", - 'hidden' => true, - 'ignore_errors' => true, - ] - ); + if ($this->env_filename) { + if ($this->use_build_server) { + $this->server = $this->original_server; + $this->execute_remote_command( + [ + 'command' => "rm -f $this->configuration_dir/{$this->env_filename}", + 'hidden' => true, + 'ignore_errors' => true, + ] + ); + $this->server = $this->build_server; + $this->execute_remote_command( + [ + 'command' => "rm -f $this->configuration_dir/{$this->env_filename}", + 'hidden' => true, + 'ignore_errors' => true, + ] + ); + } else { + $this->execute_remote_command( + [ + 'command' => "rm -f $this->configuration_dir/{$this->env_filename}", + 'hidden' => true, + 'ignore_errors' => true, + ] + ); + } } + $this->env_filename = null; } else { - $envs_base64 = base64_encode($envs->implode("\n")); + // For Nixpacks builds, we save the .env file AFTER the build to prevent + // runtime-only variables from being accessible during the build process + if ($this->application->build_pack !== 'nixpacks' || $this->skip_build) { + $envs_base64 = base64_encode($envs->implode("\n")); + $this->execute_remote_command( + [ + executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/{$this->env_filename} > /dev/null"), + ], + + ); + if ($this->use_build_server) { + $this->server = $this->original_server; + $this->execute_remote_command( + [ + "echo '$envs_base64' | base64 -d | tee $this->configuration_dir/{$this->env_filename} > /dev/null", + ] + ); + $this->server = $this->build_server; + } else { + $this->execute_remote_command( + [ + "echo '$envs_base64' | base64 -d | tee $this->configuration_dir/{$this->env_filename} > /dev/null", + ] + ); + } + } + } + $this->environment_variables = $envs; + } + + private function save_runtime_environment_variables() + { + // This method saves the .env file with runtime variables + // It should be called AFTER the build for Nixpacks to prevent runtime-only variables + // from being accessible during the build process + + if ($this->environment_variables && $this->environment_variables->isNotEmpty() && $this->env_filename) { + $envs_base64 = base64_encode($this->environment_variables->implode("\n")); + + // Write .env file to workdir (for container runtime) $this->execute_remote_command( [ executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/{$this->env_filename} > /dev/null"), ], - ); + + // Write .env file to configuration directory if ($this->use_build_server) { $this->server = $this->original_server; $this->execute_remote_command( @@ -1036,7 +1257,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue ); } } - $this->environment_variables = $envs; } private function elixir_finetunes() @@ -1047,32 +1267,17 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $envType = 'environment_variables_preview'; } $mix_env = $this->application->{$envType}->where('key', 'MIX_ENV')->first(); - if ($mix_env) { - if ($mix_env->is_build_time === false) { - $this->application_deployment_queue->addLogEntry('MIX_ENV environment variable is not set as build time.', type: 'error'); - $this->application_deployment_queue->addLogEntry('Please set MIX_ENV environment variable to be build time variable if you facing any issues with the deployment.', type: 'error'); - } - } else { + if (! $mix_env) { $this->application_deployment_queue->addLogEntry('MIX_ENV environment variable not found.', type: 'error'); $this->application_deployment_queue->addLogEntry('Please add MIX_ENV environment variable and set it to be build time variable if you facing any issues with the deployment.', type: 'error'); } $secret_key_base = $this->application->{$envType}->where('key', 'SECRET_KEY_BASE')->first(); - if ($secret_key_base) { - if ($secret_key_base->is_build_time === false) { - $this->application_deployment_queue->addLogEntry('SECRET_KEY_BASE environment variable is not set as build time.', type: 'error'); - $this->application_deployment_queue->addLogEntry('Please set SECRET_KEY_BASE environment variable to be build time variable if you facing any issues with the deployment.', type: 'error'); - } - } else { + if (! $secret_key_base) { $this->application_deployment_queue->addLogEntry('SECRET_KEY_BASE environment variable not found.', type: 'error'); $this->application_deployment_queue->addLogEntry('Please add SECRET_KEY_BASE environment variable and set it to be build time variable if you facing any issues with the deployment.', type: 'error'); } $database_url = $this->application->{$envType}->where('key', 'DATABASE_URL')->first(); - if ($database_url) { - if ($database_url->is_build_time === false) { - $this->application_deployment_queue->addLogEntry('DATABASE_URL environment variable is not set as build time.', type: 'error'); - $this->application_deployment_queue->addLogEntry('Please set DATABASE_URL environment variable to be build time variable if you facing any issues with the deployment.', type: 'error'); - } - } else { + if (! $database_url) { $this->application_deployment_queue->addLogEntry('DATABASE_URL environment variable not found.', type: 'error'); $this->application_deployment_queue->addLogEntry('Please add DATABASE_URL environment variable and set it to be build time variable if you facing any issues with the deployment.', type: 'error'); } @@ -1092,7 +1297,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $nixpacks_php_fallback_path = new EnvironmentVariable; $nixpacks_php_fallback_path->key = 'NIXPACKS_PHP_FALLBACK_PATH'; $nixpacks_php_fallback_path->value = '/index.php'; - $nixpacks_php_fallback_path->is_build_time = false; $nixpacks_php_fallback_path->resourceable_id = $this->application->id; $nixpacks_php_fallback_path->resourceable_type = 'App\Models\Application'; $nixpacks_php_fallback_path->save(); @@ -1101,7 +1305,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $nixpacks_php_root_dir = new EnvironmentVariable; $nixpacks_php_root_dir->key = 'NIXPACKS_PHP_ROOT_DIR'; $nixpacks_php_root_dir->value = '/app/public'; - $nixpacks_php_root_dir->is_build_time = false; $nixpacks_php_root_dir->resourceable_id = $this->application->id; $nixpacks_php_root_dir->resourceable_type = 'App\Models\Application'; $nixpacks_php_root_dir->save(); @@ -1112,6 +1315,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private function rolling_update() { + $this->checkForCancellation(); if ($this->server->isSwarm()) { $this->application_deployment_queue->addLogEntry('Rolling update started.'); $this->execute_remote_command( @@ -1271,8 +1475,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->add_build_env_variables_to_dockerfile(); } $this->build_image(); + // For Nixpacks, save runtime environment variables AFTER the build + if ($this->application->build_pack === 'nixpacks') { + $this->save_runtime_environment_variables(); + } $this->push_to_docker_registry(); - // $this->stop_running_container(); $this->rolling_update(); } @@ -1308,22 +1515,26 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private function prepare_builder_image() { + $this->checkForCancellation(); $settings = instanceSettings(); $helperImage = config('constants.coolify.helper_image'); $helperImage = "{$helperImage}:{$settings->helper_version}"; // Get user home directory $this->serverUserHomeDir = instant_remote_process(['echo $HOME'], $this->server); $this->dockerConfigFileExists = instant_remote_process(["test -f {$this->serverUserHomeDir}/.docker/config.json && echo 'OK' || echo 'NOK'"], $this->server); + + $env_flags = $this->generate_docker_env_flags_for_secrets(); + if ($this->use_build_server) { if ($this->dockerConfigFileExists === 'NOK') { throw new RuntimeException('Docker config file (~/.docker/config.json) not found on the build server. Please run "docker login" to login to the docker registry on the server.'); } - $runCommand = "docker run -d --name {$this->deployment_uuid} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; + $runCommand = "docker run -d --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; } else { if ($this->dockerConfigFileExists === 'OK') { - $runCommand = "docker run -d --network {$this->destination->network} --name {$this->deployment_uuid} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; + $runCommand = "docker run -d --network {$this->destination->network} --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; } else { - $runCommand = "docker run -d --network {$this->destination->network} --name {$this->deployment_uuid} --rm -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; + $runCommand = "docker run -d --network {$this->destination->network} --name {$this->deployment_uuid} {$env_flags} --rm -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; } } $this->application_deployment_queue->addLogEntry("Preparing container with helper image: $helperImage."); @@ -1531,6 +1742,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue { $nixpacks_command = $this->nixpacks_build_cmd(); $this->application_deployment_queue->addLogEntry("Generating nixpacks configuration with: $nixpacks_command"); + $this->execute_remote_command( [executeInDocker($this->deployment_uuid, $nixpacks_command), 'save' => 'nixpacks_plan', 'hidden' => true], [executeInDocker($this->deployment_uuid, "nixpacks detect {$this->workdir}"), 'save' => 'nixpacks_type', 'hidden' => true], @@ -1550,6 +1762,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $parsed = Toml::Parse($this->nixpacks_plan); // Do any modifications here + // We need to generate envs here because nixpacks need to know to generate a proper Dockerfile $this->generate_env_variables(); $merged_envs = collect(data_get($parsed, 'variables', []))->merge($this->env_args); $aptPkgs = data_get($parsed, 'phases.setup.aptPkgs', []); @@ -1622,6 +1835,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } } + // Add COOLIFY_* environment variables to Nixpacks build context + $coolify_envs = $this->generate_coolify_env_variables(); + $coolify_envs->each(function ($value, $key) { + $this->env_nixpacks_args->push("--env {$key}={$value}"); + }); + $this->env_nixpacks_args = $this->env_nixpacks_args->implode(' '); } @@ -1715,8 +1934,16 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->env_args = collect([]); $this->env_args->put('SOURCE_COMMIT', $this->commit); $coolify_envs = $this->generate_coolify_env_variables(); + + // For build process, include only environment variables where is_buildtime = true if ($this->pull_request_id === 0) { - foreach ($this->application->build_environment_variables as $env) { + // Get environment variables that are marked as available during build + $envs = $this->application->environment_variables() + ->where('key', 'not like', 'NIXPACKS_%') + ->where('is_buildtime', true) + ->get(); + + foreach ($envs as $env) { if (! is_null($env->real_value)) { $this->env_args->put($env->key, $env->real_value); if (str($env->real_value)->startsWith('$')) { @@ -1736,7 +1963,13 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } } } else { - foreach ($this->application->build_environment_variables_preview as $env) { + // Get preview environment variables that are marked as available during build + $envs = $this->application->environment_variables_preview() + ->where('key', 'not like', 'NIXPACKS_%') + ->where('is_buildtime', true) + ->get(); + + foreach ($envs as $env) { if (! is_null($env->real_value)) { $this->env_args->put($env->key, $env->real_value); if (str($env->real_value)->startsWith('$')) { @@ -1760,13 +1993,13 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private function generate_compose_file() { + $this->checkForCancellation(); $this->create_workdir(); $ports = $this->application->main_port(); $persistent_storages = $this->generate_local_persistent_volumes(); $persistent_file_volumes = $this->application->fileStorages()->get(); $volume_names = $this->generate_local_persistent_volumes_only_volume_names(); - // $environment_variables = $this->generate_environment_variables($ports); - $this->save_environment_variables(); + $this->generate_runtime_environment_variables(); if (data_get($this->application, 'custom_labels')) { $this->application->parseContainerLabels(); $labels = collect(preg_split("/\r\n|\n|\r/", base64_decode($this->application->custom_labels))); @@ -1835,7 +2068,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue ], ], ]; - if (! is_null($this->env_filename)) { + if (filled($this->env_filename)) { $docker_compose['services'][$this->container_name]['env_file'] = [$this->env_filename]; } $docker_compose['services'][$this->container_name]['healthcheck'] = [ @@ -2006,7 +2239,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $volume_name = $persistentStorage->name; } if ($this->pull_request_id !== 0) { - $volume_name = $volume_name.'-pr-'.$this->pull_request_id; + $volume_name = addPreviewDeploymentSuffix($volume_name, $this->pull_request_id); } $local_persistent_volumes[] = $volume_name.':'.$persistentStorage->mount_path; } @@ -2024,7 +2257,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $name = $persistentStorage->name; if ($this->pull_request_id !== 0) { - $name = $name.'-pr-'.$this->pull_request_id; + $name = addPreviewDeploymentSuffix($name, $this->pull_request_id); } $local_persistent_volumes_names[$name] = [ @@ -2072,16 +2305,74 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue ); } + private function build_static_image() + { + $this->application_deployment_queue->addLogEntry('----------------------------------------'); + $this->application_deployment_queue->addLogEntry('Static deployment. Copying static assets to the image.'); + if ($this->application->static_image) { + $this->pull_latest_image($this->application->static_image); + } + $dockerfile = base64_encode("FROM {$this->application->static_image} + WORKDIR /usr/share/nginx/html/ + LABEL coolify.deploymentId={$this->deployment_uuid} + COPY . . + RUN rm -f /usr/share/nginx/html/nginx.conf + RUN rm -f /usr/share/nginx/html/Dockerfile + RUN rm -f /usr/share/nginx/html/docker-compose.yaml + RUN rm -f /usr/share/nginx/html/.env + COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); + if (str($this->application->custom_nginx_configuration)->isNotEmpty()) { + $nginx_config = base64_encode($this->application->custom_nginx_configuration); + } else { + if ($this->application->settings->is_spa) { + $nginx_config = base64_encode(defaultNginxConfiguration('spa')); + } else { + $nginx_config = base64_encode(defaultNginxConfiguration()); + } + } + $build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile --progress plain -t {$this->production_image_name} {$this->workdir}"; + $base64_build_command = base64_encode($build_command); + $this->execute_remote_command( + [ + executeInDocker($this->deployment_uuid, "echo '{$dockerfile}' | base64 -d | tee {$this->workdir}/Dockerfile > /dev/null"), + ], + [ + executeInDocker($this->deployment_uuid, "echo '{$nginx_config}' | base64 -d | tee {$this->workdir}/nginx.conf > /dev/null"), + ], + [ + executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"), + 'hidden' => true, + ], + [ + executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'), + 'hidden' => true, + ], + [ + executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'), + 'hidden' => true, + ] + ); + $this->application_deployment_queue->addLogEntry('Building docker image completed.'); + } + private function build_image() { - // Add Coolify related variables to the build args - $this->environment_variables->filter(function ($key, $value) { - return str($key)->startsWith('COOLIFY_'); - })->each(function ($key, $value) { - $this->build_args->push("--build-arg '{$key}'"); - }); + // Add Coolify related variables to the build args/secrets + if ($this->dockerBuildkitSupported) { + // Coolify variables are already included in the secrets from generate_build_env_variables + // build_secrets is already a string at this point + } else { + // Traditional build args approach + $this->environment_variables->filter(function ($key, $value) { + return str($key)->startsWith('COOLIFY_'); + })->each(function ($key, $value) { + $this->build_args->push("--build-arg '{$key}'"); + }); - $this->build_args = $this->build_args->implode(' '); + $this->build_args = $this->build_args instanceof \Illuminate\Support\Collection + ? $this->build_args->implode(' ') + : (string) $this->build_args; + } $this->application_deployment_queue->addLogEntry('----------------------------------------'); if ($this->disableBuildCache) { @@ -2094,100 +2385,114 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->application_deployment_queue->addLogEntry('To check the current progress, click on Show Debug Logs.'); } - if ($this->application->settings->is_static || $this->application->build_pack === 'static') { + if ($this->application->settings->is_static) { if ($this->application->static_image) { $this->pull_latest_image($this->application->static_image); $this->application_deployment_queue->addLogEntry('Continuing with the building process.'); } - if ($this->application->build_pack === 'static') { - $dockerfile = base64_encode("FROM {$this->application->static_image} -WORKDIR /usr/share/nginx/html/ -LABEL coolify.deploymentId={$this->deployment_uuid} -COPY . . -RUN rm -f /usr/share/nginx/html/nginx.conf -RUN rm -f /usr/share/nginx/html/Dockerfile -RUN rm -f /usr/share/nginx/html/docker-compose.yaml -RUN rm -f /usr/share/nginx/html/.env -COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); - if (str($this->application->custom_nginx_configuration)->isNotEmpty()) { - $nginx_config = base64_encode($this->application->custom_nginx_configuration); - } else { - if ($this->application->settings->is_spa) { - $nginx_config = base64_encode(defaultNginxConfiguration('spa')); + if ($this->application->build_pack === 'nixpacks') { + $this->nixpacks_plan = base64_encode($this->nixpacks_plan); + $this->execute_remote_command([executeInDocker($this->deployment_uuid, "echo '{$this->nixpacks_plan}' | base64 -d | tee /artifacts/thegameplan.json > /dev/null"), 'hidden' => true]); + if ($this->force_rebuild) { + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --no-cache --no-error-without-start -n {$this->build_image_name} {$this->workdir} -o {$this->workdir}"), + 'hidden' => true, + ], [ + executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"), + 'hidden' => true, + ]); + if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) { + // Modify the nixpacks Dockerfile to use build secrets + $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); + $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; + $build_command = "DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->workdir}"; + } elseif ($this->dockerBuildkitSupported) { + // BuildKit without secrets + $build_command = "DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}"; } else { - $nginx_config = base64_encode(defaultNginxConfiguration()); + $build_command = "docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}"; + } + } else { + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --cache-key '{$this->application->uuid}' --no-error-without-start -n {$this->build_image_name} {$this->workdir} -o {$this->workdir}"), + 'hidden' => true, + ], [ + executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"), + 'hidden' => true, + ]); + if ($this->dockerBuildkitSupported) { + // Modify the nixpacks Dockerfile to use build secrets + $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); + $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; + $build_command = "DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->workdir}"; + } else { + $build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}"; } } - } else { - if ($this->application->build_pack === 'nixpacks') { - $this->nixpacks_plan = base64_encode($this->nixpacks_plan); - $this->execute_remote_command([executeInDocker($this->deployment_uuid, "echo '{$this->nixpacks_plan}' | base64 -d | tee /artifacts/thegameplan.json > /dev/null"), 'hidden' => true]); - if ($this->force_rebuild) { - $this->execute_remote_command([ - executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --no-cache --no-error-without-start -n {$this->build_image_name} {$this->workdir} -o {$this->workdir}"), - 'hidden' => true, - ]); - $build_command = "docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile {$this->build_args} --progress plain -t {$this->build_image_name} {$this->workdir}"; - } else { - $this->execute_remote_command([ - executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --cache-key '{$this->application->uuid}' --no-error-without-start -n {$this->build_image_name} {$this->workdir} -o {$this->workdir}"), - 'hidden' => true, - ]); - $build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile {$this->build_args} --progress plain -t {$this->build_image_name} {$this->workdir}"; - } - $base64_build_command = base64_encode($build_command); - $this->execute_remote_command( - [ - executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"), - 'hidden' => true, - ], - [ - executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'), - 'hidden' => true, - ], - [ - executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'), - 'hidden' => true, - ] - ); - $this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm /artifacts/thegameplan.json'), 'hidden' => true]); + $base64_build_command = base64_encode($build_command); + $this->execute_remote_command( + [ + executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"), + 'hidden' => true, + ], + [ + executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'), + 'hidden' => true, + ], + [ + executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'), + 'hidden' => true, + ] + ); + $this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm /artifacts/thegameplan.json'), 'hidden' => true]); + } else { + // Dockerfile buildpack + if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) { + // Modify the Dockerfile to use build secrets + $this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}"); + $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; + if ($this->force_rebuild) { + $build_command = "DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}"; + } else { + $build_command = "DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}"; + } } else { + // Traditional build with args if ($this->force_rebuild) { $build_command = "docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t $this->build_image_name {$this->workdir}"; - $base64_build_command = base64_encode($build_command); } else { $build_command = "docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t $this->build_image_name {$this->workdir}"; - $base64_build_command = base64_encode($build_command); } - $this->execute_remote_command( - [ - executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"), - 'hidden' => true, - ], - [ - executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'), - 'hidden' => true, - ], - [ - executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'), - 'hidden' => true, - ] - ); } - $dockerfile = base64_encode("FROM {$this->application->static_image} + $base64_build_command = base64_encode($build_command); + $this->execute_remote_command( + [ + executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"), + 'hidden' => true, + ], + [ + executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'), + 'hidden' => true, + ], + [ + executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'), + 'hidden' => true, + ] + ); + } + $dockerfile = base64_encode("FROM {$this->application->static_image} WORKDIR /usr/share/nginx/html/ LABEL coolify.deploymentId={$this->deployment_uuid} COPY --from=$this->build_image_name /app/{$this->application->publish_directory} . COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); - if (str($this->application->custom_nginx_configuration)->isNotEmpty()) { - $nginx_config = base64_encode($this->application->custom_nginx_configuration); + if (str($this->application->custom_nginx_configuration)->isNotEmpty()) { + $nginx_config = base64_encode($this->application->custom_nginx_configuration); + } else { + if ($this->application->settings->is_spa) { + $nginx_config = base64_encode(defaultNginxConfiguration('spa')); } else { - if ($this->application->settings->is_spa) { - $nginx_config = base64_encode(defaultNginxConfiguration('spa')); - } else { - $nginx_config = base64_encode(defaultNginxConfiguration()); - } + $nginx_config = base64_encode(defaultNginxConfiguration()); } } $build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; @@ -2215,10 +2520,22 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } else { // Pure Dockerfile based deployment if ($this->application->dockerfile) { - if ($this->force_rebuild) { - $build_command = "docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; + if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) { + // Modify the Dockerfile to use build secrets + $this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}"); + $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; + if ($this->force_rebuild) { + $build_command = "DOCKER_BUILDKIT=1 docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"; + } else { + $build_command = "DOCKER_BUILDKIT=1 docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"; + } } else { - $build_command = "docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; + // Traditional build with args + if ($this->force_rebuild) { + $build_command = "docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; + } else { + $build_command = "docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; + } } $base64_build_command = base64_encode($build_command); $this->execute_remote_command( @@ -2243,14 +2560,34 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $this->execute_remote_command([ executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --no-cache --no-error-without-start -n {$this->production_image_name} {$this->workdir} -o {$this->workdir}"), 'hidden' => true, + ], [ + executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"), + 'hidden' => true, ]); - $build_command = "docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; + if ($this->dockerBuildkitSupported) { + // Modify the nixpacks Dockerfile to use build secrets + $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); + $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; + $build_command = "DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"; + } else { + $build_command = "docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"; + } } else { $this->execute_remote_command([ executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --cache-key '{$this->application->uuid}' --no-error-without-start -n {$this->production_image_name} {$this->workdir} -o {$this->workdir}"), 'hidden' => true, + ], [ + executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"), + 'hidden' => true, ]); - $build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; + if ($this->dockerBuildkitSupported) { + // Modify the nixpacks Dockerfile to use build secrets + $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); + $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; + $build_command = "DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"; + } else { + $build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"; + } } $base64_build_command = base64_encode($build_command); $this->execute_remote_command( @@ -2269,13 +2606,24 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); ); $this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm /artifacts/thegameplan.json'), 'hidden' => true]); } else { - if ($this->force_rebuild) { - $build_command = "docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; - $base64_build_command = base64_encode($build_command); + // Dockerfile buildpack + if ($this->dockerBuildkitSupported) { + // Use BuildKit with secrets + $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; + if ($this->force_rebuild) { + $build_command = "DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"; + } else { + $build_command = "DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"; + } } else { - $build_command = "docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; - $base64_build_command = base64_encode($build_command); + // Traditional build with args + if ($this->force_rebuild) { + $build_command = "docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; + } else { + $build_command = "docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; + } } + $base64_build_command = base64_encode($build_command); $this->execute_remote_command( [ executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"), @@ -2319,7 +2667,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id); if ($this->pull_request_id === 0) { $containers = $containers->filter(function ($container) { - return data_get($container, 'Names') !== $this->container_name && data_get($container, 'Names') !== $this->container_name.'-pr-'.$this->pull_request_id; + return data_get($container, 'Names') !== $this->container_name && data_get($container, 'Names') !== addPreviewDeploymentSuffix($this->container_name, $this->pull_request_id); }); } $containers->each(function ($container) { @@ -2371,43 +2719,423 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $variables = collect([])->merge($this->env_args); } - $this->build_args = $variables->map(function ($value, $key) { - $value = escapeshellarg($value); + if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) { + $this->generate_build_secrets($variables); + $this->build_args = ''; + } else { + $secrets_hash = ''; + if ($variables->isNotEmpty()) { + $secrets_hash = $this->generate_secrets_hash($variables); + } - return "--build-arg {$key}={$value}"; - }); + $this->build_args = $variables->map(function ($value, $key) { + $value = escapeshellarg($value); + + return "--build-arg {$key}={$value}"; + }); + + if ($secrets_hash) { + $this->build_args->push("--build-arg COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}"); + } + } + } + + private function generate_docker_env_flags_for_secrets() + { + // Only generate env flags if build secrets are enabled + if (! $this->application->settings->use_build_secrets) { + return ''; + } + + $variables = $this->pull_request_id === 0 + ? $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->where('is_buildtime', true)->get() + : $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->where('is_buildtime', true)->get(); + + if ($variables->isEmpty()) { + return ''; + } + + $secrets_hash = $this->generate_secrets_hash($variables); + $env_flags = $variables + ->map(function ($env) { + $escaped_value = escapeshellarg($env->real_value); + + return "-e {$env->key}={$escaped_value}"; + }) + ->implode(' '); + + $env_flags .= " -e COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}"; + + return $env_flags; + } + + private function generate_build_secrets(Collection $variables) + { + if ($variables->isEmpty()) { + $this->build_secrets = ''; + + return; + } + + $this->build_secrets = $variables + ->map(function ($value, $key) { + return "--secret id={$key},env={$key}"; + }) + ->implode(' '); + + $this->build_secrets .= ' --secret id=COOLIFY_BUILD_SECRETS_HASH,env=COOLIFY_BUILD_SECRETS_HASH'; + } + + private function generate_secrets_hash($variables) + { + if (! $this->secrets_hash_key) { + $this->secrets_hash_key = bin2hex(random_bytes(32)); + } + + if ($variables instanceof Collection) { + $secrets_string = $variables + ->mapWithKeys(function ($value, $key) { + return [$key => $value]; + }) + ->sortKeys() + ->map(function ($value, $key) { + return "{$key}={$value}"; + }) + ->implode('|'); + } else { + $secrets_string = $variables + ->map(function ($env) { + return "{$env->key}={$env->real_value}"; + }) + ->sort() + ->implode('|'); + } + + return hash_hmac('sha256', $secrets_string, $this->secrets_hash_key); } private function add_build_env_variables_to_dockerfile() { - $this->execute_remote_command([ - executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"), - 'hidden' => true, - 'save' => 'dockerfile', - ]); - $dockerfile = collect(str($this->saved_outputs->get('dockerfile'))->trim()->explode("\n")); - if ($this->pull_request_id === 0) { - foreach ($this->application->build_environment_variables as $env) { - if (data_get($env, 'is_multiline') === true) { - $dockerfile->splice(1, 0, "ARG {$env->key}"); - } else { - $dockerfile->splice(1, 0, "ARG {$env->key}={$env->real_value}"); + if ($this->dockerBuildkitSupported) { + // We dont need to add build secrets to dockerfile for buildkit, as we already added them with --secret flag in function generate_docker_env_flags_for_secrets + } else { + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"), + 'hidden' => true, + 'save' => 'dockerfile', + ]); + $dockerfile = collect(str($this->saved_outputs->get('dockerfile'))->trim()->explode("\n")); + + if ($this->pull_request_id === 0) { + // Only add environment variables that are available during build + $envs = $this->application->environment_variables() + ->where('key', 'not like', 'NIXPACKS_%') + ->where('is_buildtime', true) + ->get(); + foreach ($envs as $env) { + if (data_get($env, 'is_multiline') === true) { + $dockerfile->splice(1, 0, ["ARG {$env->key}"]); + } else { + $dockerfile->splice(1, 0, ["ARG {$env->key}={$env->real_value}"]); + } + } + } else { + // Only add preview environment variables that are available during build + $envs = $this->application->environment_variables_preview() + ->where('key', 'not like', 'NIXPACKS_%') + ->where('is_buildtime', true) + ->get(); + foreach ($envs as $env) { + if (data_get($env, 'is_multiline') === true) { + $dockerfile->splice(1, 0, ["ARG {$env->key}"]); + } else { + $dockerfile->splice(1, 0, ["ARG {$env->key}={$env->real_value}"]); + } } } - } else { - foreach ($this->application->build_environment_variables_preview as $env) { - if (data_get($env, 'is_multiline') === true) { - $dockerfile->splice(1, 0, "ARG {$env->key}"); - } else { - $dockerfile->splice(1, 0, "ARG {$env->key}={$env->real_value}"); + + if ($envs->isNotEmpty()) { + $secrets_hash = $this->generate_secrets_hash($envs); + $dockerfile->splice(1, 0, ["ARG COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}"]); + } + + $dockerfile_base64 = base64_encode($dockerfile->implode("\n")); + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"), + 'hidden' => true, + ]); + } + } + + private function modify_dockerfile_for_secrets($dockerfile_path) + { + // Only process if build secrets are enabled and we have secrets to mount + if (! $this->application->settings->use_build_secrets || empty($this->build_secrets)) { + return; + } + + // Read the Dockerfile + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "cat {$dockerfile_path}"), + 'hidden' => true, + 'save' => 'dockerfile_content', + ]); + + $dockerfile = str($this->saved_outputs->get('dockerfile_content'))->trim()->explode("\n"); + + // Add BuildKit syntax directive if not present + if (! str_starts_with($dockerfile->first(), '# syntax=')) { + $dockerfile->prepend('# syntax=docker/dockerfile:1'); + } + + // Get environment variables for secrets + $variables = $this->pull_request_id === 0 + ? $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->where('is_buildtime', true)->get() + : $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->where('is_buildtime', true)->get(); + + if ($variables->isEmpty()) { + return; + } + + // Generate mount strings for all secrets + $mountStrings = $variables->map(fn ($env) => "--mount=type=secret,id={$env->key},env={$env->key}")->implode(' '); + + // Add mount for the secrets hash to ensure cache invalidation + $mountStrings .= ' --mount=type=secret,id=COOLIFY_BUILD_SECRETS_HASH,env=COOLIFY_BUILD_SECRETS_HASH'; + + $modified = false; + $dockerfile = $dockerfile->map(function ($line) use ($mountStrings, &$modified) { + $trimmed = ltrim($line); + + // Skip lines that already have secret mounts or are not RUN commands + if (str_contains($line, '--mount=type=secret') || ! str_starts_with($trimmed, 'RUN')) { + return $line; + } + + // Add mount strings to RUN command + $originalCommand = trim(substr($trimmed, 3)); + $modified = true; + + return "RUN {$mountStrings} {$originalCommand}"; + }); + + if ($modified) { + // Write the modified Dockerfile back + $dockerfile_base64 = base64_encode($dockerfile->implode("\n")); + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$dockerfile_path} > /dev/null"), + 'hidden' => true, + ]); + + $this->application_deployment_queue->addLogEntry('Modified Dockerfile to use build secrets.'); + } + } + + private function modify_dockerfiles_for_compose($composeFile) + { + if ($this->application->build_pack !== 'dockercompose') { + return; + } + + $variables = $this->pull_request_id === 0 + ? $this->application->environment_variables() + ->where('key', 'not like', 'NIXPACKS_%') + ->where('is_buildtime', true) + ->get() + : $this->application->environment_variables_preview() + ->where('key', 'not like', 'NIXPACKS_%') + ->where('is_buildtime', true) + ->get(); + + if ($variables->isEmpty()) { + $this->application_deployment_queue->addLogEntry('No build-time variables to add to Dockerfiles.'); + + return; + } + + $services = data_get($composeFile, 'services', []); + + foreach ($services as $serviceName => $service) { + if (! isset($service['build'])) { + continue; + } + + $context = '.'; + $dockerfile = 'Dockerfile'; + + if (is_string($service['build'])) { + $context = $service['build']; + } elseif (is_array($service['build'])) { + $context = data_get($service['build'], 'context', '.'); + $dockerfile = data_get($service['build'], 'dockerfile', 'Dockerfile'); + } + + $dockerfilePath = rtrim($context, '/').'/'.ltrim($dockerfile, '/'); + if (str_starts_with($dockerfilePath, './')) { + $dockerfilePath = substr($dockerfilePath, 2); + } + if (str_starts_with($dockerfilePath, '/')) { + $dockerfilePath = substr($dockerfilePath, 1); + } + + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "test -f {$this->workdir}/{$dockerfilePath} && echo 'exists' || echo 'not found'"), + 'hidden' => true, + 'save' => 'dockerfile_check_'.$serviceName, + ]); + + if (str($this->saved_outputs->get('dockerfile_check_'.$serviceName))->trim()->toString() !== 'exists') { + $this->application_deployment_queue->addLogEntry("Dockerfile not found for service {$serviceName} at {$dockerfilePath}, skipping ARG injection."); + + continue; + } + + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "cat {$this->workdir}/{$dockerfilePath}"), + 'hidden' => true, + 'save' => 'dockerfile_content_'.$serviceName, + ]); + + $dockerfileContent = $this->saved_outputs->get('dockerfile_content_'.$serviceName); + if (! $dockerfileContent) { + continue; + } + + $dockerfile_lines = collect(str($dockerfileContent)->trim()->explode("\n")); + + $fromIndices = []; + $dockerfile_lines->each(function ($line, $index) use (&$fromIndices) { + if (str($line)->trim()->startsWith('FROM')) { + $fromIndices[] = $index; + } + }); + + if (empty($fromIndices)) { + $this->application_deployment_queue->addLogEntry("No FROM instruction found in Dockerfile for service {$serviceName}, skipping."); + + continue; + } + + $isMultiStage = count($fromIndices) > 1; + + $argsToAdd = collect([]); + foreach ($variables as $env) { + $argsToAdd->push("ARG {$env->key}"); + } + + ray($argsToAdd); + if ($argsToAdd->isEmpty()) { + $this->application_deployment_queue->addLogEntry("Service {$serviceName}: No build-time variables to add."); + + continue; + } + + $totalAdded = 0; + $offset = 0; + + foreach ($fromIndices as $stageIndex => $fromIndex) { + $adjustedIndex = $fromIndex + $offset; + + $stageStart = $adjustedIndex + 1; + $stageEnd = isset($fromIndices[$stageIndex + 1]) + ? $fromIndices[$stageIndex + 1] + $offset + : $dockerfile_lines->count(); + + $existingStageArgs = collect([]); + for ($i = $stageStart; $i < $stageEnd; $i++) { + $line = $dockerfile_lines->get($i); + if (! $line || ! str($line)->trim()->startsWith('ARG')) { + break; + } + $parts = explode(' ', trim($line), 2); + if (count($parts) >= 2) { + $argPart = $parts[1]; + $keyValue = explode('=', $argPart, 2); + $existingStageArgs->push($keyValue[0]); + } + } + + $stageArgsToAdd = $argsToAdd->filter(function ($arg) use ($existingStageArgs) { + $key = str($arg)->after('ARG ')->trim()->toString(); + + return ! $existingStageArgs->contains($key); + }); + + if ($stageArgsToAdd->isNotEmpty()) { + $dockerfile_lines->splice($adjustedIndex + 1, 0, $stageArgsToAdd->toArray()); + $totalAdded += $stageArgsToAdd->count(); + $offset += $stageArgsToAdd->count(); + } + } + + if ($totalAdded > 0) { + $dockerfile_base64 = base64_encode($dockerfile_lines->implode("\n")); + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}/{$dockerfilePath} > /dev/null"), + 'hidden' => true, + ]); + + $stageInfo = $isMultiStage ? ' (multi-stage build, added to '.count($fromIndices).' stages)' : ''; + $this->application_deployment_queue->addLogEntry("Added {$totalAdded} ARG declarations to Dockerfile for service {$serviceName}{$stageInfo}."); + } else { + $this->application_deployment_queue->addLogEntry("Service {$serviceName}: All required ARG declarations already exist."); + } + + if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported && ! empty($this->build_secrets)) { + $fullDockerfilePath = "{$this->workdir}/{$dockerfilePath}"; + $this->modify_dockerfile_for_secrets($fullDockerfilePath); + $this->application_deployment_queue->addLogEntry("Modified Dockerfile for service {$serviceName} to use build secrets."); + } + } + } + + private function add_build_secrets_to_compose($composeFile) + { + // Get environment variables for secrets + $variables = $this->pull_request_id === 0 + ? $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->get() + : $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->get(); + + if ($variables->isEmpty()) { + return $composeFile; + } + + $secrets = []; + foreach ($variables as $env) { + $secrets[$env->key] = [ + 'environment' => $env->key, + ]; + } + + $services = data_get($composeFile, 'services', []); + foreach ($services as $serviceName => &$service) { + if (isset($service['build'])) { + if (is_string($service['build'])) { + $service['build'] = [ + 'context' => $service['build'], + ]; + } + if (! isset($service['build']['secrets'])) { + $service['build']['secrets'] = []; + } + foreach ($variables as $env) { + if (! in_array($env->key, $service['build']['secrets'])) { + $service['build']['secrets'][] = $env->key; + } } } } - $dockerfile_base64 = base64_encode($dockerfile->implode("\n")); - $this->execute_remote_command([ - executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"), - 'hidden' => true, - ]); + + $composeFile['services'] = $services; + $existingSecrets = data_get($composeFile, 'secrets', []); + if ($existingSecrets instanceof \Illuminate\Support\Collection) { + $existingSecrets = $existingSecrets->toArray(); + } + $composeFile['secrets'] = array_replace($existingSecrets, $secrets); + + $this->application_deployment_queue->addLogEntry('Added build secrets configuration to docker-compose file (using environment variables).'); + + return $composeFile; } private function run_pre_deployment_command() @@ -2475,9 +3203,22 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); throw new RuntimeException('Post-deployment command: Could not find a valid container. Is the container name correct?'); } + /** + * Check if the deployment was cancelled and abort if it was + */ + private function checkForCancellation(): void + { + $this->application_deployment_queue->refresh(); + if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) { + $this->application_deployment_queue->addLogEntry('Deployment cancelled by user, stopping execution.'); + throw new \RuntimeException('Deployment cancelled by user', 69420); + } + } + private function next(string $status) { - queue_next_deployment($this->application); + // Refresh to get latest status + $this->application_deployment_queue->refresh(); // Never allow changing status from FAILED or CANCELLED_BY_USER to anything else if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::FAILED->value) { @@ -2486,14 +3227,21 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); return; } if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) { - return; + // Job was cancelled, stop execution + $this->application_deployment_queue->addLogEntry('Deployment cancelled by user, stopping execution.'); + throw new \RuntimeException('Deployment cancelled by user', 69420); } $this->application_deployment_queue->update([ 'status' => $status, ]); + queue_next_deployment($this->application); + if ($status === ApplicationDeploymentStatus::FINISHED->value) { + ray($this->application->team()->id); + event(new ApplicationConfigurationChanged($this->application->team()->id)); + if (! $this->only_this_server) { $this->deploy_to_additional_destinations(); } @@ -2513,8 +3261,8 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $code = $exception->getCode(); if ($code !== 69420) { // 69420 means failed to push the image to the registry, so we don't need to remove the new version as it is the currently running one - if ($this->application->settings->is_consistent_container_name_enabled || str($this->application->settings->custom_internal_name)->isNotEmpty()) { - // do not remove already running container + if ($this->application->settings->is_consistent_container_name_enabled || str($this->application->settings->custom_internal_name)->isNotEmpty() || $this->pull_request_id !== 0) { + // do not remove already running container for PR deployments } else { $this->application_deployment_queue->addLogEntry('Deployment failed. Removing the new version of your application.', 'stderr'); $this->execute_remote_command( diff --git a/app/Jobs/DEPRECATEDContainerStatusJob.php b/app/Jobs/DEPRECATEDContainerStatusJob.php deleted file mode 100644 index df6dec7fe..000000000 --- a/app/Jobs/DEPRECATEDContainerStatusJob.php +++ /dev/null @@ -1,31 +0,0 @@ -<?php - -namespace App\Jobs; - -use App\Actions\Docker\GetContainersStatus; -use App\Models\Server; -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; - -class DEPRECATEDContainerStatusJob implements ShouldBeEncrypted, ShouldQueue -{ - use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; - - public $tries = 4; - - public function backoff(): int - { - return isDev() ? 1 : 3; - } - - public function __construct(public Server $server) {} - - public function handle() - { - GetContainersStatus::run($this->server); - } -} diff --git a/app/Jobs/DEPRECATEDServerCheckNewJob.php b/app/Jobs/DEPRECATEDServerCheckNewJob.php deleted file mode 100644 index 1118366fe..000000000 --- a/app/Jobs/DEPRECATEDServerCheckNewJob.php +++ /dev/null @@ -1,34 +0,0 @@ -<?php - -namespace App\Jobs; - -use App\Actions\Server\ResourcesCheck; -use App\Actions\Server\ServerCheck; -use App\Models\Server; -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; - -class DEPRECATEDServerCheckNewJob implements ShouldBeEncrypted, ShouldQueue -{ - use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; - - public $tries = 1; - - public $timeout = 60; - - public function __construct(public Server $server) {} - - public function handle() - { - try { - ServerCheck::run($this->server); - ResourcesCheck::dispatch($this->server); - } catch (\Throwable $e) { - return handleError($e); - } - } -} diff --git a/app/Jobs/DEPRECATEDServerResourceManager.php b/app/Jobs/DEPRECATEDServerResourceManager.php deleted file mode 100644 index c50567a01..000000000 --- a/app/Jobs/DEPRECATEDServerResourceManager.php +++ /dev/null @@ -1,162 +0,0 @@ -<?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); - } -} diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 752d1f1ca..6ac9ae1e6 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -54,6 +54,10 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue public ?string $backup_output = null; + public ?string $error_output = null; + + public bool $s3_uploaded = false; + public ?string $postgres_password = null; public ?string $mongo_root_username = null; @@ -355,7 +359,6 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue // 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).'); } } @@ -367,15 +370,34 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue 'size' => $size, ]); } catch (\Throwable $e) { - if ($this->backup_log) { - $this->backup_log->update([ - 'status' => 'failed', - 'message' => $this->backup_output, - 'size' => $size, - 'filename' => null, - ]); + // Check if backup actually failed or if it's just a post-backup issue + $actualBackupFailed = ! $this->s3_uploaded && $this->backup->save_s3; + + if ($actualBackupFailed || $size === 0) { + // Real backup failure + if ($this->backup_log) { + $this->backup_log->update([ + 'status' => 'failed', + 'message' => $this->error_output ?? $this->backup_output ?? $e->getMessage(), + 'size' => $size, + 'filename' => null, + ]); + } + $this->team?->notify(new BackupFailed($this->backup, $this->database, $this->error_output ?? $this->backup_output ?? $e->getMessage(), $database)); + } else { + // Backup succeeded but post-processing failed (cleanup, notification, etc.) + if ($this->backup_log) { + $this->backup_log->update([ + 'status' => 'success', + 'message' => $this->backup_output ? $this->backup_output."\nWarning: Post-backup cleanup encountered an issue: ".$e->getMessage() : 'Warning: '.$e->getMessage(), + 'size' => $size, + ]); + } + // Send success notification since the backup itself succeeded + $this->team->notify(new BackupSuccess($this->backup, $this->database, $database)); + // Log the post-backup issue + ray('Post-backup operation failed but backup was successful: '.$e->getMessage()); } - $this->team?->notify(new BackupFailed($this->backup, $this->database, $this->backup_output, $database)); } } if ($this->backup_log && $this->backup_log->status === 'success') { @@ -446,7 +468,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $this->backup_output = null; } } catch (\Throwable $e) { - $this->add_to_backup_output($e->getMessage()); + $this->add_to_error_output($e->getMessage()); throw $e; } } @@ -472,7 +494,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $this->backup_output = null; } } catch (\Throwable $e) { - $this->add_to_backup_output($e->getMessage()); + $this->add_to_error_output($e->getMessage()); throw $e; } } @@ -492,7 +514,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $this->backup_output = null; } } catch (\Throwable $e) { - $this->add_to_backup_output($e->getMessage()); + $this->add_to_error_output($e->getMessage()); throw $e; } } @@ -512,7 +534,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $this->backup_output = null; } } catch (\Throwable $e) { - $this->add_to_backup_output($e->getMessage()); + $this->add_to_error_output($e->getMessage()); throw $e; } } @@ -526,6 +548,15 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue } } + private function add_to_error_output($output): void + { + if ($this->error_output) { + $this->error_output = $this->error_output."\n".$output; + } else { + $this->error_output = $output; + } + } + private function calculate_size() { return instant_remote_process(["du -b $this->backup_location | cut -f1"], $this->server, false); @@ -571,9 +602,10 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $commands[] = "docker exec backup-of-{$this->backup->uuid} mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/"; instant_remote_process($commands, $this->server); - $this->add_to_backup_output('Uploaded to S3.'); + $this->s3_uploaded = true; } catch (\Throwable $e) { - $this->add_to_backup_output($e->getMessage()); + $this->s3_uploaded = false; + $this->add_to_error_output($e->getMessage()); throw $e; } finally { $command = "docker rm -f backup-of-{$this->backup->uuid}"; diff --git a/app/Jobs/PullChangelogFromGitHub.php b/app/Jobs/PullChangelog.php similarity index 77% rename from app/Jobs/PullChangelogFromGitHub.php rename to app/Jobs/PullChangelog.php index e84766f7f..052e6d557 100644 --- a/app/Jobs/PullChangelogFromGitHub.php +++ b/app/Jobs/PullChangelog.php @@ -11,8 +11,9 @@ use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; -class PullChangelogFromGitHub implements ShouldBeEncrypted, ShouldQueue +class PullChangelog implements ShouldBeEncrypted, ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; @@ -26,21 +27,36 @@ class PullChangelogFromGitHub implements ShouldBeEncrypted, ShouldQueue public function handle(): void { try { + // Fetch from CDN instead of GitHub API to avoid rate limits + $cdnUrl = config('constants.coolify.releases_url'); + $response = Http::retry(3, 1000) ->timeout(30) - ->get('https://api.github.com/repos/coollabsio/coolify/releases?per_page=10'); + ->get($cdnUrl); if ($response->successful()) { $releases = $response->json(); + + // Limit to 10 releases for processing (same as before) + $releases = array_slice($releases, 0, 10); + $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()); + // Log error instead of sending notification + Log::error('PullChangelogFromGitHub: Failed to fetch from CDN', [ + 'status' => $response->status(), + 'url' => $cdnUrl, + ]); } } catch (\Throwable $e) { - send_internal_notification('PullChangelogFromGitHub failed with: '.$e->getMessage()); + // Log error instead of sending notification + Log::error('PullChangelogFromGitHub: Exception occurred', [ + 'message' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); } } diff --git a/app/Jobs/PushServerUpdateJob.php b/app/Jobs/PushServerUpdateJob.php index 3e3aa1eb7..7726c2c73 100644 --- a/app/Jobs/PushServerUpdateJob.php +++ b/app/Jobs/PushServerUpdateJob.php @@ -65,6 +65,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced public Collection $foundApplicationPreviewsIds; + public Collection $applicationContainerStatuses; + public bool $foundProxy = false; public bool $foundLogDrainContainer = false; @@ -87,6 +89,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced $this->foundServiceApplicationIds = collect(); $this->foundApplicationPreviewsIds = collect(); $this->foundServiceDatabaseIds = collect(); + $this->applicationContainerStatuses = collect(); $this->allApplicationIds = collect(); $this->allDatabaseUuids = collect(); $this->allTcpProxyUuids = collect(); @@ -155,7 +158,14 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced if ($this->allApplicationIds->contains($applicationId) && $this->isRunning($containerStatus)) { $this->foundApplicationIds->push($applicationId); } - $this->updateApplicationStatus($applicationId, $containerStatus); + // Store container status for aggregation + if (! $this->applicationContainerStatuses->has($applicationId)) { + $this->applicationContainerStatuses->put($applicationId, collect()); + } + $containerName = $labels->get('com.docker.compose.service'); + if ($containerName) { + $this->applicationContainerStatuses->get($applicationId)->put($containerName, $containerStatus); + } } else { $previewKey = $applicationId.':'.$pullRequestId; if ($this->allApplicationPreviewsIds->contains($previewKey) && $this->isRunning($containerStatus)) { @@ -205,9 +215,86 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced $this->updateAdditionalServersStatus(); + // Aggregate multi-container application statuses + $this->aggregateMultiContainerStatuses(); + $this->checkLogDrainContainer(); } + private function aggregateMultiContainerStatuses() + { + if ($this->applicationContainerStatuses->isEmpty()) { + return; + } + + foreach ($this->applicationContainerStatuses as $applicationId => $containerStatuses) { + $application = $this->applications->where('id', $applicationId)->first(); + if (! $application) { + continue; + } + + // Parse docker compose to check for excluded containers + $dockerComposeRaw = data_get($application, 'docker_compose_raw'); + $excludedContainers = collect(); + + if ($dockerComposeRaw) { + try { + $dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw); + $services = data_get($dockerCompose, 'services', []); + + foreach ($services as $serviceName => $serviceConfig) { + // Check if container should be excluded + $excludeFromHc = data_get($serviceConfig, 'exclude_from_hc', false); + $restartPolicy = data_get($serviceConfig, 'restart', 'always'); + + if ($excludeFromHc || $restartPolicy === 'no') { + $excludedContainers->push($serviceName); + } + } + } catch (\Exception $e) { + // If we can't parse, treat all containers as included + } + } + + // Filter out excluded containers + $relevantStatuses = $containerStatuses->filter(function ($status, $containerName) use ($excludedContainers) { + return ! $excludedContainers->contains($containerName); + }); + + // If all containers are excluded, don't update status + if ($relevantStatuses->isEmpty()) { + continue; + } + + // Aggregate status: if any container is running, app is running + $hasRunning = false; + $hasUnhealthy = false; + + foreach ($relevantStatuses as $status) { + if (str($status)->contains('running')) { + $hasRunning = true; + if (str($status)->contains('unhealthy')) { + $hasUnhealthy = true; + } + } + } + + $aggregatedStatus = null; + if ($hasRunning) { + $aggregatedStatus = $hasUnhealthy ? 'running (unhealthy)' : 'running (healthy)'; + } else { + // All containers are exited + $aggregatedStatus = 'exited (unhealthy)'; + } + + // Update application status with aggregated result + if ($aggregatedStatus && $application->status !== $aggregatedStatus) { + $application->status = $aggregatedStatus; + $application->save(); + } + } + } + private function updateApplicationStatus(string $applicationId, string $containerStatus) { $application = $this->applications->where('id', $applicationId)->first(); diff --git a/app/Jobs/ScheduledTaskJob.php b/app/Jobs/ScheduledTaskJob.php index 6c0c017e7..609595356 100644 --- a/app/Jobs/ScheduledTaskJob.php +++ b/app/Jobs/ScheduledTaskJob.php @@ -3,6 +3,7 @@ namespace App\Jobs; use App\Events\ScheduledTaskDone; +use App\Exceptions\NonReportableException; use App\Models\Application; use App\Models\ScheduledTask; use App\Models\ScheduledTaskExecution; @@ -120,7 +121,7 @@ class ScheduledTaskJob implements ShouldQueue } // No valid container was found. - throw new \Exception('ScheduledTaskJob failed: No valid container was found. Is the container name correct?'); + throw new NonReportableException('ScheduledTaskJob failed: No valid container was found. Is the container name correct?'); } catch (\Throwable $e) { if ($this->task_log) { $this->task_log->update([ diff --git a/app/Jobs/ServerConnectionCheckJob.php b/app/Jobs/ServerConnectionCheckJob.php index 167bcea38..8b55434f6 100644 --- a/app/Jobs/ServerConnectionCheckJob.php +++ b/app/Jobs/ServerConnectionCheckJob.php @@ -78,11 +78,11 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue } // Server is reachable, check if Docker is available - // $isUsable = $this->checkDockerAvailability(); + $isUsable = $this->checkDockerAvailability(); $this->server->settings->update([ 'is_reachable' => true, - 'is_usable' => true, + 'is_usable' => $isUsable, ]); } catch (\Throwable $e) { diff --git a/app/Jobs/StripeProcessJob.php b/app/Jobs/StripeProcessJob.php index f1c5bc1a8..088b6c67d 100644 --- a/app/Jobs/StripeProcessJob.php +++ b/app/Jobs/StripeProcessJob.php @@ -58,7 +58,7 @@ class StripeProcessJob implements ShouldQueue case 'checkout.session.completed': $clientReferenceId = data_get($data, 'client_reference_id'); if (is_null($clientReferenceId)) { - send_internal_notification('Checkout session completed without client reference id.'); + // send_internal_notification('Checkout session completed without client reference id.'); break; } $userId = Str::before($clientReferenceId, ':'); @@ -68,7 +68,7 @@ class StripeProcessJob implements ShouldQueue $team = Team::find($teamId); $found = $team->members->where('id', $userId)->first(); if (! $found->isAdmin()) { - send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}."); + // send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}."); throw new \RuntimeException("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}."); } $subscription = Subscription::where('team_id', $teamId)->first(); @@ -95,7 +95,7 @@ class StripeProcessJob implements ShouldQueue $customerId = data_get($data, 'customer'); $planId = data_get($data, 'lines.data.0.plan.id'); if (Str::contains($excludedPlans, $planId)) { - send_internal_notification('Subscription excluded.'); + // send_internal_notification('Subscription excluded.'); break; } $subscription = Subscription::where('stripe_customer_id', $customerId)->first(); @@ -110,16 +110,38 @@ class StripeProcessJob implements ShouldQueue break; case 'invoice.payment_failed': $customerId = data_get($data, 'customer'); + $invoiceId = data_get($data, 'id'); + $paymentIntentId = data_get($data, 'payment_intent'); + $subscription = Subscription::where('stripe_customer_id', $customerId)->first(); if (! $subscription) { - send_internal_notification('invoice.payment_failed failed but no subscription found in Coolify for customer: '.$customerId); + // send_internal_notification('invoice.payment_failed failed but no subscription found in Coolify for customer: '.$customerId); throw new \RuntimeException("No subscription found for customer: {$customerId}"); } $team = data_get($subscription, 'team'); if (! $team) { - send_internal_notification('invoice.payment_failed failed but no team found in Coolify for customer: '.$customerId); + // send_internal_notification('invoice.payment_failed failed but no team found in Coolify for customer: '.$customerId); throw new \RuntimeException("No team found in Coolify for customer: {$customerId}"); } + + // Verify payment status with Stripe API before sending failure notification + if ($paymentIntentId) { + try { + $stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key')); + $paymentIntent = $stripe->paymentIntents->retrieve($paymentIntentId); + + if (in_array($paymentIntent->status, ['processing', 'succeeded', 'requires_action', 'requires_confirmation'])) { + break; + } + + if (! $subscription->stripe_invoice_paid && $subscription->created_at->diffInMinutes(now()) < 5) { + SubscriptionInvoiceFailedJob::dispatch($team)->delay(now()->addSeconds(60)); + break; + } + } catch (\Exception $e) { + } + } + if (! $subscription->stripe_invoice_paid) { SubscriptionInvoiceFailedJob::dispatch($team); // send_internal_notification('Invoice payment failed: '.$customerId); @@ -129,11 +151,11 @@ class StripeProcessJob implements ShouldQueue $customerId = data_get($data, 'customer'); $subscription = Subscription::where('stripe_customer_id', $customerId)->first(); if (! $subscription) { - send_internal_notification('payment_intent.payment_failed, no subscription found in Coolify for customer: '.$customerId); + // send_internal_notification('payment_intent.payment_failed, no subscription found in Coolify for customer: '.$customerId); throw new \RuntimeException("No subscription found in Coolify for customer: {$customerId}"); } if ($subscription->stripe_invoice_paid) { - send_internal_notification('payment_intent.payment_failed but invoice is active for customer: '.$customerId); + // send_internal_notification('payment_intent.payment_failed but invoice is active for customer: '.$customerId); return; } @@ -154,7 +176,7 @@ class StripeProcessJob implements ShouldQueue $team = Team::find($teamId); $found = $team->members->where('id', $userId)->first(); if (! $found->isAdmin()) { - send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}."); + // send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}."); throw new \RuntimeException("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}."); } $subscription = Subscription::where('team_id', $teamId)->first(); @@ -177,7 +199,7 @@ class StripeProcessJob implements ShouldQueue $subscriptionId = data_get($data, 'items.data.0.subscription') ?? data_get($data, 'id'); $planId = data_get($data, 'items.data.0.plan.id') ?? data_get($data, 'plan.id'); if (Str::contains($excludedPlans, $planId)) { - send_internal_notification('Subscription excluded.'); + // send_internal_notification('Subscription excluded.'); break; } $subscription = Subscription::where('stripe_customer_id', $customerId)->first(); @@ -194,7 +216,7 @@ class StripeProcessJob implements ShouldQueue 'stripe_invoice_paid' => false, ]); } else { - send_internal_notification('No subscription and team id found'); + // send_internal_notification('No subscription and team id found'); throw new \RuntimeException('No subscription and team id found'); } } @@ -230,7 +252,7 @@ class StripeProcessJob implements ShouldQueue $subscription->update([ 'stripe_past_due' => true, ]); - send_internal_notification('Past Due: '.$customerId.'Subscription ID: '.$subscriptionId); + // send_internal_notification('Past Due: '.$customerId.'Subscription ID: '.$subscriptionId); } } if ($status === 'unpaid') { @@ -238,13 +260,13 @@ class StripeProcessJob implements ShouldQueue $subscription->update([ 'stripe_invoice_paid' => false, ]); - send_internal_notification('Unpaid: '.$customerId.'Subscription ID: '.$subscriptionId); + // send_internal_notification('Unpaid: '.$customerId.'Subscription ID: '.$subscriptionId); } $team = data_get($subscription, 'team'); if ($team) { $team->subscriptionEnded(); } else { - send_internal_notification('Subscription unpaid but no team found in Coolify for customer: '.$customerId); + // send_internal_notification('Subscription unpaid but no team found in Coolify for customer: '.$customerId); throw new \RuntimeException("No team found in Coolify for customer: {$customerId}"); } } @@ -273,11 +295,11 @@ class StripeProcessJob implements ShouldQueue if ($team) { $team->subscriptionEnded(); } else { - send_internal_notification('Subscription deleted but no team found in Coolify for customer: '.$customerId); + // send_internal_notification('Subscription deleted but no team found in Coolify for customer: '.$customerId); throw new \RuntimeException("No team found in Coolify for customer: {$customerId}"); } } else { - send_internal_notification('Subscription deleted but no subscription found in Coolify for customer: '.$customerId); + // send_internal_notification('Subscription deleted but no subscription found in Coolify for customer: '.$customerId); throw new \RuntimeException("No subscription found in Coolify for customer: {$customerId}"); } break; diff --git a/app/Jobs/SubscriptionInvoiceFailedJob.php b/app/Jobs/SubscriptionInvoiceFailedJob.php index dc511f445..927d50467 100755 --- a/app/Jobs/SubscriptionInvoiceFailedJob.php +++ b/app/Jobs/SubscriptionInvoiceFailedJob.php @@ -23,6 +23,47 @@ class SubscriptionInvoiceFailedJob implements ShouldBeEncrypted, ShouldQueue public function handle() { try { + // Double-check subscription status before sending failure notification + $subscription = $this->team->subscription; + if ($subscription && $subscription->stripe_customer_id) { + try { + $stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key')); + + if ($subscription->stripe_subscription_id) { + $stripeSubscription = $stripe->subscriptions->retrieve($subscription->stripe_subscription_id); + + if (in_array($stripeSubscription->status, ['active', 'trialing'])) { + if (! $subscription->stripe_invoice_paid) { + $subscription->update([ + 'stripe_invoice_paid' => true, + 'stripe_past_due' => false, + ]); + } + + return; + } + } + + $invoices = $stripe->invoices->all([ + 'customer' => $subscription->stripe_customer_id, + 'limit' => 3, + ]); + + foreach ($invoices->data as $invoice) { + if ($invoice->paid && $invoice->created > (time() - 3600)) { + $subscription->update([ + 'stripe_invoice_paid' => true, + 'stripe_past_due' => false, + ]); + + return; + } + } + } catch (\Exception $e) { + } + } + + // If we reach here, payment genuinely failed $session = getStripeCustomerPortalSession($this->team); $mail = new MailMessage; $mail->view('emails.subscription-invoice-failed', [ diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php new file mode 100644 index 000000000..dacc0d4db --- /dev/null +++ b/app/Livewire/GlobalSearch.php @@ -0,0 +1,372 @@ +<?php + +namespace App\Livewire; + +use App\Models\Application; +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 Illuminate\Support\Facades\Cache; +use Livewire\Component; + +class GlobalSearch extends Component +{ + public $searchQuery = ''; + + public $isModalOpen = false; + + public $searchResults = []; + + public $allSearchableItems = []; + + public function mount() + { + $this->searchQuery = ''; + $this->isModalOpen = false; + $this->searchResults = []; + $this->allSearchableItems = []; + } + + public function openSearchModal() + { + $this->isModalOpen = true; + $this->loadSearchableItems(); + $this->dispatch('search-modal-opened'); + } + + public function closeSearchModal() + { + $this->isModalOpen = false; + $this->searchQuery = ''; + $this->searchResults = []; + } + + public static function getCacheKey($teamId) + { + return 'global_search_items_'.$teamId; + } + + public static function clearTeamCache($teamId) + { + Cache::forget(self::getCacheKey($teamId)); + } + + public function updatedSearchQuery() + { + $this->search(); + } + + private function loadSearchableItems() + { + // Try to get from Redis cache first + $cacheKey = self::getCacheKey(auth()->user()->currentTeam()->id); + + $this->allSearchableItems = Cache::remember($cacheKey, 300, function () { + ray()->showQueries(); + $items = collect(); + $team = auth()->user()->currentTeam(); + + // Get all applications + $applications = Application::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($app) { + // Collect all FQDNs from the application + $fqdns = collect([]); + + // For regular applications + if ($app->fqdn) { + $fqdns = collect(explode(',', $app->fqdn))->map(fn ($fqdn) => trim($fqdn)); + } + + // For docker compose based applications + if ($app->build_pack === 'dockercompose' && $app->docker_compose_domains) { + try { + $composeDomains = json_decode($app->docker_compose_domains, true); + if (is_array($composeDomains)) { + foreach ($composeDomains as $serviceName => $domains) { + if (is_array($domains)) { + $fqdns = $fqdns->merge($domains); + } + } + } + } catch (\Exception $e) { + // Ignore JSON parsing errors + } + } + + $fqdnsString = $fqdns->implode(' '); + + return [ + 'id' => $app->id, + 'name' => $app->name, + 'type' => 'application', + 'uuid' => $app->uuid, + 'description' => $app->description, + 'link' => $app->link(), + 'project' => $app->environment->project->name ?? null, + 'environment' => $app->environment->name ?? null, + 'fqdns' => $fqdns->take(2)->implode(', '), // Show first 2 FQDNs in UI + 'search_text' => strtolower($app->name.' '.$app->description.' '.$fqdnsString), + ]; + }); + + // Get all services + $services = Service::ownedByCurrentTeam() + ->with(['environment.project', 'applications']) + ->get() + ->map(function ($service) { + // Collect all FQDNs from service applications + $fqdns = collect([]); + foreach ($service->applications as $app) { + if ($app->fqdn) { + $appFqdns = collect(explode(',', $app->fqdn))->map(fn ($fqdn) => trim($fqdn)); + $fqdns = $fqdns->merge($appFqdns); + } + } + $fqdnsString = $fqdns->implode(' '); + + return [ + 'id' => $service->id, + 'name' => $service->name, + 'type' => 'service', + 'uuid' => $service->uuid, + 'description' => $service->description, + 'link' => $service->link(), + 'project' => $service->environment->project->name ?? null, + 'environment' => $service->environment->name ?? null, + 'fqdns' => $fqdns->take(2)->implode(', '), // Show first 2 FQDNs in UI + 'search_text' => strtolower($service->name.' '.$service->description.' '.$fqdnsString), + ]; + }); + + // Get all standalone databases + $databases = collect(); + + // PostgreSQL + $databases = $databases->merge( + StandalonePostgresql::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'postgresql', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' postgresql '.$db->description), + ]; + }) + ); + + // MySQL + $databases = $databases->merge( + StandaloneMysql::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'mysql', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' mysql '.$db->description), + ]; + }) + ); + + // MariaDB + $databases = $databases->merge( + StandaloneMariadb::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'mariadb', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' mariadb '.$db->description), + ]; + }) + ); + + // MongoDB + $databases = $databases->merge( + StandaloneMongodb::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'mongodb', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' mongodb '.$db->description), + ]; + }) + ); + + // Redis + $databases = $databases->merge( + StandaloneRedis::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'redis', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' redis '.$db->description), + ]; + }) + ); + + // KeyDB + $databases = $databases->merge( + StandaloneKeydb::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'keydb', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' keydb '.$db->description), + ]; + }) + ); + + // Dragonfly + $databases = $databases->merge( + StandaloneDragonfly::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'dragonfly', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' dragonfly '.$db->description), + ]; + }) + ); + + // Clickhouse + $databases = $databases->merge( + StandaloneClickhouse::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'clickhouse', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' clickhouse '.$db->description), + ]; + }) + ); + + // Get all servers + $servers = Server::ownedByCurrentTeam() + ->get() + ->map(function ($server) { + return [ + 'id' => $server->id, + 'name' => $server->name, + 'type' => 'server', + 'uuid' => $server->uuid, + 'description' => $server->description, + 'link' => $server->url(), + 'project' => null, + 'environment' => null, + 'search_text' => strtolower($server->name.' '.$server->ip.' '.$server->description), + ]; + }); + + // Merge all collections + $items = $items->merge($applications) + ->merge($services) + ->merge($databases) + ->merge($servers); + + return $items->toArray(); + }); + } + + private function search() + { + if (strlen($this->searchQuery) < 2) { + $this->searchResults = []; + + return; + } + + $query = strtolower($this->searchQuery); + + // Case-insensitive search in the items + $this->searchResults = collect($this->allSearchableItems) + ->filter(function ($item) use ($query) { + return str_contains($item['search_text'], $query); + }) + ->take(20) + ->values() + ->toArray(); + } + + public function render() + { + return view('livewire.global-search'); + } +} diff --git a/app/Livewire/Help.php b/app/Livewire/Help.php index 913710588..490515875 100644 --- a/app/Livewire/Help.php +++ b/app/Livewire/Help.php @@ -42,7 +42,7 @@ class Help extends Component 'content' => 'User: `'.auth()->user()?->email.'` with subject: `'.$this->subject.'` has the following problem: `'.$this->description.'`', ]); } else { - send_user_an_email($mail, auth()->user()?->email, 'hi@coollabs.io'); + send_user_an_email($mail, auth()->user()?->email, 'feedback@coollabs.io'); } $this->dispatch('success', 'Feedback sent.', 'We will get in touch with you as soon as possible.'); $this->reset('description', 'subject'); diff --git a/app/Livewire/Profile/Index.php b/app/Livewire/Profile/Index.php index a6b4dbe9e..4a419a12f 100644 --- a/app/Livewire/Profile/Index.php +++ b/app/Livewire/Profile/Index.php @@ -78,6 +78,8 @@ class Index extends Component 'new_email' => ['required', 'email', 'unique:users,email'], ]); + $this->new_email = strtolower($this->new_email); + // Skip rate limiting in development mode if (! isDev()) { // Rate limit by current user's email (1 request per 2 minutes) @@ -90,7 +92,7 @@ class Index extends Component } // Rate limit by new email address (3 requests per hour per email) - $newEmailKey = 'email-change:email:'.md5(strtolower($this->new_email)); + $newEmailKey = 'email-change:email:'.md5($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.'); diff --git a/app/Livewire/Project/Application/Advanced.php b/app/Livewire/Project/Application/Advanced.php index 862dc20d8..ed15ab258 100644 --- a/app/Livewire/Project/Application/Advanced.php +++ b/app/Livewire/Project/Application/Advanced.php @@ -28,6 +28,9 @@ class Advanced extends Component #[Validate(['boolean'])] public bool $isPreviewDeploymentsEnabled = false; + #[Validate(['boolean'])] + public bool $isPrDeploymentsPublicEnabled = false; + #[Validate(['boolean'])] public bool $isAutoDeployEnabled = true; @@ -91,6 +94,7 @@ class Advanced extends Component $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_pr_deployments_public_enabled = $this->isPrDeploymentsPublicEnabled; $this->application->settings->is_auto_deploy_enabled = $this->isAutoDeployEnabled; $this->application->settings->is_log_drain_enabled = $this->isLogDrainEnabled; $this->application->settings->is_gpu_enabled = $this->isGpuEnabled; @@ -117,6 +121,7 @@ class Advanced extends Component $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->isPrDeploymentsPublicEnabled = $this->application->settings->is_pr_deployments_public_enabled ?? false; $this->isAutoDeployEnabled = $this->application->settings->is_auto_deploy_enabled; $this->isGpuEnabled = $this->application->settings->is_gpu_enabled; $this->gpuDriver = $this->application->settings->gpu_driver; diff --git a/app/Livewire/Project/Application/DeploymentNavbar.php b/app/Livewire/Project/Application/DeploymentNavbar.php index 66f387fcf..dccd1e499 100644 --- a/app/Livewire/Project/Application/DeploymentNavbar.php +++ b/app/Livewire/Project/Application/DeploymentNavbar.php @@ -52,15 +52,24 @@ class DeploymentNavbar extends Component public function cancel() { - $kill_command = "docker rm -f {$this->application_deployment_queue->deployment_uuid}"; + $deployment_uuid = $this->application_deployment_queue->deployment_uuid; + $kill_command = "docker rm -f {$deployment_uuid}"; $build_server_id = $this->application_deployment_queue->build_server_id ?? $this->application->destination->server_id; $server_id = $this->application_deployment_queue->server_id ?? $this->application->destination->server_id; + + // First, mark the deployment as cancelled to prevent further processing + $this->application_deployment_queue->update([ + 'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value, + ]); + try { if ($this->application->settings->is_build_server_enabled) { $server = Server::ownedByCurrentTeam()->find($build_server_id); } else { $server = Server::ownedByCurrentTeam()->find($server_id); } + + // Add cancellation log entry if ($this->application_deployment_queue->logs) { $previous_logs = json_decode($this->application_deployment_queue->logs, associative: true, flags: JSON_THROW_ON_ERROR); @@ -77,13 +86,35 @@ class DeploymentNavbar extends Component 'logs' => json_encode($previous_logs, flags: JSON_THROW_ON_ERROR), ]); } - instant_remote_process([$kill_command], $server); + + // Try to stop the helper container if it exists + // Check if container exists first + $checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'"; + $containerExists = instant_remote_process([$checkCommand], $server); + + if ($containerExists && str($containerExists)->trim()->isNotEmpty()) { + // Container exists, kill it + instant_remote_process([$kill_command], $server); + } else { + // Container hasn't started yet + $this->application_deployment_queue->addLogEntry('Helper container not yet started. Deployment will be cancelled when job checks status.'); + } + + // Also try to kill any running process if we have a process ID + if ($this->application_deployment_queue->current_process_id) { + try { + $processKillCommand = "kill -9 {$this->application_deployment_queue->current_process_id}"; + instant_remote_process([$processKillCommand], $server); + } catch (\Throwable $e) { + // Process might already be gone, that's ok + } + } } catch (\Throwable $e) { + // Still mark as cancelled even if cleanup fails return handleError($e, $this); } finally { $this->application_deployment_queue->update([ 'current_process_id' => null, - 'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value, ]); next_after_cancel($server); } diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index aa72b7c5f..c77d050cb 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -487,7 +487,7 @@ class General extends Component $domains = str($this->application->fqdn)->trim()->explode(','); if ($this->application->additional_servers->count() === 0) { foreach ($domains as $domain) { - if (! validate_dns_entry($domain, $this->application->destination->server)) { + if (! validateDNSEntry($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."); } } @@ -615,7 +615,7 @@ class General extends Component foreach ($this->parsedServiceDomains as $service) { $domain = data_get($service, 'domain'); if ($domain) { - if (! validate_dns_entry($domain, $this->application->destination->server)) { + if (! validateDNSEntry($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."); } } @@ -671,7 +671,7 @@ class General extends Component $domains = collect(json_decode($this->application->docker_compose_domains, true)) ?? collect([]); foreach ($domains as $serviceName => $service) { - $serviceNameFormatted = str($serviceName)->upper()->replace('-', '_'); + $serviceNameFormatted = str($serviceName)->upper()->replace('-', '_')->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) @@ -703,7 +703,6 @@ class General extends Component 'key' => "SERVICE_FQDN_{$serviceNameFormatted}", ], [ 'value' => $fqdnValue, - 'is_build_time' => false, 'is_preview' => false, ]); @@ -712,7 +711,6 @@ class General extends Component 'key' => "SERVICE_URL_{$serviceNameFormatted}", ], [ 'value' => $urlValue, - 'is_build_time' => false, 'is_preview' => false, ]); // Create/update port-specific variables if port exists @@ -721,7 +719,6 @@ class General extends Component 'key' => "SERVICE_FQDN_{$serviceNameFormatted}_{$port}", ], [ 'value' => $fqdnValue, - 'is_build_time' => false, 'is_preview' => false, ]); @@ -729,7 +726,6 @@ class General extends Component 'key' => "SERVICE_URL_{$serviceNameFormatted}_{$port}", ], [ 'value' => $urlValue, - 'is_build_time' => false, 'is_preview' => false, ]); } diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php index ebfd84489..1cb2ef2c5 100644 --- a/app/Livewire/Project/Application/Previews.php +++ b/app/Livewire/Project/Application/Previews.php @@ -77,7 +77,7 @@ class Previews extends Component $preview->fqdn = str($preview->fqdn)->replaceEnd(',', '')->trim(); $preview->fqdn = str($preview->fqdn)->replaceStart(',', '')->trim(); $preview->fqdn = str($preview->fqdn)->trim()->lower(); - if (! validate_dns_entry($preview->fqdn, $this->application->destination->server)) { + if (! validateDNSEntry($preview->fqdn, $this->application->destination->server)) { $this->dispatch('error', 'Validating DNS failed.', "Make sure you have added the DNS records correctly.<br><br>$preview->fqdn->{$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."); $success = false; } @@ -231,6 +231,18 @@ class Previews extends Component $this->parameters['deployment_uuid'] = $this->deployment_uuid; } + private function stopContainers(array $containers, $server) + { + $containersToStop = collect($containers)->pluck('Names')->toArray(); + + foreach ($containersToStop as $containerName) { + instant_remote_process(command: [ + "docker stop --time=30 $containerName", + "docker rm -f $containerName", + ], server: $server, throwError: false); + } + } + public function stop(int $pull_request_id) { $this->authorize('deploy', $this->application); diff --git a/app/Livewire/Project/CloneMe.php b/app/Livewire/Project/CloneMe.php index be9de139f..3b3e42619 100644 --- a/app/Livewire/Project/CloneMe.php +++ b/app/Livewire/Project/CloneMe.php @@ -2,7 +2,6 @@ namespace App\Livewire\Project; -use App\Actions\Application\StopApplication; use App\Actions\Database\StartDatabase; use App\Actions\Database\StopDatabase; use App\Actions\Service\StartService; @@ -128,144 +127,10 @@ class CloneMe extends Component $databases = $this->environment->databases(); $services = $this->environment->services; foreach ($applications as $application) { - $applicationSettings = $application->settings; - - $uuid = (string) new Cuid2; - $url = $application->fqdn; - if ($this->server->proxyType() !== 'NONE' && $applicationSettings->is_container_label_readonly_enabled === true) { - $url = generateUrl(server: $this->server, random: $uuid); - } - - $newApplication = $application->replicate([ - 'id', - 'created_at', - 'updated_at', - 'additional_servers_count', - 'additional_networks_count', - ])->fill([ - 'uuid' => $uuid, - 'fqdn' => $url, - 'status' => 'exited', + $selectedDestination = $this->servers->flatMap(fn ($server) => $server->destinations())->where('id', $this->selectedDestination)->first(); + clone_application($application, $selectedDestination, [ 'environment_id' => $environment->id, - 'destination_id' => $this->selectedDestination, - ]); - $newApplication->save(); - - if ($newApplication->destination->server->proxyType() !== 'NONE' && $applicationSettings->is_container_label_readonly_enabled === true) { - $customLabels = str(implode('|coolify|', generateLabelsApplication($newApplication)))->replace('|coolify|', "\n"); - $newApplication->custom_labels = base64_encode($customLabels); - $newApplication->save(); - } - - $newApplication->settings()->delete(); - if ($applicationSettings) { - $newApplicationSettings = $applicationSettings->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'application_id' => $newApplication->id, - ]); - $newApplicationSettings->save(); - } - - $tags = $application->tags; - foreach ($tags as $tag) { - $newApplication->tags()->attach($tag->id); - } - - $scheduledTasks = $application->scheduled_tasks()->get(); - foreach ($scheduledTasks as $task) { - $newTask = $task->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'uuid' => (string) new Cuid2, - 'application_id' => $newApplication->id, - 'team_id' => currentTeam()->id, - ]); - $newTask->save(); - } - - $applicationPreviews = $application->previews()->get(); - foreach ($applicationPreviews as $preview) { - $newPreview = $preview->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'application_id' => $newApplication->id, - 'status' => 'exited', - ]); - $newPreview->save(); - } - - $persistentVolumes = $application->persistentStorages()->get(); - foreach ($persistentVolumes as $volume) { - $newName = ''; - if (str_starts_with($volume->name, $application->uuid)) { - $newName = str($volume->name)->replace($application->uuid, $newApplication->uuid); - } else { - $newName = $newApplication->uuid.'-'.$volume->name; - } - - $newPersistentVolume = $volume->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'name' => $newName, - 'resource_id' => $newApplication->id, - ]); - $newPersistentVolume->save(); - - if ($this->cloneVolumeData) { - try { - StopApplication::dispatch($application, false, false); - $sourceVolume = $volume->name; - $targetVolume = $newPersistentVolume->name; - $sourceServer = $application->destination->server; - $targetServer = $newApplication->destination->server; - - VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume); - - queue_application_deployment( - deployment_uuid: (string) new Cuid2, - application: $application, - server: $sourceServer, - destination: $application->destination, - no_questions_asked: true - ); - } catch (\Exception $e) { - \Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage()); - } - } - } - - $fileStorages = $application->fileStorages()->get(); - foreach ($fileStorages as $storage) { - $newStorage = $storage->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'resource_id' => $newApplication->id, - ]); - $newStorage->save(); - } - - $environmentVaribles = $application->environment_variables()->get(); - foreach ($environmentVaribles as $environmentVarible) { - $newEnvironmentVariable = $environmentVarible->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'resourceable_id' => $newApplication->id, - ]); - $newEnvironmentVariable->save(); - } + ], $this->cloneVolumeData); } foreach ($databases as $database) { diff --git a/app/Livewire/Project/New/DockerCompose.php b/app/Livewire/Project/New/DockerCompose.php index 7c81e810c..5cda1dedd 100644 --- a/app/Livewire/Project/New/DockerCompose.php +++ b/app/Livewire/Project/New/DockerCompose.php @@ -63,7 +63,6 @@ class DockerCompose extends Component EnvironmentVariable::create([ 'key' => $key, 'value' => $variable, - 'is_build_time' => false, 'is_preview' => false, 'resourceable_id' => $service->id, 'resourceable_type' => $service->getMorphClass(), diff --git a/app/Livewire/Project/Resource/Create.php b/app/Livewire/Project/Resource/Create.php index 3dbe4230c..73960d288 100644 --- a/app/Livewire/Project/Resource/Create.php +++ b/app/Livewire/Project/Resource/Create.php @@ -97,7 +97,6 @@ class Create extends Component 'value' => $value, 'resourceable_id' => $service->id, 'resourceable_type' => $service->getMorphClass(), - 'is_build_time' => false, 'is_preview' => false, ]); } diff --git a/app/Livewire/Project/Shared/ConfigurationChecker.php b/app/Livewire/Project/Shared/ConfigurationChecker.php index ab9f3785d..ce9ce7780 100644 --- a/app/Livewire/Project/Shared/ConfigurationChecker.php +++ b/app/Livewire/Project/Shared/ConfigurationChecker.php @@ -20,7 +20,15 @@ class ConfigurationChecker extends Component public Application|Service|StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource; - protected $listeners = ['configurationChanged']; + public function getListeners() + { + $teamId = auth()->user()->currentTeam()->id; + + return [ + "echo-private:team.{$teamId},ApplicationConfigurationChanged" => 'configurationChanged', + 'configurationChanged' => 'configurationChanged', + ]; + } public function mount() { diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php index cf7843f84..23a2cd59d 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php @@ -19,28 +19,32 @@ class Add extends Component public ?string $value = null; - public bool $is_build_time = false; - public bool $is_multiline = false; public bool $is_literal = false; + public bool $is_runtime = true; + + public bool $is_buildtime = true; + protected $listeners = ['clearAddEnv' => 'clear']; protected $rules = [ 'key' => 'required|string', 'value' => 'nullable', - 'is_build_time' => 'required|boolean', 'is_multiline' => 'required|boolean', 'is_literal' => 'required|boolean', + 'is_runtime' => 'required|boolean', + 'is_buildtime' => 'required|boolean', ]; protected $validationAttributes = [ 'key' => 'key', 'value' => 'value', - 'is_build_time' => 'build', 'is_multiline' => 'multiline', 'is_literal' => 'literal', + 'is_runtime' => 'runtime', + 'is_buildtime' => 'buildtime', ]; public function mount() @@ -54,9 +58,10 @@ class Add extends Component $this->dispatch('saveKey', [ 'key' => $this->key, 'value' => $this->value, - 'is_build_time' => $this->is_build_time, 'is_multiline' => $this->is_multiline, 'is_literal' => $this->is_literal, + 'is_runtime' => $this->is_runtime, + 'is_buildtime' => $this->is_buildtime, 'is_preview' => $this->is_preview, ]); $this->clear(); @@ -66,8 +71,9 @@ class Add extends Component { $this->key = ''; $this->value = ''; - $this->is_build_time = false; $this->is_multiline = false; $this->is_literal = false; + $this->is_runtime = true; + $this->is_buildtime = true; } } diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index 3631a43c8..639c025c7 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -25,6 +25,8 @@ class All extends Component public bool $is_env_sorting_enabled = false; + public bool $use_build_secrets = false; + protected $listeners = [ 'saveKey' => 'submit', 'refreshEnvs', @@ -34,13 +36,14 @@ class All extends Component public function mount() { $this->is_env_sorting_enabled = data_get($this->resource, 'settings.is_env_sorting_enabled', false); + $this->use_build_secrets = data_get($this->resource, 'settings.use_build_secrets', false); $this->resourceClass = get_class($this->resource); $resourceWithPreviews = [\App\Models\Application::class]; $simpleDockerfile = filled(data_get($this->resource, 'dockerfile')); if (str($this->resourceClass)->contains($resourceWithPreviews) && ! $simpleDockerfile) { $this->showPreview = true; } - $this->sortEnvironmentVariables(); + $this->getDevView(); } public function instantSave() @@ -49,34 +52,38 @@ class All extends Component $this->authorize('manageEnvironment', $this->resource); $this->resource->settings->is_env_sorting_enabled = $this->is_env_sorting_enabled; + $this->resource->settings->use_build_secrets = $this->use_build_secrets; $this->resource->settings->save(); - $this->sortEnvironmentVariables(); + $this->getDevView(); $this->dispatch('success', 'Environment variable settings updated.'); } catch (\Throwable $e) { return handleError($e, $this); } } - public function sortEnvironmentVariables() + public function getEnvironmentVariablesProperty() { if ($this->is_env_sorting_enabled === false) { - if ($this->resource->environment_variables) { - $this->resource->environment_variables = $this->resource->environment_variables->sortBy('order')->values(); - } - - if ($this->resource->environment_variables_preview) { - $this->resource->environment_variables_preview = $this->resource->environment_variables_preview->sortBy('order')->values(); - } + return $this->resource->environment_variables()->orderBy('order')->get(); } - $this->getDevView(); + return $this->resource->environment_variables; + } + + public function getEnvironmentVariablesPreviewProperty() + { + if ($this->is_env_sorting_enabled === false) { + return $this->resource->environment_variables_preview()->orderBy('order')->get(); + } + + return $this->resource->environment_variables_preview; } public function getDevView() { - $this->variables = $this->formatEnvironmentVariables($this->resource->environment_variables); + $this->variables = $this->formatEnvironmentVariables($this->environmentVariables); if ($this->showPreview) { - $this->variablesPreview = $this->formatEnvironmentVariables($this->resource->environment_variables_preview); + $this->variablesPreview = $this->formatEnvironmentVariables($this->environmentVariablesPreview); } } @@ -97,7 +104,7 @@ class All extends Component public function switch() { $this->view = $this->view === 'normal' ? 'dev' : 'normal'; - $this->sortEnvironmentVariables(); + $this->getDevView(); } public function submit($data = null) @@ -111,7 +118,7 @@ class All extends Component } $this->updateOrder(); - $this->sortEnvironmentVariables(); + $this->getDevView(); } catch (\Throwable $e) { return handleError($e, $this); } finally { @@ -212,9 +219,10 @@ class All extends Component $environment = new EnvironmentVariable; $environment->key = $data['key']; $environment->value = $data['value']; - $environment->is_build_time = $data['is_build_time'] ?? false; $environment->is_multiline = $data['is_multiline'] ?? false; $environment->is_literal = $data['is_literal'] ?? false; + $environment->is_runtime = $data['is_runtime'] ?? true; + $environment->is_buildtime = $data['is_buildtime'] ?? true; $environment->is_preview = $data['is_preview'] ?? false; $environment->resourceable_id = $this->resource->id; $environment->resourceable_type = $this->resource->getMorphClass(); @@ -257,7 +265,7 @@ class All extends Component { $count = 0; foreach ($variables as $key => $value) { - if (str($key)->startsWith('SERVICE_FQDN') || str($key)->startsWith('SERVICE_URL')) { + if (str($key)->startsWith('SERVICE_FQDN') || str($key)->startsWith('SERVICE_URL') || str($key)->startsWith('SERVICE_NAME')) { continue; } $method = $isPreview ? 'environment_variables_preview' : 'environment_variables'; @@ -276,7 +284,6 @@ class All extends Component $environment = new EnvironmentVariable; $environment->key = $key; $environment->value = $value; - $environment->is_build_time = false; $environment->is_multiline = false; $environment->is_preview = $isPreview; $environment->resourceable_id = $this->resource->id; @@ -293,7 +300,6 @@ class All extends Component public function refreshEnvs() { $this->resource->refresh(); - $this->sortEnvironmentVariables(); $this->getDevView(); } } diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index 1a9daf77b..0d0467c13 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -32,14 +32,16 @@ class Show extends Component public bool $is_shared = false; - public bool $is_build_time = false; - public bool $is_multiline = false; public bool $is_literal = false; public bool $is_shown_once = false; + public bool $is_runtime = true; + + public bool $is_buildtime = true; + public bool $is_required = false; public bool $is_really_required = false; @@ -55,10 +57,11 @@ class Show extends Component protected $rules = [ 'key' => 'required|string', 'value' => 'nullable', - 'is_build_time' => 'required|boolean', 'is_multiline' => 'required|boolean', 'is_literal' => 'required|boolean', 'is_shown_once' => 'required|boolean', + 'is_runtime' => 'required|boolean', + 'is_buildtime' => 'required|boolean', 'real_value' => 'nullable', 'is_required' => 'required|boolean', ]; @@ -101,8 +104,9 @@ class Show extends Component ]); } else { $this->validate(); - $this->env->is_build_time = $this->is_build_time; $this->env->is_required = $this->is_required; + $this->env->is_runtime = $this->is_runtime; + $this->env->is_buildtime = $this->is_buildtime; $this->env->is_shared = $this->is_shared; } $this->env->key = $this->key; @@ -114,10 +118,11 @@ class Show extends Component } else { $this->key = $this->env->key; $this->value = $this->env->value; - $this->is_build_time = $this->env->is_build_time ?? false; $this->is_multiline = $this->env->is_multiline; $this->is_literal = $this->env->is_literal; $this->is_shown_once = $this->env->is_shown_once; + $this->is_runtime = $this->env->is_runtime ?? true; + $this->is_buildtime = $this->env->is_buildtime ?? true; $this->is_required = $this->env->is_required ?? false; $this->is_really_required = $this->env->is_really_required ?? false; $this->is_shared = $this->env->is_shared ?? false; @@ -128,7 +133,7 @@ class Show extends Component public function checkEnvs() { $this->isDisabled = false; - if (str($this->env->key)->startsWith('SERVICE_FQDN') || str($this->env->key)->startsWith('SERVICE_URL')) { + if (str($this->env->key)->startsWith('SERVICE_FQDN') || str($this->env->key)->startsWith('SERVICE_URL') || str($this->env->key)->startsWith('SERVICE_NAME')) { $this->isDisabled = true; } if ($this->env->is_shown_once) { @@ -139,9 +144,6 @@ class Show extends Component public function serialize() { data_forget($this->env, 'real_value'); - if ($this->env->getMorphClass() === \App\Models\SharedEnvironmentVariable::class) { - data_forget($this->env, 'is_build_time'); - } } public function lock() diff --git a/app/Livewire/Project/Shared/Metrics.php b/app/Livewire/Project/Shared/Metrics.php index fdc35fc0f..e5b87b48c 100644 --- a/app/Livewire/Project/Shared/Metrics.php +++ b/app/Livewire/Project/Shared/Metrics.php @@ -8,7 +8,7 @@ class Metrics extends Component { public $resource; - public $chartId = 'container-cpu'; + public $chartId = 'metrics'; public $data; diff --git a/app/Livewire/Project/Shared/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php index 28a6380d5..47b3534a2 100644 --- a/app/Livewire/Project/Shared/ResourceOperations.php +++ b/app/Livewire/Project/Shared/ResourceOperations.php @@ -2,7 +2,6 @@ namespace App\Livewire\Project\Shared; -use App\Actions\Application\StopApplication; use App\Actions\Database\StartDatabase; use App\Actions\Database\StopDatabase; use App\Actions\Service\StartService; @@ -61,145 +60,7 @@ class ResourceOperations extends Component $server = $new_destination->server; if ($this->resource->getMorphClass() === \App\Models\Application::class) { - $name = 'clone-of-'.str($this->resource->name)->limit(20).'-'.$uuid; - $applicationSettings = $this->resource->settings; - $url = $this->resource->fqdn; - - if ($server->proxyType() !== 'NONE' && $applicationSettings->is_container_label_readonly_enabled === true) { - $url = generateUrl(server: $server, random: $uuid); - } - - $new_resource = $this->resource->replicate([ - 'id', - 'created_at', - 'updated_at', - 'additional_servers_count', - 'additional_networks_count', - ])->fill([ - 'uuid' => $uuid, - 'name' => $name, - 'fqdn' => $url, - 'status' => 'exited', - 'destination_id' => $new_destination->id, - ]); - $new_resource->save(); - - if ($new_resource->destination->server->proxyType() !== 'NONE' && $applicationSettings->is_container_label_readonly_enabled === true) { - $customLabels = str(implode('|coolify|', generateLabelsApplication($new_resource)))->replace('|coolify|', "\n"); - $new_resource->custom_labels = base64_encode($customLabels); - $new_resource->save(); - } - - $new_resource->settings()->delete(); - if ($applicationSettings) { - $newApplicationSettings = $applicationSettings->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'application_id' => $new_resource->id, - ]); - $newApplicationSettings->save(); - } - - $tags = $this->resource->tags; - foreach ($tags as $tag) { - $new_resource->tags()->attach($tag->id); - } - - $scheduledTasks = $this->resource->scheduled_tasks()->get(); - foreach ($scheduledTasks as $task) { - $newTask = $task->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'uuid' => (string) new Cuid2, - 'application_id' => $new_resource->id, - 'team_id' => currentTeam()->id, - ]); - $newTask->save(); - } - - $applicationPreviews = $this->resource->previews()->get(); - foreach ($applicationPreviews as $preview) { - $newPreview = $preview->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'application_id' => $new_resource->id, - 'status' => 'exited', - ]); - $newPreview->save(); - } - - $persistentVolumes = $this->resource->persistentStorages()->get(); - foreach ($persistentVolumes as $volume) { - $newName = ''; - if (str_starts_with($volume->name, $this->resource->uuid)) { - $newName = str($volume->name)->replace($this->resource->uuid, $new_resource->uuid); - } else { - $newName = $new_resource->uuid.'-'.str($volume->name)->afterLast('-'); - } - - $newPersistentVolume = $volume->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'name' => $newName, - 'resource_id' => $new_resource->id, - ]); - $newPersistentVolume->save(); - - if ($this->cloneVolumeData) { - try { - StopApplication::dispatch($this->resource, false, false); - $sourceVolume = $volume->name; - $targetVolume = $newPersistentVolume->name; - $sourceServer = $this->resource->destination->server; - $targetServer = $new_resource->destination->server; - - VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume); - - queue_application_deployment( - deployment_uuid: (string) new Cuid2, - application: $this->resource, - server: $sourceServer, - destination: $this->resource->destination, - no_questions_asked: true - ); - } catch (\Exception $e) { - \Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage()); - } - } - } - - $fileStorages = $this->resource->fileStorages()->get(); - foreach ($fileStorages as $storage) { - $newStorage = $storage->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'resource_id' => $new_resource->id, - ]); - $newStorage->save(); - } - - $environmentVaribles = $this->resource->environment_variables()->get(); - foreach ($environmentVaribles as $environmentVarible) { - $newEnvironmentVariable = $environmentVarible->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'resourceable_id' => $new_resource->id, - 'resourceable_type' => $new_resource->getMorphClass(), - ]); - $newEnvironmentVariable->save(); - } + $new_resource = clone_application($this->resource, $new_destination, ['uuid' => $uuid], $this->cloneVolumeData); $route = route('project.application.configuration', [ 'project_uuid' => $this->projectUuid, diff --git a/app/Livewire/Project/Shared/ScheduledTask/Executions.php b/app/Livewire/Project/Shared/ScheduledTask/Executions.php index 6f62a5b5b..ca2bbd9b4 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Executions.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Executions.php @@ -105,6 +105,19 @@ class Executions extends Component $this->currentPage++; } + public function loadAllLogs() + { + if (! $this->selectedExecution || ! $this->selectedExecution->message) { + return; + } + + $lines = collect(explode("\n", $this->selectedExecution->message)); + $totalLines = $lines->count(); + $totalPages = ceil($totalLines / $this->logsPerPage); + + $this->currentPage = $totalPages; + } + public function getLogLinesProperty() { if (! $this->selectedExecution) { diff --git a/app/Livewire/Project/Shared/Storages/All.php b/app/Livewire/Project/Shared/Storages/All.php index c26315d3b..63fc06a36 100644 --- a/app/Livewire/Project/Shared/Storages/All.php +++ b/app/Livewire/Project/Shared/Storages/All.php @@ -9,4 +9,15 @@ class All extends Component public $resource; protected $listeners = ['refreshStorages' => '$refresh']; + + public function getFirstStorageIdProperty() + { + if ($this->resource->persistentStorages->isEmpty()) { + return null; + } + + // Use the storage with the smallest ID as the "first" one + // This ensures stability even when storages are deleted + return $this->resource->persistentStorages->sortBy('id')->first()->id; + } } diff --git a/app/Livewire/Server/Navbar.php b/app/Livewire/Server/Navbar.php index 055290580..beefed12a 100644 --- a/app/Livewire/Server/Navbar.php +++ b/app/Livewire/Server/Navbar.php @@ -32,7 +32,7 @@ class Navbar extends Component $teamId = auth()->user()->currentTeam()->id; return [ - 'refreshServerShow' => '$refresh', + 'refreshServerShow' => 'refreshServer', "echo-private:team.{$teamId},ProxyStatusChangedUI" => 'showNotification', ]; } @@ -134,6 +134,12 @@ class Navbar extends Component } + public function refreshServer() + { + $this->server->refresh(); + $this->server->load('settings'); + } + public function render() { return view('livewire.server.navbar'); diff --git a/app/Livewire/Server/Proxy.php b/app/Livewire/Server/Proxy.php index 49adf7fe6..5ef559862 100644 --- a/app/Livewire/Server/Proxy.php +++ b/app/Livewire/Server/Proxy.php @@ -2,8 +2,8 @@ namespace App\Livewire\Server; -use App\Actions\Proxy\CheckConfiguration; -use App\Actions\Proxy\SaveConfiguration; +use App\Actions\Proxy\GetProxyConfiguration; +use App\Actions\Proxy\SaveProxyConfiguration; use App\Models\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -16,11 +16,11 @@ class Proxy extends Component public ?string $selectedProxy = null; - public $proxy_settings = null; + public $proxySettings = null; - public bool $redirect_enabled = true; + public bool $redirectEnabled = true; - public ?string $redirect_url = null; + public ?string $redirectUrl = null; public function getListeners() { @@ -39,14 +39,14 @@ class Proxy extends Component public function mount() { $this->selectedProxy = $this->server->proxyType(); - $this->redirect_enabled = data_get($this->server, 'proxy.redirect_enabled', true); - $this->redirect_url = data_get($this->server, 'proxy.redirect_url'); + $this->redirectEnabled = data_get($this->server, 'proxy.redirect_enabled', true); + $this->redirectUrl = data_get($this->server, 'proxy.redirect_url'); } - // public function proxyStatusUpdated() - // { - // $this->dispatch('refresh')->self(); - // } + public function getConfigurationFilePathProperty() + { + return $this->server->proxyPath().'docker-compose.yml'; + } public function changeProxy() { @@ -86,7 +86,7 @@ class Proxy extends Component { try { $this->authorize('update', $this->server); - $this->server->proxy->redirect_enabled = $this->redirect_enabled; + $this->server->proxy->redirect_enabled = $this->redirectEnabled; $this->server->save(); $this->server->setupDefaultRedirect(); $this->dispatch('success', 'Proxy configuration saved.'); @@ -99,8 +99,8 @@ class Proxy extends Component { try { $this->authorize('update', $this->server); - SaveConfiguration::run($this->server, $this->proxy_settings); - $this->server->proxy->redirect_url = $this->redirect_url; + SaveProxyConfiguration::run($this->server, $this->proxySettings); + $this->server->proxy->redirect_url = $this->redirectUrl; $this->server->save(); $this->server->setupDefaultRedirect(); $this->dispatch('success', 'Proxy configuration saved.'); @@ -109,14 +109,15 @@ class Proxy extends Component } } - public function reset_proxy_configuration() + public function resetProxyConfiguration() { try { $this->authorize('update', $this->server); - $this->proxy_settings = CheckConfiguration::run($this->server, true); - SaveConfiguration::run($this->server, $this->proxy_settings); + // Explicitly regenerate default configuration + $this->proxySettings = GetProxyConfiguration::run($this->server, forceRegenerate: true); + SaveProxyConfiguration::run($this->server, $this->proxySettings); $this->server->save(); - $this->dispatch('success', 'Proxy configuration saved.'); + $this->dispatch('success', 'Proxy configuration reset to default.'); } catch (\Throwable $e) { return handleError($e, $this); } @@ -125,7 +126,7 @@ class Proxy extends Component public function loadProxyConfiguration() { try { - $this->proxy_settings = CheckConfiguration::run($this->server); + $this->proxySettings = GetProxyConfiguration::run($this->server); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index f4ae6dd7e..473e0b60e 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -63,6 +63,8 @@ class Show extends Component public bool $isSentinelDebugEnabled; + public ?string $sentinelCustomDockerImage = null; + public string $serverTimezone; public function getListeners() @@ -267,7 +269,8 @@ class Show extends Component { try { $this->authorize('manageSentinel', $this->server); - $this->server->restartSentinel(); + $customImage = isDev() ? $this->sentinelCustomDockerImage : null; + $this->server->restartSentinel($customImage); $this->dispatch('success', 'Restarting Sentinel.'); } catch (\Throwable $e) { return handleError($e, $this); @@ -295,12 +298,38 @@ class Show extends Component } } + public function updatedIsBuildServer($value) + { + try { + $this->authorize('update', $this->server); + if ($value === true && $this->isSentinelEnabled) { + $this->isSentinelEnabled = false; + $this->isMetricsEnabled = false; + $this->isSentinelDebugEnabled = false; + StopSentinel::dispatch($this->server); + $this->dispatch('info', 'Sentinel has been disabled as build servers cannot run Sentinel.'); + } + $this->submit(); + // Dispatch event to refresh the navbar + $this->dispatch('refreshServerShow'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function updatedIsSentinelEnabled($value) { try { $this->authorize('manageSentinel', $this->server); if ($value === true) { - StartSentinel::run($this->server, true); + if ($this->isBuildServer) { + $this->isSentinelEnabled = false; + $this->dispatch('error', 'Sentinel cannot be enabled on build servers.'); + + return; + } + $customImage = isDev() ? $this->sentinelCustomDockerImage : null; + StartSentinel::run($this->server, true, null, $customImage); } else { $this->isMetricsEnabled = false; $this->isSentinelDebugEnabled = false; diff --git a/app/Livewire/Settings/Index.php b/app/Livewire/Settings/Index.php index d05433082..13d690352 100644 --- a/app/Livewire/Settings/Index.php +++ b/app/Livewire/Settings/Index.php @@ -115,7 +115,7 @@ class Index extends Component $this->validate(); if ($this->settings->is_dns_validation_enabled && $this->fqdn) { - if (! validate_dns_entry($this->fqdn, $this->server)) { + if (! validateDNSEntry($this->fqdn, $this->server)) { $this->dispatch('error', "Validating DNS failed.<br><br>Make sure you have added the DNS records correctly.<br><br>{$this->fqdn}->{$this->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."); $error_show = true; } diff --git a/app/Livewire/SettingsDropdown.php b/app/Livewire/SettingsDropdown.php index 314957462..7afa763df 100644 --- a/app/Livewire/SettingsDropdown.php +++ b/app/Livewire/SettingsDropdown.php @@ -2,7 +2,7 @@ namespace App\Livewire; -use App\Jobs\PullChangelogFromGitHub; +use App\Jobs\PullChangelog; use App\Services\ChangelogService; use Illuminate\Support\Facades\Auth; use Livewire\Component; @@ -23,6 +23,11 @@ class SettingsDropdown extends Component return app(ChangelogService::class)->getEntriesForUser($user); } + public function getCurrentVersionProperty() + { + return 'v'.config('constants.coolify.version'); + } + public function openWhatsNewModal() { $this->showWhatsNewModal = true; @@ -50,7 +55,7 @@ class SettingsDropdown extends Component } try { - PullChangelogFromGitHub::dispatch(); + PullChangelog::dispatch(); $this->dispatch('success', 'Changelog fetch initiated! Check back in a few moments.'); } catch (\Throwable $e) { $this->dispatch('error', 'Failed to fetch changelog: '.$e->getMessage()); @@ -62,6 +67,7 @@ class SettingsDropdown extends Component return view('livewire.settings-dropdown', [ 'entries' => $this->entries, 'unreadCount' => $this->unreadCount, + 'currentVersion' => $this->currentVersion, ]); } } diff --git a/app/Models/Application.php b/app/Models/Application.php index 378161602..094e5c82b 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Enums\ApplicationDeploymentStatus; use App\Services\ConfigurationGenerator; +use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasConfiguration; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -110,7 +111,7 @@ use Visus\Cuid2\Cuid2; class Application extends BaseModel { - use HasConfiguration, HasFactory, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasSafeStringAttribute, SoftDeletes; private static $parserVersion = '5'; @@ -123,66 +124,6 @@ class Application extends BaseModel 'http_basic_auth_password' => 'encrypted', ]; - public function customNetworkAliases(): Attribute - { - return Attribute::make( - set: function ($value) { - if (is_null($value) || $value === '') { - return null; - } - - // If it's already a JSON string, decode it - if (is_string($value) && $this->isJson($value)) { - $value = json_decode($value, true); - } - - // If it's a string but not JSON, treat it as a comma-separated list - if (is_string($value) && ! is_array($value)) { - $value = explode(',', $value); - } - - $value = collect($value) - ->map(function ($alias) { - if (is_string($alias)) { - return str_replace(' ', '-', trim($alias)); - } - - return null; - }) - ->filter() - ->unique() // Remove duplicate values - ->values() - ->toArray(); - - return empty($value) ? null : json_encode($value); - }, - get: function ($value) { - if (is_null($value)) { - return null; - } - - if (is_string($value) && $this->isJson($value)) { - return json_decode($value, true); - } - - return is_array($value) ? $value : []; - } - ); - } - - /** - * Check if a string is a valid JSON - */ - private function isJson($string) - { - if (! is_string($string)) { - return false; - } - json_decode($string); - - return json_last_error() === JSON_ERROR_NONE; - } - protected static function booted() { static::addGlobalScope('withRelations', function ($builder) { @@ -250,6 +191,66 @@ class Application extends BaseModel }); } + public function customNetworkAliases(): Attribute + { + return Attribute::make( + set: function ($value) { + if (is_null($value) || $value === '') { + return null; + } + + // If it's already a JSON string, decode it + if (is_string($value) && $this->isJson($value)) { + $value = json_decode($value, true); + } + + // If it's a string but not JSON, treat it as a comma-separated list + if (is_string($value) && ! is_array($value)) { + $value = explode(',', $value); + } + + $value = collect($value) + ->map(function ($alias) { + if (is_string($alias)) { + return str_replace(' ', '-', trim($alias)); + } + + return null; + }) + ->filter() + ->unique() // Remove duplicate values + ->values() + ->toArray(); + + return empty($value) ? null : json_encode($value); + }, + get: function ($value) { + if (is_null($value)) { + return null; + } + + if (is_string($value) && $this->isJson($value)) { + return json_decode($value, true); + } + + return is_array($value) ? $value : []; + } + ); + } + + /** + * Check if a string is a valid JSON + */ + private function isJson($string) + { + if (! is_string($string)) { + return false; + } + json_decode($string); + + return json_last_error() === JSON_ERROR_NONE; + } + public static function ownedByCurrentTeamAPI(int $teamId) { return Application::whereRelation('environment.project.team', 'id', $teamId)->orderBy('name'); @@ -728,7 +729,14 @@ class Application extends BaseModel { return $this->morphMany(EnvironmentVariable::class, 'resourceable') ->where('is_preview', false) - ->orderBy('key', 'asc'); + ->orderByRaw(" + CASE + WHEN LOWER(key) LIKE 'service_%' THEN 1 + WHEN is_required = true AND (value IS NULL OR value = '') THEN 2 + ELSE 3 + END, + LOWER(key) ASC + "); } public function runtime_environment_variables() @@ -738,14 +746,6 @@ class Application extends BaseModel ->where('key', 'not like', 'NIXPACKS_%'); } - public function build_environment_variables() - { - return $this->morphMany(EnvironmentVariable::class, 'resourceable') - ->where('is_preview', false) - ->where('is_build_time', true) - ->where('key', 'not like', 'NIXPACKS_%'); - } - public function nixpacks_environment_variables() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') @@ -757,7 +757,14 @@ class Application extends BaseModel { return $this->morphMany(EnvironmentVariable::class, 'resourceable') ->where('is_preview', true) - ->orderByRaw("LOWER(key) LIKE LOWER('SERVICE%') DESC, LOWER(key) ASC"); + ->orderByRaw(" + CASE + WHEN LOWER(key) LIKE 'service_%' THEN 1 + WHEN is_required = true AND (value IS NULL OR value = '') THEN 2 + ELSE 3 + END, + LOWER(key) ASC + "); } public function runtime_environment_variables_preview() @@ -767,14 +774,6 @@ class Application extends BaseModel ->where('key', 'not like', 'NIXPACKS_%'); } - public function build_environment_variables_preview() - { - return $this->morphMany(EnvironmentVariable::class, 'resourceable') - ->where('is_preview', true) - ->where('is_build_time', true) - ->where('key', 'not like', 'NIXPACKS_%'); - } - public function nixpacks_environment_variables_preview() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') @@ -934,11 +933,11 @@ class Application extends BaseModel public function isConfigurationChanged(bool $save = false) { - $newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->custom_labels); + $newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->custom_labels.$this->settings->use_build_secrets); if ($this->pull_request_id === 0 || $this->pull_request_id === null) { - $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); + $newConfigHash .= json_encode($this->environment_variables()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort()); } else { - $newConfigHash .= json_encode($this->environment_variables_preview->get('value')->sort()); + $newConfigHash .= json_encode($this->environment_variables_preview->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort()); } $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); @@ -1481,14 +1480,14 @@ class Application extends BaseModel $json = collect(json_decode($this->docker_compose_domains)); foreach ($json as $key => $value) { if (str($key)->contains('-')) { - $key = str($key)->replace('-', '_'); + $key = str($key)->replace('-', '_')->replace('.', '_'); } $json->put((string) $key, $value); } $services = collect(data_get($parsedServices, 'services', [])); foreach ($services as $name => $service) { if (str($name)->contains('-')) { - $replacedName = str($name)->replace('-', '_'); + $replacedName = str($name)->replace('-', '_')->replace('.', '_'); $services->put((string) $replacedName, $service); $services->forget((string) $name); } @@ -1572,7 +1571,19 @@ class Application extends BaseModel if (is_null($this->watch_paths)) { return false; } - $watch_paths = collect(explode("\n", $this->watch_paths)); + $watch_paths = collect(explode("\n", $this->watch_paths)) + ->map(function (string $path): string { + return trim($path); + }) + ->filter(function (string $path): bool { + return strlen($path) > 0; + }); + + // If no valid patterns after filtering, don't trigger + if ($watch_paths->isEmpty()) { + return false; + } + $matches = $modified_files->filter(function ($file) use ($watch_paths) { return $watch_paths->contains(function ($glob) use ($file) { return fnmatch($glob, $file); diff --git a/app/Models/ApplicationDeploymentQueue.php b/app/Models/ApplicationDeploymentQueue.php index 2a9bea67a..8df6877ab 100644 --- a/app/Models/ApplicationDeploymentQueue.php +++ b/app/Models/ApplicationDeploymentQueue.php @@ -85,6 +85,47 @@ class ApplicationDeploymentQueue extends Model return str($this->commit_message)->value(); } + private function redactSensitiveInfo($text) + { + $text = remove_iip($text); + + $app = $this->application; + if (! $app) { + return $text; + } + + $lockedVars = collect([]); + + if ($app->environment_variables) { + $lockedVars = $lockedVars->merge( + $app->environment_variables + ->where('is_shown_once', true) + ->pluck('real_value', 'key') + ->filter() + ); + } + + if ($this->pull_request_id !== 0 && $app->environment_variables_preview) { + $lockedVars = $lockedVars->merge( + $app->environment_variables_preview + ->where('is_shown_once', true) + ->pluck('real_value', 'key') + ->filter() + ); + } + + foreach ($lockedVars as $key => $value) { + $escapedValue = preg_quote($value, '/'); + $text = preg_replace( + '/'.$escapedValue.'/', + REDACTED, + $text + ); + } + + return $text; + } + public function addLogEntry(string $message, string $type = 'stdout', bool $hidden = false) { if ($type === 'error') { @@ -96,7 +137,7 @@ class ApplicationDeploymentQueue extends Model } $newLogEntry = [ 'command' => null, - 'output' => remove_iip($message), + 'output' => $this->redactSensitiveInfo($message), 'type' => $type, 'timestamp' => Carbon::now('UTC'), 'hidden' => $hidden, diff --git a/app/Models/ApplicationSetting.php b/app/Models/ApplicationSetting.php index d05081d21..4b03c69e1 100644 --- a/app/Models/ApplicationSetting.php +++ b/app/Models/ApplicationSetting.php @@ -13,6 +13,7 @@ class ApplicationSetting extends Model 'is_force_https_enabled' => 'boolean', 'is_debug_enabled' => 'boolean', 'is_preview_deployments_enabled' => 'boolean', + 'is_pr_deployments_public_enabled' => 'boolean', 'is_git_submodules_enabled' => 'boolean', 'is_git_lfs_enabled' => 'boolean', 'is_git_shallow_clone_enabled' => 'boolean', diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index b8bde5c84..80399a16b 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -14,10 +14,11 @@ use OpenApi\Attributes as OA; 'uuid' => ['type' => 'string'], 'resourceable_type' => ['type' => 'string'], 'resourceable_id' => ['type' => 'integer'], - 'is_build_time' => ['type' => 'boolean'], 'is_literal' => ['type' => 'boolean'], 'is_multiline' => ['type' => 'boolean'], 'is_preview' => ['type' => 'boolean'], + 'is_runtime' => ['type' => 'boolean'], + 'is_buildtime' => ['type' => 'boolean'], 'is_shared' => ['type' => 'boolean'], 'is_shown_once' => ['type' => 'boolean'], 'key' => ['type' => 'string'], @@ -35,15 +36,16 @@ class EnvironmentVariable extends BaseModel protected $casts = [ 'key' => 'string', 'value' => 'encrypted', - 'is_build_time' => 'boolean', 'is_multiline' => 'boolean', 'is_preview' => 'boolean', + 'is_runtime' => 'boolean', + 'is_buildtime' => 'boolean', 'version' => 'string', 'resourceable_type' => 'string', 'resourceable_id' => 'integer', ]; - protected $appends = ['real_value', 'is_shared', 'is_really_required']; + protected $appends = ['real_value', 'is_shared', 'is_really_required', 'is_nixpacks', 'is_coolify']; protected static function booted() { @@ -61,8 +63,8 @@ class EnvironmentVariable extends BaseModel ModelsEnvironmentVariable::create([ 'key' => $environment_variable->key, 'value' => $environment_variable->value, - 'is_build_time' => $environment_variable->is_build_time, 'is_multiline' => $environment_variable->is_multiline ?? false, + 'is_literal' => $environment_variable->is_literal ?? false, 'resourceable_type' => Application::class, 'resourceable_id' => $environment_variable->resourceable_id, 'is_preview' => true, @@ -137,6 +139,32 @@ class EnvironmentVariable extends BaseModel ); } + protected function isNixpacks(): Attribute + { + return Attribute::make( + get: function () { + if (str($this->key)->startsWith('NIXPACKS_')) { + return true; + } + + return false; + } + ); + } + + protected function isCoolify(): Attribute + { + return Attribute::make( + get: function () { + if (str($this->key)->startsWith('SERVICE_')) { + return true; + } + + return false; + } + ); + } + protected function isShared(): Attribute { return Attribute::make( diff --git a/app/Models/Kubernetes.php b/app/Models/Kubernetes.php deleted file mode 100644 index 174cb5bc8..000000000 --- a/app/Models/Kubernetes.php +++ /dev/null @@ -1,5 +0,0 @@ -<?php - -namespace App\Models; - -class Kubernetes extends BaseModel {} diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php index c56cd7694..b3e71d75d 100644 --- a/app/Models/LocalFileVolume.php +++ b/app/Models/LocalFileVolume.php @@ -119,6 +119,7 @@ class LocalFileVolume extends BaseModel $commands = collect([]); if ($this->is_directory) { $commands->push("mkdir -p $this->fs_path > /dev/null 2>&1 || true"); + $commands->push("mkdir -p $workdir > /dev/null 2>&1 || true"); $commands->push("cd $workdir"); } if (str($this->fs_path)->startsWith('.') || str($this->fs_path)->startsWith('/') || str($this->fs_path)->startsWith('~')) { diff --git a/app/Models/PrivateKey.php b/app/Models/PrivateKey.php index f70f32bc4..c210f3c5b 100644 --- a/app/Models/PrivateKey.php +++ b/app/Models/PrivateKey.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Traits\HasSafeStringAttribute; use DanHarrin\LivewireRateLimiting\WithRateLimiting; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; use Illuminate\Validation\ValidationException; use OpenApi\Attributes as OA; @@ -99,11 +100,18 @@ class PrivateKey extends BaseModel public static function createAndStore(array $data) { - $privateKey = new self($data); - $privateKey->save(); - $privateKey->storeInFileSystem(); + return DB::transaction(function () use ($data) { + $privateKey = new self($data); + $privateKey->save(); - return $privateKey; + try { + $privateKey->storeInFileSystem(); + } catch (\Exception $e) { + throw new \Exception('Failed to store SSH key: '.$e->getMessage()); + } + + return $privateKey; + }); } public static function generateNewKeyPair($type = 'rsa') @@ -151,15 +159,64 @@ class PrivateKey extends BaseModel public function storeInFileSystem() { $filename = "ssh_key@{$this->uuid}"; - Storage::disk('ssh-keys')->put($filename, $this->private_key); + $disk = Storage::disk('ssh-keys'); - return "/var/www/html/storage/app/ssh/keys/{$filename}"; + // Ensure the storage directory exists and is writable + $this->ensureStorageDirectoryExists(); + + // Attempt to store the private key + $success = $disk->put($filename, $this->private_key); + + if (! $success) { + throw new \Exception("Failed to write SSH key to filesystem. Check disk space and permissions for: {$this->getKeyLocation()}"); + } + + // Verify the file was actually created and has content + if (! $disk->exists($filename)) { + throw new \Exception("SSH key file was not created: {$this->getKeyLocation()}"); + } + + $storedContent = $disk->get($filename); + if (empty($storedContent) || $storedContent !== $this->private_key) { + $disk->delete($filename); // Clean up the bad file + throw new \Exception("SSH key file content verification failed: {$this->getKeyLocation()}"); + } + + return $this->getKeyLocation(); } public static function deleteFromStorage(self $privateKey) { $filename = "ssh_key@{$privateKey->uuid}"; - Storage::disk('ssh-keys')->delete($filename); + $disk = Storage::disk('ssh-keys'); + + if ($disk->exists($filename)) { + $disk->delete($filename); + } + } + + protected function ensureStorageDirectoryExists() + { + $disk = Storage::disk('ssh-keys'); + $directoryPath = ''; + + if (! $disk->exists($directoryPath)) { + $success = $disk->makeDirectory($directoryPath); + if (! $success) { + throw new \Exception('Failed to create SSH keys storage directory'); + } + } + + // Check if directory is writable by attempting a test file + $testFilename = '.test_write_'.uniqid(); + $testSuccess = $disk->put($testFilename, 'test'); + + if (! $testSuccess) { + throw new \Exception('SSH keys storage directory is not writable'); + } + + // Clean up test file + $disk->delete($testFilename); } public function getKeyLocation() @@ -169,10 +226,17 @@ class PrivateKey extends BaseModel public function updatePrivateKey(array $data) { - $this->update($data); - $this->storeInFileSystem(); + return DB::transaction(function () use ($data) { + $this->update($data); - return $this; + try { + $this->storeInFileSystem(); + } catch (\Exception $e) { + throw new \Exception('Failed to update SSH key: '.$e->getMessage()); + } + + return $this; + }); } public function servers() diff --git a/app/Models/Server.php b/app/Models/Server.php index 0f92bd390..829a4b5aa 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -13,6 +13,7 @@ use App\Jobs\RegenerateSslCertJob; use App\Notifications\Server\Reachable; use App\Notifications\Server\Unreachable; use App\Services\ConfigurationRepository; +use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -55,7 +56,7 @@ use Visus\Cuid2\Cuid2; class Server extends BaseModel { - use HasFactory, SchemalessAttributesTrait, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, SchemalessAttributesTrait, SoftDeletes; public static $batch_counter = 0; @@ -1259,13 +1260,13 @@ $schema://$host { return str($this->ip)->contains(':'); } - public function restartSentinel(bool $async = true) + public function restartSentinel(?string $customImage = null, bool $async = true) { try { if ($async) { - StartSentinel::dispatch($this, true); + StartSentinel::dispatch($this, true, null, $customImage); } else { - StartSentinel::run($this, true); + StartSentinel::run($this, true, null, $customImage); } } catch (\Throwable $e) { return handleError($e); diff --git a/app/Models/Service.php b/app/Models/Service.php index 43cb32d85..d42d471c6 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Enums\ProcessStatus; +use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -41,7 +42,7 @@ use Visus\Cuid2\Cuid2; )] class Service extends BaseModel { - use HasFactory, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; private static $parserVersion = '5'; @@ -1113,7 +1114,6 @@ class Service extends BaseModel $this->environment_variables()->create([ 'key' => $key, 'value' => $value, - 'is_build_time' => false, 'resourceable_id' => $this->id, 'resourceable_type' => $this->getMorphClass(), 'is_preview' => false, @@ -1230,14 +1230,14 @@ class Service extends BaseModel public function environment_variables() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') - ->orderBy('key', 'asc'); - } - - public function environment_variables_preview() - { - return $this->morphMany(EnvironmentVariable::class, 'resourceable') - ->where('is_preview', true) - ->orderByRaw("LOWER(key) LIKE LOWER('SERVICE%') DESC, LOWER(key) ASC"); + ->orderByRaw(" + CASE + WHEN LOWER(key) LIKE 'service_%' THEN 1 + WHEN is_required = true AND (value IS NULL OR value = '') THEN 2 + ELSE 3 + END, + LOWER(key) ASC + "); } public function workdir() diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index 60a750a99..146ee0a2d 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneClickhouse extends BaseModel { - use HasFactory, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; protected $guarded = []; @@ -28,7 +29,6 @@ class StandaloneClickhouse extends BaseModel 'host_path' => null, 'resource_id' => $database->id, 'resource_type' => $database->getMorphClass(), - 'is_readonly' => true, ]); }); static::forceDeleting(function ($database) { @@ -44,6 +44,11 @@ class StandaloneClickhouse extends BaseModel }); } + public static function ownedByCurrentTeam() + { + return StandaloneClickhouse::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); + } + protected function serverStatus(): Attribute { return Attribute::make( @@ -267,7 +272,14 @@ class StandaloneClickhouse extends BaseModel public function environment_variables() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') - ->orderBy('key', 'asc'); + ->orderByRaw(" + CASE + WHEN LOWER(key) LIKE 'service_%' THEN 1 + WHEN is_required = true AND (value IS NULL OR value = '') THEN 2 + ELSE 3 + END, + LOWER(key) ASC + "); } public function runtime_environment_variables() diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php index 673851713..90e7304f1 100644 --- a/app/Models/StandaloneDragonfly.php +++ b/app/Models/StandaloneDragonfly.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneDragonfly extends BaseModel { - use HasFactory, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; protected $guarded = []; @@ -28,7 +29,6 @@ class StandaloneDragonfly extends BaseModel 'host_path' => null, 'resource_id' => $database->id, 'resource_type' => $database->getMorphClass(), - 'is_readonly' => true, ]); }); static::forceDeleting(function ($database) { @@ -44,6 +44,11 @@ class StandaloneDragonfly extends BaseModel }); } + public static function ownedByCurrentTeam() + { + return StandaloneDragonfly::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); + } + protected function serverStatus(): Attribute { return Attribute::make( @@ -342,6 +347,13 @@ class StandaloneDragonfly extends BaseModel public function environment_variables() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') - ->orderBy('key', 'asc'); + ->orderByRaw(" + CASE + WHEN LOWER(key) LIKE 'service_%' THEN 1 + WHEN is_required = true AND (value IS NULL OR value = '') THEN 2 + ELSE 3 + END, + LOWER(key) ASC + "); } } diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php index e6562193b..ad0cabf7e 100644 --- a/app/Models/StandaloneKeydb.php +++ b/app/Models/StandaloneKeydb.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneKeydb extends BaseModel { - use HasFactory, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; protected $guarded = []; @@ -28,7 +29,6 @@ class StandaloneKeydb extends BaseModel 'host_path' => null, 'resource_id' => $database->id, 'resource_type' => $database->getMorphClass(), - 'is_readonly' => true, ]); }); static::forceDeleting(function ($database) { @@ -44,6 +44,11 @@ class StandaloneKeydb extends BaseModel }); } + public static function ownedByCurrentTeam() + { + return StandaloneKeydb::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); + } + protected function serverStatus(): Attribute { return Attribute::make( @@ -342,6 +347,13 @@ class StandaloneKeydb extends BaseModel public function environment_variables() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') - ->orderBy('key', 'asc'); + ->orderByRaw(" + CASE + WHEN LOWER(key) LIKE 'service_%' THEN 1 + WHEN is_required = true AND (value IS NULL OR value = '') THEN 2 + ELSE 3 + END, + LOWER(key) ASC + "); } } diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php index 1aa9d63c1..3d9e38147 100644 --- a/app/Models/StandaloneMariadb.php +++ b/app/Models/StandaloneMariadb.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -10,7 +11,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMariadb extends BaseModel { - use HasFactory, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; protected $guarded = []; @@ -29,7 +30,6 @@ class StandaloneMariadb extends BaseModel 'host_path' => null, 'resource_id' => $database->id, 'resource_type' => $database->getMorphClass(), - 'is_readonly' => true, ]); }); static::forceDeleting(function ($database) { @@ -45,6 +45,11 @@ class StandaloneMariadb extends BaseModel }); } + public static function ownedByCurrentTeam() + { + return StandaloneMariadb::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); + } + protected function serverStatus(): Attribute { return Attribute::make( @@ -263,7 +268,14 @@ class StandaloneMariadb extends BaseModel public function environment_variables() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') - ->orderBy('key', 'asc'); + ->orderByRaw(" + CASE + WHEN LOWER(key) LIKE 'service_%' THEN 1 + WHEN is_required = true AND (value IS NULL OR value = '') THEN 2 + ELSE 3 + END, + LOWER(key) ASC + "); } public function runtime_environment_variables() diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php index 299ea75b2..7cccd332a 100644 --- a/app/Models/StandaloneMongodb.php +++ b/app/Models/StandaloneMongodb.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMongodb extends BaseModel { - use HasFactory, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; protected $guarded = []; @@ -24,7 +25,6 @@ class StandaloneMongodb extends BaseModel 'host_path' => null, 'resource_id' => $database->id, 'resource_type' => $database->getMorphClass(), - 'is_readonly' => true, ]); LocalPersistentVolume::create([ 'name' => 'mongodb-db-'.$database->uuid, @@ -32,7 +32,6 @@ class StandaloneMongodb extends BaseModel 'host_path' => null, 'resource_id' => $database->id, 'resource_type' => $database->getMorphClass(), - 'is_readonly' => true, ]); }); static::forceDeleting(function ($database) { @@ -48,6 +47,11 @@ class StandaloneMongodb extends BaseModel }); } + public static function ownedByCurrentTeam() + { + return StandaloneMongodb::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); + } + protected function serverStatus(): Attribute { return Attribute::make( @@ -365,6 +369,13 @@ class StandaloneMongodb extends BaseModel public function environment_variables() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') - ->orderBy('key', 'asc'); + ->orderByRaw(" + CASE + WHEN LOWER(key) LIKE 'service_%' THEN 1 + WHEN is_required = true AND (value IS NULL OR value = '') THEN 2 + ELSE 3 + END, + LOWER(key) ASC + "); } } diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php index f376c7644..80269972f 100644 --- a/app/Models/StandaloneMysql.php +++ b/app/Models/StandaloneMysql.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMysql extends BaseModel { - use HasFactory, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; protected $guarded = []; @@ -29,7 +30,6 @@ class StandaloneMysql extends BaseModel 'host_path' => null, 'resource_id' => $database->id, 'resource_type' => $database->getMorphClass(), - 'is_readonly' => true, ]); }); static::forceDeleting(function ($database) { @@ -45,6 +45,11 @@ class StandaloneMysql extends BaseModel }); } + public static function ownedByCurrentTeam() + { + return StandaloneMysql::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); + } + protected function serverStatus(): Attribute { return Attribute::make( @@ -346,6 +351,13 @@ class StandaloneMysql extends BaseModel public function environment_variables() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') - ->orderBy('key', 'asc'); + ->orderByRaw(" + CASE + WHEN LOWER(key) LIKE 'service_%' THEN 1 + WHEN is_required = true AND (value IS NULL OR value = '') THEN 2 + ELSE 3 + END, + LOWER(key) ASC + "); } } diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php index 0bca2f4a7..acde7a20c 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandalonePostgresql extends BaseModel { - use HasFactory, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; protected $guarded = []; @@ -29,7 +30,6 @@ class StandalonePostgresql extends BaseModel 'host_path' => null, 'resource_id' => $database->id, 'resource_type' => $database->getMorphClass(), - 'is_readonly' => true, ]); }); static::forceDeleting(function ($database) { @@ -45,6 +45,11 @@ class StandalonePostgresql extends BaseModel }); } + public static function ownedByCurrentTeam() + { + return StandalonePostgresql::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); + } + public function workdir() { return database_configuration_dir()."/{$this->uuid}"; @@ -297,7 +302,14 @@ class StandalonePostgresql extends BaseModel public function environment_variables() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') - ->orderBy('key', 'asc'); + ->orderByRaw(" + CASE + WHEN LOWER(key) LIKE 'service_%' THEN 1 + WHEN is_required = true AND (value IS NULL OR value = '') THEN 2 + ELSE 3 + END, + LOWER(key) ASC + "); } public function isBackupSolutionAvailable() diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php index 6a44ee714..001ebe36a 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneRedis extends BaseModel { - use HasFactory, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; protected $guarded = []; @@ -24,7 +25,6 @@ class StandaloneRedis extends BaseModel 'host_path' => null, 'resource_id' => $database->id, 'resource_type' => $database->getMorphClass(), - 'is_readonly' => true, ]); }); static::forceDeleting(function ($database) { @@ -46,6 +46,11 @@ class StandaloneRedis extends BaseModel }); } + public static function ownedByCurrentTeam() + { + return StandaloneRedis::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); + } + protected function serverStatus(): Attribute { return Attribute::make( @@ -389,6 +394,13 @@ class StandaloneRedis extends BaseModel public function environment_variables() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') - ->orderBy('key', 'asc'); + ->orderByRaw(" + CASE + WHEN LOWER(key) LIKE 'service_%' THEN 1 + WHEN is_required = true AND (value IS NULL OR value = '') THEN 2 + ELSE 3 + END, + LOWER(key) ASC + "); } } diff --git a/app/Models/User.php b/app/Models/User.php index 48651d292..9ab9fefe9 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -56,6 +56,22 @@ class User extends Authenticatable implements SendsEmail 'email_change_code_expires_at' => 'datetime', ]; + /** + * Set the email attribute to lowercase. + */ + public function setEmailAttribute($value) + { + $this->attributes['email'] = strtolower($value); + } + + /** + * Set the pending_email attribute to lowercase. + */ + public function setPendingEmailAttribute($value) + { + $this->attributes['pending_email'] = $value ? strtolower($value) : null; + } + protected static function boot() { parent::boot(); diff --git a/app/Models/Webhook.php b/app/Models/Webhook.php deleted file mode 100644 index 8e2b62955..000000000 --- a/app/Models/Webhook.php +++ /dev/null @@ -1,15 +0,0 @@ -<?php - -namespace App\Models; - -use Illuminate\Database\Eloquent\Model; - -class Webhook extends Model -{ - protected $guarded = []; - - protected $casts = [ - 'type' => 'string', - 'payload' => 'encrypted', - ]; -} diff --git a/app/Notifications/Channels/EmailChannel.php b/app/Notifications/Channels/EmailChannel.php index 47994c690..245bd85f0 100644 --- a/app/Notifications/Channels/EmailChannel.php +++ b/app/Notifications/Channels/EmailChannel.php @@ -2,6 +2,7 @@ namespace App\Notifications\Channels; +use App\Exceptions\NonReportableException; use App\Models\Team; use Exception; use Illuminate\Notifications\Notification; @@ -101,13 +102,11 @@ class EmailChannel $mailer->send($email); } } catch (\Throwable $e) { - \Illuminate\Support\Facades\Log::error('EmailChannel failed: '.$e->getMessage(), [ - 'notification' => get_class($notification), - 'notifiable' => get_class($notifiable), - 'team_id' => data_get($notifiable, 'id'), - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), - ]); + // Check if this is a Resend domain verification error on cloud instances + if (isCloud() && str_contains($e->getMessage(), 'domain is not verified')) { + // Throw as NonReportableException so it won't go to Sentry + throw NonReportableException::fromException($e); + } throw $e; } } diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index ed27a158a..30d909388 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -80,9 +80,23 @@ class FortifyServiceProvider extends ServiceProvider ) { $user->updated_at = now(); $user->save(); - $user->currentTeam = $user->teams->firstWhere('personal_team', true); - if (! $user->currentTeam) { - $user->currentTeam = $user->recreate_personal_team(); + + // Check if user has a pending invitation they haven't accepted yet + $invitation = \App\Models\TeamInvitation::whereEmail($email)->first(); + if ($invitation && $invitation->isValid()) { + // User is logging in for the first time after being invited + // Attach them to the invited team if not already attached + if (! $user->teams()->where('team_id', $invitation->team->id)->exists()) { + $user->teams()->attach($invitation->team->id, ['role' => $invitation->role]); + } + $user->currentTeam = $invitation->team; + $invitation->delete(); + } else { + // Normal login - use personal team + $user->currentTeam = $user->teams->firstWhere('personal_team', true); + if (! $user->currentTeam) { + $user->currentTeam = $user->recreate_personal_team(); + } } session(['currentTeam' => $user->currentTeam]); diff --git a/app/Services/ConfigurationGenerator.php b/app/Services/ConfigurationGenerator.php index a7e4b31be..320e3f32a 100644 --- a/app/Services/ConfigurationGenerator.php +++ b/app/Services/ConfigurationGenerator.php @@ -129,7 +129,6 @@ class ConfigurationGenerator $variables->push([ 'key' => $env->key, 'value' => $env->value, - 'is_build_time' => $env->is_build_time, 'is_preview' => $env->is_preview, 'is_multiline' => $env->is_multiline, ]); @@ -145,7 +144,6 @@ class ConfigurationGenerator $variables->push([ 'key' => $env->key, 'value' => $env->value, - 'is_build_time' => $env->is_build_time, 'is_preview' => $env->is_preview, 'is_multiline' => $env->is_multiline, ]); diff --git a/app/Traits/ClearsGlobalSearchCache.php b/app/Traits/ClearsGlobalSearchCache.php new file mode 100644 index 000000000..0bcc5d319 --- /dev/null +++ b/app/Traits/ClearsGlobalSearchCache.php @@ -0,0 +1,81 @@ +<?php + +namespace App\Traits; + +use App\Livewire\GlobalSearch; + +trait ClearsGlobalSearchCache +{ + protected static function bootClearsGlobalSearchCache() + { + static::saving(function ($model) { + // Only clear cache if searchable fields are being changed + if ($model->hasSearchableChanges()) { + $teamId = $model->getTeamIdForCache(); + if (filled($teamId)) { + GlobalSearch::clearTeamCache($teamId); + } + } + }); + + static::created(function ($model) { + // Always clear cache when model is created + $teamId = $model->getTeamIdForCache(); + if (filled($teamId)) { + GlobalSearch::clearTeamCache($teamId); + } + }); + + static::deleted(function ($model) { + // Always clear cache when model is deleted + $teamId = $model->getTeamIdForCache(); + if (filled($teamId)) { + GlobalSearch::clearTeamCache($teamId); + } + }); + } + + private function hasSearchableChanges(): bool + { + // Define searchable fields based on model type + $searchableFields = ['name', 'description']; + + // Add model-specific searchable fields + if ($this instanceof \App\Models\Application) { + $searchableFields[] = 'fqdn'; + $searchableFields[] = 'docker_compose_domains'; + } elseif ($this instanceof \App\Models\Server) { + $searchableFields[] = 'ip'; + } elseif ($this instanceof \App\Models\Service) { + // Services don't have direct fqdn, but name and description are covered + } + // Database models only have name and description as searchable + + // Check if any searchable field is dirty + foreach ($searchableFields as $field) { + if ($this->isDirty($field)) { + return true; + } + } + + return false; + } + + private function getTeamIdForCache() + { + // For database models, team is accessed through environment.project.team + if (method_exists($this, 'team')) { + $team = $this->team(); + if (filled($team)) { + return is_object($team) ? $team->id : null; + } + } + + // For models with direct team_id property + if (property_exists($this, 'team_id') || isset($this->team_id)) { + return $this->team_id; + } + + return null; + } +} diff --git a/app/Traits/EnvironmentVariableProtection.php b/app/Traits/EnvironmentVariableProtection.php index b6b8d2687..ecc484966 100644 --- a/app/Traits/EnvironmentVariableProtection.php +++ b/app/Traits/EnvironmentVariableProtection.php @@ -14,7 +14,7 @@ trait EnvironmentVariableProtection */ protected function isProtectedEnvironmentVariable(string $key): bool { - return str($key)->startsWith('SERVICE_FQDN') || str($key)->startsWith('SERVICE_URL'); + return str($key)->startsWith('SERVICE_FQDN_') || str($key)->startsWith('SERVICE_URL_') || str($key)->startsWith('SERVICE_NAME_'); } /** diff --git a/app/Traits/ExecuteRemoteCommand.php b/app/Traits/ExecuteRemoteCommand.php index a228a5d10..f9df19c16 100644 --- a/app/Traits/ExecuteRemoteCommand.php +++ b/app/Traits/ExecuteRemoteCommand.php @@ -11,10 +11,52 @@ use Illuminate\Support\Facades\Process; trait ExecuteRemoteCommand { + use SshRetryable; + public ?string $save = null; public static int $batch_counter = 0; + private function redact_sensitive_info($text) + { + $text = remove_iip($text); + + if (! isset($this->application)) { + return $text; + } + + $lockedVars = collect([]); + + if (isset($this->application->environment_variables)) { + $lockedVars = $lockedVars->merge( + $this->application->environment_variables + ->where('is_shown_once', true) + ->pluck('real_value', 'key') + ->filter() + ); + } + + if (isset($this->pull_request_id) && $this->pull_request_id !== 0 && isset($this->application->environment_variables_preview)) { + $lockedVars = $lockedVars->merge( + $this->application->environment_variables_preview + ->where('is_shown_once', true) + ->pluck('real_value', 'key') + ->filter() + ); + } + + foreach ($lockedVars as $key => $value) { + $escapedValue = preg_quote($value, '/'); + $text = preg_replace( + '/'.$escapedValue.'/', + REDACTED, + $text + ); + } + + return $text; + } + public function execute_remote_command(...$commands) { static::$batch_counter++; @@ -43,76 +85,188 @@ trait ExecuteRemoteCommand $command = parseLineForSudo($command, $this->server); } } - $remote_command = SshMultiplexingHelper::generateSshCommand($this->server, $command); - $process = Process::timeout(3600)->idleTimeout(3600)->start($remote_command, function (string $type, string $output) use ($command, $hidden, $customType, $append) { - $output = str($output)->trim(); - if ($output->startsWith('╔')) { - $output = "\n".$output; + + // Check for cancellation before executing commands + if (isset($this->application_deployment_queue)) { + $this->application_deployment_queue->refresh(); + if ($this->application_deployment_queue->status === \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value) { + throw new \RuntimeException('Deployment cancelled by user', 69420); } + } - // Sanitize output to ensure valid UTF-8 encoding before JSON encoding - $sanitized_output = sanitize_utf8_text($output); - - $new_log_entry = [ - 'command' => remove_iip($command), - 'output' => remove_iip($sanitized_output), - 'type' => $customType ?? $type === 'err' ? 'stderr' : 'stdout', - 'timestamp' => Carbon::now('UTC'), - 'hidden' => $hidden, - 'batch' => static::$batch_counter, - ]; - if (! $this->application_deployment_queue->logs) { - $new_log_entry['order'] = 1; - } else { - try { - $previous_logs = json_decode($this->application_deployment_queue->logs, associative: true, flags: JSON_THROW_ON_ERROR); - } catch (\JsonException $e) { - // If existing logs are corrupted, start fresh - $previous_logs = []; - $new_log_entry['order'] = 1; - } - if (is_array($previous_logs)) { - $new_log_entry['order'] = count($previous_logs) + 1; - } else { - $previous_logs = []; - $new_log_entry['order'] = 1; - } - } - $previous_logs[] = $new_log_entry; + $maxRetries = config('constants.ssh.max_retries'); + $attempt = 0; + $lastError = null; + $commandExecuted = false; + while ($attempt < $maxRetries && ! $commandExecuted) { try { - $this->application_deployment_queue->logs = json_encode($previous_logs, flags: JSON_THROW_ON_ERROR); - } catch (\JsonException $e) { - // If JSON encoding still fails, use fallback with invalid sequences replacement - $this->application_deployment_queue->logs = json_encode($previous_logs, flags: JSON_INVALID_UTF8_SUBSTITUTE); - } + $this->executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors); + $commandExecuted = true; + } catch (\RuntimeException $e) { + $lastError = $e; + $errorMessage = $e->getMessage(); + // Only retry if it's an SSH connection error and we haven't exhausted retries + if ($this->isRetryableSshError($errorMessage) && $attempt < $maxRetries - 1) { + $attempt++; + $delay = $this->calculateRetryDelay($attempt - 1); - $this->application_deployment_queue->save(); + // Track SSH retry event in Sentry + $this->trackSshRetryEvent($attempt, $maxRetries, $delay, $errorMessage, [ + 'server' => $this->server->name ?? $this->server->ip ?? 'unknown', + 'command' => $this->redact_sensitive_info($command), + 'trait' => 'ExecuteRemoteCommand', + ]); - if ($this->save) { - if (data_get($this->saved_outputs, $this->save, null) === null) { - data_set($this->saved_outputs, $this->save, str()); - } - if ($append) { - $this->saved_outputs[$this->save] .= str($sanitized_output)->trim(); - $this->saved_outputs[$this->save] = str($this->saved_outputs[$this->save]); + // Add log entry for the retry + if (isset($this->application_deployment_queue)) { + $this->addRetryLogEntry($attempt, $maxRetries, $delay, $errorMessage); + + // Check for cancellation during retry wait + $this->application_deployment_queue->refresh(); + if ($this->application_deployment_queue->status === \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value) { + throw new \RuntimeException('Deployment cancelled by user during retry', 69420); + } + } + + sleep($delay); } else { - $this->saved_outputs[$this->save] = str($sanitized_output)->trim(); + // Not retryable or max retries reached + throw $e; } } - }); - $this->application_deployment_queue->update([ - 'current_process_id' => $process->id(), - ]); + } - $process_result = $process->wait(); - if ($process_result->exitCode() !== 0) { - if (! $ignore_errors) { + // If we exhausted all retries and still failed + if (! $commandExecuted && $lastError) { + // Now we can set the status to FAILED since all retries have been exhausted + if (isset($this->application_deployment_queue)) { $this->application_deployment_queue->status = ApplicationDeploymentStatus::FAILED->value; $this->application_deployment_queue->save(); - throw new \RuntimeException($process_result->errorOutput()); } + throw $lastError; } }); } + + /** + * Execute the actual command with process handling + */ + private function executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors) + { + $remote_command = SshMultiplexingHelper::generateSshCommand($this->server, $command); + $process = Process::timeout(3600)->idleTimeout(3600)->start($remote_command, function (string $type, string $output) use ($command, $hidden, $customType, $append) { + $output = str($output)->trim(); + if ($output->startsWith('╔')) { + $output = "\n".$output; + } + + // Sanitize output to ensure valid UTF-8 encoding before JSON encoding + $sanitized_output = sanitize_utf8_text($output); + + $new_log_entry = [ + 'command' => $this->redact_sensitive_info($command), + 'output' => $this->redact_sensitive_info($sanitized_output), + 'type' => $customType ?? $type === 'err' ? 'stderr' : 'stdout', + 'timestamp' => Carbon::now('UTC'), + 'hidden' => $hidden, + 'batch' => static::$batch_counter, + ]; + if (! $this->application_deployment_queue->logs) { + $new_log_entry['order'] = 1; + } else { + try { + $previous_logs = json_decode($this->application_deployment_queue->logs, associative: true, flags: JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + // If existing logs are corrupted, start fresh + $previous_logs = []; + $new_log_entry['order'] = 1; + } + if (is_array($previous_logs)) { + $new_log_entry['order'] = count($previous_logs) + 1; + } else { + $previous_logs = []; + $new_log_entry['order'] = 1; + } + } + $previous_logs[] = $new_log_entry; + + try { + $this->application_deployment_queue->logs = json_encode($previous_logs, flags: JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + // If JSON encoding still fails, use fallback with invalid sequences replacement + $this->application_deployment_queue->logs = json_encode($previous_logs, flags: JSON_INVALID_UTF8_SUBSTITUTE); + } + + $this->application_deployment_queue->save(); + + if ($this->save) { + if (data_get($this->saved_outputs, $this->save, null) === null) { + data_set($this->saved_outputs, $this->save, str()); + } + if ($append) { + $this->saved_outputs[$this->save] .= str($sanitized_output)->trim(); + $this->saved_outputs[$this->save] = str($this->saved_outputs[$this->save]); + } else { + $this->saved_outputs[$this->save] = str($sanitized_output)->trim(); + } + } + }); + $this->application_deployment_queue->update([ + 'current_process_id' => $process->id(), + ]); + + $process_result = $process->wait(); + if ($process_result->exitCode() !== 0) { + if (! $ignore_errors) { + // Don't immediately set to FAILED - let the retry logic handle it + // This prevents premature status changes during retryable SSH errors + throw new \RuntimeException($process_result->errorOutput()); + } + } + } + + /** + * Add a log entry for SSH retry attempts + */ + private function addRetryLogEntry(int $attempt, int $maxRetries, int $delay, string $errorMessage) + { + $retryMessage = "SSH connection failed. Retrying... (Attempt {$attempt}/{$maxRetries}, waiting {$delay}s)\nError: {$errorMessage}"; + + $new_log_entry = [ + 'output' => $this->redact_sensitive_info($retryMessage), + 'type' => 'stdout', + 'timestamp' => Carbon::now('UTC'), + 'hidden' => false, + 'batch' => static::$batch_counter, + ]; + + if (! $this->application_deployment_queue->logs) { + $new_log_entry['order'] = 1; + $previous_logs = []; + } else { + try { + $previous_logs = json_decode($this->application_deployment_queue->logs, associative: true, flags: JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + $previous_logs = []; + $new_log_entry['order'] = 1; + } + if (is_array($previous_logs)) { + $new_log_entry['order'] = count($previous_logs) + 1; + } else { + $previous_logs = []; + $new_log_entry['order'] = 1; + } + } + + $previous_logs[] = $new_log_entry; + + try { + $this->application_deployment_queue->logs = json_encode($previous_logs, flags: JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + $this->application_deployment_queue->logs = json_encode($previous_logs, flags: JSON_INVALID_UTF8_SUBSTITUTE); + } + + $this->application_deployment_queue->save(); + } } diff --git a/app/Traits/SshRetryable.php b/app/Traits/SshRetryable.php new file mode 100644 index 000000000..a26481056 --- /dev/null +++ b/app/Traits/SshRetryable.php @@ -0,0 +1,174 @@ +<?php + +namespace App\Traits; + +use Illuminate\Support\Facades\Log; + +trait SshRetryable +{ + /** + * Check if an error message indicates a retryable SSH connection error + */ + protected function isRetryableSshError(string $errorOutput): bool + { + $retryablePatterns = [ + 'kex_exchange_identification', + 'Connection reset by peer', + 'Connection refused', + 'Connection timed out', + 'Connection closed by remote host', + 'ssh_exchange_identification', + 'Bad file descriptor', + 'Broken pipe', + 'No route to host', + 'Network is unreachable', + 'Host is down', + 'No buffer space available', + 'Connection reset by', + 'Permission denied, please try again', + 'Received disconnect from', + 'Disconnected from', + 'Connection to .* closed', + 'ssh: connect to host .* port .*: Connection', + 'Lost connection', + 'Timeout, server not responding', + 'Cannot assign requested address', + 'Network is down', + 'Host key verification failed', + 'Operation timed out', + 'Connection closed unexpectedly', + 'Remote host closed connection', + 'Authentication failed', + 'Too many authentication failures', + ]; + + $lowerErrorOutput = strtolower($errorOutput); + foreach ($retryablePatterns as $pattern) { + if (str_contains($lowerErrorOutput, strtolower($pattern))) { + return true; + } + } + + return false; + } + + /** + * Calculate delay for exponential backoff + */ + protected function calculateRetryDelay(int $attempt): int + { + $baseDelay = config('constants.ssh.retry_base_delay'); + $maxDelay = config('constants.ssh.retry_max_delay'); + $multiplier = config('constants.ssh.retry_multiplier'); + + $delay = min($baseDelay * pow($multiplier, $attempt), $maxDelay); + + return (int) $delay; + } + + /** + * Execute a callback with SSH retry logic + * + * @param callable $callback The operation to execute + * @param array $context Context for logging (server, command, etc.) + * @param bool $throwError Whether to throw error on final failure + * @return mixed The result from the callback + */ + protected function executeWithSshRetry(callable $callback, array $context = [], bool $throwError = true) + { + $maxRetries = config('constants.ssh.max_retries'); + $lastError = null; + $lastErrorMessage = ''; + // Randomly fail the command with a key exchange error for testing + // if (random_int(1, 10) === 1) { // 10% chance to fail + // ray('SSH key exchange failed: kex_exchange_identification: read: Connection reset by peer'); + // throw new \RuntimeException('SSH key exchange failed: kex_exchange_identification: read: Connection reset by peer'); + // } + for ($attempt = 0; $attempt < $maxRetries; $attempt++) { + try { + return $callback(); + } catch (\Throwable $e) { + $lastError = $e; + $lastErrorMessage = $e->getMessage(); + + // Check if it's retryable and not the last attempt + if ($this->isRetryableSshError($lastErrorMessage) && $attempt < $maxRetries - 1) { + $delay = $this->calculateRetryDelay($attempt); + + // Track SSH retry event in Sentry + $this->trackSshRetryEvent($attempt + 1, $maxRetries, $delay, $lastErrorMessage, $context); + + // Add deployment log if available (for ExecuteRemoteCommand trait) + if (isset($this->application_deployment_queue) && method_exists($this, 'addRetryLogEntry')) { + $this->addRetryLogEntry($attempt + 1, $maxRetries, $delay, $lastErrorMessage); + } + + sleep($delay); + + continue; + } + + // Not retryable or max retries reached + break; + } + } + + // All retries exhausted + if ($attempt >= $maxRetries) { + Log::error('SSH operation failed after all retries', array_merge($context, [ + 'attempts' => $attempt, + 'error' => $lastErrorMessage, + ])); + } + + if ($throwError && $lastError) { + // If the error message is empty, provide a more meaningful one + if (empty($lastErrorMessage) || trim($lastErrorMessage) === '') { + $contextInfo = isset($context['server']) ? " to server {$context['server']}" : ''; + $attemptInfo = $attempt > 1 ? " after {$attempt} attempts" : ''; + throw new \RuntimeException("SSH connection failed{$contextInfo}{$attemptInfo}", $lastError->getCode()); + } + throw $lastError; + } + + return null; + } + + /** + * Track SSH retry event in Sentry + */ + protected function trackSshRetryEvent(int $attempt, int $maxRetries, int $delay, string $errorMessage, array $context = []): void + { + // Only track in production/cloud instances + if (isDev() || ! config('constants.sentry.sentry_dsn')) { + return; + } + + try { + app('sentry')->captureMessage( + 'SSH connection retry triggered', + \Sentry\Severity::warning(), + [ + 'extra' => [ + 'attempt' => $attempt, + 'max_retries' => $maxRetries, + 'delay_seconds' => $delay, + 'error_message' => $errorMessage, + 'context' => $context, + 'retryable_error' => true, + ], + 'tags' => [ + 'component' => 'ssh_retry', + 'error_type' => 'connection_retry', + ], + ] + ); + } catch (\Throwable $e) { + // Don't let Sentry tracking errors break the SSH retry flow + Log::warning('Failed to track SSH retry event in Sentry', [ + 'error' => $e->getMessage(), + 'original_attempt' => $attempt, + ]); + } + } +} diff --git a/bootstrap/helpers/applications.php b/bootstrap/helpers/applications.php index 919b2bde5..db7767c1e 100644 --- a/bootstrap/helpers/applications.php +++ b/bootstrap/helpers/applications.php @@ -1,12 +1,15 @@ <?php +use App\Actions\Application\StopApplication; use App\Enums\ApplicationDeploymentStatus; use App\Jobs\ApplicationDeploymentJob; +use App\Jobs\VolumeCloneJob; use App\Models\Application; use App\Models\ApplicationDeploymentQueue; use App\Models\Server; use App\Models\StandaloneDocker; use Spatie\Url\Url; +use Visus\Cuid2\Cuid2; function queue_application_deployment(Application $application, string $deployment_uuid, ?int $pull_request_id = 0, string $commit = 'HEAD', bool $force_rebuild = false, bool $is_webhook = false, bool $is_api = false, bool $restart_only = false, ?string $git_type = null, bool $no_questions_asked = false, ?Server $server = null, ?StandaloneDocker $destination = null, bool $only_this_server = false, bool $rollback = false) { @@ -68,7 +71,7 @@ function queue_application_deployment(Application $application, string $deployme ApplicationDeploymentJob::dispatch( application_deployment_queue_id: $deployment->id, ); - } elseif (next_queuable($server_id, $application_id, $commit)) { + } elseif (next_queuable($server_id, $application_id, $commit, $pull_request_id)) { ApplicationDeploymentJob::dispatch( application_deployment_queue_id: $deployment->id, ); @@ -93,32 +96,31 @@ function force_start_deployment(ApplicationDeploymentQueue $deployment) function queue_next_deployment(Application $application) { $server_id = $application->destination->server_id; - $next_found = ApplicationDeploymentQueue::where('server_id', $server_id)->where('status', ApplicationDeploymentStatus::QUEUED)->get()->sortBy('created_at')->first(); - if ($next_found) { - $next_found->update([ - 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value, - ]); + $queued_deployments = ApplicationDeploymentQueue::where('server_id', $server_id) + ->where('status', ApplicationDeploymentStatus::QUEUED) + ->get() + ->sortBy('created_at'); - ApplicationDeploymentJob::dispatch( - application_deployment_queue_id: $next_found->id, - ); + foreach ($queued_deployments as $next_deployment) { + // Check if this queued deployment can actually run + if (next_queuable($next_deployment->server_id, $next_deployment->application_id, $next_deployment->commit, $next_deployment->pull_request_id)) { + $next_deployment->update([ + 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value, + ]); + + ApplicationDeploymentJob::dispatch( + application_deployment_queue_id: $next_deployment->id, + ); + } } } -function next_queuable(string $server_id, string $application_id, string $commit = 'HEAD'): bool +function next_queuable(string $server_id, string $application_id, string $commit = 'HEAD', int $pull_request_id = 0): bool { - // Check if there's already a deployment in progress for this application and commit - $existing_deployment = ApplicationDeploymentQueue::where('application_id', $application_id) - ->where('commit', $commit) - ->where('status', ApplicationDeploymentStatus::IN_PROGRESS->value) - ->first(); - - if ($existing_deployment) { - return false; - } - - // Check if there's any deployment in progress for this application + // Check if there's already a deployment in progress for this application with the same pull_request_id + // This allows normal deployments and PR deployments to run concurrently $in_progress = ApplicationDeploymentQueue::where('application_id', $application_id) + ->where('pull_request_id', $pull_request_id) ->where('status', ApplicationDeploymentStatus::IN_PROGRESS->value) ->exists(); @@ -142,13 +144,15 @@ function next_queuable(string $server_id, string $application_id, string $commit function next_after_cancel(?Server $server = null) { if ($server) { - $next_found = ApplicationDeploymentQueue::where('server_id', data_get($server, 'id'))->where('status', ApplicationDeploymentStatus::QUEUED)->get()->sortBy('created_at'); + $next_found = ApplicationDeploymentQueue::where('server_id', data_get($server, 'id')) + ->where('status', ApplicationDeploymentStatus::QUEUED) + ->get() + ->sortBy('created_at'); + if ($next_found->count() > 0) { foreach ($next_found as $next) { - $server = Server::find($next->server_id); - $concurrent_builds = $server->settings->concurrent_builds; - $inprogress_deployments = ApplicationDeploymentQueue::where('server_id', $next->server_id)->whereIn('status', [ApplicationDeploymentStatus::QUEUED])->get()->sortByDesc('created_at'); - if ($inprogress_deployments->count() < $concurrent_builds) { + // Use next_queuable to properly check if this deployment can run + if (next_queuable($next->server_id, $next->application_id, $next->commit, $next->pull_request_id)) { $next->update([ 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value, ]); @@ -157,8 +161,195 @@ function next_after_cancel(?Server $server = null) application_deployment_queue_id: $next->id, ); } - break; } } } } + +function clone_application(Application $source, $destination, array $overrides = [], bool $cloneVolumeData = false): Application +{ + $uuid = $overrides['uuid'] ?? (string) new Cuid2; + $server = $destination->server; + + // Prepare name and URL + $name = $overrides['name'] ?? 'clone-of-'.str($source->name)->limit(20).'-'.$uuid; + $applicationSettings = $source->settings; + $url = $overrides['fqdn'] ?? $source->fqdn; + + if ($server->proxyType() !== 'NONE' && $applicationSettings->is_container_label_readonly_enabled === true) { + $url = generateUrl(server: $server, random: $uuid); + } + + // Clone the application + $newApplication = $source->replicate([ + 'id', + 'created_at', + 'updated_at', + 'additional_servers_count', + 'additional_networks_count', + ])->fill(array_merge([ + 'uuid' => $uuid, + 'name' => $name, + 'fqdn' => $url, + 'status' => 'exited', + 'destination_id' => $destination->id, + ], $overrides)); + $newApplication->save(); + + // Update custom labels if needed + if ($newApplication->destination->server->proxyType() !== 'NONE' && $applicationSettings->is_container_label_readonly_enabled === true) { + $customLabels = str(implode('|coolify|', generateLabelsApplication($newApplication)))->replace('|coolify|', "\n"); + $newApplication->custom_labels = base64_encode($customLabels); + $newApplication->save(); + } + + // Clone settings + $newApplication->settings()->delete(); + if ($applicationSettings) { + $newApplicationSettings = $applicationSettings->replicate([ + 'id', + 'created_at', + 'updated_at', + ])->fill([ + 'application_id' => $newApplication->id, + ]); + $newApplicationSettings->save(); + } + + // Clone tags + $tags = $source->tags; + foreach ($tags as $tag) { + $newApplication->tags()->attach($tag->id); + } + + // Clone scheduled tasks + $scheduledTasks = $source->scheduled_tasks()->get(); + foreach ($scheduledTasks as $task) { + $newTask = $task->replicate([ + 'id', + 'created_at', + 'updated_at', + ])->fill([ + 'uuid' => (string) new Cuid2, + 'application_id' => $newApplication->id, + 'team_id' => currentTeam()->id, + ]); + $newTask->save(); + } + + // Clone previews with FQDN regeneration + $applicationPreviews = $source->previews()->get(); + foreach ($applicationPreviews as $preview) { + $newPreview = $preview->replicate([ + 'id', + 'created_at', + 'updated_at', + ])->fill([ + 'uuid' => (string) new Cuid2, + 'application_id' => $newApplication->id, + 'status' => 'exited', + 'fqdn' => null, + 'docker_compose_domains' => null, + ]); + $newPreview->save(); + + // Regenerate FQDN for the cloned preview + if ($newApplication->build_pack === 'dockercompose') { + $newPreview->generate_preview_fqdn_compose(); + } else { + $newPreview->generate_preview_fqdn(); + } + } + + // Clone persistent volumes + $persistentVolumes = $source->persistentStorages()->get(); + foreach ($persistentVolumes as $volume) { + $newName = ''; + if (str_starts_with($volume->name, $source->uuid)) { + $newName = str($volume->name)->replace($source->uuid, $newApplication->uuid); + } else { + $newName = $newApplication->uuid.'-'.str($volume->name)->afterLast('-'); + } + + $newPersistentVolume = $volume->replicate([ + 'id', + 'created_at', + 'updated_at', + ])->fill([ + 'name' => $newName, + 'resource_id' => $newApplication->id, + ]); + $newPersistentVolume->save(); + + if ($cloneVolumeData) { + try { + StopApplication::dispatch($source, false, false); + $sourceVolume = $volume->name; + $targetVolume = $newPersistentVolume->name; + $sourceServer = $source->destination->server; + $targetServer = $newApplication->destination->server; + + VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume); + + queue_application_deployment( + deployment_uuid: (string) new Cuid2, + application: $source, + server: $sourceServer, + destination: $source->destination, + no_questions_asked: true + ); + } catch (\Exception $e) { + \Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage()); + } + } + } + + // Clone file storages + $fileStorages = $source->fileStorages()->get(); + foreach ($fileStorages as $storage) { + $newStorage = $storage->replicate([ + 'id', + 'created_at', + 'updated_at', + ])->fill([ + 'resource_id' => $newApplication->id, + ]); + $newStorage->save(); + } + + // Clone production environment variables without triggering the created hook + $environmentVariables = $source->environment_variables()->get(); + foreach ($environmentVariables as $environmentVariable) { + \App\Models\EnvironmentVariable::withoutEvents(function () use ($environmentVariable, $newApplication) { + $newEnvironmentVariable = $environmentVariable->replicate([ + 'id', + 'created_at', + 'updated_at', + ])->fill([ + 'resourceable_id' => $newApplication->id, + 'resourceable_type' => $newApplication->getMorphClass(), + 'is_preview' => false, + ]); + $newEnvironmentVariable->save(); + }); + } + + // Clone preview environment variables + $previewEnvironmentVariables = $source->environment_variables_preview()->get(); + foreach ($previewEnvironmentVariables as $previewEnvironmentVariable) { + \App\Models\EnvironmentVariable::withoutEvents(function () use ($previewEnvironmentVariable, $newApplication) { + $newPreviewEnvironmentVariable = $previewEnvironmentVariable->replicate([ + 'id', + 'created_at', + 'updated_at', + ])->fill([ + 'resourceable_id' => $newApplication->id, + 'resourceable_type' => $newApplication->getMorphClass(), + 'is_preview' => true, + ]); + $newPreviewEnvironmentVariable->save(); + }); + } + + return $newApplication; +} diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index f61abc806..1491e4712 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -1093,11 +1093,11 @@ function getContainerLogs(Server $server, string $container_id, int $lines = 100 { if ($server->isSwarm()) { $output = instant_remote_process([ - "docker service logs -n {$lines} {$container_id}", + "docker service logs -n {$lines} {$container_id} 2>&1", ], $server); } else { $output = instant_remote_process([ - "docker logs -n {$lines} {$container_id}", + "docker logs -n {$lines} {$container_id} 2>&1", ], $server); } @@ -1105,7 +1105,6 @@ function getContainerLogs(Server $server, string $container_id, int $lines = 100 return $output; } - function escapeEnvVariables($value) { $search = ['\\', "\r", "\t", "\x0", '"', "'"]; diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index f7041c3da..d4701d251 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -342,7 +342,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int 'resourceable_id' => $resource->id, ], [ 'value' => $fqdn, - 'is_build_time' => false, 'is_preview' => false, ]); } @@ -355,7 +354,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int 'resourceable_id' => $resource->id, ], [ 'value' => $fqdn, - 'is_build_time' => false, 'is_preview' => false, ]); } @@ -373,7 +371,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $fqdnFor = $key->after('SERVICE_FQDN_')->lower()->value(); $originalFqdnFor = str($fqdnFor)->replace('_', '-'); if (str($fqdnFor)->contains('-')) { - $fqdnFor = str($fqdnFor)->replace('-', '_'); + $fqdnFor = str($fqdnFor)->replace('-', '_')->replace('.', '_'); } // Generated FQDN & URL $fqdn = generateFqdn(server: $server, random: "$originalFqdnFor-$uuid", parserVersion: $resource->compose_parsing_version); @@ -384,7 +382,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int 'resourceable_id' => $resource->id, ], [ 'value' => $fqdn, - 'is_build_time' => false, 'is_preview' => false, ]); if ($resource->build_pack === 'dockercompose') { @@ -409,7 +406,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $urlFor = $key->after('SERVICE_URL_')->lower()->value(); $originalUrlFor = str($urlFor)->replace('_', '-'); if (str($urlFor)->contains('-')) { - $urlFor = str($urlFor)->replace('-', '_'); + $urlFor = str($urlFor)->replace('-', '_')->replace('.', '_'); } $url = generateUrl(server: $server, random: "$originalUrlFor-$uuid"); $resource->environment_variables()->firstOrCreate([ @@ -418,7 +415,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int 'resourceable_id' => $resource->id, ], [ 'value' => $url, - 'is_build_time' => false, 'is_preview' => false, ]); if ($resource->build_pack === 'dockercompose') { @@ -446,7 +442,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int 'resourceable_id' => $resource->id, ], [ 'value' => $value, - 'is_build_time' => false, 'is_preview' => false, ]); } @@ -454,6 +449,12 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int } } + // generate SERVICE_NAME variables for docker compose services + $serviceNameEnvironments = collect([]); + if ($resource->build_pack === 'dockercompose') { + $serviceNameEnvironments = generateDockerComposeServiceName($services, $pullRequestId); + } + // Parse the rest of the services foreach ($services as $serviceName => $service) { $image = data_get_str($service, 'image'); @@ -567,7 +568,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int } $source = replaceLocalSource($source, $mainDirectory); if ($isPullRequest) { - $source = $source."-pr-$pullRequestId"; + $source = addPreviewDeploymentSuffix($source, $pull_request_id); } LocalFileVolume::updateOrCreate( [ @@ -610,7 +611,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $name = "{$uuid}_{$slugWithoutUuid}"; if ($isPullRequest) { - $name = "{$name}-pr-$pullRequestId"; + $name = addPreviewDeploymentSuffix($name, $pull_request_id); } if (is_string($volume)) { $parsed = parseDockerVolumeString($volume); @@ -651,11 +652,11 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $newDependsOn = collect([]); $depends_on->each(function ($dependency, $condition) use ($pullRequestId, $newDependsOn) { if (is_numeric($condition)) { - $dependency = "$dependency-pr-$pullRequestId"; + $dependency = addPreviewDeploymentSuffix($dependency, $pullRequestId); $newDependsOn->put($condition, $dependency); } else { - $condition = "$condition-pr-$pullRequestId"; + $condition = addPreviewDeploymentSuffix($condition, $pullRequestId); $newDependsOn->put($condition, $dependency); } }); @@ -754,7 +755,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int 'resourceable_id' => $resource->id, ], [ 'value' => $value, - 'is_build_time' => false, 'is_preview' => false, ]); @@ -771,7 +771,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int 'resourceable_id' => $resource->id, ], [ 'value' => $value, - 'is_build_time' => false, 'is_preview' => false, ]); } else { @@ -807,7 +806,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ - 'is_build_time' => false, 'is_preview' => false, 'is_required' => $isRequired, ]); @@ -822,7 +820,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int 'resourceable_id' => $resource->id, ], [ 'value' => $value, - 'is_build_time' => false, 'is_preview' => false, 'is_required' => $isRequired, ]); @@ -858,13 +855,13 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int if ($resource->build_pack !== 'dockercompose') { $domains = collect([]); } - $changedServiceName = str($serviceName)->replace('-', '_')->value(); + $changedServiceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value(); $fqdns = data_get($domains, "$changedServiceName.domain"); // Generate SERVICE_FQDN & SERVICE_URL for dockercompose if ($resource->build_pack === 'dockercompose') { foreach ($domains as $forServiceName => $domain) { $parsedDomain = data_get($domain, 'domain'); - $serviceNameFormatted = str($serviceName)->upper()->replace('-', '_'); + $serviceNameFormatted = str($serviceName)->upper()->replace('-', '_')->replace('.', '_'); if (filled($parsedDomain)) { $parsedDomain = str($parsedDomain)->explode(',')->first(); @@ -872,24 +869,22 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $coolifyScheme = $coolifyUrl->getScheme(); $coolifyFqdn = $coolifyUrl->getHost(); $coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null); - $coolifyEnvironments->put('SERVICE_URL_'.str($forServiceName)->upper()->replace('-', '_'), $coolifyUrl->__toString()); - $coolifyEnvironments->put('SERVICE_FQDN_'.str($forServiceName)->upper()->replace('-', '_'), $coolifyFqdn); + $coolifyEnvironments->put('SERVICE_URL_'.str($forServiceName)->upper()->replace('-', '_')->replace('.', '_'), $coolifyUrl->__toString()); + $coolifyEnvironments->put('SERVICE_FQDN_'.str($forServiceName)->upper()->replace('-', '_')->replace('.', '_'), $coolifyFqdn); $resource->environment_variables()->updateOrCreate([ 'resourceable_type' => Application::class, 'resourceable_id' => $resource->id, - 'key' => 'SERVICE_URL_'.str($forServiceName)->upper()->replace('-', '_'), + 'key' => 'SERVICE_URL_'.str($forServiceName)->upper()->replace('-', '_')->replace('.', '_'), ], [ 'value' => $coolifyUrl->__toString(), - 'is_build_time' => false, 'is_preview' => false, ]); $resource->environment_variables()->updateOrCreate([ 'resourceable_type' => Application::class, 'resourceable_id' => $resource->id, - 'key' => 'SERVICE_FQDN_'.str($forServiceName)->upper()->replace('-', '_'), + 'key' => 'SERVICE_FQDN_'.str($forServiceName)->upper()->replace('-', '_')->replace('.', '_'), ], [ 'value' => $coolifyFqdn, - 'is_build_time' => false, 'is_preview' => false, ]); } else { @@ -1082,7 +1077,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $payload['volumes'] = $volumesParsed; } if ($environment->count() > 0 || $coolifyEnvironments->count() > 0) { - $payload['environment'] = $environment->merge($coolifyEnvironments); + $payload['environment'] = $environment->merge($coolifyEnvironments)->merge($serviceNameEnvironments); } if ($logging) { $payload['logging'] = $logging; @@ -1091,7 +1086,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $payload['depends_on'] = $depends_on; } if ($isPullRequest) { - $serviceName = "{$serviceName}-pr-{$pullRequestId}"; + $serviceName = addPreviewDeploymentSuffix($serviceName, $pullRequestId); } $parsedServices->put($serviceName, $payload); @@ -1337,7 +1332,6 @@ function serviceParser(Service $resource): Collection 'resourceable_id' => $resource->id, ], [ 'value' => $fqdn, - 'is_build_time' => false, 'is_preview' => false, ]); $resource->environment_variables()->updateOrCreate([ @@ -1346,7 +1340,6 @@ function serviceParser(Service $resource): Collection 'resourceable_id' => $resource->id, ], [ 'value' => $url, - 'is_build_time' => false, 'is_preview' => false, ]); } @@ -1358,7 +1351,6 @@ function serviceParser(Service $resource): Collection 'resourceable_id' => $resource->id, ], [ 'value' => $fqdn, - 'is_build_time' => false, 'is_preview' => false, ]); $resource->environment_variables()->updateOrCreate([ @@ -1367,7 +1359,6 @@ function serviceParser(Service $resource): Collection 'resourceable_id' => $resource->id, ], [ 'value' => $url, - 'is_build_time' => false, 'is_preview' => false, ]); } @@ -1397,7 +1388,6 @@ function serviceParser(Service $resource): Collection 'resourceable_id' => $resource->id, ], [ 'value' => $fqdn, - 'is_build_time' => false, 'is_preview' => false, ]); @@ -1417,7 +1407,6 @@ function serviceParser(Service $resource): Collection 'resourceable_id' => $resource->id, ], [ 'value' => $url, - 'is_build_time' => false, 'is_preview' => false, ]); @@ -1429,7 +1418,6 @@ function serviceParser(Service $resource): Collection 'resourceable_id' => $resource->id, ], [ 'value' => $value, - 'is_build_time' => false, 'is_preview' => false, ]); } @@ -1748,7 +1736,6 @@ function serviceParser(Service $resource): Collection 'resourceable_id' => $resource->id, ], [ 'value' => $value, - 'is_build_time' => false, 'is_preview' => false, ]); @@ -1765,7 +1752,6 @@ function serviceParser(Service $resource): Collection 'resourceable_id' => $resource->id, ], [ 'value' => $value, - 'is_build_time' => false, 'is_preview' => false, ]); } else { @@ -1801,7 +1787,6 @@ function serviceParser(Service $resource): Collection 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ - 'is_build_time' => false, 'is_preview' => false, 'is_required' => $isRequired, ]); @@ -1816,7 +1801,6 @@ function serviceParser(Service $resource): Collection 'resourceable_id' => $resource->id, ], [ 'value' => $value, - 'is_build_time' => false, 'is_preview' => false, 'is_required' => $isRequired, ]); diff --git a/bootstrap/helpers/proxy.php b/bootstrap/helpers/proxy.php index 2d479a193..5bc1d005e 100644 --- a/bootstrap/helpers/proxy.php +++ b/bootstrap/helpers/proxy.php @@ -1,6 +1,6 @@ <?php -use App\Actions\Proxy\SaveConfiguration; +use App\Actions\Proxy\SaveProxyConfiguration; use App\Enums\ProxyTypes; use App\Models\Application; use App\Models\Server; @@ -267,7 +267,7 @@ function generate_default_proxy_configuration(Server $server) } $config = Yaml::dump($config, 12, 2); - SaveConfiguration::run($server, $config); + SaveProxyConfiguration::run($server, $config); return $config; } diff --git a/bootstrap/helpers/remoteProcess.php b/bootstrap/helpers/remoteProcess.php index 6c1e2beab..56386a55f 100644 --- a/bootstrap/helpers/remoteProcess.php +++ b/bootstrap/helpers/remoteProcess.php @@ -60,15 +60,86 @@ function remote_process( function instant_scp(string $source, string $dest, Server $server, $throwError = true) { - $scp_command = SshMultiplexingHelper::generateScpCommand($server, $source, $dest); - $process = Process::timeout(config('constants.ssh.command_timeout'))->run($scp_command); - $output = trim($process->output()); - $exitCode = $process->exitCode(); - if ($exitCode !== 0) { - return $throwError ? excludeCertainErrors($process->errorOutput(), $exitCode) : null; - } + return \App\Helpers\SshRetryHandler::retry( + function () use ($source, $dest, $server) { + $scp_command = SshMultiplexingHelper::generateScpCommand($server, $source, $dest); + $process = Process::timeout(config('constants.ssh.command_timeout'))->run($scp_command); - return $output === 'null' ? null : $output; + $output = trim($process->output()); + $exitCode = $process->exitCode(); + + if ($exitCode !== 0) { + excludeCertainErrors($process->errorOutput(), $exitCode); + } + + return $output === 'null' ? null : $output; + }, + [ + 'server' => $server->ip, + 'source' => $source, + 'dest' => $dest, + 'function' => 'instant_scp', + ], + $throwError + ); +} + +function transfer_file_to_container(string $content, string $container_path, string $deployment_uuid, Server $server, bool $throwError = true): ?string +{ + $temp_file = tempnam(sys_get_temp_dir(), 'coolify_env_'); + + try { + // Write content to temporary file + file_put_contents($temp_file, $content); + + // Generate unique filename for server transfer + $server_temp_file = '/tmp/coolify_env_'.uniqid().'_'.$deployment_uuid; + + // Transfer file to server + instant_scp($temp_file, $server_temp_file, $server, $throwError); + + // Ensure parent directory exists in container, then copy file + $parent_dir = dirname($container_path); + $commands = []; + if ($parent_dir !== '.' && $parent_dir !== '/') { + $commands[] = executeInDocker($deployment_uuid, "mkdir -p \"$parent_dir\""); + } + $commands[] = "docker cp $server_temp_file $deployment_uuid:$container_path"; + $commands[] = "rm -f $server_temp_file"; // Cleanup server temp file + + return instant_remote_process_with_timeout($commands, $server, $throwError); + + } finally { + // Always cleanup local temp file + if (file_exists($temp_file)) { + unlink($temp_file); + } + } +} + +function transfer_file_to_server(string $content, string $server_path, Server $server, bool $throwError = true): ?string +{ + $temp_file = tempnam(sys_get_temp_dir(), 'coolify_env_'); + + try { + // Write content to temporary file + file_put_contents($temp_file, $content); + + // Ensure parent directory exists on server + $parent_dir = dirname($server_path); + if ($parent_dir !== '.' && $parent_dir !== '/') { + instant_remote_process_with_timeout(["mkdir -p \"$parent_dir\""], $server, $throwError); + } + + // Transfer file directly to server destination + return instant_scp($temp_file, $server_path, $server, $throwError); + + } finally { + // Always cleanup local temp file + if (file_exists($temp_file)) { + unlink($temp_file); + } + } } function instant_remote_process_with_timeout(Collection|array $command, Server $server, bool $throwError = true, bool $no_sudo = false): ?string @@ -79,54 +150,65 @@ function instant_remote_process_with_timeout(Collection|array $command, Server $ } $command_string = implode("\n", $command); - // $start_time = microtime(true); - $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string); - $process = Process::timeout(30)->run($sshCommand); - // $end_time = microtime(true); + return \App\Helpers\SshRetryHandler::retry( + function () use ($server, $command_string) { + $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string); + $process = Process::timeout(30)->run($sshCommand); - // $execution_time = ($end_time - $start_time) * 1000; // Convert to milliseconds - // ray('SSH command execution time:', $execution_time.' ms')->orange(); + $output = trim($process->output()); + $exitCode = $process->exitCode(); - $output = trim($process->output()); - $exitCode = $process->exitCode(); + if ($exitCode !== 0) { + excludeCertainErrors($process->errorOutput(), $exitCode); + } - if ($exitCode !== 0) { - return $throwError ? excludeCertainErrors($process->errorOutput(), $exitCode) : null; - } + // Sanitize output to ensure valid UTF-8 encoding + $output = $output === 'null' ? null : sanitize_utf8_text($output); - // Sanitize output to ensure valid UTF-8 encoding - $output = $output === 'null' ? null : sanitize_utf8_text($output); - - return $output; + return $output; + }, + [ + 'server' => $server->ip, + 'command_preview' => substr($command_string, 0, 100), + 'function' => 'instant_remote_process_with_timeout', + ], + $throwError + ); } function instant_remote_process(Collection|array $command, Server $server, bool $throwError = true, bool $no_sudo = false): ?string { $command = $command instanceof Collection ? $command->toArray() : $command; + if ($server->isNonRoot() && ! $no_sudo) { $command = parseCommandsByLineForSudo(collect($command), $server); } $command_string = implode("\n", $command); - // $start_time = microtime(true); - $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string); - $process = Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand); - // $end_time = microtime(true); + return \App\Helpers\SshRetryHandler::retry( + function () use ($server, $command_string) { + $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string); + $process = Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand); - // $execution_time = ($end_time - $start_time) * 1000; // Convert to milliseconds - // ray('SSH command execution time:', $execution_time.' ms')->orange(); + $output = trim($process->output()); + $exitCode = $process->exitCode(); - $output = trim($process->output()); - $exitCode = $process->exitCode(); + if ($exitCode !== 0) { + excludeCertainErrors($process->errorOutput(), $exitCode); + } - if ($exitCode !== 0) { - return $throwError ? excludeCertainErrors($process->errorOutput(), $exitCode) : null; - } + // Sanitize output to ensure valid UTF-8 encoding + $output = $output === 'null' ? null : sanitize_utf8_text($output); - // Sanitize output to ensure valid UTF-8 encoding - $output = $output === 'null' ? null : sanitize_utf8_text($output); - - return $output; + return $output; + }, + [ + 'server' => $server->ip, + 'command_preview' => substr($command_string, 0, 100), + 'function' => 'instant_remote_process', + ], + $throwError + ); } function excludeCertainErrors(string $errorOutput, ?int $exitCode = null) @@ -136,11 +218,18 @@ function excludeCertainErrors(string $errorOutput, ?int $exitCode = null) 'Could not resolve hostname', ]); $ignored = $ignoredErrors->contains(fn ($error) => Str::contains($errorOutput, $error)); + + // Ensure we always have a meaningful error message + $errorMessage = trim($errorOutput); + if (empty($errorMessage)) { + $errorMessage = "SSH command failed with exit code: $exitCode"; + } + if ($ignored) { // TODO: Create new exception and disable in sentry - throw new \RuntimeException($errorOutput, $exitCode); + throw new \RuntimeException($errorMessage, $exitCode); } - throw new \RuntimeException($errorOutput, $exitCode); + throw new \RuntimeException($errorMessage, $exitCode); } function decode_remote_command_output(?ApplicationDeploymentQueue $application_deployment_queue = null): Collection diff --git a/bootstrap/helpers/services.php b/bootstrap/helpers/services.php index cf12a28a5..a124272a2 100644 --- a/bootstrap/helpers/services.php +++ b/bootstrap/helpers/services.php @@ -115,14 +115,14 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource) $resource->save(); } - $serviceName = str($resource->name)->upper()->replace('-', '_'); + $serviceName = str($resource->name)->upper()->replace('-', '_')->replace('.', '_'); $resource->service->environment_variables()->where('key', 'LIKE', "SERVICE_FQDN_{$serviceName}%")->delete(); $resource->service->environment_variables()->where('key', 'LIKE', "SERVICE_URL_{$serviceName}%")->delete(); if ($resource->fqdn) { $resourceFqdns = str($resource->fqdn)->explode(','); $resourceFqdns = $resourceFqdns->first(); - $variableName = 'SERVICE_URL_'.str($resource->name)->upper()->replace('-', '_'); + $variableName = 'SERVICE_URL_'.str($resource->name)->upper()->replace('-', '_')->replace('.', '_'); $url = Url::fromString($resourceFqdns); $port = $url->getPort(); $path = $url->getPath(); @@ -134,7 +134,6 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource) 'key' => $variableName, ], [ 'value' => $urlValue, - 'is_build_time' => false, 'is_preview' => false, ]); if ($port) { @@ -145,11 +144,10 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource) 'key' => $variableName, ], [ 'value' => $urlValue, - 'is_build_time' => false, 'is_preview' => false, ]); } - $variableName = 'SERVICE_FQDN_'.str($resource->name)->upper()->replace('-', '_'); + $variableName = 'SERVICE_FQDN_'.str($resource->name)->upper()->replace('-', '_')->replace('.', '_'); $fqdn = Url::fromString($resourceFqdns); $port = $fqdn->getPort(); $path = $fqdn->getPath(); @@ -164,7 +162,6 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource) 'key' => $variableName, ], [ 'value' => $fqdnValue, - 'is_build_time' => false, 'is_preview' => false, ]); if ($port) { @@ -175,7 +172,6 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource) 'key' => $variableName, ], [ 'value' => $fqdnValue, - 'is_build_time' => false, 'is_preview' => false, ]); } diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index e01f4d58b..656c607bf 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -204,7 +204,6 @@ function get_latest_version_of_coolify(): string return data_get($versions, 'coolify.v4.version'); } catch (\Throwable $e) { - ray($e->getMessage()); return '0.0.0'; } @@ -635,10 +634,14 @@ function getTopLevelNetworks(Service|Application $resource) $definedNetwork = collect([$resource->uuid]); $services = collect($services)->map(function ($service, $_) use ($topLevelNetworks, $definedNetwork) { $serviceNetworks = collect(data_get($service, 'networks', [])); - $hasHostNetworkMode = data_get($service, 'network_mode') === 'host' ? true : false; + $networkMode = data_get($service, 'network_mode'); - // Only add 'networks' key if 'network_mode' is not 'host' - if (! $hasHostNetworkMode) { + $hasValidNetworkMode = + $networkMode === 'host' || + (is_string($networkMode) && (str_starts_with($networkMode, 'service:') || str_starts_with($networkMode, 'container:'))); + + // Only add 'networks' key if 'network_mode' is not 'host' or does not start with 'service:' or 'container:' + if (! $hasValidNetworkMode) { // Collect/create/update networks if ($serviceNetworks->count() > 0) { foreach ($serviceNetworks as $networkName => $networkDetails) { @@ -962,7 +965,7 @@ function getRealtime() } } -function validate_dns_entry(string $fqdn, Server $server) +function validateDNSEntry(string $fqdn, Server $server) { // https://www.cloudflare.com/ips-v4/# $cloudflare_ips = collect(['173.245.48.0/20', '103.21.244.0/22', '103.22.200.0/22', '103.31.4.0/22', '141.101.64.0/18', '108.162.192.0/18', '190.93.240.0/20', '188.114.96.0/20', '197.234.240.0/22', '198.41.128.0/17', '162.158.0.0/15', '104.16.0.0/13', '172.64.0.0/13', '131.0.72.0/22']); @@ -995,7 +998,7 @@ function validate_dns_entry(string $fqdn, Server $server) } else { foreach ($results as $result) { if ($result->getType() == $type) { - if (ip_match($result->getData(), $cloudflare_ips->toArray(), $match)) { + if (ipMatch($result->getData(), $cloudflare_ips->toArray(), $match)) { $found_matching_ip = true; break; } @@ -1013,7 +1016,7 @@ function validate_dns_entry(string $fqdn, Server $server) return $found_matching_ip; } -function ip_match($ip, $cidrs, &$match = null) +function ipMatch($ip, $cidrs, &$match = null) { foreach ((array) $cidrs as $cidr) { [$subnet, $mask] = explode('/', $cidr); @@ -1027,7 +1030,7 @@ function ip_match($ip, $cidrs, &$match = null) return false; } -function check_ip_against_allowlist($ip, $allowlist) +function checkIPAgainstAllowlist($ip, $allowlist) { if (empty($allowlist)) { return false; @@ -1085,78 +1088,6 @@ function check_ip_against_allowlist($ip, $allowlist) return false; } -function parseCommandsByLineForSudo(Collection $commands, Server $server): array -{ - $commands = $commands->map(function ($line) { - if ( - ! str(trim($line))->startsWith([ - 'cd', - 'command', - 'echo', - 'true', - 'if', - 'fi', - ]) - ) { - return "sudo $line"; - } - - if (str(trim($line))->startsWith('if')) { - return str_replace('if', 'if sudo', $line); - } - - return $line; - }); - - $commands = $commands->map(function ($line) use ($server) { - if (Str::startsWith($line, 'sudo mkdir -p')) { - return "$line && sudo chown -R $server->user:$server->user ".Str::after($line, 'sudo mkdir -p').' && sudo chmod -R o-rwx '.Str::after($line, 'sudo mkdir -p'); - } - - return $line; - }); - - $commands = $commands->map(function ($line) { - $line = str($line); - if (str($line)->contains('$(')) { - $line = $line->replace('$(', '$(sudo '); - } - if (str($line)->contains('||')) { - $line = $line->replace('||', '|| sudo'); - } - if (str($line)->contains('&&')) { - $line = $line->replace('&&', '&& sudo'); - } - if (str($line)->contains(' | ')) { - $line = $line->replace(' | ', ' | sudo '); - } - - return $line->value(); - }); - - return $commands->toArray(); -} -function parseLineForSudo(string $command, Server $server): string -{ - if (! str($command)->startSwith('cd') && ! str($command)->startSwith('command')) { - $command = "sudo $command"; - } - if (Str::startsWith($command, 'sudo mkdir -p')) { - $command = "$command && sudo chown -R $server->user:$server->user ".Str::after($command, 'sudo mkdir -p').' && sudo chmod -R o-rwx '.Str::after($command, 'sudo mkdir -p'); - } - if (str($command)->contains('$(') || str($command)->contains('`')) { - $command = str($command)->replace('$(', '$(sudo ')->replace('`', '`sudo ')->value(); - } - if (str($command)->contains('||')) { - $command = str($command)->replace('||', '|| sudo ')->value(); - } - if (str($command)->contains('&&')) { - $command = str($command)->replace('&&', '&& sudo ')->value(); - } - - return $command; -} - function get_public_ips() { try { @@ -1198,30 +1129,77 @@ function get_public_ips() function isAnyDeploymentInprogress() { $runningJobs = ApplicationDeploymentQueue::where('horizon_job_worker', gethostname())->where('status', ApplicationDeploymentStatus::IN_PROGRESS->value)->get(); - $basicDetails = $runningJobs->map(function ($job) { - return [ - 'id' => $job->id, - 'created_at' => $job->created_at, - 'application_id' => $job->application_id, - 'server_id' => $job->server_id, - 'horizon_job_id' => $job->horizon_job_id, - 'status' => $job->status, - ]; - }); - echo 'Running jobs: '.json_encode($basicDetails)."\n"; + + if ($runningJobs->isEmpty()) { + echo "No deployments in progress.\n"; + exit(0); + } + $horizonJobIds = []; + $deploymentDetails = []; + foreach ($runningJobs as $runningJob) { $horizonJobStatus = getJobStatus($runningJob->horizon_job_id); if ($horizonJobStatus === 'unknown' || $horizonJobStatus === 'reserved') { $horizonJobIds[] = $runningJob->horizon_job_id; + + // Get application and team information + $application = Application::find($runningJob->application_id); + $teamMembers = []; + $deploymentUrl = ''; + + if ($application) { + // Get team members through the application's project + $team = $application->team(); + if ($team) { + $teamMembers = $team->members()->pluck('email')->toArray(); + } + + // Construct the full deployment URL + if ($runningJob->deployment_url) { + $baseUrl = base_url(); + $deploymentUrl = $baseUrl.$runningJob->deployment_url; + } + } + + $deploymentDetails[] = [ + 'id' => $runningJob->id, + 'application_name' => $runningJob->application_name ?? 'Unknown', + 'server_name' => $runningJob->server_name ?? 'Unknown', + 'deployment_url' => $deploymentUrl, + 'team_members' => $teamMembers, + 'created_at' => $runningJob->created_at->format('Y-m-d H:i:s'), + 'horizon_job_id' => $runningJob->horizon_job_id, + ]; } } + if (count($horizonJobIds) === 0) { - echo "No deployments in progress.\n"; + echo "No active deployments in progress (all jobs completed or failed).\n"; exit(0); } - $horizonJobIds = collect($horizonJobIds)->unique()->toArray(); - echo 'There are '.count($horizonJobIds)." deployments in progress.\n"; + + // Display enhanced deployment information + echo "\n=== Running Deployments ===\n"; + echo 'Total active deployments: '.count($horizonJobIds)."\n\n"; + + foreach ($deploymentDetails as $index => $deployment) { + echo 'Deployment #'.($index + 1).":\n"; + echo ' Application: '.$deployment['application_name']."\n"; + echo ' Server: '.$deployment['server_name']."\n"; + echo ' Started: '.$deployment['created_at']."\n"; + if ($deployment['deployment_url']) { + echo ' URL: '.$deployment['deployment_url']."\n"; + } + if (! empty($deployment['team_members'])) { + echo ' Team members: '.implode(', ', $deployment['team_members'])."\n"; + } else { + echo " Team members: No team members found\n"; + } + echo ' Horizon Job ID: '.$deployment['horizon_job_id']."\n"; + echo "\n"; + } + exit(1); } @@ -1298,7 +1276,12 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $serviceNetworks = collect(data_get($service, 'networks', [])); $serviceVariables = collect(data_get($service, 'environment', [])); $serviceLabels = collect(data_get($service, 'labels', [])); - $hasHostNetworkMode = data_get($service, 'network_mode') === 'host' ? true : false; + $networkMode = data_get($service, 'network_mode'); + + $hasValidNetworkMode = + $networkMode === 'host' || + (is_string($networkMode) && (str_starts_with($networkMode, 'service:') || str_starts_with($networkMode, 'container:'))); + if ($serviceLabels->count() > 0) { $removedLabels = collect([]); $serviceLabels = $serviceLabels->filter(function ($serviceLabel, $serviceLabelName) use ($removedLabels) { @@ -1409,7 +1392,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $savedService->ports = $collectedPorts->implode(','); $savedService->save(); - if (! $hasHostNetworkMode) { + if (! $hasValidNetworkMode) { // Add Coolify specific networks $definedNetworkExists = $topLevelNetworks->contains(function ($value, $_) use ($definedNetwork) { return $value == $definedNetwork; @@ -1637,7 +1620,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal EnvironmentVariable::create([ 'key' => $key, 'value' => $fqdn, - 'is_build_time' => false, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, 'is_preview' => false, @@ -1717,7 +1699,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal EnvironmentVariable::create([ 'key' => $key, 'value' => $fqdn, - 'is_build_time' => false, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, 'is_preview' => false, @@ -1756,7 +1737,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal EnvironmentVariable::create([ 'key' => $key, 'value' => $generatedValue, - 'is_build_time' => false, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, 'is_preview' => false, @@ -1795,7 +1775,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal 'resourceable_id' => $resource->id, ], [ 'value' => $defaultValue, - 'is_build_time' => false, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, 'is_preview' => false, @@ -2059,12 +2038,12 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $name = $name->replaceFirst('~', $dir); } if ($pull_request_id !== 0) { - $name = $name."-pr-$pull_request_id"; + $name = addPreviewDeploymentSuffix($name, $pull_request_id); } $volume = str("$name:$mount"); } else { if ($pull_request_id !== 0) { - $name = $name."-pr-$pull_request_id"; + $name = addPreviewDeploymentSuffix($name, $pull_request_id); $volume = str("$name:$mount"); if ($topLevelVolumes->has($name)) { $v = $topLevelVolumes->get($name); @@ -2103,7 +2082,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $name = $volume->before(':'); $mount = $volume->after(':'); if ($pull_request_id !== 0) { - $name = $name."-pr-$pull_request_id"; + $name = addPreviewDeploymentSuffix($name, $pull_request_id); } $volume = str("$name:$mount"); } @@ -2122,7 +2101,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $source = str($source)->replaceFirst('~', $dir); } if ($pull_request_id !== 0) { - $source = $source."-pr-$pull_request_id"; + $source = addPreviewDeploymentSuffix($source, $pull_request_id); } if ($read_only) { data_set($volume, 'source', $source.':'.$target.':ro'); @@ -2131,7 +2110,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal } } else { if ($pull_request_id !== 0) { - $source = $source."-pr-$pull_request_id"; + $source = addPreviewDeploymentSuffix($source, $pull_request_id); } if ($read_only) { data_set($volume, 'source', $source.':'.$target.':ro'); @@ -2183,13 +2162,13 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $name = $name->replaceFirst('~', $dir); } if ($pull_request_id !== 0) { - $name = $name."-pr-$pull_request_id"; + $name = addPreviewDeploymentSuffix($name, $pull_request_id); } $volume = str("$name:$mount"); } else { if ($pull_request_id !== 0) { $uuid = $resource->uuid; - $name = $uuid."-$name-pr-$pull_request_id"; + $name = $uuid.'-'.addPreviewDeploymentSuffix($name, $pull_request_id); $volume = str("$name:$mount"); if ($topLevelVolumes->has($name)) { $v = $topLevelVolumes->get($name); @@ -2231,7 +2210,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $name = $volume->before(':'); $mount = $volume->after(':'); if ($pull_request_id !== 0) { - $name = $name."-pr-$pull_request_id"; + $name = addPreviewDeploymentSuffix($name, $pull_request_id); } $volume = str("$name:$mount"); } @@ -2259,7 +2238,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal if ($pull_request_id === 0) { $source = $uuid."-$source"; } else { - $source = $uuid."-$source-pr-$pull_request_id"; + $source = $uuid.'-'.addPreviewDeploymentSuffix($source, $pull_request_id); } if ($read_only) { data_set($volume, 'source', $source.':'.$target.':ro'); @@ -2299,7 +2278,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal if ($pull_request_id !== 0 && count($serviceDependencies) > 0) { $serviceDependencies = $serviceDependencies->map(function ($dependency) use ($pull_request_id) { - return $dependency."-pr-$pull_request_id"; + return addPreviewDeploymentSuffix($dependency, $pull_request_id); }); data_set($service, 'depends_on', $serviceDependencies->toArray()); } @@ -2486,7 +2465,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal EnvironmentVariable::create([ 'key' => $key, 'value' => $fqdn, - 'is_build_time' => false, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, 'is_preview' => false, @@ -2498,7 +2476,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal EnvironmentVariable::create([ 'key' => $key, 'value' => $generatedValue, - 'is_build_time' => false, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, 'is_preview' => false, @@ -2532,20 +2509,17 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal if ($foundEnv) { $defaultValue = data_get($foundEnv, 'value'); } - $isBuildTime = data_get($foundEnv, 'is_build_time', false); if ($foundEnv) { $foundEnv->update([ 'key' => $key, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, - 'is_build_time' => $isBuildTime, 'value' => $defaultValue, ]); } else { EnvironmentVariable::create([ 'key' => $key, 'value' => $defaultValue, - 'is_build_time' => $isBuildTime, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, 'is_preview' => false, @@ -2693,7 +2667,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal }); if ($pull_request_id !== 0) { $services->each(function ($service, $serviceName) use ($pull_request_id, $services) { - $services[$serviceName."-pr-$pull_request_id"] = $service; + $services[addPreviewDeploymentSuffix($serviceName, $pull_request_id)] = $service; data_forget($services, $serviceName); }); } @@ -3073,3 +3047,18 @@ function parseDockerfileInterval(string $something) return $seconds; } + +function addPreviewDeploymentSuffix(string $name, int $pull_request_id = 0): string +{ + return ($pull_request_id === 0) ? $name : $name.'-pr-'.$pull_request_id; +} + +function generateDockerComposeServiceName(mixed $services, int $pullRequestId = 0): Collection +{ + $collection = collect([]); + foreach ($services as $serviceName => $_) { + $collection->put('SERVICE_NAME_'.str($serviceName)->replace('-', '_')->replace('.', '_')->upper(), addPreviewDeploymentSuffix($serviceName, $pullRequestId)); + } + + return $collection; +} diff --git a/bootstrap/helpers/sudo.php b/bootstrap/helpers/sudo.php new file mode 100644 index 000000000..ba252c64f --- /dev/null +++ b/bootstrap/helpers/sudo.php @@ -0,0 +1,101 @@ +<?php + +use App\Models\Server; +use Illuminate\Support\Collection; +use Illuminate\Support\Str; + +function shouldChangeOwnership(string $path): bool +{ + $path = trim($path); + + $systemPaths = ['/var', '/etc', '/usr', '/opt', '/sys', '/proc', '/dev', '/bin', '/sbin', '/lib', '/lib64', '/boot', '/root', '/home', '/media', '/mnt', '/srv', '/run']; + + foreach ($systemPaths as $systemPath) { + if ($path === $systemPath || Str::startsWith($path, $systemPath.'/')) { + return false; + } + } + + $isCoolifyPath = Str::startsWith($path, '/data/coolify') || Str::startsWith($path, '/tmp/coolify'); + + return $isCoolifyPath; +} +function parseCommandsByLineForSudo(Collection $commands, Server $server): array +{ + $commands = $commands->map(function ($line) { + if ( + ! str(trim($line))->startsWith([ + 'cd', + 'command', + 'echo', + 'true', + 'if', + 'fi', + ]) + ) { + return "sudo $line"; + } + + if (str(trim($line))->startsWith('if')) { + return str_replace('if', 'if sudo', $line); + } + + return $line; + }); + + $commands = $commands->map(function ($line) use ($server) { + if (Str::startsWith($line, 'sudo mkdir -p')) { + $path = trim(Str::after($line, 'sudo mkdir -p')); + if (shouldChangeOwnership($path)) { + return "$line && sudo chown -R $server->user:$server->user $path && sudo chmod -R o-rwx $path"; + } + + return $line; + } + + return $line; + }); + + $commands = $commands->map(function ($line) { + $line = str($line); + if (str($line)->contains('$(')) { + $line = $line->replace('$(', '$(sudo '); + } + if (str($line)->contains('||')) { + $line = $line->replace('||', '|| sudo'); + } + if (str($line)->contains('&&')) { + $line = $line->replace('&&', '&& sudo'); + } + if (str($line)->contains(' | ')) { + $line = $line->replace(' | ', ' | sudo '); + } + + return $line->value(); + }); + + return $commands->toArray(); +} +function parseLineForSudo(string $command, Server $server): string +{ + if (! str($command)->startSwith('cd') && ! str($command)->startSwith('command')) { + $command = "sudo $command"; + } + if (Str::startsWith($command, 'sudo mkdir -p')) { + $path = trim(Str::after($command, 'sudo mkdir -p')); + if (shouldChangeOwnership($path)) { + $command = "$command && sudo chown -R $server->user:$server->user $path && sudo chmod -R o-rwx $path"; + } + } + if (str($command)->contains('$(') || str($command)->contains('`')) { + $command = str($command)->replace('$(', '$(sudo ')->replace('`', '`sudo ')->value(); + } + if (str($command)->contains('||')) { + $command = str($command)->replace('||', '|| sudo ')->value(); + } + if (str($command)->contains('&&')) { + $command = str($command)->replace('&&', '&& sudo ')->value(); + } + + return $command; +} diff --git a/composer.json b/composer.json index 38756edf9..ea466049d 100644 --- a/composer.json +++ b/composer.json @@ -62,6 +62,7 @@ "barryvdh/laravel-debugbar": "^3.15.4", "driftingly/rector-laravel": "^2.0.5", "fakerphp/faker": "^1.24.1", + "laravel/boost": "^1.1", "laravel/dusk": "^8.3.3", "laravel/pint": "^1.24", "laravel/telescope": "^5.10", diff --git a/composer.lock b/composer.lock index c7de9ad34..6320db071 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "a78cf8fdfec25eac43de77c05640dc91", + "content-hash": "a993799242581bd06b5939005ee458d9", "packages": [ { "name": "amphp/amp", @@ -12747,6 +12747,71 @@ }, "time": "2025-04-30T06:54:44+00:00" }, + { + "name": "laravel/boost", + "version": "v1.1.4", + "source": { + "type": "git", + "url": "https://github.com/laravel/boost.git", + "reference": "70f909465bf73dad7e791fad8b7716b3b2712076" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/boost/zipball/70f909465bf73dad7e791fad8b7716b3b2712076", + "reference": "70f909465bf73dad7e791fad8b7716b3b2712076", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^7.9", + "illuminate/console": "^10.0|^11.0|^12.0", + "illuminate/contracts": "^10.0|^11.0|^12.0", + "illuminate/routing": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "laravel/mcp": "^0.1.1", + "laravel/prompts": "^0.1.9|^0.3", + "laravel/roster": "^0.2.5", + "php": "^8.1" + }, + "require-dev": { + "laravel/pint": "^1.14", + "mockery/mockery": "^1.6", + "orchestra/testbench": "^8.22.0|^9.0|^10.0", + "pestphp/pest": "^2.0|^3.0", + "phpstan/phpstan": "^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Boost\\BoostServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Boost\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Laravel Boost accelerates AI-assisted development to generate high-quality, Laravel-specific code.", + "homepage": "https://github.com/laravel/boost", + "keywords": [ + "ai", + "dev", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/boost/issues", + "source": "https://github.com/laravel/boost" + }, + "time": "2025-09-04T12:16:09+00:00" + }, { "name": "laravel/dusk", "version": "v8.3.3", @@ -12821,6 +12886,70 @@ }, "time": "2025-06-10T13:59:27+00:00" }, + { + "name": "laravel/mcp", + "version": "v0.1.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/mcp.git", + "reference": "6d6284a491f07c74d34f48dfd999ed52c567c713" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/mcp/zipball/6d6284a491f07c74d34f48dfd999ed52c567c713", + "reference": "6d6284a491f07c74d34f48dfd999ed52c567c713", + "shasum": "" + }, + "require": { + "illuminate/console": "^10.0|^11.0|^12.0", + "illuminate/contracts": "^10.0|^11.0|^12.0", + "illuminate/http": "^10.0|^11.0|^12.0", + "illuminate/routing": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "illuminate/validation": "^10.0|^11.0|^12.0", + "php": "^8.1|^8.2" + }, + "require-dev": { + "laravel/pint": "^1.14", + "orchestra/testbench": "^8.22.0|^9.0|^10.0", + "phpstan/phpstan": "^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp" + }, + "providers": [ + "Laravel\\Mcp\\Server\\McpServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Mcp\\": "src/", + "Workbench\\App\\": "workbench/app/", + "Laravel\\Mcp\\Tests\\": "tests/", + "Laravel\\Mcp\\Server\\": "src/Server/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "The easiest way to add MCP servers to your Laravel app.", + "homepage": "https://github.com/laravel/mcp", + "keywords": [ + "dev", + "laravel", + "mcp" + ], + "support": { + "issues": "https://github.com/laravel/mcp/issues", + "source": "https://github.com/laravel/mcp" + }, + "time": "2025-08-16T09:50:43+00:00" + }, { "name": "laravel/pint", "version": "v1.24.0", @@ -12890,6 +13019,67 @@ }, "time": "2025-07-10T18:09:32+00:00" }, + { + "name": "laravel/roster", + "version": "v0.2.6", + "source": { + "type": "git", + "url": "https://github.com/laravel/roster.git", + "reference": "5615acdf860c5a5c61d04aba44f2d3312550c514" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/roster/zipball/5615acdf860c5a5c61d04aba44f2d3312550c514", + "reference": "5615acdf860c5a5c61d04aba44f2d3312550c514", + "shasum": "" + }, + "require": { + "illuminate/console": "^10.0|^11.0|^12.0", + "illuminate/contracts": "^10.0|^11.0|^12.0", + "illuminate/routing": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "php": "^8.1|^8.2", + "symfony/yaml": "^6.4|^7.2" + }, + "require-dev": { + "laravel/pint": "^1.14", + "mockery/mockery": "^1.6", + "orchestra/testbench": "^8.22.0|^9.0|^10.0", + "pestphp/pest": "^2.0|^3.0", + "phpstan/phpstan": "^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Roster\\RosterServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Roster\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Detect packages & approaches in use within a Laravel project", + "homepage": "https://github.com/laravel/roster", + "keywords": [ + "dev", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/roster/issues", + "source": "https://github.com/laravel/roster" + }, + "time": "2025-09-04T07:31:39+00:00" + }, { "name": "laravel/telescope", "version": "v5.10.2", diff --git a/config/constants.php b/config/constants.php index 022886df2..224f2dfb5 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,8 +2,8 @@ return [ 'coolify' => [ - 'version' => '4.0.0-beta.427', - 'helper_version' => '1.0.10', + 'version' => '4.0.0-beta.429', + 'helper_version' => '1.0.11', 'realtime_version' => '1.0.10', 'self_hosted' => env('SELF_HOSTED', true), 'autoupdate' => env('AUTOUPDATE'), @@ -12,6 +12,7 @@ return [ 'helper_image' => env('HELPER_IMAGE', env('REGISTRY_URL', 'ghcr.io').'/coollabsio/coolify-helper'), 'realtime_image' => env('REALTIME_IMAGE', env('REGISTRY_URL', 'ghcr.io').'/coollabsio/coolify-realtime'), 'is_windows_docker_desktop' => env('IS_WINDOWS_DOCKER_DESKTOP', false), + 'releases_url' => 'https://cdn.coollabs.io/coolify/releases.json', ], 'urls' => [ @@ -58,9 +59,16 @@ return [ 'ssh' => [ 'mux_enabled' => env('MUX_ENABLED', env('SSH_MUX_ENABLED', true)), 'mux_persist_time' => env('SSH_MUX_PERSIST_TIME', 3600), + 'mux_health_check_enabled' => env('SSH_MUX_HEALTH_CHECK_ENABLED', true), + 'mux_health_check_timeout' => env('SSH_MUX_HEALTH_CHECK_TIMEOUT', 5), + 'mux_max_age' => env('SSH_MUX_MAX_AGE', 1800), // 30 minutes 'connection_timeout' => 10, 'server_interval' => 20, 'command_timeout' => 7200, + 'max_retries' => env('SSH_MAX_RETRIES', 3), + 'retry_base_delay' => env('SSH_RETRY_BASE_DELAY', 2), // seconds + 'retry_max_delay' => env('SSH_RETRY_MAX_DELAY', 30), // seconds + 'retry_multiplier' => env('SSH_RETRY_MULTIPLIER', 2), ], 'invitation' => [ diff --git a/database/migrations/2025_09_05_142446_add_pr_deployments_public_enabled_to_application_settings.php b/database/migrations/2025_09_05_142446_add_pr_deployments_public_enabled_to_application_settings.php new file mode 100644 index 000000000..5d84ce42d --- /dev/null +++ b/database/migrations/2025_09_05_142446_add_pr_deployments_public_enabled_to_application_settings.php @@ -0,0 +1,28 @@ +<?php + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + /** + * Run the migrations. + */ + public function up(): void + { + Schema::table('application_settings', function (Blueprint $table) { + $table->boolean('is_pr_deployments_public_enabled')->default(false)->after('is_preview_deployments_enabled'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('application_settings', function (Blueprint $table) { + $table->dropColumn('is_pr_deployments_public_enabled'); + }); + } +}; diff --git a/database/migrations/2025_09_10_172952_remove_is_readonly_from_local_persistent_volumes_table.php b/database/migrations/2025_09_10_172952_remove_is_readonly_from_local_persistent_volumes_table.php new file mode 100644 index 000000000..31398bd35 --- /dev/null +++ b/database/migrations/2025_09_10_172952_remove_is_readonly_from_local_persistent_volumes_table.php @@ -0,0 +1,28 @@ +<?php + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + /** + * Run the migrations. + */ + public function up(): void + { + Schema::table('local_persistent_volumes', function (Blueprint $table) { + $table->dropColumn('is_readonly'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('local_persistent_volumes', function (Blueprint $table) { + $table->boolean('is_readonly')->default(false); + }); + } +}; diff --git a/database/migrations/2025_09_10_173300_drop_webhooks_table.php b/database/migrations/2025_09_10_173300_drop_webhooks_table.php new file mode 100644 index 000000000..4cb1b4e70 --- /dev/null +++ b/database/migrations/2025_09_10_173300_drop_webhooks_table.php @@ -0,0 +1,31 @@ +<?php + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + /** + * Run the migrations. + */ + public function up(): void + { + Schema::dropIfExists('webhooks'); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::create('webhooks', function (Blueprint $table) { + $table->id(); + $table->enum('status', ['pending', 'success', 'failed'])->default('pending'); + $table->string('type'); + $table->longText('payload'); + $table->longText('failure_reason')->nullable(); + $table->timestamps(); + }); + } +}; diff --git a/database/migrations/2025_09_10_173402_drop_kubernetes_table.php b/database/migrations/2025_09_10_173402_drop_kubernetes_table.php new file mode 100644 index 000000000..329ed0e7e --- /dev/null +++ b/database/migrations/2025_09_10_173402_drop_kubernetes_table.php @@ -0,0 +1,28 @@ +<?php + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + /** + * Run the migrations. + */ + public function up(): void + { + Schema::dropIfExists('kubernetes'); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::create('kubernetes', function (Blueprint $table) { + $table->id(); + $table->string('uuid')->unique(); + $table->timestamps(); + }); + } +}; diff --git a/database/migrations/2025_09_11_143432_remove_is_build_time_from_environment_variables_table.php b/database/migrations/2025_09_11_143432_remove_is_build_time_from_environment_variables_table.php new file mode 100644 index 000000000..076ee8e09 --- /dev/null +++ b/database/migrations/2025_09_11_143432_remove_is_build_time_from_environment_variables_table.php @@ -0,0 +1,38 @@ +<?php + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + /** + * Run the migrations. + */ + public function up(): void + { + Schema::table('environment_variables', function (Blueprint $table) { + // Check if the column exists before trying to drop it + if (Schema::hasColumn('environment_variables', 'is_build_time')) { + // Drop the is_build_time column + // Note: The unique constraints that included is_build_time were tied to old foreign key columns + // (application_id, service_id, database_id) which were removed in migration 2024_12_16_134437. + // Those constraints should no longer exist in the database. + $table->dropColumn('is_build_time'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('environment_variables', function (Blueprint $table) { + // Re-add the is_build_time column + if (! Schema::hasColumn('environment_variables', 'is_build_time')) { + $table->boolean('is_build_time')->default(false)->after('value'); + } + }); + } +}; diff --git a/database/migrations/2025_09_11_150344_add_is_buildtime_only_to_environment_variables_table.php b/database/migrations/2025_09_11_150344_add_is_buildtime_only_to_environment_variables_table.php new file mode 100644 index 000000000..d95f351d5 --- /dev/null +++ b/database/migrations/2025_09_11_150344_add_is_buildtime_only_to_environment_variables_table.php @@ -0,0 +1,28 @@ +<?php + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + /** + * Run the migrations. + */ + public function up(): void + { + Schema::table('environment_variables', function (Blueprint $table) { + $table->boolean('is_buildtime_only')->default(false)->after('is_preview'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('environment_variables', function (Blueprint $table) { + $table->dropColumn('is_buildtime_only'); + }); + } +}; diff --git a/database/migrations/2025_09_17_081112_add_use_build_secrets_to_application_settings.php b/database/migrations/2025_09_17_081112_add_use_build_secrets_to_application_settings.php new file mode 100644 index 000000000..b78f391fc --- /dev/null +++ b/database/migrations/2025_09_17_081112_add_use_build_secrets_to_application_settings.php @@ -0,0 +1,28 @@ +<?php + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + /** + * Run the migrations. + */ + public function up(): void + { + Schema::table('application_settings', function (Blueprint $table) { + $table->boolean('use_build_secrets')->default(false)->after('is_build_server_enabled'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('application_settings', function (Blueprint $table) { + $table->dropColumn('use_build_secrets'); + }); + } +}; diff --git a/database/migrations/2025_09_18_080152_add_runtime_and_buildtime_to_environment_variables_table.php b/database/migrations/2025_09_18_080152_add_runtime_and_buildtime_to_environment_variables_table.php new file mode 100644 index 000000000..6fd4bfed6 --- /dev/null +++ b/database/migrations/2025_09_18_080152_add_runtime_and_buildtime_to_environment_variables_table.php @@ -0,0 +1,67 @@ +<?php + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + /** + * Run the migrations. + */ + public function up(): void + { + Schema::table('environment_variables', function (Blueprint $table) { + // Add new boolean fields with defaults + $table->boolean('is_runtime')->default(true)->after('is_buildtime_only'); + $table->boolean('is_buildtime')->default(true)->after('is_runtime'); + }); + + // Migrate existing data from is_buildtime_only to new fields + DB::table('environment_variables') + ->where('is_buildtime_only', true) + ->update([ + 'is_runtime' => false, + 'is_buildtime' => true, + ]); + + DB::table('environment_variables') + ->where('is_buildtime_only', false) + ->update([ + 'is_runtime' => true, + 'is_buildtime' => true, + ]); + + // Remove the old is_buildtime_only column + Schema::table('environment_variables', function (Blueprint $table) { + $table->dropColumn('is_buildtime_only'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('environment_variables', function (Blueprint $table) { + // Re-add the is_buildtime_only column + $table->boolean('is_buildtime_only')->default(false)->after('is_preview'); + }); + + // Restore data to is_buildtime_only based on new fields + DB::table('environment_variables') + ->where('is_runtime', false) + ->where('is_buildtime', true) + ->update(['is_buildtime_only' => true]); + + DB::table('environment_variables') + ->where('is_runtime', true) + ->update(['is_buildtime_only' => false]); + + // Remove new columns + Schema::table('environment_variables', function (Blueprint $table) { + $table->dropColumn(['is_runtime', 'is_buildtime']); + }); + } +}; diff --git a/docker/coolify-helper/Dockerfile b/docker/coolify-helper/Dockerfile index c66b8d67e..212703798 100644 --- a/docker/coolify-helper/Dockerfile +++ b/docker/coolify-helper/Dockerfile @@ -10,9 +10,10 @@ ARG DOCKER_BUILDX_VERSION=0.25.0 # https://github.com/buildpacks/pack/releases ARG PACK_VERSION=0.38.2 # https://github.com/railwayapp/nixpacks/releases -ARG NIXPACKS_VERSION=1.39.0 +ARG NIXPACKS_VERSION=1.40.0 # https://github.com/minio/mc/releases -ARG MINIO_VERSION=RELEASE.2025-03-12T17-29-24Z +ARG MINIO_VERSION=RELEASE.2025-08-13T08-35-41Z + FROM minio/mc:${MINIO_VERSION} AS minio-client diff --git a/docker/coolify-realtime/package-lock.json b/docker/coolify-realtime/package-lock.json index 49907cbd4..c445c972c 100644 --- a/docker/coolify-realtime/package-lock.json +++ b/docker/coolify-realtime/package-lock.json @@ -7,7 +7,7 @@ "dependencies": { "@xterm/addon-fit": "0.10.0", "@xterm/xterm": "5.5.0", - "axios": "1.8.4", + "axios": "1.12.0", "cookie": "1.0.2", "dotenv": "16.5.0", "node-pty": "1.0.0", @@ -36,13 +36,13 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz", - "integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==", + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.0.tgz", + "integrity": "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", + "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, diff --git a/docker/coolify-realtime/package.json b/docker/coolify-realtime/package.json index 7851d7f4d..aec3dbe3d 100644 --- a/docker/coolify-realtime/package.json +++ b/docker/coolify-realtime/package.json @@ -5,7 +5,7 @@ "@xterm/addon-fit": "0.10.0", "@xterm/xterm": "5.5.0", "cookie": "1.0.2", - "axios": "1.8.4", + "axios": "1.12.0", "dotenv": "16.5.0", "node-pty": "1.0.0", "ws": "8.18.1" diff --git a/hooks/pre-commit b/hooks/pre-commit index 029f67917..fc96e9766 100644 --- a/hooks/pre-commit +++ b/hooks/pre-commit @@ -4,6 +4,19 @@ if sh -c ": >/dev/tty" >/dev/null 2>/dev/null; then exec </dev/tty fi +# Generate service templates and OpenAPI documentation +echo "🔄 Generating service templates..." +php artisan generate:services + +echo "📚 Generating OpenAPI documentation..." +php artisan generate:openapi + +# Add the generated files to the commit +git add templates/service-templates*.json +git add openapi.json openapi.yaml + +echo "✅ Generated files have been added to the commit" + # Get list of stashed PHP files stashed_files=$(git diff --cached --name-only --diff-filter=ACM -- '*.php') diff --git a/openapi.json b/openapi.json index ad20633c4..2b0a81c6e 100644 --- a/openapi.json +++ b/openapi.json @@ -2773,10 +2773,6 @@ "type": "boolean", "description": "The flag to indicate if the environment variable is used in preview deployments." }, - "is_build_time": { - "type": "boolean", - "description": "The flag to indicate if the environment variable is used in build time." - }, "is_literal": { "type": "boolean", "description": "The flag to indicate if the environment variable is a literal, nothing espaced." @@ -2870,10 +2866,6 @@ "type": "boolean", "description": "The flag to indicate if the environment variable is used in preview deployments." }, - "is_build_time": { - "type": "boolean", - "description": "The flag to indicate if the environment variable is used in build time." - }, "is_literal": { "type": "boolean", "description": "The flag to indicate if the environment variable is a literal, nothing espaced." @@ -2972,10 +2964,6 @@ "type": "boolean", "description": "The flag to indicate if the environment variable is used in preview deployments." }, - "is_build_time": { - "type": "boolean", - "description": "The flag to indicate if the environment variable is used in build time." - }, "is_literal": { "type": "boolean", "description": "The flag to indicate if the environment variable is a literal, nothing espaced." @@ -7179,10 +7167,6 @@ "type": "boolean", "description": "The flag to indicate if the environment variable is used in preview deployments." }, - "is_build_time": { - "type": "boolean", - "description": "The flag to indicate if the environment variable is used in build time." - }, "is_literal": { "type": "boolean", "description": "The flag to indicate if the environment variable is a literal, nothing espaced." @@ -7276,10 +7260,6 @@ "type": "boolean", "description": "The flag to indicate if the environment variable is used in preview deployments." }, - "is_build_time": { - "type": "boolean", - "description": "The flag to indicate if the environment variable is used in build time." - }, "is_literal": { "type": "boolean", "description": "The flag to indicate if the environment variable is a literal, nothing espaced." @@ -7378,10 +7358,6 @@ "type": "boolean", "description": "The flag to indicate if the environment variable is used in preview deployments." }, - "is_build_time": { - "type": "boolean", - "description": "The flag to indicate if the environment variable is used in build time." - }, "is_literal": { "type": "boolean", "description": "The flag to indicate if the environment variable is a literal, nothing espaced." @@ -8375,9 +8351,6 @@ "resourceable_id": { "type": "integer" }, - "is_build_time": { - "type": "boolean" - }, "is_literal": { "type": "boolean" }, @@ -8387,6 +8360,12 @@ "is_preview": { "type": "boolean" }, + "is_runtime": { + "type": "boolean" + }, + "is_buildtime": { + "type": "boolean" + }, "is_shared": { "type": "boolean" }, diff --git a/openapi.yaml b/openapi.yaml index ddd814e32..9529fcf87 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1778,9 +1778,6 @@ paths: is_preview: type: boolean description: 'The flag to indicate if the environment variable is used in preview deployments.' - is_build_time: - type: boolean - description: 'The flag to indicate if the environment variable is used in build time.' is_literal: type: boolean description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' @@ -1843,9 +1840,6 @@ paths: is_preview: type: boolean description: 'The flag to indicate if the environment variable is used in preview deployments.' - is_build_time: - type: boolean - description: 'The flag to indicate if the environment variable is used in build time.' is_literal: type: boolean description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' @@ -1901,7 +1895,7 @@ paths: properties: data: type: array - items: { properties: { key: { type: string, description: 'The key of the environment variable.' }, value: { type: string, description: 'The value of the environment variable.' }, is_preview: { type: boolean, description: 'The flag to indicate if the environment variable is used in preview deployments.' }, is_build_time: { type: boolean, description: 'The flag to indicate if the environment variable is used in build time.' }, is_literal: { type: boolean, description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' }, is_multiline: { type: boolean, description: 'The flag to indicate if the environment variable is multiline.' }, is_shown_once: { type: boolean, description: "The flag to indicate if the environment variable's value is shown on the UI." } }, type: object } + items: { properties: { key: { type: string, description: 'The key of the environment variable.' }, value: { type: string, description: 'The value of the environment variable.' }, is_preview: { type: boolean, description: 'The flag to indicate if the environment variable is used in preview deployments.' }, is_literal: { type: boolean, description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' }, is_multiline: { type: boolean, description: 'The flag to indicate if the environment variable is multiline.' }, is_shown_once: { type: boolean, description: "The flag to indicate if the environment variable's value is shown on the UI." } }, type: object } type: object responses: '201': @@ -4615,9 +4609,6 @@ paths: is_preview: type: boolean description: 'The flag to indicate if the environment variable is used in preview deployments.' - is_build_time: - type: boolean - description: 'The flag to indicate if the environment variable is used in build time.' is_literal: type: boolean description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' @@ -4680,9 +4671,6 @@ paths: is_preview: type: boolean description: 'The flag to indicate if the environment variable is used in preview deployments.' - is_build_time: - type: boolean - description: 'The flag to indicate if the environment variable is used in build time.' is_literal: type: boolean description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' @@ -4738,7 +4726,7 @@ paths: properties: data: type: array - items: { properties: { key: { type: string, description: 'The key of the environment variable.' }, value: { type: string, description: 'The value of the environment variable.' }, is_preview: { type: boolean, description: 'The flag to indicate if the environment variable is used in preview deployments.' }, is_build_time: { type: boolean, description: 'The flag to indicate if the environment variable is used in build time.' }, is_literal: { type: boolean, description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' }, is_multiline: { type: boolean, description: 'The flag to indicate if the environment variable is multiline.' }, is_shown_once: { type: boolean, description: "The flag to indicate if the environment variable's value is shown on the UI." } }, type: object } + items: { properties: { key: { type: string, description: 'The key of the environment variable.' }, value: { type: string, description: 'The value of the environment variable.' }, is_preview: { type: boolean, description: 'The flag to indicate if the environment variable is used in preview deployments.' }, is_literal: { type: boolean, description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' }, is_multiline: { type: boolean, description: 'The flag to indicate if the environment variable is multiline.' }, is_shown_once: { type: boolean, description: "The flag to indicate if the environment variable's value is shown on the UI." } }, type: object } type: object responses: '201': @@ -5417,14 +5405,16 @@ components: type: string resourceable_id: type: integer - is_build_time: - type: boolean is_literal: type: boolean is_multiline: type: boolean is_preview: type: boolean + is_runtime: + type: boolean + is_buildtime: + type: boolean is_shared: type: boolean is_shown_once: diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 4da699d67..fd5dccaf0 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,19 +1,19 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.427" - }, - "nightly": { "version": "4.0.0-beta.428" }, + "nightly": { + "version": "4.0.0-beta.429" + }, "helper": { - "version": "1.0.10" + "version": "1.0.11" }, "realtime": { "version": "1.0.10" }, "sentinel": { - "version": "0.0.15" + "version": "0.0.16" } } } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 34b2c1dd5..56e48288c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ "pusher-js": "8.4.0", "tailwind-scrollbar": "4.0.2", "tailwindcss": "4.1.10", - "vite": "6.3.5", + "vite": "6.3.6", "vue": "3.5.16" } }, @@ -1131,6 +1131,66 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.4.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.0.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.4.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.0.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.10", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.9.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.9.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.0", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.10.tgz", @@ -2635,9 +2695,9 @@ "license": "MIT" }, "node_modules/vite": { - "version": "6.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", - "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "version": "6.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz", + "integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 10ec71415..e29c5e8e6 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "pusher-js": "8.4.0", "tailwind-scrollbar": "4.0.2", "tailwindcss": "4.1.10", - "vite": "6.3.5", + "vite": "6.3.6", "vue": "3.5.16" }, "dependencies": { diff --git a/resources/css/utilities.css b/resources/css/utilities.css index d09d7f49c..694ad61a3 100644 --- a/resources/css/utilities.css +++ b/resources/css/utilities.css @@ -6,10 +6,31 @@ @apply hidden!; } +@utility apexcharts-grid-borders { + @apply dark:hidden!; +} + @utility apexcharts-xaxistooltip { @apply hidden!; } +@utility apexcharts-tooltip-custom { + @apply bg-white dark:bg-coolgray-100 border border-neutral-200 dark:border-coolgray-300 rounded-lg shadow-lg p-3 text-sm; + min-width: 160px; +} + +@utility apexcharts-tooltip-custom-value { + @apply text-neutral-700 dark:text-neutral-300 mb-1; +} + +@utility apexcharts-tooltip-value-bold { + @apply font-bold text-black dark:text-white; +} + +@utility apexcharts-tooltip-custom-title { + @apply text-xs text-neutral-500 dark:text-neutral-400 font-medium; +} + @utility input-sticky { @apply block py-1.5 w-full text-sm text-black rounded-sm border-0 ring-1 ring-inset dark:bg-coolgray-100 dark:text-white ring-neutral-200 dark:ring-coolgray-300 focus:ring-2 focus:ring-neutral-400 dark:focus:ring-coolgray-300; } diff --git a/resources/views/components/modal-confirmation.blade.php b/resources/views/components/modal-confirmation.blade.php index 0d185782f..1c82614a6 100644 --- a/resources/views/components/modal-confirmation.blade.php +++ b/resources/views/components/modal-confirmation.blade.php @@ -11,6 +11,7 @@ 'content' => null, 'checkboxes' => [], 'actions' => [], + 'warningMessage' => null, 'confirmWithText' => true, 'confirmationText' => 'Confirm Deletion', 'confirmationLabel' => 'Please confirm the execution of the actions by entering the Name below', @@ -228,7 +229,7 @@ <div x-show="step === 2"> <div class="p-4 mb-4 text-white border-l-4 border-red-500 bg-error" role="alert"> <p class="font-bold">Warning</p> - <p>This operation is permanent and cannot be undone. Please think again before proceeding! + <p>{!! $warningMessage ?: 'This operation is permanent and cannot be undone. Please think again before proceeding!' !!} </p> </div> <div class="mb-4">The following actions will be performed:</div> diff --git a/resources/views/components/navbar.blade.php b/resources/views/components/navbar.blade.php index 7ec7e4d4c..1c5987e82 100644 --- a/resources/views/components/navbar.blade.php +++ b/resources/views/components/navbar.blade.php @@ -59,20 +59,20 @@ if (this.zoom === '90') { const style = document.createElement('style'); style.textContent = ` - html { - font-size: 93.75%; - } - - :root { - --vh: 1vh; - } - - @media (min-width: 1024px) { html { - font-size: 87.5%; + font-size: 93.75%; } - } - `; + + :root { + --vh: 1vh; + } + + @media (min-width: 1024px) { + html { + font-size: 87.5%; + } + } + `; document.head.appendChild(style); } } @@ -82,6 +82,9 @@ <div class="text-2xl font-bold tracking-wide dark:text-white">Coolify</div> <x-version /> </div> + <div> + <livewire:global-search /> + </div> <livewire:settings-dropdown /> </div> <div class="px-2 pt-2 pb-7"> @@ -278,7 +281,7 @@ Teams </a> </li> - @if (isCloud()) + @if (isCloud() && auth()->user()->isAdmin()) <li> <a title="Subscription" class="{{ request()->is('subscription*') ? 'menu-item-active menu-item' : 'menu-item' }}" diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php index ebb134324..c074412d3 100644 --- a/resources/views/layouts/base.blade.php +++ b/resources/views/layouts/base.blade.php @@ -138,7 +138,8 @@ } } let theme = localStorage.theme - let baseColor = '#FCD452' + let cpuColor = '#1e90ff' + let ramColor = '#00ced1' let textColor = '#ffffff' let editorBackground = '#181818' let editorTheme = 'blackboard' @@ -149,12 +150,14 @@ theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' } if (theme == 'dark') { - baseColor = '#FCD452' + cpuColor = '#1e90ff' + ramColor = '#00ced1' textColor = '#ffffff' editorBackground = '#181818' editorTheme = 'blackboard' } else { - baseColor = 'black' + cpuColor = '#1e90ff' + ramColor = '#00ced1' textColor = '#000000' editorBackground = '#ffffff' editorTheme = null diff --git a/resources/views/livewire/global-search.blade.php b/resources/views/livewire/global-search.blade.php new file mode 100644 index 000000000..0792dadfb --- /dev/null +++ b/resources/views/livewire/global-search.blade.php @@ -0,0 +1,236 @@ +<div x-data="{ + modalOpen: false, + selectedIndex: -1, + openModal() { + this.modalOpen = true; + this.selectedIndex = -1; + @this.openSearchModal(); + }, + closeModal() { + this.modalOpen = false; + this.selectedIndex = -1; + @this.closeSearchModal(); + }, + navigateResults(direction) { + const results = document.querySelectorAll('.search-result-item'); + if (results.length === 0) return; + + if (direction === 'down') { + this.selectedIndex = Math.min(this.selectedIndex + 1, results.length - 1); + } else if (direction === 'up') { + this.selectedIndex = Math.max(this.selectedIndex - 1, -1); + } + + if (this.selectedIndex >= 0 && this.selectedIndex < results.length) { + results[this.selectedIndex].focus(); + results[this.selectedIndex].scrollIntoView({ block: 'nearest' }); + } else if (this.selectedIndex === -1) { + this.$refs.searchInput?.focus(); + } + }, + init() { + // Listen for / key press globally + document.addEventListener('keydown', (e) => { + if (e.key === '/' && !['INPUT', 'TEXTAREA'].includes(e.target.tagName) && !this.modalOpen) { + e.preventDefault(); + this.openModal(); + } + }); + + // Listen for Cmd+K or Ctrl+K globally + document.addEventListener('keydown', (e) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + if (this.modalOpen) { + this.closeModal(); + } else { + this.openModal(); + } + } + }); + + // Listen for Escape key to close modal + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && this.modalOpen) { + this.closeModal(); + } + }); + + // Listen for arrow keys when modal is open + document.addEventListener('keydown', (e) => { + if (!this.modalOpen) return; + if (e.key === 'ArrowDown') { + e.preventDefault(); + this.navigateResults('down'); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + this.navigateResults('up'); + } + }); + } +}"> + <!-- Search bar in navbar --> + <div class="flex justify-center"> + <button @click="openModal()" type="button" title="Search (Press / or ⌘K)" + class="flex items-center gap-1.5 px-2.5 py-1.5 bg-neutral-100 dark:bg-coolgray-100 border border-neutral-300 dark:border-coolgray-200 rounded-md hover:bg-neutral-200 dark:hover:bg-coolgray-200 transition-colors"> + <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-neutral-500 dark:text-neutral-400" fill="none" + viewBox="0 0 24 24" stroke="currentColor"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" + d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> + </svg> + <kbd + class="px-1 py-0.5 text-xs font-semibold text-neutral-500 dark:text-neutral-400 bg-neutral-200 dark:bg-coolgray-200 rounded">/</kbd> + </button> + </div> + + <!-- Modal overlay --> + <template x-teleport="body"> + <div x-show="modalOpen" x-cloak + class="fixed top-0 lg:pt-10 left-0 z-99 flex items-start justify-center w-screen h-screen"> + <div @click="closeModal()" class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"> + </div> + <div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100" + x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95" + x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100" + x-transition:leave="ease-in duration-100" + x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100" + x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95" + class="relative w-full py-6 border rounded-sm min-w-full lg:min-w-[36rem] max-w-[48rem] bg-neutral-100 border-neutral-400 dark:bg-base px-7 dark:border-coolgray-300" + @click.stop> + + <div class="flex justify-between items-center pb-3"> + <h3 class="pr-8 text-2xl font-bold">Search</h3> + <button @click="closeModal()" + class="flex absolute top-2 right-2 justify-center items-center w-8 h-8 rounded-full dark:text-white hover:bg-coolgray-300"> + <svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" + stroke-width="1.5" stroke="currentColor"> + <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /> + </svg> + </button> + </div> + + <div class="relative w-auto"> + <input type="text" wire:model.live.debounce.500ms="searchQuery" + placeholder="Type to search for applications, services, databases, and servers..." + x-ref="searchInput" x-init="$watch('modalOpen', value => { if (value) setTimeout(() => $refs.searchInput.focus(), 100) })" class="w-full input mb-4" /> + + <!-- Search results --> + <div class="relative min-h-[330px] max-h-[400px] overflow-y-auto scrollbar"> + <!-- Loading indicator --> + <div wire:loading.flex wire:target="searchQuery" + class="min-h-[330px] items-center justify-center"> + <div class="text-center"> + <svg class="animate-spin mx-auto h-8 w-8 text-neutral-400" + xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> + <circle class="opacity-25" cx="12" cy="12" r="10" + stroke="currentColor" stroke-width="4"></circle> + <path class="opacity-75" fill="currentColor" + d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"> + </path> + </svg> + <p class="mt-2 text-sm text-neutral-600 dark:text-neutral-400"> + Searching... + </p> + </div> + </div> + + <!-- Results content - hidden while loading --> + <div wire:loading.remove wire:target="searchQuery"> + @if (strlen($searchQuery) >= 2 && count($searchResults) > 0) + <div class="space-y-1 my-4 pb-4"> + @foreach ($searchResults as $index => $result) + <a href="{{ $result['link'] ?? '#' }}" + class="search-result-item block p-3 mx-1 hover:bg-neutral-200 dark:hover:bg-coolgray-200 transition-colors focus:outline-none focus:ring-1 focus:ring-coollabs focus:bg-neutral-100 dark:focus:bg-coolgray-200 "> + <div class="flex items-center justify-between"> + <div class="flex-1"> + <div class="flex items-center gap-2"> + <span class="font-medium text-neutral-900 dark:text-white"> + {{ $result['name'] }} + </span> + @if ($result['type'] === 'server') + <span + class="px-2 py-0.5 text-xs rounded bg-coolgray-100 text-white"> + Server + </span> + @endif + </div> + <div class="flex items-center gap-2"> + @if (!empty($result['project']) && !empty($result['environment'])) + <span + class="text-xs text-neutral-500 dark:text-neutral-400"> + {{ $result['project'] }} / {{ $result['environment'] }} + </span> + @endif + @if ($result['type'] === 'application') + <span + class="px-2 py-0.5 text-xs rounded bg-coolgray-100 text-white"> + Application + </span> + @elseif ($result['type'] === 'service') + <span + class="px-2 py-0.5 text-xs rounded bg-coolgray-100 text-white"> + Service + </span> + @elseif ($result['type'] === 'database') + <span + class="px-2 py-0.5 text-xs rounded bg-coolgray-100 text-white"> + {{ ucfirst($result['subtype'] ?? 'Database') }} + </span> + @endif + </div> + @if (!empty($result['description'])) + <div + class="text-sm text-neutral-600 dark:text-neutral-400 mt-0.5"> + {{ Str::limit($result['description'], 100) }} + </div> + @endif + </div> + <svg xmlns="http://www.w3.org/2000/svg" + class="shrink-0 ml-2 h-4 w-4 text-neutral-400" fill="none" + viewBox="0 0 24 24" stroke="currentColor"> + <path stroke-linecap="round" stroke-linejoin="round" + stroke-width="2" d="M9 5l7 7-7 7" /> + </svg> + </div> + </a> + @endforeach + </div> + @elseif (strlen($searchQuery) >= 2 && count($searchResults) === 0) + <div class="flex items-center justify-center min-h-[330px]"> + <div class="text-center"> + <p class="text-sm text-neutral-600 dark:text-neutral-400"> + No results found for "<strong>{{ $searchQuery }}</strong>" + </p> + <p class="text-xs text-neutral-500 dark:text-neutral-500 mt-2"> + Try different keywords or check the spelling + </p> + </div> + </div> + @elseif (strlen($searchQuery) > 0 && strlen($searchQuery) < 2) + <div class="flex items-center justify-center min-h-[330px]"> + <div class="text-center"> + <p class="text-sm text-neutral-600 dark:text-neutral-400"> + Type at least 2 characters to search + </p> + </div> + </div> + @else + <div class="flex items-center justify-center min-h-[330px]"> + <div class="text-center"> + <p class="text-sm text-neutral-600 dark:text-neutral-400"> + Start typing to search + </p> + <p class="text-xs text-neutral-500 dark:text-neutral-500 mt-2"> + Search for applications, services, databases, and servers + </p> + </div> + </div> + @endif + </div> + </div> + </div> + </div> + </div> +</div> +</template> +</div> diff --git a/resources/views/livewire/project/application/advanced.blade.php b/resources/views/livewire/project/application/advanced.blade.php index 6dd5c872c..62d4380e9 100644 --- a/resources/views/livewire/project/application/advanced.blade.php +++ b/resources/views/livewire/project/application/advanced.blade.php @@ -13,6 +13,12 @@ helper="Allow to automatically deploy Preview Deployments for all opened PR's.<br><br>Closing a PR will delete Preview Deployments." instantSave id="isPreviewDeploymentsEnabled" label="Preview Deployments" canGate="update" :canResource="$application" /> + @if ($isPreviewDeploymentsEnabled) + <x-forms.checkbox + helper="When enabled, anyone can trigger PR deployments. When disabled, only repository members, collaborators, and contributors can trigger PR deployments." + instantSave id="isPrDeploymentsPublicEnabled" label="Allow Public PR Deployments" canGate="update" + :canResource="$application" /> + @endif @endif <x-forms.checkbox helper="Disable Docker build cache on every deployment." instantSave id="disableBuildCache" label="Disable Build Cache" canGate="update" :canResource="$application" /> diff --git a/resources/views/livewire/project/application/deployment/show.blade.php b/resources/views/livewire/project/application/deployment/show.blade.php index 0857818d6..b52a6eaf1 100644 --- a/resources/views/livewire/project/application/deployment/show.blade.php +++ b/resources/views/livewire/project/application/deployment/show.blade.php @@ -113,7 +113,7 @@ ])> <span x-show="showTimestamps" class="shrink-0 text-gray-500">{{ $line['timestamp'] }}</span> <span @class([ - 'text-coollabs dark:text-warning' => $line['hidden'], + 'text-success dark:text-warning' => $line['hidden'], 'text-red-500' => $line['stderr'], 'font-bold' => isset($line['command']) && $line['command'], 'whitespace-pre-wrap', diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index 315385593..f2468c6b7 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -8,6 +8,9 @@ <form wire:submit='submit' class="flex flex-col pb-32"> <div class="flex items-center gap-2"> <h2>General</h2> + @if (isDev()) + <div>{{ $application->compose_parsing_version }}</div> + @endif <x-forms.button canGate="update" :canResource="$application" type="submit">Save</x-forms.button> </div> <div>General configuration for your application.</div> @@ -462,12 +465,9 @@ </div> </div> </form> - - <x-domain-conflict-modal - :conflicts="$domainConflicts" - :showModal="$showDomainConflictModal" - confirmAction="confirmDomainUsage" /> - + + <x-domain-conflict-modal :conflicts="$domainConflicts" :showModal="$showDomainConflictModal" confirmAction="confirmDomainUsage" /> + @script <script> $wire.$on('loadCompose', (isInit = true) => { diff --git a/resources/views/livewire/project/application/source.blade.php b/resources/views/livewire/project/application/source.blade.php index 9e746fadb..9d0d53f2e 100644 --- a/resources/views/livewire/project/application/source.blade.php +++ b/resources/views/livewire/project/application/source.blade.php @@ -5,25 +5,25 @@ @can('update', $application) <x-forms.button type="submit">Save</x-forms.button> @endcan - <a target="_blank" class="hover:no-underline" href="{{ $application?->gitBranchLocation }}"> - <x-forms.button> + <div class="flex items-center gap-4 px-2"> + <a target="_blank" class="hover:no-underline flex items-center gap-1" + href="{{ $application?->gitBranchLocation }}"> Open Repository <x-external-link /> - </x-forms.button> - </a> - @if (data_get($application, 'source.is_public') === false) - <a target="_blank" class="hover:no-underline" href="{{ getInstallationPath($application->source) }}"> - <x-forms.button> + </a> + @if (data_get($application, 'source.is_public') === false) + <a target="_blank" class="hover:no-underline flex items-center gap-1" + href="{{ getInstallationPath($application->source) }}"> Open Git App <x-external-link /> - </x-forms.button> - </a> - @endif - <a target="_blank" class="flex hover:no-underline" href="{{ $application?->gitCommits }}"> - <x-forms.button>Open Commits on Git + </a> + @endif + <a target="_blank" class="flex hover:no-underline items-center gap-1" + href="{{ $application?->gitCommits }}"> + Open Commits on Git <x-external-link /> - </x-forms.button> - </a> + </a> + </div> </div> <div class="pb-4">Code source of your application.</div> @@ -34,11 +34,13 @@ </div> @endif <div class="flex gap-2"> - <x-forms.input placeholder="coollabsio/coolify-example" id="gitRepository" label="Repository" canGate="update" :canResource="$application" /> + <x-forms.input placeholder="coollabsio/coolify-example" id="gitRepository" label="Repository" + canGate="update" :canResource="$application" /> <x-forms.input placeholder="main" id="gitBranch" label="Branch" canGate="update" :canResource="$application" /> </div> <div class="flex items-end gap-2"> - <x-forms.input placeholder="HEAD" id="gitCommitSha" placeholder="HEAD" label="Commit SHA" canGate="update" :canResource="$application" /> + <x-forms.input placeholder="HEAD" id="gitCommitSha" placeholder="HEAD" label="Commit SHA" + canGate="update" :canResource="$application" /> </div> </div> diff --git a/resources/views/livewire/project/shared/environment-variable/add.blade.php b/resources/views/livewire/project/shared/environment-variable/add.blade.php index 6dd75aa9a..104cb8003 100644 --- a/resources/views/livewire/project/shared/environment-variable/add.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/add.blade.php @@ -3,17 +3,19 @@ <x-forms.textarea x-show="$wire.is_multiline === true" x-cloak id="value" label="Value" required /> <x-forms.input x-show="$wire.is_multiline === false" x-cloak placeholder="production" id="value" x-bind:label="$wire.is_multiline === false && 'Value'" required /> - @if (data_get($parameters, 'application_uuid')) - <x-forms.checkbox id="is_build_time" - helper="If you are using Docker, remember to modify the file to be ready to receive the build time args. Ex.: for docker file, add `ARG name_of_the_variable`, or dockercompose add `- 'name_of_the_variable=${name_of_the_variable}'`" - label="Is Build Variable?" /> - @endif - <x-forms.checkbox id="is_multiline" label="Is Multiline?" /> - @if (!$shared) + @if (!$shared || $isNixpacks) + <x-forms.checkbox id="is_buildtime" + helper="Make this variable available during Docker build process. Useful for build secrets and dependencies." + label="Available at Buildtime" /> + <x-forms.checkbox id="is_runtime" + helper="Make this variable available in the running container at runtime." + label="Available at Runtime" /> <x-forms.checkbox id="is_literal" helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true." label="Is Literal?" /> @endif + + <x-forms.checkbox id="is_multiline" label="Is Multiline?" /> <x-forms.button type="submit" @click="slideOverOpen=false"> Save </x-forms.button> diff --git a/resources/views/livewire/project/shared/environment-variable/all.blade.php b/resources/views/livewire/project/shared/environment-variable/all.blade.php index c75407179..cee6b291d 100644 --- a/resources/views/livewire/project/shared/environment-variable/all.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/all.blade.php @@ -13,17 +13,32 @@ @endcan </div> <div>Environment variables (secrets) for this resource. </div> - @if ($resourceClass === 'App\Models\Application' && data_get($resource, 'build_pack') !== 'dockercompose') - <div class="w-64 pt-2"> - @can('manageEnvironment', $resource) - <x-forms.checkbox id="is_env_sorting_enabled" label="Sort alphabetically" - helper="Turn this off if one environment is dependent on an other. It will be sorted by creation order (like you pasted them or in the order you created them)." - instantSave></x-forms.checkbox> - @else - <x-forms.checkbox id="is_env_sorting_enabled" label="Sort alphabetically" - helper="Turn this off if one environment is dependent on an other. It will be sorted by creation order (like you pasted them or in the order you created them)." - disabled></x-forms.checkbox> - @endcan + @if ($resourceClass === 'App\Models\Application') + <div class="flex flex-col gap-2 pt-2"> + @if (data_get($resource, 'build_pack') !== 'dockercompose') + <div class="w-64"> + @can('manageEnvironment', $resource) + <x-forms.checkbox id="is_env_sorting_enabled" label="Sort alphabetically" + helper="Turn this off if one environment is dependent on another. It will be sorted by creation order (like you pasted them or in the order you created them)." + instantSave></x-forms.checkbox> + @else + <x-forms.checkbox id="is_env_sorting_enabled" label="Sort alphabetically" + helper="Turn this off if one environment is dependent on another. It will be sorted by creation order (like you pasted them or in the order you created them)." + disabled></x-forms.checkbox> + @endcan + </div> + @endif + <div class="w-64"> + @can('manageEnvironment', $resource) + <x-forms.checkbox id="use_build_secrets" label="Use Docker Build Secrets" + helper="Enable Docker BuildKit secrets for enhanced security during builds. Secrets won't be exposed in the final image. Requires Docker 18.09+ with BuildKit support." + instantSave></x-forms.checkbox> + @else + <x-forms.checkbox id="use_build_secrets" label="Use Docker Build Secrets" + helper="Enable Docker BuildKit secrets for enhanced security during builds. Secrets won't be exposed in the final image. Requires Docker 18.09+ with BuildKit support." + disabled></x-forms.checkbox> + @endcan + </div> </div> @endif @if ($resource->type() === 'service' || $resource?->build_pack === 'dockercompose') @@ -45,14 +60,7 @@ <h3>Production Environment Variables</h3> <div>Environment (secrets) variables for Production.</div> </div> - @php - $requiredEmptyVars = $resource->environment_variables->filter(function ($env) { - return $env->is_required && empty($env->value); - }); - - $otherVars = $resource->environment_variables->diff($requiredEmptyVars); - @endphp - @forelse ($requiredEmptyVars->merge($otherVars) as $env) + @forelse ($this->environmentVariables as $env) <livewire:project.shared.environment-variable.show wire:key="environment-{{ $env->id }}" :env="$env" :type="$resource->type()" /> @empty @@ -63,7 +71,7 @@ <h3>Preview Deployments Environment Variables</h3> <div>Environment (secrets) variables for Preview Deployments.</div> </div> - @foreach ($resource->environment_variables_preview as $env) + @foreach ($this->environmentVariablesPreview as $env) <livewire:project.shared.environment-variable.show wire:key="environment-{{ $env->id }}" :env="$env" :type="$resource->type()" /> @endforeach diff --git a/resources/views/livewire/project/shared/environment-variable/show.blade.php b/resources/views/livewire/project/shared/environment-variable/show.blade.php index 258c65219..80cd19121 100644 --- a/resources/views/livewire/project/shared/environment-variable/show.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/show.blade.php @@ -7,7 +7,7 @@ @if ($isLocked) <div class="flex flex-1 w-full gap-2"> <x-forms.input disabled id="key" /> - <svg class="icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> + <svg class="icon my-1" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> <g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"> <path d="M5 13a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-6z" /> <path d="M11 16a1 1 0 1 0 2 0a1 1 0 0 0-2 0m-3-5V7a4 4 0 1 1 8 0v4" /> @@ -21,6 +21,95 @@ step2ButtonText="Permanently Delete" /> @endcan </div> + @can('update', $this->env) + <div class="flex flex-col w-full gap-3"> + <div class="flex w-full items-center gap-4 overflow-x-auto whitespace-nowrap"> + @if (!$is_redis_credential) + @if ($type === 'service') + <x-forms.checkbox instantSave id="is_buildtime" + helper="Make this variable available during Docker build process. Useful for build secrets and dependencies." + label="Available at Buildtime" /> + <x-forms.checkbox instantSave id="is_runtime" + helper="Make this variable available in the running container at runtime." + label="Available at Runtime" /> + <x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" /> + <x-forms.checkbox instantSave id="is_literal" + helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true." + label="Is Literal?" /> + @else + @if ($is_shared) + <x-forms.checkbox instantSave id="is_literal" + helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true." + label="Is Literal?" /> + @else + @if ($isSharedVariable) + <x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" /> + @else + @if (!$env->is_nixpacks) + <x-forms.checkbox instantSave id="is_buildtime" + helper="Make this variable available during Docker build process. Useful for build secrets and dependencies." + label="Available at Buildtime" /> + @endif + <x-forms.checkbox instantSave id="is_runtime" + helper="Make this variable available in the running container at runtime." + label="Available at Runtime" /> + @if (!$env->is_nixpacks) + <x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" /> + @if ($is_multiline === false) + <x-forms.checkbox instantSave id="is_literal" + helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true." + label="Is Literal?" /> + @endif + @endif + @endif + @endif + @endif + @endif + </div> + </div> + @else + <div class="flex flex-col w-full gap-3"> + <div class="flex w-full items-center gap-4 overflow-x-auto whitespace-nowrap"> + @if (!$is_redis_credential) + @if ($type === 'service') + <x-forms.checkbox disabled id="is_buildtime" + helper="Make this variable available during Docker build process. Useful for build secrets and dependencies." + label="Available at Buildtime" /> + <x-forms.checkbox disabled id="is_runtime" + helper="Make this variable available in the running container at runtime." + label="Available at Runtime" /> + <x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" /> + <x-forms.checkbox disabled id="is_literal" + helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true." + label="Is Literal?" /> + @else + @if ($is_shared) + <x-forms.checkbox disabled id="is_literal" + helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true." + label="Is Literal?" /> + @else + @if ($isSharedVariable) + <x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" /> + @else + <x-forms.checkbox disabled id="is_buildtime" + helper="Make this variable available during Docker build process. Useful for build secrets and dependencies." + label="Available at Buildtime" /> + <x-forms.checkbox disabled id="is_runtime" + helper="Make this variable available in the running container at runtime." + label="Available at Runtime" /> + <x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" /> + @if ($is_multiline === false) + <x-forms.checkbox disabled id="is_literal" + helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true." + label="Is Literal?" /> + @endif + @endif + @endif + @endif + @endif + </div> + </div> + @endcan @else @can('update', $this->env) @if ($isDisabled) @@ -55,107 +144,113 @@ </div> @endcan @can('update', $this->env) - <div class="flex flex-col w-full gap-2 lg:flex-row"> - @if (!$is_redis_credential) - @if ($type === 'service') - <x-forms.checkbox instantSave id="is_build_time" - helper="If you are using Docker, remember to modify the file to be ready to receive the build time args. Ex.: for docker file, add `ARG name_of_the_variable`, or dockercompose add `- 'name_of_the_variable=${name_of_the_variable}'`" - label="Is Build Variable?" /> - <x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" /> - <x-forms.checkbox instantSave id="is_literal" - helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true." - label="Is Literal?" /> - @else - @if ($is_shared) - <x-forms.checkbox instantSave id="is_build_time" - helper="If you are using Docker, remember to modify the file to be ready to receive the build time args. Ex.: for docker file, add `ARG name_of_the_variable`, or dockercompose add `- 'name_of_the_variable=${name_of_the_variable}'`" - label="Is Build Variable?" /> + <div class="flex flex-col w-full gap-3"> + <div class="flex w-full items-center gap-4 overflow-x-auto whitespace-nowrap"> + @if (!$is_redis_credential) + @if ($type === 'service') + <x-forms.checkbox instantSave id="is_buildtime" + helper="Make this variable available during Docker build process. Useful for build secrets and dependencies." + label="Available at Buildtime" /> + <x-forms.checkbox instantSave id="is_runtime" + helper="Make this variable available in the running container at runtime." + label="Available at Runtime" /> + <x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" /> <x-forms.checkbox instantSave id="is_literal" helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true." label="Is Literal?" /> @else - @if ($isSharedVariable) - <x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" /> + @if ($is_shared) + <x-forms.checkbox instantSave id="is_literal" + helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true." + label="Is Literal?" /> @else - <x-forms.checkbox instantSave id="is_build_time" - helper="If you are using Docker, remember to modify the file to be ready to receive the build time args. Ex.: for dockerfile, add `ARG name_of_the_variable`, or dockercompose add `- 'name_of_the_variable=${name_of_the_variable}'`" - label="Is Build Variable?" /> - <x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" /> - @if ($is_multiline === false) - <x-forms.checkbox instantSave id="is_literal" - helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true." - label="Is Literal?" /> + @if ($isSharedVariable) + <x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" /> + @else + @if (!$env->is_nixpacks) + <x-forms.checkbox instantSave id="is_buildtime" + helper="Make this variable available during Docker build process. Useful for build secrets and dependencies." + label="Available at Buildtime" /> + @endif + <x-forms.checkbox instantSave id="is_runtime" + helper="Make this variable available in the running container at runtime." + label="Available at Runtime" /> + @if (!$env->is_nixpacks) + <x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" /> + @if ($is_multiline === false) + <x-forms.checkbox instantSave id="is_literal" + helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true." + label="Is Literal?" /> + @endif + @endif @endif @endif @endif @endif - @endif - <div class="flex-1"></div> - @if ($isDisabled) - <x-forms.button disabled type="submit"> - Update - </x-forms.button> - <x-forms.button wire:click='lock'> - Lock - </x-forms.button> - <x-modal-confirmation title="Confirm Environment Variable Deletion?" isErrorButton - buttonTitle="Delete" submitAction="delete" :actions="['The selected environment variable will be permanently deleted.']" - confirmationText="{{ $key }}" buttonFullWidth="true" - confirmationLabel="Please confirm the execution of the actions by entering the Environment Variable Name below" - shortConfirmationLabel="Environment Variable Name" :confirmWithPassword="false" - step2ButtonText="Permanently Delete" /> - @else - <x-forms.button type="submit"> - Update - </x-forms.button> - <x-forms.button wire:click='lock'> - Lock - </x-forms.button> - <x-modal-confirmation title="Confirm Environment Variable Deletion?" isErrorButton - buttonTitle="Delete" submitAction="delete" :actions="['The selected environment variable will be permanently deleted.']" - confirmationText="{{ $key }}" buttonFullWidth="true" - confirmationLabel="Please confirm the execution of the actions by entering the Environment Variable Name below" - shortConfirmationLabel="Environment Variable Name" :confirmWithPassword="false" - step2ButtonText="Permanently Delete" /> - @endif + </div> + <div class="flex w-full justify-end gap-2"> + @if ($isDisabled) + <x-forms.button disabled type="submit">Update</x-forms.button> + <x-forms.button wire:click='lock'>Lock</x-forms.button> + <x-modal-confirmation title="Confirm Environment Variable Deletion?" isErrorButton + buttonTitle="Delete" submitAction="delete" :actions="['The selected environment variable will be permanently deleted.']" + confirmationText="{{ $key }}" buttonFullWidth="true" + confirmationLabel="Please confirm the execution of the actions by entering the Environment Variable Name below" + shortConfirmationLabel="Environment Variable Name" :confirmWithPassword="false" + step2ButtonText="Permanently Delete" /> + @else + <x-forms.button type="submit">Update</x-forms.button> + <x-forms.button wire:click='lock'>Lock</x-forms.button> + <x-modal-confirmation title="Confirm Environment Variable Deletion?" isErrorButton + buttonTitle="Delete" submitAction="delete" :actions="['The selected environment variable will be permanently deleted.']" + confirmationText="{{ $key }}" buttonFullWidth="true" + confirmationLabel="Please confirm the execution of the actions by entering the Environment Variable Name below" + shortConfirmationLabel="Environment Variable Name" :confirmWithPassword="false" + step2ButtonText="Permanently Delete" /> + @endif + </div> </div> @else - <div class="flex flex-col w-full gap-2 flex-wrap lg:flex-row"> - @if (!$is_redis_credential) - @if ($type === 'service') - <x-forms.checkbox disabled id="is_build_time" - helper="If you are using Docker, remember to modify the file to be ready to receive the build time args. Ex.: for docker file, add `ARG name_of_the_variable`, or dockercompose add `- 'name_of_the_variable=${name_of_the_variable}'`" - label="Is Build Variable?" /> - <x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" /> - <x-forms.checkbox disabled id="is_literal" - helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true." - label="Is Literal?" /> - @else - @if ($is_shared) - <x-forms.checkbox disabled id="is_build_time" - helper="If you are using Docker, remember to modify the file to be ready to receive the build time args. Ex.: for docker file, add `ARG name_of_the_variable`, or dockercompose add `- 'name_of_the_variable=${name_of_the_variable}'`" - label="Is Build Variable?" /> + <div class="flex flex-col w-full gap-3"> + <div class="flex w-full items-center gap-4 overflow-x-auto whitespace-nowrap"> + @if (!$is_redis_credential) + @if ($type === 'service') + <x-forms.checkbox disabled id="is_buildtime" + helper="Make this variable available during Docker build process. Useful for build secrets and dependencies." + label="Available at Buildtime" /> + <x-forms.checkbox disabled id="is_runtime" + helper="Make this variable available in the running container at runtime." + label="Available at Runtime" /> + <x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" /> <x-forms.checkbox disabled id="is_literal" helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true." label="Is Literal?" /> @else - @if ($isSharedVariable) - <x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" /> + @if ($is_shared) + <x-forms.checkbox disabled id="is_literal" + helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true." + label="Is Literal?" /> @else - <x-forms.checkbox disabled id="is_build_time" - helper="If you are using Docker, remember to modify the file to be ready to receive the build time args. Ex.: for dockerfile, add `ARG name_of_the_variable`, or dockercompose add `- 'name_of_the_variable=${name_of_the_variable}'`" - label="Is Build Variable?" /> - <x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" /> - @if ($is_multiline === false) - <x-forms.checkbox disabled id="is_literal" - helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true." - label="Is Literal?" /> + @if ($isSharedVariable) + <x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" /> + @else + <x-forms.checkbox disabled id="is_buildtime" + helper="Make this variable available during Docker build process. Useful for build secrets and dependencies." + label="Available at Buildtime" /> + <x-forms.checkbox disabled id="is_runtime" + helper="Make this variable available in the running container at runtime." + label="Available at Runtime" /> + <x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" /> + @if ($is_multiline === false) + <x-forms.checkbox disabled id="is_literal" + helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true." + label="Is Literal?" /> + @endif @endif @endif @endif @endif - @endif - <div class="flex-1"></div> + </div> </div> @endcan @endif diff --git a/resources/views/livewire/project/shared/execute-container-command.blade.php b/resources/views/livewire/project/shared/execute-container-command.blade.php index 7fe208a9b..f980d6f3c 100644 --- a/resources/views/livewire/project/shared/execute-container-command.blade.php +++ b/resources/views/livewire/project/shared/execute-container-command.blade.php @@ -20,7 +20,11 @@ @if (count($containers) === 0) <div>No containers are running or terminal access is disabled on this server.</div> @else - <form class="w-full flex gap-2 items-end" wire:submit="$dispatchSelf('connectToContainer')"> + <form class="w-96 min-w-fit flex gap-2 items-end" wire:submit="$dispatchSelf('connectToContainer')" + x-data="{ autoConnected: false }" x-init="if ({{ count($containers) }} === 1 && !autoConnected) { + autoConnected = true; + $nextTick(() => $wire.dispatchSelf('connectToContainer')); + }"> <x-forms.select label="Container" id="container" required wire:model.live="selected_container"> @foreach ($containers as $container) @if ($loop->first) diff --git a/resources/views/livewire/project/shared/metrics.blade.php b/resources/views/livewire/project/shared/metrics.blade.php index cfe83ded6..7c7456b5e 100644 --- a/resources/views/livewire/project/shared/metrics.blade.php +++ b/resources/views/livewire/project/shared/metrics.blade.php @@ -1,21 +1,20 @@ <div> - <div class="flex items-center gap-2 "> + <div class="flex items-center gap-2"> <h2>Metrics</h2> </div> - <div class="pb-4">Basic metrics for your container.</div> - @if ($resource->getMorphClass() === 'App\Models\Application' && $resource->build_pack === 'dockercompose') - <div class="alert alert-warning">Metrics are not available for Docker Compose applications yet!</div> - @elseif(!$resource->destination->server->isMetricsEnabled()) - <div class="alert alert-warning">Metrics are only available for servers with Sentinel & Metrics enabled!</div> - <div> Go to <a class="underline dark:text-white" - href="{{ route('server.show', $resource->destination->server->uuid) }}">Server settings</a> to - enable - it.</div> - @else - @if (!str($resource->status)->contains('running')) - <div class="alert alert-warning">Metrics are only available when this resource is running!</div> + <div class="pb-4">Basic metrics for your application container.</div> + <div> + @if ($resource->getMorphClass() === 'App\Models\Application' && $resource->build_pack === 'dockercompose') + <div class="alert alert-warning">Metrics are not available for Docker Compose applications yet!</div> + @elseif(!$resource->destination->server->isMetricsEnabled()) + <div class="alert alert-warning">Metrics are only available for servers with Sentinel & Metrics enabled!</div> + <div>Go to <a class="underline dark:text-white" href="{{ route('server.show', $resource->destination->server->uuid) }}">Server settings</a> to enable it.</div> @else - <x-forms.select label="Interval" wire:change="setInterval" id="interval"> + @if (!str($resource->status)->contains('running')) + <div class="alert alert-warning">Metrics are only available when the application container is running!</div> + @else + <div> + <x-forms.select label="Interval" wire:change="setInterval" id="interval"> <option value="5">5 minutes (live)</option> <option value="10">10 minutes (live)</option> <option value="30">30 minutes</option> @@ -26,7 +25,7 @@ </x-forms.select> <div @if ($poll) wire:poll.5000ms='pollData' @endif x-init="$wire.loadData()" class="pt-5"> - <h4>CPU (%)</h4> + <h4>CPU Usage</h4> <div wire:ignore id="{!! $chartId !!}-cpu"></div> <script> @@ -34,6 +33,7 @@ const optionsServerCpu = { stroke: { curve: 'straight', + width: 2, }, chart: { height: '150px', @@ -52,7 +52,7 @@ }, }, animations: { - enabled: false, + enabled: true, }, }, fill: { @@ -68,74 +68,90 @@ enabled: false, } }, - grid: { - show: true, - borderColor: '', - }, - colors: [baseColor], - xaxis: { - type: 'datetime', - }, - series: [{ - name: "CPU %", - data: [] - }], - noData: { - text: 'Loading...', - style: { - color: textColor, - } - }, - tooltip: { - enabled: true, - marker: { - show: false, - } - }, - legend: { - show: false - } + grid: { + show: true, + borderColor: '', + }, + colors: [cpuColor], + xaxis: { + type: 'datetime', + }, + series: [{ + name: "CPU %", + data: [] + }], + noData: { + text: 'Loading...', + style: { + color: textColor, + } + }, + tooltip: { + enabled: true, + marker: { + show: false, + }, + custom: function({ series, seriesIndex, dataPointIndex, w }) { + const value = series[seriesIndex][dataPointIndex]; + const timestamp = w.globals.seriesX[seriesIndex][dataPointIndex]; + const date = new Date(timestamp); + const timeString = String(date.getUTCHours()).padStart(2, '0') + ':' + + String(date.getUTCMinutes()).padStart(2, '0') + ':' + + String(date.getUTCSeconds()).padStart(2, '0') + ', ' + + date.getUTCFullYear() + '-' + + String(date.getUTCMonth() + 1).padStart(2, '0') + '-' + + String(date.getUTCDate()).padStart(2, '0'); + return '<div class="apexcharts-tooltip-custom">' + + '<div class="apexcharts-tooltip-custom-value">CPU: <span class="apexcharts-tooltip-value-bold">' + value + '%</span></div>' + + '<div class="apexcharts-tooltip-custom-title">' + timeString + '</div>' + + '</div>'; + } + }, + legend: { + show: false + } } - const serverCpuChart = new ApexCharts(document.getElementById(`{!! $chartId !!}-cpu`), optionsServerCpu); - serverCpuChart.render(); - document.addEventListener('livewire:init', () => { - Livewire.on('refreshChartData-{!! $chartId !!}-cpu', (chartData) => { - checkTheme(); - serverCpuChart.updateOptions({ - series: [{ - data: chartData[0].seriesData, - }], - colors: [baseColor], - xaxis: { - type: 'datetime', - labels: { - show: true, - style: { - colors: textColor, - } - } - }, - yaxis: { - show: true, - labels: { - show: true, - style: { - colors: textColor, - } - } - }, - noData: { - text: 'Loading...', - style: { - color: textColor, - } - } - }); - }); - }); + const serverCpuChart = new ApexCharts(document.getElementById(`{!! $chartId !!}-cpu`), optionsServerCpu); + serverCpuChart.render(); + Livewire.on('refreshChartData-{!! $chartId !!}-cpu', (chartData) => { + checkTheme(); + serverCpuChart.updateOptions({ + series: [{ + data: chartData[0].seriesData, + }], + colors: [cpuColor], + xaxis: { + type: 'datetime', + labels: { + show: true, + style: { + colors: textColor, + } + } + }, + yaxis: { + show: true, + labels: { + show: true, + style: { + colors: textColor, + }, + formatter: function(value) { + return Math.round(value) + ' %'; + } + } + }, + noData: { + text: 'Loading...', + style: { + color: textColor, + } + } + }); + }); </script> - <h3>Memory (MB)</h3> + <h4>Memory Usage</h4> <div wire:ignore id="{!! $chartId !!}-memory"></div> <script> @@ -143,6 +159,7 @@ const optionsServerMemory = { stroke: { curve: 'straight', + width: 2, }, chart: { height: '150px', @@ -161,7 +178,7 @@ }, }, animations: { - enabled: false, + enabled: true, }, }, fill: { @@ -177,81 +194,99 @@ enabled: false, } }, - grid: { - show: true, - borderColor: '', - }, - colors: [baseColor], - xaxis: { - type: 'datetime', - labels: { - show: true, - style: { - colors: textColor, - } - } - }, - series: [{ - name: "Memory (MB)", - data: [] - }], - noData: { - text: 'Loading...', - style: { - color: textColor, - } - }, - tooltip: { - enabled: true, - marker: { - show: false, - } - }, - legend: { - show: false - } + grid: { + show: true, + borderColor: '', + }, + colors: [ramColor], + xaxis: { + type: 'datetime', + labels: { + show: true, + style: { + colors: textColor, + } + } + }, + series: [{ + name: "Memory (MB)", + data: [] + }], + noData: { + text: 'Loading...', + style: { + color: textColor, + } + }, + tooltip: { + enabled: true, + marker: { + show: false, + }, + custom: function({ series, seriesIndex, dataPointIndex, w }) { + const value = series[seriesIndex][dataPointIndex]; + const timestamp = w.globals.seriesX[seriesIndex][dataPointIndex]; + const date = new Date(timestamp); + const timeString = String(date.getUTCHours()).padStart(2, '0') + ':' + + String(date.getUTCMinutes()).padStart(2, '0') + ':' + + String(date.getUTCSeconds()).padStart(2, '0') + ', ' + + date.getUTCFullYear() + '-' + + String(date.getUTCMonth() + 1).padStart(2, '0') + '-' + + String(date.getUTCDate()).padStart(2, '0'); + return '<div class="apexcharts-tooltip-custom">' + + '<div class="apexcharts-tooltip-custom-value">Memory: <span class="apexcharts-tooltip-value-bold">' + value + ' MB</span></div>' + + '<div class="apexcharts-tooltip-custom-title">' + timeString + '</div>' + + '</div>'; + } + }, + legend: { + show: false + } } - const serverMemoryChart = new ApexCharts(document.getElementById(`{!! $chartId !!}-memory`), - optionsServerMemory); - serverMemoryChart.render(); - document.addEventListener('livewire:init', () => { - Livewire.on('refreshChartData-{!! $chartId !!}-memory', (chartData) => { - checkTheme(); - serverMemoryChart.updateOptions({ - series: [{ - data: chartData[0].seriesData, - }], - colors: [baseColor], - xaxis: { - type: 'datetime', - labels: { - show: true, - style: { - colors: textColor, - } - } - }, - yaxis: { - min: 0, - show: true, - labels: { - show: true, - style: { - colors: textColor, - } - } - }, - noData: { - text: 'Loading...', - style: { - color: textColor, - } - } - }); - }); - }); + const serverMemoryChart = new ApexCharts(document.getElementById(`{!! $chartId !!}-memory`), + optionsServerMemory); + serverMemoryChart.render(); + Livewire.on('refreshChartData-{!! $chartId !!}-memory', (chartData) => { + checkTheme(); + serverMemoryChart.updateOptions({ + series: [{ + data: chartData[0].seriesData, + }], + colors: [ramColor], + xaxis: { + type: 'datetime', + labels: { + show: true, + style: { + colors: textColor, + } + } + }, + yaxis: { + min: 0, + show: true, + labels: { + show: true, + style: { + colors: textColor, + }, + formatter: function(value) { + return Math.round(value) + ' MB'; + } + } + }, + noData: { + text: 'Loading...', + style: { + color: textColor, + } + } + }); + }); </script> </div> + </div> @endif @endif + </div> </div> diff --git a/resources/views/livewire/project/shared/scheduled-task/executions.blade.php b/resources/views/livewire/project/shared/scheduled-task/executions.blade.php index 8f0f309c6..2ed3adc0c 100644 --- a/resources/views/livewire/project/shared/scheduled-task/executions.blade.php +++ b/resources/views/livewire/project/shared/scheduled-task/executions.blade.php @@ -14,7 +14,7 @@ }"> @forelse($executions as $execution) <a wire:click="selectTask({{ data_get($execution, 'id') }})" @class([ - 'flex flex-col border-l-2 transition-colors p-4 cursor-pointer bg-white hover:bg-gray-100 dark:bg-coolgray-100 dark:hover:bg-coolgray-200 text-black dark:text-white', + 'relative flex flex-col border-l-2 transition-colors p-4 cursor-pointer bg-white hover:bg-gray-100 dark:bg-coolgray-100 dark:hover:bg-coolgray-200 text-black dark:text-white', 'bg-gray-200 dark:bg-coolgray-200' => data_get($execution, 'id') == $selectedKey, 'border-blue-500/50 border-dashed' => data_get($execution, 'status') === 'running', 'border-error' => data_get($execution, 'status') === 'failed', @@ -67,18 +67,22 @@ @endif @if ($this->logLines->isNotEmpty()) <div> - <pre class="whitespace-pre-wrap"> + <div class="max-h-[600px] overflow-y-auto border border-gray-200 dark:border-coolgray-300 rounded p-4 bg-gray-50 dark:bg-coolgray-100 scrollbar"> + <pre class="whitespace-pre-wrap"> @foreach ($this->logLines as $line) {{ $line }} @endforeach </pre> - <div class="flex gap-2"> + </div> + <div class="flex gap-2 mt-4"> @if ($this->hasMoreLogs()) <x-forms.button wire:click.prevent="loadMoreLogs" isHighlighted> Load More </x-forms.button> + <x-forms.button wire:click.prevent="loadAllLogs"> + Load All + </x-forms.button> @endif - </div> </div> @else diff --git a/resources/views/livewire/project/shared/storages/all.blade.php b/resources/views/livewire/project/shared/storages/all.blade.php index 4ed1d1b52..d62362562 100644 --- a/resources/views/livewire/project/shared/storages/all.blade.php +++ b/resources/views/livewire/project/shared/storages/all.blade.php @@ -3,11 +3,10 @@ @foreach ($resource->persistentStorages as $storage) @if ($resource->type() === 'service') <livewire:project.shared.storages.show wire:key="storage-{{ $storage->id }}" :storage="$storage" - :resource="$resource" :isFirst="$loop->first" isService='true' /> + :resource="$resource" :isFirst="$storage->id === $this->firstStorageId" isService='true' /> @else <livewire:project.shared.storages.show wire:key="storage-{{ $storage->id }}" :storage="$storage" - :resource="$resource" isReadOnly="{{ data_get($storage, 'is_readonly') }}" - startedAt="{{ data_get($resource, 'started_at') }}" /> + :resource="$resource" :isFirst="$storage->id === $this->firstStorageId" startedAt="{{ data_get($resource, 'started_at') }}" /> @endif @endforeach </div> diff --git a/resources/views/livewire/server/charts.blade.php b/resources/views/livewire/server/charts.blade.php index b84e0240f..6a64d3d7d 100644 --- a/resources/views/livewire/server/charts.blade.php +++ b/resources/views/livewire/server/charts.blade.php @@ -7,7 +7,7 @@ <x-server.sidebar :server="$server" activeMenu="metrics" /> <div class="w-full"> <h2>Metrics</h2> - <div class="pb-4">Basic metrics for your container.</div> + <div class="pb-4">Basic metrics for your server.</div> @if ($server->isMetricsEnabled()) <div @if ($poll) wire:poll.5000ms='pollData' @endif x-init="$wire.loadData()"> <x-forms.select label="Interval" wire:change="setInterval" id="interval"> @@ -19,7 +19,7 @@ <option value="10080">1 week</option> <option value="43200">30 days</option> </x-forms.select> - <h4 class="pt-4">CPU (%)</h4> + <h4 class="pt-4">CPU Usage</h4> <div wire:ignore id="{!! $chartId !!}-cpu"></div> <script> @@ -27,6 +27,7 @@ const optionsServerCpu = { stroke: { curve: 'straight', + width: 2, }, chart: { height: '150px', @@ -45,7 +46,7 @@ }, }, animations: { - enabled: false, + enabled: true, }, }, fill: { @@ -61,16 +62,16 @@ enabled: false, } }, - grid: { - show: true, - borderColor: '', - }, - colors: [baseColor], - xaxis: { - type: 'datetime', - }, - series: [{ - name: 'CPU %', + grid: { + show: true, + borderColor: '', + }, + colors: [cpuColor], + xaxis: { + type: 'datetime', + }, + series: [{ + name: 'CPU %', data: [] }], noData: { @@ -79,12 +80,27 @@ color: textColor, } }, - tooltip: { - enabled: true, - marker: { - show: false, - } - }, + tooltip: { + enabled: true, + marker: { + show: false, + }, + custom: function({ series, seriesIndex, dataPointIndex, w }) { + const value = series[seriesIndex][dataPointIndex]; + const timestamp = w.globals.seriesX[seriesIndex][dataPointIndex]; + const date = new Date(timestamp); + const timeString = String(date.getUTCHours()).padStart(2, '0') + ':' + + String(date.getUTCMinutes()).padStart(2, '0') + ':' + + String(date.getUTCSeconds()).padStart(2, '0') + ', ' + + date.getUTCFullYear() + '-' + + String(date.getUTCMonth() + 1).padStart(2, '0') + '-' + + String(date.getUTCDate()).padStart(2, '0'); + return '<div class="apexcharts-tooltip-custom">' + + '<div class="apexcharts-tooltip-custom-value">CPU: <span class="apexcharts-tooltip-value-bold">' + value + '%</span></div>' + + '<div class="apexcharts-tooltip-custom-title">' + timeString + '</div>' + + '</div>'; + } + }, legend: { show: false } @@ -95,11 +111,11 @@ document.addEventListener('livewire:init', () => { Livewire.on('refreshChartData-{!! $chartId !!}-cpu', (chartData) => { checkTheme(); - serverCpuChart.updateOptions({ - series: [{ - data: chartData[0].seriesData, - }], - colors: [baseColor], + serverCpuChart.updateOptions({ + series: [{ + data: chartData[0].seriesData, + }], + colors: [cpuColor], xaxis: { type: 'datetime', labels: { @@ -109,15 +125,18 @@ } } }, - yaxis: { - show: true, - labels: { - show: true, - style: { - colors: textColor, - } - } - }, + yaxis: { + show: true, + labels: { + show: true, + style: { + colors: textColor, + }, + formatter: function(value) { + return Math.round(value) + ' %'; + } + } + }, noData: { text: 'Loading...', style: { @@ -130,7 +149,7 @@ </script> <div> - <h4>Memory (%)</h4> + <h4>Memory Usage</h4> <div wire:ignore id="{!! $chartId !!}-memory"></div> <script> @@ -138,6 +157,7 @@ const optionsServerMemory = { stroke: { curve: 'straight', + width: 2, }, chart: { height: '150px', @@ -156,7 +176,7 @@ }, }, animations: { - enabled: false, + enabled: true, }, }, fill: { @@ -172,15 +192,15 @@ enabled: false, } }, - grid: { - show: true, - borderColor: '', - }, - colors: [baseColor], - xaxis: { - type: 'datetime', - labels: { - show: true, + grid: { + show: true, + borderColor: '', + }, + colors: [ramColor], + xaxis: { + type: 'datetime', + labels: { + show: true, style: { colors: textColor, } @@ -196,12 +216,27 @@ color: textColor, } }, - tooltip: { - enabled: true, - marker: { - show: false, - } - }, + tooltip: { + enabled: true, + marker: { + show: false, + }, + custom: function({ series, seriesIndex, dataPointIndex, w }) { + const value = series[seriesIndex][dataPointIndex]; + const timestamp = w.globals.seriesX[seriesIndex][dataPointIndex]; + const date = new Date(timestamp); + const timeString = String(date.getUTCHours()).padStart(2, '0') + ':' + + String(date.getUTCMinutes()).padStart(2, '0') + ':' + + String(date.getUTCSeconds()).padStart(2, '0') + ', ' + + date.getUTCFullYear() + '-' + + String(date.getUTCMonth() + 1).padStart(2, '0') + '-' + + String(date.getUTCDate()).padStart(2, '0'); + return '<div class="apexcharts-tooltip-custom">' + + '<div class="apexcharts-tooltip-custom-value">Memory: <span class="apexcharts-tooltip-value-bold">' + value + '%</span></div>' + + '<div class="apexcharts-tooltip-custom-title">' + timeString + '</div>' + + '</div>'; + } + }, legend: { show: false } @@ -212,11 +247,11 @@ document.addEventListener('livewire:init', () => { Livewire.on('refreshChartData-{!! $chartId !!}-memory', (chartData) => { checkTheme(); - serverMemoryChart.updateOptions({ - series: [{ - data: chartData[0].seriesData, - }], - colors: [baseColor], + serverMemoryChart.updateOptions({ + series: [{ + data: chartData[0].seriesData, + }], + colors: [ramColor], xaxis: { type: 'datetime', labels: { @@ -226,16 +261,19 @@ } } }, - yaxis: { - min: 0, - show: true, - labels: { - show: true, - style: { - colors: textColor, - } - } - }, + yaxis: { + min: 0, + show: true, + labels: { + show: true, + style: { + colors: textColor, + }, + formatter: function(value) { + return Math.round(value) + ' %'; + } + } + }, noData: { text: 'Loading...', style: { diff --git a/resources/views/livewire/server/proxy.blade.php b/resources/views/livewire/server/proxy.blade.php index 506b05e87..c40bdd31b 100644 --- a/resources/views/livewire/server/proxy.blade.php +++ b/resources/views/livewire/server/proxy.blade.php @@ -7,37 +7,60 @@ <div class="flex items-center gap-2"> <h2>Configuration</h2> @if ($server->proxy->status === 'exited' || $server->proxy->status === 'removing') - <x-forms.button canGate="update" :canResource="$server" wire:click.prevent="changeProxy">Switch Proxy</x-forms.button> + @can('update', $server) + <x-modal-confirmation title="Confirm Proxy Switching?" buttonTitle="Switch Proxy" + submitAction="changeProxy" :actions="['Custom proxy configurations may be reset to their default settings.']" + warningMessage="This operation may cause issues. Please refer to the guide <a href='https://coolify.io/docs/knowledge-base/server/proxies#switch-between-proxies' target='_blank' class='underline text-white'>switching between proxies</a> before proceeding!" + step2ButtonText="Switch Proxy" :confirmWithText="false" :confirmWithPassword="false"> + </x-modal-confirmation> + @endcan @else - <x-forms.button canGate="update" :canResource="$server" disabled wire:click.prevent="changeProxy">Switch Proxy</x-forms.button> + <x-forms.button canGate="update" :canResource="$server" + wire:click="$dispatch('error', 'Currently running proxy must be stopped before switching proxy')">Switch + Proxy</x-forms.button> @endif <x-forms.button canGate="update" :canResource="$server" type="submit">Save</x-forms.button> </div> - <div class="pb-4 "> <svg class="inline-flex w-6 h-6 mr-2 dark:text-warning" viewBox="0 0 256 256" - xmlns="http://www.w3.org/2000/svg"> - <path fill="currentColor" - d="M240.26 186.1L152.81 34.23a28.74 28.74 0 0 0-49.62 0L15.74 186.1a27.45 27.45 0 0 0 0 27.71A28.31 28.31 0 0 0 40.55 228h174.9a28.31 28.31 0 0 0 24.79-14.19a27.45 27.45 0 0 0 .02-27.71m-20.8 15.7a4.46 4.46 0 0 1-4 2.2H40.55a4.46 4.46 0 0 1-4-2.2a3.56 3.56 0 0 1 0-3.73L124 46.2a4.77 4.77 0 0 1 8 0l87.44 151.87a3.56 3.56 0 0 1 .02 3.73M116 136v-32a12 12 0 0 1 24 0v32a12 12 0 0 1-24 0m28 40a16 16 0 1 1-16-16a16 16 0 0 1 16 16" /> - </svg>Before switching proxies, please read <a class="underline dark:text-white" - href="https://coolify.io/docs/knowledge-base/server/proxies#switch-between-proxies">this</a>. - </div> + <div class="subtitle">Configure your proxy settings and advanced options.</div> <h3>Advanced</h3> - <div class="pb-4 w-96"> + <div class="pb-6 w-96"> <x-forms.checkbox canGate="update" :canResource="$server" helper="If set, all resources will only have docker container labels for {{ str($server->proxyType())->title() }}.<br>For applications, labels needs to be regenerated manually. <br>Resources needs to be restarted." id="server.settings.generate_exact_labels" label="Generate labels only for {{ str($server->proxyType())->title() }}" instantSave /> <x-forms.checkbox canGate="update" :canResource="$server" instantSave="instantSaveRedirect" - id="redirect_enabled" label="Override default request handler" + id="redirectEnabled" label="Override default request handler" helper="Requests to unknown hosts or stopped services will receive a 503 response or be redirected to the URL you set below (need to enable this first)." /> - @if ($redirect_enabled) + @if ($redirectEnabled) <x-forms.input canGate="update" :canResource="$server" placeholder="https://app.coolify.io" - id="redirect_url" label="Redirect to (optional)" /> + id="redirectUrl" label="Redirect to (optional)" /> @endif </div> - @if ($server->proxyType() === ProxyTypes::TRAEFIK->value) - <h3>Traefik</h3> - @elseif ($server->proxyType() === 'CADDY') - <h3>Caddy</h3> + @php + $proxyTitle = + $server->proxyType() === ProxyTypes::TRAEFIK->value + ? 'Traefik (Coolify Proxy)' + : 'Caddy (Coolify Proxy)'; + @endphp + @if ($server->proxyType() === ProxyTypes::TRAEFIK->value || $server->proxyType() === 'CADDY') + <div class="flex items-center gap-2"> + <h3>{{ $proxyTitle }}</h3> + @if ($proxySettings) + @can('update', $server) + <x-modal-confirmation title="Reset Proxy Configuration?" + buttonTitle="Reset Configuration" submitAction="resetProxyConfiguration" + :actions="[ + 'Reset proxy configuration to default settings', + 'All custom configurations will be lost', + 'Custom ports and entrypoints will be removed', + ]" confirmationText="{{ $server->name }}" + confirmationLabel="Please confirm by entering the server name below" + shortConfirmationLabel="Server Name" step2ButtonText="Reset Configuration" + :confirmWithPassword="false" :confirmWithText="true"> + </x-modal-confirmation> + @endcan + @endif + </div> @endif @if ( $server->proxy->last_applied_settings && @@ -50,15 +73,12 @@ <x-loading text="Loading proxy configuration..." /> </div> <div wire:loading.remove wire:target="loadProxyConfiguration"> - @if ($proxy_settings) - <div class="flex flex-col gap-2 pt-4"> + @if ($proxySettings) + <div class="flex flex-col gap-2 pt-2"> <x-forms.textarea canGate="update" :canResource="$server" useMonacoEditor - monacoEditorLanguage="yaml" label="Configuration file" name="proxy_settings" - id="proxy_settings" rows="30" /> - <x-forms.button canGate="update" :canResource="$server" - wire:click.prevent="reset_proxy_configuration"> - Reset configuration to default - </x-forms.button> + monacoEditorLanguage="yaml" + label="Configuration file ( {{ $this->configurationFilePath }} )" + name="proxySettings" id="proxySettings" rows="30" /> </div> @endif </div> diff --git a/resources/views/livewire/server/show.blade.php b/resources/views/livewire/server/show.blade.php index 8d08f26da..c463b1b74 100644 --- a/resources/views/livewire/server/show.blade.php +++ b/resources/views/livewire/server/show.blade.php @@ -211,6 +211,14 @@ :canResource="$server">Save</x-forms.button> <x-forms.button wire:click='restartSentinel' canGate="update" :canResource="$server">Restart</x-forms.button> + <x-slide-over fullScreen> + <x-slot:title>Sentinel Logs</x-slot:title> + <x-slot:content> + <livewire:project.shared.get-logs :server="$server" + container="coolify-sentinel" lazy /> + </x-slot:content> + <x-forms.button @click="slideOverOpen=true">Logs</x-forms.button> + </x-slide-over> @else <x-status.stopped status="Out of sync" noLoading title="{{ $sentinelUpdatedAt }}" /> @@ -218,6 +226,14 @@ :canResource="$server">Save</x-forms.button> <x-forms.button wire:click='restartSentinel' canGate="update" :canResource="$server">Sync</x-forms.button> + <x-slide-over fullScreen> + <x-slot:title>Sentinel Logs</x-slot:title> + <x-slot:content> + <livewire:project.shared.get-logs :server="$server" + container="coolify-sentinel" lazy /> + </x-slot:content> + <x-forms.button @click="slideOverOpen=true">Logs</x-forms.button> + </x-slide-over> @endif </div> @endif @@ -243,6 +259,22 @@ label="Enable Metrics (enable Sentinel first)" /> @endif </div> + @if (isDev() && $server->isSentinelEnabled()) + <div class="pt-4" x-data="{ + customImage: localStorage.getItem('sentinel_custom_docker_image_{{ $server->uuid }}') || '', + saveCustomImage() { + localStorage.setItem('sentinel_custom_docker_image_{{ $server->uuid }}', this.customImage); + $wire.set('sentinelCustomDockerImage', this.customImage); + } + }" x-init="$wire.set('sentinelCustomDockerImage', customImage)"> + <x-forms.input + x-model="customImage" + @input.debounce.500ms="saveCustomImage()" + placeholder="e.g., sentinel:latest or myregistry/sentinel:dev" + label="Custom Sentinel Docker Image (Dev Only)" + helper="Override the default Sentinel Docker image for testing. Leave empty to use the default." /> + </div> + @endif @if ($server->isSentinelEnabled()) <div class="flex flex-wrap gap-2 sm:flex-nowrap items-end"> <x-forms.input canGate="update" :canResource="$server" type="password" id="sentinelToken" diff --git a/resources/views/livewire/settings-dropdown.blade.php b/resources/views/livewire/settings-dropdown.blade.php index c67e02923..37cc420ed 100644 --- a/resources/views/livewire/settings-dropdown.blade.php +++ b/resources/views/livewire/settings-dropdown.blade.php @@ -242,6 +242,9 @@ <p class="mt-1 text-sm dark:text-neutral-400"> Stay up to date with the latest features and improvements. </p> + <p class="mt-1 text-xs dark:text-neutral-500"> + Current version: <span class="font-semibold dark:text-neutral-300">{{ $currentVersion }}</span> + </p> </div> <div class="flex items-center gap-2"> @if (isDev()) @@ -299,6 +302,10 @@ <span x-text="entry.title"></span> <x-external-link /> </a></span> + <span x-show="entry.tag_name === '{{ $currentVersion }}'" + class="px-2 py-1 text-xs font-semibold bg-success text-white rounded-sm"> + CURRENT VERSION + </span> <span class="text-xs dark:text-neutral-400" x-text="new Date(entry.published_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })"></span> </div> diff --git a/routes/web.php b/routes/web.php index 02b23cc37..e6567daad 100644 --- a/routes/web.php +++ b/routes/web.php @@ -326,7 +326,11 @@ Route::middleware(['auth'])->group(function () { 'root' => '/', ]); if (! $disk->exists($filename)) { - return response()->json(['message' => 'Backup not found.'], 404); + if ($execution->scheduledDatabaseBackup->disable_local_backup === true && $execution->scheduledDatabaseBackup->save_s3 === true) { + return response()->json(['message' => 'Backup not available locally, but available on S3.'], 404); + } + + return response()->json(['message' => 'Backup not found locally on the server.'], 404); } return new StreamedResponse(function () use ($disk, $filename) { diff --git a/templates/compose/appwrite.yaml b/templates/compose/appwrite.yaml index 1645eba84..07f7336e1 100644 --- a/templates/compose/appwrite.yaml +++ b/templates/compose/appwrite.yaml @@ -23,6 +23,7 @@ services: environment: - SERVICE_URL_APPWRITE=/ - _APP_ENV=${_APP_ENV:-production} + - _APP_EDITION=${_APP_EDITION:-self-hosted} - _APP_WORKER_PER_CORE=${_APP_WORKER_PER_CORE:-6} - _APP_LOCALE=${_APP_LOCALE:-en} - _APP_COMPRESSION_MIN_SIZE_BYTES=${_APP_COMPRESSION_MIN_SIZE_BYTES} @@ -41,11 +42,14 @@ services: - _APP_OPTIONS_FORCE_HTTPS=${_APP_OPTIONS_FORCE_HTTPS:-disabled} - _APP_OPTIONS_ROUTER_FORCE_HTTPS=${_APP_OPTIONS_ROUTER_FORCE_HTTPS:-disabled} - _APP_OPENSSL_KEY_V1=$SERVICE_PASSWORD_64_APPWRITE - - _APP_DOMAIN=$SERVICE_URL_APPWRITE + - _APP_CONSOLE_DOMAIN=${_APP_CONSOLE_DOMAIN} + - _APP_DOMAIN=${_APP_DOMAIN:-$SERVICE_FQDN_APPWRITE} - _APP_DOMAIN_TARGET_CNAME=${_APP_DOMAIN_TARGET_CNAME:-localhost} - _APP_DOMAIN_TARGET_AAAA=${_APP_DOMAIN_TARGET_AAAA:-::1} - _APP_DOMAIN_TARGET_A=${_APP_DOMAIN_TARGET_A:-127.0.0.1} - - _APP_DOMAIN_FUNCTIONS=$SERVICE_URL_APPWRITE + - _APP_DOMAIN_TARGET_CAA=${_APP_DOMAIN_TARGET_CAA} + - _APP_DOMAIN_FUNCTIONS=${_APP_DOMAIN_FUNCTIONS:-functions.$SERVICE_FQDN_APPWRITE} + - _APP_DNS=${_APP_DNS} - _APP_REDIS_HOST=${_APP_REDIS_HOST:-appwrite-redis} - _APP_REDIS_PORT=${_APP_REDIS_PORT:-6379} - _APP_REDIS_USER=${_APP_REDIS_USER} @@ -96,7 +100,7 @@ services: - _APP_COMPUTE_MEMORY=${_APP_COMPUTE_MEMORY:-0} - _APP_FUNCTIONS_RUNTIMES=${_APP_FUNCTIONS_RUNTIMES:-node-20.0,php-8.2,python-3.11,ruby-3.2} - _APP_SITES_RUNTIMES=${_APP_SITES_RUNTIMES} - - _APP_DOMAIN_SITES=${_APP_DOMAIN_SITES:-appwrite.network} + - _APP_DOMAIN_SITES=${_APP_DOMAIN_SITES:-sites.$SERVICE_FQDN_APPWRITE} - _APP_EXECUTOR_SECRET=$SERVICE_PASSWORD_64_APPWRITE - _APP_EXECUTOR_HOST=${_APP_EXECUTOR_HOST:-http://appwrite-executor/v1} - _APP_LOGGING_CONFIG=${_APP_LOGGING_CONFIG} @@ -124,9 +128,20 @@ services: - _APP_MIGRATIONS_FIREBASE_CLIENT_ID=${_APP_MIGRATIONS_FIREBASE_CLIENT_ID} - _APP_MIGRATIONS_FIREBASE_CLIENT_SECRET=${_APP_MIGRATIONS_FIREBASE_CLIENT_SECRET} - _APP_ASSISTANT_OPENAI_API_KEY=${_APP_ASSISTANT_OPENAI_API_KEY} + - _APP_MESSAGE_SMS_TEST_DSN=${_APP_MESSAGE_SMS_TEST_DSN} + - _APP_MESSAGE_EMAIL_TEST_DSN=${_APP_MESSAGE_EMAIL_TEST_DSN} + - _APP_MESSAGE_PUSH_TEST_DSN=${_APP_MESSAGE_PUSH_TEST_DSN} + - _APP_CONSOLE_COUNTRIES_DENYLIST=${_APP_CONSOLE_COUNTRIES_DENYLIST} + - _APP_EXPERIMENT_LOGGING_PROVIDER=${_APP_EXPERIMENT_LOGGING_PROVIDER} + - _APP_EXPERIMENT_LOGGING_CONFIG=${_APP_EXPERIMENT_LOGGING_CONFIG} + - _APP_DATABASE_SHARED_TABLES=${_APP_DATABASE_SHARED_TABLES} + - _APP_DATABASE_SHARED_TABLES_V1=${_APP_DATABASE_SHARED_TABLES_V1} + - _APP_DATABASE_SHARED_NAMESPACE=${_APP_DATABASE_SHARED_NAMESPACE} + - _APP_FUNCTIONS_CREATION_ABUSE_LIMIT=${_APP_FUNCTIONS_CREATION_ABUSE_LIMIT} + - _APP_CUSTOM_DOMAIN_DENY_LIST=${_APP_CUSTOM_DOMAIN_DENY_LIST} appwrite-console: - image: appwrite/console:6.0.13 + image: appwrite/console:6.1.28 container_name: appwrite-console environment: - SERVICE_URL_APPWRITE=/console @@ -156,6 +171,7 @@ services: - _APP_DB_PASS=$SERVICE_PASSWORD_MARIADB - _APP_USAGE_STATS=${_APP_USAGE_STATS:-enabled} - _APP_LOGGING_CONFIG=${_APP_LOGGING_CONFIG} + - _APP_DATABASE_SHARED_TABLES=${_APP_DATABASE_SHARED_TABLES} appwrite-worker-audits: image: appwrite/appwrite:1.7.4 @@ -178,6 +194,7 @@ services: - _APP_DB_USER=$SERVICE_USER_MARIADB - _APP_DB_PASS=$SERVICE_PASSWORD_MARIADB - _APP_LOGGING_CONFIG=${_APP_LOGGING_CONFIG} + - _APP_DATABASE_SHARED_TABLES=${_APP_DATABASE_SHARED_TABLES} appwrite-worker-webhooks: image: appwrite/appwrite:1.7.4 @@ -202,6 +219,8 @@ services: - _APP_REDIS_USER=${_APP_REDIS_USER} - _APP_REDIS_PASS=${_APP_REDIS_PASS} - _APP_LOGGING_CONFIG=${_APP_LOGGING_CONFIG} + - _APP_WEBHOOK_MAX_FAILED_ATTEMPTS=${_APP_WEBHOOK_MAX_FAILED_ATTEMPTS} + - _APP_DATABASE_SHARED_TABLES=${_APP_DATABASE_SHARED_TABLES} appwrite-worker-deletes: image: appwrite/appwrite:1.7.4 @@ -255,12 +274,11 @@ services: - _APP_LOGGING_CONFIG=${_APP_LOGGING_CONFIG} - _APP_EXECUTOR_SECRET=$SERVICE_PASSWORD_64_APPWRITE - _APP_EXECUTOR_HOST=${_APP_EXECUTOR_HOST:-http://appwrite-executor/v1} - - _APP_MAINTENANCE_RETENTION_ABUSE=${_APP_MAINTENANCE_RETENTION_ABUSE:-86400} + - _APP_DATABASE_SHARED_TABLES=${_APP_DATABASE_SHARED_TABLES} + - _APP_DATABASE_SHARED_TABLES_V1=${_APP_DATABASE_SHARED_TABLES_V1} + - _APP_EMAIL_CERTIFICATES=${_APP_EMAIL_CERTIFICATES} - _APP_MAINTENANCE_RETENTION_AUDIT=${_APP_MAINTENANCE_RETENTION_AUDIT:-1209600} - _APP_MAINTENANCE_RETENTION_AUDIT_CONSOLE=${_APP_MAINTENANCE_RETENTION_AUDIT_CONSOLE} - - _APP_MAINTENANCE_RETENTION_EXECUTION=${_APP_MAINTENANCE_RETENTION_EXECUTION:-1209600} - - _APP_SYSTEM_SECURITY_EMAIL_ADDRESS=${_APP_SYSTEM_SECURITY_EMAIL_ADDRESS} - - _APP_EMAIL_CERTIFICATES=${_APP_EMAIL_CERTIFICATES} appwrite-worker-databases: image: appwrite/appwrite:1.7.4 @@ -283,6 +301,9 @@ services: - _APP_DB_USER=$SERVICE_USER_MARIADB - _APP_DB_PASS=$SERVICE_PASSWORD_MARIADB - _APP_LOGGING_CONFIG=${_APP_LOGGING_CONFIG} + - _APP_WORKERS_NUM=${_APP_WORKERS_NUM} + - _APP_QUEUE_NAME=${_APP_QUEUE_NAME} + - _APP_DATABASE_SHARED_TABLES=${_APP_DATABASE_SHARED_TABLES} appwrite-worker-builds: image: appwrite/appwrite:1.7.4 @@ -323,7 +344,7 @@ services: - _APP_COMPUTE_SIZE_LIMIT=${_APP_COMPUTE_SIZE_LIMIT:-30000000} - _APP_OPTIONS_FORCE_HTTPS=${_APP_OPTIONS_FORCE_HTTPS:-disabled} - _APP_OPTIONS_ROUTER_FORCE_HTTPS=${_APP_OPTIONS_ROUTER_FORCE_HTTPS:-disabled} - - _APP_DOMAIN=$SERVICE_URL_APPWRITE + - _APP_DOMAIN=${_APP_DOMAIN:-$SERVICE_FQDN_APPWRITE} - _APP_STORAGE_DEVICE=${_APP_STORAGE_DEVICE:-local} - _APP_STORAGE_S3_ACCESS_KEY=${_APP_STORAGE_S3_ACCESS_KEY} - _APP_STORAGE_S3_SECRET=${_APP_STORAGE_S3_SECRET} @@ -346,7 +367,10 @@ services: - _APP_STORAGE_WASABI_SECRET=${_APP_STORAGE_WASABI_SECRET} - _APP_STORAGE_WASABI_REGION=${_APP_STORAGE_WASABI_REGION:-eu-central-1} - _APP_STORAGE_WASABI_BUCKET=${_APP_STORAGE_WASABI_BUCKET} - - _APP_DOMAIN_SITES=${_APP_DOMAIN_SITES} + - _APP_DATABASE_SHARED_TABLES=${_APP_DATABASE_SHARED_TABLES} + - _APP_DOMAIN_SITES=${_APP_DOMAIN_SITES:-sites.$SERVICE_FQDN_APPWRITE} + - _APP_BROWSER_HOST=${_APP_BROWSER_HOST} + - _APP_CONSOLE_DOMAIN=${_APP_CONSOLE_DOMAIN} appwrite-worker-certificates: image: appwrite/appwrite:1.7.4 @@ -362,11 +386,13 @@ services: - _APP_ENV=${_APP_ENV:-production} - _APP_WORKER_PER_CORE=${_APP_WORKER_PER_CORE:-6} - _APP_OPENSSL_KEY_V1=$SERVICE_PASSWORD_64_APPWRITE - - _APP_DOMAIN=$SERVICE_URL_APPWRITE + - _APP_DOMAIN=${_APP_DOMAIN:-$SERVICE_FQDN_APPWRITE} - _APP_DOMAIN_TARGET_CNAME=${_APP_DOMAIN_TARGET_CNAME} - _APP_DOMAIN_TARGET_AAAA=${_APP_DOMAIN_TARGET_AAAA} - _APP_DOMAIN_TARGET_A=${_APP_DOMAIN_TARGET_A} - - _APP_DOMAIN_FUNCTIONS=$SERVICE_URL_APPWRITE + - _APP_DOMAIN_TARGET_CAA=${_APP_DOMAIN_TARGET_CAA} + - _APP_DOMAIN_FUNCTIONS=${_APP_DOMAIN_FUNCTIONS:-functions.$SERVICE_FQDN_APPWRITE} + - _APP_DNS=${_APP_DNS} - _APP_EMAIL_CERTIFICATES=${_APP_EMAIL_CERTIFICATES:-enabled} - _APP_REDIS_HOST=${_APP_REDIS_HOST:-appwrite-redis} - _APP_REDIS_PORT=${_APP_REDIS_PORT:-6379} @@ -378,6 +404,7 @@ services: - _APP_DB_USER=$SERVICE_USER_MARIADB - _APP_DB_PASS=$SERVICE_PASSWORD_MARIADB - _APP_LOGGING_CONFIG=${_APP_LOGGING_CONFIG} + - _APP_DATABASE_SHARED_TABLES=${_APP_DATABASE_SHARED_TABLES} appwrite-worker-functions: image: appwrite/appwrite:1.7.4 @@ -391,7 +418,7 @@ services: - _APP_ENV=${_APP_ENV:-production} - _APP_WORKER_PER_CORE=${_APP_WORKER_PER_CORE:-6} - _APP_OPENSSL_KEY_V1=$SERVICE_PASSWORD_64_APPWRITE - - _APP_DOMAIN=$SERVICE_URL_APPWRITE + - _APP_DOMAIN=${_APP_DOMAIN:-$SERVICE_FQDN_APPWRITE} - _APP_OPTIONS_FORCE_HTTPS=${_APP_OPTIONS_FORCE_HTTPS:-disabled} - _APP_REDIS_HOST=${_APP_REDIS_HOST:-appwrite-redis} - _APP_REDIS_PORT=${_APP_REDIS_PORT:-6379} @@ -413,6 +440,8 @@ services: - _APP_DOCKER_HUB_USERNAME=${_APP_DOCKER_HUB_USERNAME} - _APP_DOCKER_HUB_PASSWORD=${_APP_DOCKER_HUB_PASSWORD} - _APP_LOGGING_CONFIG=${_APP_LOGGING_CONFIG} + - _APP_LOGGING_PROVIDER=${_APP_LOGGING_PROVIDER} + - _APP_DATABASE_SHARED_TABLES=${_APP_DATABASE_SHARED_TABLES} appwrite-worker-mails: image: appwrite/appwrite:1.7.4 @@ -420,6 +449,7 @@ services: container_name: appwrite-worker-mails depends_on: - appwrite-redis + - appwrite-mariadb environment: - _APP_ENV=${_APP_ENV:-production} - _APP_WORKER_PER_CORE=${_APP_WORKER_PER_CORE:-6} @@ -441,8 +471,9 @@ services: - _APP_SMTP_USERNAME=${_APP_SMTP_USERNAME} - _APP_SMTP_PASSWORD=${_APP_SMTP_PASSWORD} - _APP_LOGGING_CONFIG=${_APP_LOGGING_CONFIG} - - _APP_DOMAIN=$SERVICE_URL_APPWRITE + - _APP_DOMAIN=${_APP_DOMAIN:-$SERVICE_FQDN_APPWRITE} - _APP_OPTIONS_FORCE_HTTPS=${_APP_OPTIONS_FORCE_HTTPS:-disabled} + - _APP_DATABASE_SHARED_TABLES=${_APP_DATABASE_SHARED_TABLES} appwrite-worker-messaging: image: appwrite/appwrite:1.7.4 @@ -468,6 +499,7 @@ services: - _APP_LOGGING_CONFIG=${_APP_LOGGING_CONFIG} - _APP_SMS_FROM=${_APP_SMS_FROM} - _APP_SMS_PROVIDER=${_APP_SMS_PROVIDER} + - _APP_SMS_PROJECTS_DENY_LIST=${_APP_SMS_PROJECTS_DENY_LIST} - _APP_STORAGE_DEVICE=${_APP_STORAGE_DEVICE:-local} - _APP_STORAGE_S3_ACCESS_KEY=${_APP_STORAGE_S3_ACCESS_KEY} - _APP_STORAGE_S3_SECRET=${_APP_STORAGE_S3_SECRET} @@ -490,6 +522,7 @@ services: - _APP_STORAGE_WASABI_SECRET=${_APP_STORAGE_WASABI_SECRET} - _APP_STORAGE_WASABI_REGION=${_APP_STORAGE_WASABI_REGION:-eu-central-1} - _APP_STORAGE_WASABI_BUCKET=${_APP_STORAGE_WASABI_BUCKET} + - _APP_DATABASE_SHARED_TABLES=${_APP_DATABASE_SHARED_TABLES} appwrite-worker-migrations: image: appwrite/appwrite:1.7.4 @@ -503,10 +536,12 @@ services: - _APP_ENV=${_APP_ENV:-production} - _APP_WORKER_PER_CORE=${_APP_WORKER_PER_CORE:-6} - _APP_OPENSSL_KEY_V1=$SERVICE_PASSWORD_64_APPWRITE - - _APP_DOMAIN=$SERVICE_URL_APPWRITE + - _APP_DOMAIN=${_APP_DOMAIN:-$SERVICE_FQDN_APPWRITE} - _APP_DOMAIN_TARGET_CNAME=${_APP_DOMAIN_TARGET_CNAME} - _APP_DOMAIN_TARGET_AAAA=${_APP_DOMAIN_TARGET_AAAA} - _APP_DOMAIN_TARGET_A=${_APP_DOMAIN_TARGET_A} + - _APP_DOMAIN_TARGET_CAA=${_APP_DOMAIN_TARGET_CAA} + - _APP_DNS=${_APP_DNS} - _APP_EMAIL_SECURITY=${_APP_EMAIL_SECURITY:-certs@appwrite.io} - _APP_REDIS_HOST=${_APP_REDIS_HOST:-appwrite-redis} - _APP_REDIS_PORT=${_APP_REDIS_PORT:-6379} @@ -520,6 +555,7 @@ services: - _APP_LOGGING_CONFIG=${_APP_LOGGING_CONFIG} - _APP_MIGRATIONS_FIREBASE_CLIENT_ID=${_APP_MIGRATIONS_FIREBASE_CLIENT_ID} - _APP_MIGRATIONS_FIREBASE_CLIENT_SECRET=${_APP_MIGRATIONS_FIREBASE_CLIENT_SECRET} + - _APP_DATABASE_SHARED_TABLES=${_APP_DATABASE_SHARED_TABLES} appwrite-task-maintenance: image: appwrite/appwrite:1.7.4 @@ -530,11 +566,13 @@ services: environment: - _APP_ENV=${_APP_ENV:-production} - _APP_WORKER_PER_CORE=${_APP_WORKER_PER_CORE:-6} - - _APP_DOMAIN=$SERVICE_URL_APPWRITE + - _APP_DOMAIN=${_APP_DOMAIN:-$SERVICE_FQDN_APPWRITE} - _APP_DOMAIN_TARGET_CNAME=${_APP_DOMAIN_TARGET_CNAME} - _APP_DOMAIN_TARGET_AAAA=${_APP_DOMAIN_TARGET_AAAA} - _APP_DOMAIN_TARGET_A=${_APP_DOMAIN_TARGET_A} - - _APP_DOMAIN_FUNCTIONS=$SERVICE_URL_APPWRITE + - _APP_DOMAIN_TARGET_CAA=${_APP_DOMAIN_TARGET_CAA} + - _APP_DOMAIN_FUNCTIONS=${_APP_DOMAIN_FUNCTIONS:-functions.$SERVICE_FQDN_APPWRITE} + - _APP_DNS=${_APP_DNS} - _APP_OPENSSL_KEY_V1=$SERVICE_PASSWORD_64_APPWRITE - _APP_REDIS_HOST=${_APP_REDIS_HOST:-appwrite-redis} - _APP_REDIS_PORT=${_APP_REDIS_PORT:-6379} @@ -545,14 +583,16 @@ services: - _APP_DB_SCHEMA=${_APP_DB_SCHEMA:-appwrite} - _APP_DB_USER=$SERVICE_USER_MARIADB - _APP_DB_PASS=$SERVICE_PASSWORD_MARIADB - - _APP_MAINTENANCE_INTERVAL=${_APP_MAINTENANCE_INTERVAL} - - _APP_MAINTENANCE_RETENTION_EXECUTION=${_APP_MAINTENANCE_RETENTION_EXECUTION} + - _APP_MAINTENANCE_INTERVAL=${_APP_MAINTENANCE_INTERVAL:-86400} + - _APP_MAINTENANCE_RETENTION_EXECUTION=${_APP_MAINTENANCE_RETENTION_EXECUTION:-1209600} - _APP_MAINTENANCE_RETENTION_CACHE=${_APP_MAINTENANCE_RETENTION_CACHE:-2592000} - _APP_MAINTENANCE_RETENTION_ABUSE=${_APP_MAINTENANCE_RETENTION_ABUSE:-86400} - _APP_MAINTENANCE_RETENTION_AUDIT=${_APP_MAINTENANCE_RETENTION_AUDIT:-1209600} - _APP_MAINTENANCE_RETENTION_AUDIT_CONSOLE=${_APP_MAINTENANCE_RETENTION_AUDIT_CONSOLE} - _APP_MAINTENANCE_RETENTION_USAGE_HOURLY=${_APP_MAINTENANCE_RETENTION_USAGE_HOURLY:-8640000} - _APP_MAINTENANCE_RETENTION_SCHEDULES=${_APP_MAINTENANCE_RETENTION_SCHEDULES:-86400} + - _APP_MAINTENANCE_START_TIME=${_APP_MAINTENANCE_START_TIME} + - _APP_DATABASE_SHARED_TABLES=${_APP_DATABASE_SHARED_TABLES} appwrite-task-stats-resources: image: appwrite/appwrite:1.7.4 @@ -626,6 +666,7 @@ services: - _APP_USAGE_STATS=${_APP_USAGE_STATS:-enabled} - _APP_LOGGING_CONFIG=${_APP_LOGGING_CONFIG} - _APP_USAGE_AGGREGATION_INTERVAL=${_APP_USAGE_AGGREGATION_INTERVAL:-30} + - _APP_DATABASE_SHARED_TABLES=${_APP_DATABASE_SHARED_TABLES} appwrite-task-scheduler-functions: image: appwrite/appwrite:1.7.4 @@ -647,6 +688,7 @@ services: - _APP_DB_SCHEMA=${_APP_DB_SCHEMA:-appwrite} - _APP_DB_USER=$SERVICE_USER_MARIADB - _APP_DB_PASS=$SERVICE_PASSWORD_MARIADB + - _APP_DATABASE_SHARED_TABLES=${_APP_DATABASE_SHARED_TABLES} appwrite-task-scheduler-executions: image: appwrite/appwrite:1.7.4 @@ -668,6 +710,7 @@ services: - _APP_DB_SCHEMA=${_APP_DB_SCHEMA:-appwrite} - _APP_DB_USER=$SERVICE_USER_MARIADB - _APP_DB_PASS=$SERVICE_PASSWORD_MARIADB + - _APP_DATABASE_SHARED_TABLES=${_APP_DATABASE_SHARED_TABLES} appwrite-task-scheduler-messages: image: appwrite/appwrite:1.7.4 @@ -689,9 +732,10 @@ services: - _APP_DB_SCHEMA=${_APP_DB_SCHEMA:-appwrite} - _APP_DB_USER=$SERVICE_USER_MARIADB - _APP_DB_PASS=$SERVICE_PASSWORD_MARIADB + - _APP_DATABASE_SHARED_TABLES=${_APP_DATABASE_SHARED_TABLES} appwrite-assistant: - image: appwrite/assistant:0.4.0 + image: appwrite/assistant:0.8.3 container_name: appwrite-assistant environment: - _APP_ASSISTANT_OPENAI_API_KEY=${_APP_ASSISTANT_OPENAI_API_KEY} @@ -699,12 +743,13 @@ services: appwrite-browser: image: appwrite/browser:0.2.4 container_name: appwrite-browser + hostname: appwrite-browser openruntimes-executor: container_name: openruntimes-executor hostname: appwrite-executor stop_signal: SIGINT - image: openruntimes/executor:0.7.14 + image: openruntimes/executor:0.8.6 networks: - runtimes volumes: @@ -714,6 +759,7 @@ services: - appwrite-sites:/storage/sites:rw - /tmp:/tmp:rw environment: + - OPR_EXECUTOR_IMAGE_PULL=disabled - OPR_EXECUTOR_INACTIVE_TRESHOLD=${_APP_COMPUTE_INACTIVE_THRESHOLD} - OPR_EXECUTOR_MAINTENANCE_INTERVAL=${_APP_COMPUTE_MAINTENANCE_INTERVAL} - OPR_EXECUTOR_NETWORK=${_APP_COMPUTE_RUNTIMES_NETWORK:-runtimes} diff --git a/templates/compose/getoutline.yaml b/templates/compose/getoutline.yaml index f96200d3d..3a20fef5a 100644 --- a/templates/compose/getoutline.yaml +++ b/templates/compose/getoutline.yaml @@ -18,7 +18,7 @@ services: environment: - SERVICE_URL_OUTLINE_3000 - NODE_ENV=production - - SECRET_KEY=${SERVICE_BASE64_OUTLINE} + - SECRET_KEY=${SERVICE_HEX_32_OUTLINE} - UTILS_SECRET=${SERVICE_PASSWORD_64_OUTLINE} - DATABASE_URL=postgres://${SERVICE_USER_POSTGRES}:${SERVICE_PASSWORD_64_POSTGRES}@postgres:5432/${POSTGRES_DATABASE:-outline} - REDIS_URL=redis://:${SERVICE_PASSWORD_64_REDIS}@redis:6379 diff --git a/templates/compose/n8n-with-postgres-and-worker.yaml b/templates/compose/n8n-with-postgres-and-worker.yaml new file mode 100644 index 000000000..3b9520c20 --- /dev/null +++ b/templates/compose/n8n-with-postgres-and-worker.yaml @@ -0,0 +1,103 @@ +# documentation: https://n8n.io +# slogan: n8n is an extendable workflow automation tool with queue mode and workers. +# category: automation +# tags: n8n,workflow,automation,open,source,low,code,queue,worker,scalable +# logo: svgs/n8n.png +# port: 5678 + +services: + n8n: + image: docker.n8n.io/n8nio/n8n + environment: + - SERVICE_URL_N8N_5678 + - N8N_EDITOR_BASE_URL=${SERVICE_URL_N8N} + - WEBHOOK_URL=${SERVICE_URL_N8N} + - N8N_HOST=${SERVICE_URL_N8N} + - GENERIC_TIMEZONE=${GENERIC_TIMEZONE:-Europe/Berlin} + - TZ=${TZ:-Europe/Berlin} + - DB_TYPE=postgresdb + - DB_POSTGRESDB_DATABASE=${POSTGRES_DB:-n8n} + - DB_POSTGRESDB_HOST=postgresql + - DB_POSTGRESDB_PORT=5432 + - DB_POSTGRESDB_USER=$SERVICE_USER_POSTGRES + - DB_POSTGRESDB_SCHEMA=public + - DB_POSTGRESDB_PASSWORD=$SERVICE_PASSWORD_POSTGRES + - EXECUTIONS_MODE=queue + - QUEUE_BULL_REDIS_HOST=redis + - QUEUE_HEALTH_CHECK_ACTIVE=true + - N8N_ENCRYPTION_KEY=${SERVICE_PASSWORD_ENCRYPTION} + - N8N_RUNNERS_ENABLED=true + - OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS=true + - N8N_BLOCK_ENV_ACCESS_IN_NODE=${N8N_BLOCK_ENV_ACCESS_IN_NODE:-true} + - N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=${N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS:-true} + volumes: + - n8n-data:/home/node/.n8n + depends_on: + postgresql: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:5678/"] + interval: 5s + timeout: 20s + retries: 10 + + n8n-worker: + image: docker.n8n.io/n8nio/n8n + command: worker + environment: + - GENERIC_TIMEZONE=${GENERIC_TIMEZONE:-Europe/Berlin} + - TZ=${TZ:-Europe/Berlin} + - DB_TYPE=postgresdb + - DB_POSTGRESDB_DATABASE=${POSTGRES_DB:-n8n} + - DB_POSTGRESDB_HOST=postgresql + - DB_POSTGRESDB_PORT=5432 + - DB_POSTGRESDB_USER=$SERVICE_USER_POSTGRES + - DB_POSTGRESDB_SCHEMA=public + - DB_POSTGRESDB_PASSWORD=$SERVICE_PASSWORD_POSTGRES + - EXECUTIONS_MODE=queue + - QUEUE_BULL_REDIS_HOST=redis + - QUEUE_HEALTH_CHECK_ACTIVE=true + - N8N_ENCRYPTION_KEY=${SERVICE_PASSWORD_ENCRYPTION} + - N8N_RUNNERS_ENABLED=true + - N8N_BLOCK_ENV_ACCESS_IN_NODE=${N8N_BLOCK_ENV_ACCESS_IN_NODE:-true} + - N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=${N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS:-true} + volumes: + - n8n-data:/home/node/.n8n + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:5678/healthz"] + interval: 5s + timeout: 20s + retries: 10 + depends_on: + n8n: + condition: service_healthy + postgresql: + condition: service_healthy + redis: + condition: service_healthy + + postgresql: + image: postgres:16-alpine + volumes: + - postgresql-data:/var/lib/postgresql/data + environment: + - POSTGRES_USER=$SERVICE_USER_POSTGRES + - POSTGRES_PASSWORD=$SERVICE_PASSWORD_POSTGRES + - POSTGRES_DB=${POSTGRES_DB:-n8n} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 5s + timeout: 20s + retries: 10 + + redis: + image: redis:6-alpine + volumes: + - redis-data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 10 \ No newline at end of file diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index 4ba6d0f2c..35bdd37c0 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -101,7 +101,7 @@ "appwrite": { "documentation": "https://appwrite.io?utm_source=coolify.io", "slogan": "A backend-as-a-service platform that simplifies the web & mobile app development.", - "compose": "c2VydmljZXM6CiAgYXBwd3JpdGU6CiAgICBpbWFnZTogJ2FwcHdyaXRlL2FwcHdyaXRlOjEuNy40JwogICAgY29udGFpbmVyX25hbWU6IGFwcHdyaXRlCiAgICB2b2x1bWVzOgogICAgICAtICdhcHB3cml0ZS11cGxvYWRzOi9zdG9yYWdlL3VwbG9hZHM6cncnCiAgICAgIC0gJ2FwcHdyaXRlLWltcG9ydHM6L3N0b3JhZ2UvaW1wb3J0czpydycKICAgICAgLSAnYXBwd3JpdGUtY2FjaGU6L3N0b3JhZ2UvY2FjaGU6cncnCiAgICAgIC0gJ2FwcHdyaXRlLWNvbmZpZzovc3RvcmFnZS9jb25maWc6cncnCiAgICAgIC0gJ2FwcHdyaXRlLWNlcnRpZmljYXRlczovc3RvcmFnZS9jZXJ0aWZpY2F0ZXM6cncnCiAgICAgIC0gJ2FwcHdyaXRlLWZ1bmN0aW9uczovc3RvcmFnZS9mdW5jdGlvbnM6cncnCiAgICAgIC0gJ2FwcHdyaXRlLXNpdGVzOi9zdG9yYWdlL3NpdGVzOnJ3JwogICAgICAtICdhcHB3cml0ZS1idWlsZHM6L3N0b3JhZ2UvYnVpbGRzOnJ3JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcHB3cml0ZS1tYXJpYWRiCiAgICAgIC0gYXBwd3JpdGUtcmVkaXMKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0FQUFdSSVRFPS8KICAgICAgLSAnX0FQUF9FTlY9JHtfQVBQX0VOVjotcHJvZHVjdGlvbn0nCiAgICAgIC0gJ19BUFBfV09SS0VSX1BFUl9DT1JFPSR7X0FQUF9XT1JLRVJfUEVSX0NPUkU6LTZ9JwogICAgICAtICdfQVBQX0xPQ0FMRT0ke19BUFBfTE9DQUxFOi1lbn0nCiAgICAgIC0gJ19BUFBfQ09NUFJFU1NJT05fTUlOX1NJWkVfQllURVM9JHtfQVBQX0NPTVBSRVNTSU9OX01JTl9TSVpFX0JZVEVTfScKICAgICAgLSAnX0FQUF9DT05TT0xFX1dISVRFTElTVF9ST09UPSR7X0FQUF9DT05TT0xFX1dISVRFTElTVF9ST09UOi1lbmFibGVkfScKICAgICAgLSAnX0FQUF9DT05TT0xFX1dISVRFTElTVF9FTUFJTFM9JHtfQVBQX0NPTlNPTEVfV0hJVEVMSVNUX0VNQUlMU30nCiAgICAgIC0gJ19BUFBfQ09OU09MRV9TRVNTSU9OX0FMRVJUUz0ke19BUFBfQ09OU09MRV9TRVNTSU9OX0FMRVJUU30nCiAgICAgIC0gJ19BUFBfQ09OU09MRV9XSElURUxJU1RfSVBTPSR7X0FQUF9DT05TT0xFX1dISVRFTElTVF9JUFN9JwogICAgICAtICdfQVBQX0NPTlNPTEVfSE9TVE5BTUVTPSR7X0FQUF9DT05TT0xFX0hPU1ROQU1FU30nCiAgICAgIC0gJ19BUFBfU1lTVEVNX0VNQUlMX05BTUU9JHtfQVBQX1NZU1RFTV9FTUFJTF9OQU1FOi1BcHB3cml0ZX0nCiAgICAgIC0gJ19BUFBfU1lTVEVNX0VNQUlMX0FERFJFU1M9JHtfQVBQX1NZU1RFTV9FTUFJTF9BRERSRVNTOi10ZWFtQGFwcHdyaXRlLmlvfScKICAgICAgLSAnX0FQUF9TWVNURU1fVEVBTV9FTUFJTD0ke19BUFBfU1lTVEVNX1RFQU1fRU1BSUw6LXRlYW1AYXBwd3JpdGUuaW99JwogICAgICAtICdfQVBQX0VNQUlMX1NFQ1VSSVRZPSR7X0FQUF9FTUFJTF9TRUNVUklUWTotY2VydHNAYXBwd3JpdGUuaW99JwogICAgICAtICdfQVBQX1NZU1RFTV9SRVNQT05TRV9GT1JNQVQ9JHtfQVBQX1NZU1RFTV9SRVNQT05TRV9GT1JNQVR9JwogICAgICAtICdfQVBQX09QVElPTlNfQUJVU0U9JHtfQVBQX09QVElPTlNfQUJVU0U6LWVuYWJsZWR9JwogICAgICAtICdfQVBQX09QVElPTlNfUk9VVEVSX1BST1RFQ1RJT049JHtfQVBQX09QVElPTlNfUk9VVEVSX1BST1RFQ1RJT046LWRpc2FibGVkfScKICAgICAgLSAnX0FQUF9PUFRJT05TX0ZPUkNFX0hUVFBTPSR7X0FQUF9PUFRJT05TX0ZPUkNFX0hUVFBTOi1kaXNhYmxlZH0nCiAgICAgIC0gJ19BUFBfT1BUSU9OU19ST1VURVJfRk9SQ0VfSFRUUFM9JHtfQVBQX09QVElPTlNfUk9VVEVSX0ZPUkNFX0hUVFBTOi1kaXNhYmxlZH0nCiAgICAgIC0gX0FQUF9PUEVOU1NMX0tFWV9WMT0kU0VSVklDRV9QQVNTV09SRF82NF9BUFBXUklURQogICAgICAtIF9BUFBfRE9NQUlOPSRTRVJWSUNFX1VSTF9BUFBXUklURQogICAgICAtICdfQVBQX0RPTUFJTl9UQVJHRVRfQ05BTUU9JHtfQVBQX0RPTUFJTl9UQVJHRVRfQ05BTUU6LWxvY2FsaG9zdH0nCiAgICAgIC0gJ19BUFBfRE9NQUlOX1RBUkdFVF9BQUFBPSR7X0FQUF9ET01BSU5fVEFSR0VUX0FBQUE6LTo6MX0nCiAgICAgIC0gJ19BUFBfRE9NQUlOX1RBUkdFVF9BPSR7X0FQUF9ET01BSU5fVEFSR0VUX0E6LTEyNy4wLjAuMX0nCiAgICAgIC0gX0FQUF9ET01BSU5fRlVOQ1RJT05TPSRTRVJWSUNFX1VSTF9BUFBXUklURQogICAgICAtICdfQVBQX1JFRElTX0hPU1Q9JHtfQVBQX1JFRElTX0hPU1Q6LWFwcHdyaXRlLXJlZGlzfScKICAgICAgLSAnX0FQUF9SRURJU19QT1JUPSR7X0FQUF9SRURJU19QT1JUOi02Mzc5fScKICAgICAgLSAnX0FQUF9SRURJU19VU0VSPSR7X0FQUF9SRURJU19VU0VSfScKICAgICAgLSAnX0FQUF9SRURJU19QQVNTPSR7X0FQUF9SRURJU19QQVNTfScKICAgICAgLSAnX0FQUF9EQl9IT1NUPSR7X0FQUF9EQl9IT1NUOi1hcHB3cml0ZS1tYXJpYWRifScKICAgICAgLSAnX0FQUF9EQl9QT1JUPSR7X0FQUF9EQl9QT1JUOi0zMzA2fScKICAgICAgLSAnX0FQUF9EQl9TQ0hFTUE9JHtfQVBQX0RCX1NDSEVNQTotYXBwd3JpdGV9JwogICAgICAtIF9BUFBfREJfVVNFUj0kU0VSVklDRV9VU0VSX01BUklBREIKICAgICAgLSBfQVBQX0RCX1BBU1M9JFNFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQgogICAgICAtICdfQVBQX1NNVFBfSE9TVD0ke19BUFBfU01UUF9IT1NUfScKICAgICAgLSAnX0FQUF9TTVRQX1BPUlQ9JHtfQVBQX1NNVFBfUE9SVH0nCiAgICAgIC0gJ19BUFBfU01UUF9TRUNVUkU9JHtfQVBQX1NNVFBfU0VDVVJFfScKICAgICAgLSAnX0FQUF9TTVRQX1VTRVJOQU1FPSR7X0FQUF9TTVRQX1VTRVJOQU1FfScKICAgICAgLSAnX0FQUF9TTVRQX1BBU1NXT1JEPSR7X0FQUF9TTVRQX1BBU1NXT1JEfScKICAgICAgLSAnX0FQUF9VU0FHRV9TVEFUUz0ke19BUFBfVVNBR0VfU1RBVFM6LWVuYWJsZWR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfTElNSVQ9JHtfQVBQX1NUT1JBR0VfTElNSVQ6LTMwMDAwMDAwfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1BSRVZJRVdfTElNSVQ9JHtfQVBQX1NUT1JBR0VfUFJFVklFV19MSU1JVDotMjAwMDAwMDB9JwogICAgICAtICdfQVBQX1NUT1JBR0VfQU5USVZJUlVTPSR7X0FQUF9TVE9SQUdFX0FOVElWSVJVUzotZGlzYWJsZWR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfQU5USVZJUlVTX0hPU1Q9JHtfQVBQX1NUT1JBR0VfQU5USVZJUlVTX0hPU1Q6LWFwcHdyaXRlLWNsYW1hdn0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9BTlRJVklSVVNfUE9SVD0ke19BUFBfU1RPUkFHRV9BTlRJVklSVVNfUE9SVDotMzMxMH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9ERVZJQ0U9JHtfQVBQX1NUT1JBR0VfREVWSUNFOi1sb2NhbH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9TM19BQ0NFU1NfS0VZPSR7X0FQUF9TVE9SQUdFX1MzX0FDQ0VTU19LRVl9JwogICAgICAtICdfQVBQX1NUT1JBR0VfUzNfU0VDUkVUPSR7X0FQUF9TVE9SQUdFX1MzX1NFQ1JFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9TM19SRUdJT049JHtfQVBQX1NUT1JBR0VfUzNfUkVHSU9OOi11cy1lYXN0LTF9JwogICAgICAtICdfQVBQX1NUT1JBR0VfUzNfQlVDS0VUPSR7X0FQUF9TVE9SQUdFX1MzX0JVQ0tFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9TM19FTkRQT0lOVD0ke19BUFBfU1RPUkFHRV9TM19FTkRQT0lOVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9ET19TUEFDRVNfQUNDRVNTX0tFWT0ke19BUFBfU1RPUkFHRV9ET19TUEFDRVNfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9ET19TUEFDRVNfU0VDUkVUPSR7X0FQUF9TVE9SQUdFX0RPX1NQQUNFU19TRUNSRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX1JFR0lPTj0ke19BUFBfU1RPUkFHRV9ET19TUEFDRVNfUkVHSU9OOi11cy1lYXN0LTF9JwogICAgICAtICdfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX0JVQ0tFVD0ke19BUFBfU1RPUkFHRV9ET19TUEFDRVNfQlVDS0VUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9BQ0NFU1NfS0VZPSR7X0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9BQ0NFU1NfS0VZfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9TRUNSRVQ9JHtfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX1NFQ1JFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfUkVHSU9OPSR7X0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9SRUdJT046LXVzLXdlc3QtMDA0fScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9CVUNLRVQ9JHtfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX0JVQ0tFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9MSU5PREVfQUNDRVNTX0tFWT0ke19BUFBfU1RPUkFHRV9MSU5PREVfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9MSU5PREVfU0VDUkVUPSR7X0FQUF9TVE9SQUdFX0xJTk9ERV9TRUNSRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfTElOT0RFX1JFR0lPTj0ke19BUFBfU1RPUkFHRV9MSU5PREVfUkVHSU9OOi1ldS1jZW50cmFsLTF9JwogICAgICAtICdfQVBQX1NUT1JBR0VfTElOT0RFX0JVQ0tFVD0ke19BUFBfU1RPUkFHRV9MSU5PREVfQlVDS0VUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1dBU0FCSV9BQ0NFU1NfS0VZPSR7X0FQUF9TVE9SQUdFX1dBU0FCSV9BQ0NFU1NfS0VZfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1dBU0FCSV9TRUNSRVQ9JHtfQVBQX1NUT1JBR0VfV0FTQUJJX1NFQ1JFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9XQVNBQklfUkVHSU9OPSR7X0FQUF9TVE9SQUdFX1dBU0FCSV9SRUdJT046LWV1LWNlbnRyYWwtMX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9XQVNBQklfQlVDS0VUPSR7X0FQUF9TVE9SQUdFX1dBU0FCSV9CVUNLRVR9JwogICAgICAtICdfQVBQX0NPTVBVVEVfU0laRV9MSU1JVD0ke19BUFBfQ09NUFVURV9TSVpFX0xJTUlUOi0zMDAwMDAwMH0nCiAgICAgIC0gJ19BUFBfRlVOQ1RJT05TX1RJTUVPVVQ9JHtfQVBQX0ZVTkNUSU9OU19USU1FT1VUOi05MDB9JwogICAgICAtICdfQVBQX1NJVEVTX1RJTUVPVVQ9JHtfQVBQX1NJVEVTX1RJTUVPVVQ6LTkwMH0nCiAgICAgIC0gJ19BUFBfQ09NUFVURV9CVUlMRF9USU1FT1VUPSR7X0FQUF9DT01QVVRFX0JVSUxEX1RJTUVPVVQ6LTkwMH0nCiAgICAgIC0gJ19BUFBfQ09NUFVURV9DUFVTPSR7X0FQUF9DT01QVVRFX0NQVVM6LTB9JwogICAgICAtICdfQVBQX0NPTVBVVEVfTUVNT1JZPSR7X0FQUF9DT01QVVRFX01FTU9SWTotMH0nCiAgICAgIC0gJ19BUFBfRlVOQ1RJT05TX1JVTlRJTUVTPSR7X0FQUF9GVU5DVElPTlNfUlVOVElNRVM6LW5vZGUtMjAuMCxwaHAtOC4yLHB5dGhvbi0zLjExLHJ1YnktMy4yfScKICAgICAgLSAnX0FQUF9TSVRFU19SVU5USU1FUz0ke19BUFBfU0lURVNfUlVOVElNRVN9JwogICAgICAtICdfQVBQX0RPTUFJTl9TSVRFUz0ke19BUFBfRE9NQUlOX1NJVEVTOi1hcHB3cml0ZS5uZXR3b3JrfScKICAgICAgLSBfQVBQX0VYRUNVVE9SX1NFQ1JFVD0kU0VSVklDRV9QQVNTV09SRF82NF9BUFBXUklURQogICAgICAtICdfQVBQX0VYRUNVVE9SX0hPU1Q9JHtfQVBQX0VYRUNVVE9SX0hPU1Q6LWh0dHA6Ly9hcHB3cml0ZS1leGVjdXRvci92MX0nCiAgICAgIC0gJ19BUFBfTE9HR0lOR19DT05GSUc9JHtfQVBQX0xPR0dJTkdfQ09ORklHfScKICAgICAgLSAnX0FQUF9NQUlOVEVOQU5DRV9JTlRFUlZBTD0ke19BUFBfTUFJTlRFTkFOQ0VfSU5URVJWQUw6LTg2NDAwfScKICAgICAgLSAnX0FQUF9NQUlOVEVOQU5DRV9ERUxBWT0ke19BUFBfTUFJTlRFTkFOQ0VfREVMQVl9JwogICAgICAtICdfQVBQX01BSU5URU5BTkNFX1NUQVJUX1RJTUU9JHtfQVBQX01BSU5URU5BTkNFX1NUQVJUX1RJTUV9JwogICAgICAtICdfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9FWEVDVVRJT049JHtfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9FWEVDVVRJT046LTEyMDk2MDB9JwogICAgICAtICdfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9DQUNIRT0ke19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0NBQ0hFOi0yNTkyMDAwfScKICAgICAgLSAnX0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fQUJVU0U9JHtfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9BQlVTRTotODY0MDB9JwogICAgICAtICdfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9BVURJVD0ke19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0FVRElUOi0xMjA5NjAwfScKICAgICAgLSAnX0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fQVVESVRfQ09OU09MRT0ke19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0FVRElUX0NPTlNPTEV9JwogICAgICAtICdfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9VU0FHRV9IT1VSTFk9JHtfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9VU0FHRV9IT1VSTFk6LTg2NDAwMDB9JwogICAgICAtICdfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9TQ0hFRFVMRVM9JHtfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9TQ0hFRFVMRVM6LTg2NDAwfScKICAgICAgLSAnX0FQUF9TTVNfUFJPVklERVI9JHtfQVBQX1NNU19QUk9WSURFUn0nCiAgICAgIC0gJ19BUFBfU01TX0ZST009JHtfQVBQX1NNU19GUk9NfScKICAgICAgLSAnX0FQUF9HUkFQSFFMX01BWF9CQVRDSF9TSVpFPSR7X0FQUF9HUkFQSFFMX01BWF9CQVRDSF9TSVpFOi0xMH0nCiAgICAgIC0gJ19BUFBfR1JBUEhRTF9NQVhfQ09NUExFWElUWT0ke19BUFBfR1JBUEhRTF9NQVhfQ09NUExFWElUWTotMjUwfScKICAgICAgLSAnX0FQUF9HUkFQSFFMX01BWF9ERVBUSD0ke19BUFBfR1JBUEhRTF9NQVhfREVQVEg6LTN9JwogICAgICAtICdfQVBQX1ZDU19HSVRIVUJfQVBQX05BTUU9JHtfQVBQX1ZDU19HSVRIVUJfQVBQX05BTUV9JwogICAgICAtICdfQVBQX1ZDU19HSVRIVUJfUFJJVkFURV9LRVk9JHtfQVBQX1ZDU19HSVRIVUJfUFJJVkFURV9LRVl9JwogICAgICAtICdfQVBQX1ZDU19HSVRIVUJfQVBQX0lEPSR7X0FQUF9WQ1NfR0lUSFVCX0FQUF9JRH0nCiAgICAgIC0gJ19BUFBfVkNTX0dJVEhVQl9XRUJIT09LX1NFQ1JFVD0ke19BUFBfVkNTX0dJVEhVQl9XRUJIT09LX1NFQ1JFVH0nCiAgICAgIC0gJ19BUFBfVkNTX0dJVEhVQl9DTElFTlRfU0VDUkVUPSR7X0FQUF9WQ1NfR0lUSFVCX0NMSUVOVF9TRUNSRVR9JwogICAgICAtICdfQVBQX1ZDU19HSVRIVUJfQ0xJRU5UX0lEPSR7X0FQUF9WQ1NfR0lUSFVCX0NMSUVOVF9JRH0nCiAgICAgIC0gJ19BUFBfTUlHUkFUSU9OU19GSVJFQkFTRV9DTElFTlRfSUQ9JHtfQVBQX01JR1JBVElPTlNfRklSRUJBU0VfQ0xJRU5UX0lEfScKICAgICAgLSAnX0FQUF9NSUdSQVRJT05TX0ZJUkVCQVNFX0NMSUVOVF9TRUNSRVQ9JHtfQVBQX01JR1JBVElPTlNfRklSRUJBU0VfQ0xJRU5UX1NFQ1JFVH0nCiAgICAgIC0gJ19BUFBfQVNTSVNUQU5UX09QRU5BSV9BUElfS0VZPSR7X0FQUF9BU1NJU1RBTlRfT1BFTkFJX0FQSV9LRVl9JwogIGFwcHdyaXRlLWNvbnNvbGU6CiAgICBpbWFnZTogJ2FwcHdyaXRlL2NvbnNvbGU6Ni4wLjEzJwogICAgY29udGFpbmVyX25hbWU6IGFwcHdyaXRlLWNvbnNvbGUKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0FQUFdSSVRFPS9jb25zb2xlCiAgYXBwd3JpdGUtcmVhbHRpbWU6CiAgICBpbWFnZTogJ2FwcHdyaXRlL2FwcHdyaXRlOjEuNy40JwogICAgZW50cnlwb2ludDogcmVhbHRpbWUKICAgIGNvbnRhaW5lcl9uYW1lOiBhcHB3cml0ZS1yZWFsdGltZQogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcHB3cml0ZS1tYXJpYWRiCiAgICAgIC0gYXBwd3JpdGUtcmVkaXMKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0FQUFdSSVRFPS92MS9yZWFsdGltZQogICAgICAtICdfQVBQX0VOVj0ke19BUFBfRU5WOi1wcm9kdWN0aW9ufScKICAgICAgLSAnX0FQUF9XT1JLRVJfUEVSX0NPUkU9JHtfQVBQX1dPUktFUl9QRVJfQ09SRTotNn0nCiAgICAgIC0gJ19BUFBfT1BUSU9OU19BQlVTRT0ke19BUFBfT1BUSU9OU19BQlVTRTotZW5hYmxlZH0nCiAgICAgIC0gJ19BUFBfT1BUSU9OU19ST1VURVJfUFJPVEVDVElPTj0ke19BUFBfT1BUSU9OU19ST1VURVJfUFJPVEVDVElPTjotZGlzYWJsZWR9JwogICAgICAtIF9BUFBfT1BFTlNTTF9LRVlfVjE9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfQVBQV1JJVEUKICAgICAgLSAnX0FQUF9SRURJU19IT1NUPSR7X0FQUF9SRURJU19IT1NUOi1hcHB3cml0ZS1yZWRpc30nCiAgICAgIC0gJ19BUFBfUkVESVNfUE9SVD0ke19BUFBfUkVESVNfUE9SVDotNjM3OX0nCiAgICAgIC0gJ19BUFBfUkVESVNfVVNFUj0ke19BUFBfUkVESVNfVVNFUn0nCiAgICAgIC0gJ19BUFBfUkVESVNfUEFTUz0ke19BUFBfUkVESVNfUEFTU30nCiAgICAgIC0gJ19BUFBfREJfSE9TVD0ke19BUFBfREJfSE9TVDotYXBwd3JpdGUtbWFyaWFkYn0nCiAgICAgIC0gJ19BUFBfREJfUE9SVD0ke19BUFBfREJfUE9SVDotMzMwNn0nCiAgICAgIC0gJ19BUFBfREJfU0NIRU1BPSR7X0FQUF9EQl9TQ0hFTUE6LWFwcHdyaXRlfScKICAgICAgLSBfQVBQX0RCX1VTRVI9JFNFUlZJQ0VfVVNFUl9NQVJJQURCCiAgICAgIC0gX0FQUF9EQl9QQVNTPSRTRVJWSUNFX1BBU1NXT1JEX01BUklBREIKICAgICAgLSAnX0FQUF9VU0FHRV9TVEFUUz0ke19BUFBfVVNBR0VfU1RBVFM6LWVuYWJsZWR9JwogICAgICAtICdfQVBQX0xPR0dJTkdfQ09ORklHPSR7X0FQUF9MT0dHSU5HX0NPTkZJR30nCiAgYXBwd3JpdGUtd29ya2VyLWF1ZGl0czoKICAgIGltYWdlOiAnYXBwd3JpdGUvYXBwd3JpdGU6MS43LjQnCiAgICBlbnRyeXBvaW50OiB3b3JrZXItYXVkaXRzCiAgICBjb250YWluZXJfbmFtZTogYXBwd3JpdGUtd29ya2VyLWF1ZGl0cwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcHB3cml0ZS1yZWRpcwogICAgICAtIGFwcHdyaXRlLW1hcmlhZGIKICAgIGVudmlyb25tZW50OgogICAgICAtICdfQVBQX0VOVj0ke19BUFBfRU5WOi1wcm9kdWN0aW9ufScKICAgICAgLSAnX0FQUF9XT1JLRVJfUEVSX0NPUkU9JHtfQVBQX1dPUktFUl9QRVJfQ09SRTotNn0nCiAgICAgIC0gX0FQUF9PUEVOU1NMX0tFWV9WMT0kU0VSVklDRV9QQVNTV09SRF82NF9BUFBXUklURQogICAgICAtICdfQVBQX1JFRElTX0hPU1Q9JHtfQVBQX1JFRElTX0hPU1Q6LWFwcHdyaXRlLXJlZGlzfScKICAgICAgLSAnX0FQUF9SRURJU19QT1JUPSR7X0FQUF9SRURJU19QT1JUOi02Mzc5fScKICAgICAgLSAnX0FQUF9SRURJU19VU0VSPSR7X0FQUF9SRURJU19VU0VSfScKICAgICAgLSAnX0FQUF9SRURJU19QQVNTPSR7X0FQUF9SRURJU19QQVNTfScKICAgICAgLSAnX0FQUF9EQl9IT1NUPSR7X0FQUF9EQl9IT1NUOi1hcHB3cml0ZS1tYXJpYWRifScKICAgICAgLSAnX0FQUF9EQl9QT1JUPSR7X0FQUF9EQl9QT1JUOi0zMzA2fScKICAgICAgLSAnX0FQUF9EQl9TQ0hFTUE9JHtfQVBQX0RCX1NDSEVNQTotYXBwd3JpdGV9JwogICAgICAtIF9BUFBfREJfVVNFUj0kU0VSVklDRV9VU0VSX01BUklBREIKICAgICAgLSBfQVBQX0RCX1BBU1M9JFNFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQgogICAgICAtICdfQVBQX0xPR0dJTkdfQ09ORklHPSR7X0FQUF9MT0dHSU5HX0NPTkZJR30nCiAgYXBwd3JpdGUtd29ya2VyLXdlYmhvb2tzOgogICAgaW1hZ2U6ICdhcHB3cml0ZS9hcHB3cml0ZToxLjcuNCcKICAgIGVudHJ5cG9pbnQ6IHdvcmtlci13ZWJob29rcwogICAgY29udGFpbmVyX25hbWU6IGFwcHdyaXRlLXdvcmtlci13ZWJob29rcwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcHB3cml0ZS1yZWRpcwogICAgICAtIGFwcHdyaXRlLW1hcmlhZGIKICAgIGVudmlyb25tZW50OgogICAgICAtICdfQVBQX0VOVj0ke19BUFBfRU5WOi1wcm9kdWN0aW9ufScKICAgICAgLSAnX0FQUF9XT1JLRVJfUEVSX0NPUkU9JHtfQVBQX1dPUktFUl9QRVJfQ09SRTotNn0nCiAgICAgIC0gX0FQUF9PUEVOU1NMX0tFWV9WMT0kU0VSVklDRV9QQVNTV09SRF82NF9BUFBXUklURQogICAgICAtICdfQVBQX0VNQUlMX1NFQ1VSSVRZPSR7X0FQUF9FTUFJTF9TRUNVUklUWTotY2VydHNAYXBwd3JpdGUuaW99JwogICAgICAtICdfQVBQX1NZU1RFTV9TRUNVUklUWV9FTUFJTF9BRERSRVNTPSR7X0FQUF9TWVNURU1fU0VDVVJJVFlfRU1BSUxfQUREUkVTU30nCiAgICAgIC0gJ19BUFBfREJfSE9TVD0ke19BUFBfREJfSE9TVDotYXBwd3JpdGUtbWFyaWFkYn0nCiAgICAgIC0gJ19BUFBfREJfUE9SVD0ke19BUFBfREJfUE9SVDotMzMwNn0nCiAgICAgIC0gJ19BUFBfREJfU0NIRU1BPSR7X0FQUF9EQl9TQ0hFTUE6LWFwcHdyaXRlfScKICAgICAgLSBfQVBQX0RCX1VTRVI9JFNFUlZJQ0VfVVNFUl9NQVJJQURCCiAgICAgIC0gX0FQUF9EQl9QQVNTPSRTRVJWSUNFX1BBU1NXT1JEX01BUklBREIKICAgICAgLSAnX0FQUF9SRURJU19IT1NUPSR7X0FQUF9SRURJU19IT1NUOi1hcHB3cml0ZS1yZWRpc30nCiAgICAgIC0gJ19BUFBfUkVESVNfUE9SVD0ke19BUFBfUkVESVNfUE9SVDotNjM3OX0nCiAgICAgIC0gJ19BUFBfUkVESVNfVVNFUj0ke19BUFBfUkVESVNfVVNFUn0nCiAgICAgIC0gJ19BUFBfUkVESVNfUEFTUz0ke19BUFBfUkVESVNfUEFTU30nCiAgICAgIC0gJ19BUFBfTE9HR0lOR19DT05GSUc9JHtfQVBQX0xPR0dJTkdfQ09ORklHfScKICBhcHB3cml0ZS13b3JrZXItZGVsZXRlczoKICAgIGltYWdlOiAnYXBwd3JpdGUvYXBwd3JpdGU6MS43LjQnCiAgICBlbnRyeXBvaW50OiB3b3JrZXItZGVsZXRlcwogICAgY29udGFpbmVyX25hbWU6IGFwcHdyaXRlLXdvcmtlci1kZWxldGVzCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwcHdyaXRlLXJlZGlzCiAgICAgIC0gYXBwd3JpdGUtbWFyaWFkYgogICAgdm9sdW1lczoKICAgICAgLSAnYXBwd3JpdGUtdXBsb2Fkczovc3RvcmFnZS91cGxvYWRzOnJ3JwogICAgICAtICdhcHB3cml0ZS1jYWNoZTovc3RvcmFnZS9jYWNoZTpydycKICAgICAgLSAnYXBwd3JpdGUtZnVuY3Rpb25zOi9zdG9yYWdlL2Z1bmN0aW9uczpydycKICAgICAgLSAnYXBwd3JpdGUtc2l0ZXM6L3N0b3JhZ2Uvc2l0ZXM6cncnCiAgICAgIC0gJ2FwcHdyaXRlLWJ1aWxkczovc3RvcmFnZS9idWlsZHM6cncnCiAgICAgIC0gJ2FwcHdyaXRlLWNlcnRpZmljYXRlczovc3RvcmFnZS9jZXJ0aWZpY2F0ZXM6cncnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnX0FQUF9FTlY9JHtfQVBQX0VOVjotcHJvZHVjdGlvbn0nCiAgICAgIC0gJ19BUFBfV09SS0VSX1BFUl9DT1JFPSR7X0FQUF9XT1JLRVJfUEVSX0NPUkU6LTZ9JwogICAgICAtIF9BUFBfT1BFTlNTTF9LRVlfVjE9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfQVBQV1JJVEUKICAgICAgLSAnX0FQUF9SRURJU19IT1NUPSR7X0FQUF9SRURJU19IT1NUOi1hcHB3cml0ZS1yZWRpc30nCiAgICAgIC0gJ19BUFBfUkVESVNfUE9SVD0ke19BUFBfUkVESVNfUE9SVDotNjM3OX0nCiAgICAgIC0gJ19BUFBfUkVESVNfVVNFUj0ke19BUFBfUkVESVNfVVNFUn0nCiAgICAgIC0gJ19BUFBfUkVESVNfUEFTUz0ke19BUFBfUkVESVNfUEFTU30nCiAgICAgIC0gJ19BUFBfREJfSE9TVD0ke19BUFBfREJfSE9TVDotYXBwd3JpdGUtbWFyaWFkYn0nCiAgICAgIC0gJ19BUFBfREJfUE9SVD0ke19BUFBfREJfUE9SVDotMzMwNn0nCiAgICAgIC0gJ19BUFBfREJfU0NIRU1BPSR7X0FQUF9EQl9TQ0hFTUE6LWFwcHdyaXRlfScKICAgICAgLSBfQVBQX0RCX1VTRVI9JFNFUlZJQ0VfVVNFUl9NQVJJQURCCiAgICAgIC0gX0FQUF9EQl9QQVNTPSRTRVJWSUNFX1BBU1NXT1JEX01BUklBREIKICAgICAgLSAnX0FQUF9TVE9SQUdFX0RFVklDRT0ke19BUFBfU1RPUkFHRV9ERVZJQ0U6LWxvY2FsfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1MzX0FDQ0VTU19LRVk9JHtfQVBQX1NUT1JBR0VfUzNfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9TM19TRUNSRVQ9JHtfQVBQX1NUT1JBR0VfUzNfU0VDUkVUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1MzX1JFR0lPTj0ke19BUFBfU1RPUkFHRV9TM19SRUdJT046LXVzLWVhc3QtMX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9TM19CVUNLRVQ9JHtfQVBQX1NUT1JBR0VfUzNfQlVDS0VUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1MzX0VORFBPSU5UPSR7X0FQUF9TVE9SQUdFX1MzX0VORFBPSU5UfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0RPX1NQQUNFU19BQ0NFU1NfS0VZPSR7X0FQUF9TVE9SQUdFX0RPX1NQQUNFU19BQ0NFU1NfS0VZfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0RPX1NQQUNFU19TRUNSRVQ9JHtfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX1NFQ1JFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9ET19TUEFDRVNfUkVHSU9OPSR7X0FQUF9TVE9SQUdFX0RPX1NQQUNFU19SRUdJT046LXVzLWVhc3QtMX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9ET19TUEFDRVNfQlVDS0VUPSR7X0FQUF9TVE9SQUdFX0RPX1NQQUNFU19CVUNLRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX0FDQ0VTU19LRVk9JHtfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX0FDQ0VTU19LRVl9JwogICAgICAtICdfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX1NFQ1JFVD0ke19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfU0VDUkVUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9SRUdJT049JHtfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX1JFR0lPTjotdXMtd2VzdC0wMDR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX0JVQ0tFVD0ke19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfQlVDS0VUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0xJTk9ERV9BQ0NFU1NfS0VZPSR7X0FQUF9TVE9SQUdFX0xJTk9ERV9BQ0NFU1NfS0VZfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0xJTk9ERV9TRUNSRVQ9JHtfQVBQX1NUT1JBR0VfTElOT0RFX1NFQ1JFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9MSU5PREVfUkVHSU9OPSR7X0FQUF9TVE9SQUdFX0xJTk9ERV9SRUdJT046LWV1LWNlbnRyYWwtMX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9MSU5PREVfQlVDS0VUPSR7X0FQUF9TVE9SQUdFX0xJTk9ERV9CVUNLRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfV0FTQUJJX0FDQ0VTU19LRVk9JHtfQVBQX1NUT1JBR0VfV0FTQUJJX0FDQ0VTU19LRVl9JwogICAgICAtICdfQVBQX1NUT1JBR0VfV0FTQUJJX1NFQ1JFVD0ke19BUFBfU1RPUkFHRV9XQVNBQklfU0VDUkVUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1dBU0FCSV9SRUdJT049JHtfQVBQX1NUT1JBR0VfV0FTQUJJX1JFR0lPTjotZXUtY2VudHJhbC0xfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1dBU0FCSV9CVUNLRVQ9JHtfQVBQX1NUT1JBR0VfV0FTQUJJX0JVQ0tFVH0nCiAgICAgIC0gJ19BUFBfTE9HR0lOR19DT05GSUc9JHtfQVBQX0xPR0dJTkdfQ09ORklHfScKICAgICAgLSBfQVBQX0VYRUNVVE9SX1NFQ1JFVD0kU0VSVklDRV9QQVNTV09SRF82NF9BUFBXUklURQogICAgICAtICdfQVBQX0VYRUNVVE9SX0hPU1Q9JHtfQVBQX0VYRUNVVE9SX0hPU1Q6LWh0dHA6Ly9hcHB3cml0ZS1leGVjdXRvci92MX0nCiAgICAgIC0gJ19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0FCVVNFPSR7X0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fQUJVU0U6LTg2NDAwfScKICAgICAgLSAnX0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fQVVESVQ9JHtfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9BVURJVDotMTIwOTYwMH0nCiAgICAgIC0gJ19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0FVRElUX0NPTlNPTEU9JHtfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9BVURJVF9DT05TT0xFfScKICAgICAgLSAnX0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fRVhFQ1VUSU9OPSR7X0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fRVhFQ1VUSU9OOi0xMjA5NjAwfScKICAgICAgLSAnX0FQUF9TWVNURU1fU0VDVVJJVFlfRU1BSUxfQUREUkVTUz0ke19BUFBfU1lTVEVNX1NFQ1VSSVRZX0VNQUlMX0FERFJFU1N9JwogICAgICAtICdfQVBQX0VNQUlMX0NFUlRJRklDQVRFUz0ke19BUFBfRU1BSUxfQ0VSVElGSUNBVEVTfScKICBhcHB3cml0ZS13b3JrZXItZGF0YWJhc2VzOgogICAgaW1hZ2U6ICdhcHB3cml0ZS9hcHB3cml0ZToxLjcuNCcKICAgIGVudHJ5cG9pbnQ6IHdvcmtlci1kYXRhYmFzZXMKICAgIGNvbnRhaW5lcl9uYW1lOiBhcHB3cml0ZS13b3JrZXItZGF0YWJhc2VzCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwcHdyaXRlLXJlZGlzCiAgICAgIC0gYXBwd3JpdGUtbWFyaWFkYgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ19BUFBfRU5WPSR7X0FQUF9FTlY6LXByb2R1Y3Rpb259JwogICAgICAtICdfQVBQX1dPUktFUl9QRVJfQ09SRT0ke19BUFBfV09SS0VSX1BFUl9DT1JFOi02fScKICAgICAgLSBfQVBQX09QRU5TU0xfS0VZX1YxPSRTRVJWSUNFX1BBU1NXT1JEXzY0X0FQUFdSSVRFCiAgICAgIC0gJ19BUFBfUkVESVNfSE9TVD0ke19BUFBfUkVESVNfSE9TVDotYXBwd3JpdGUtcmVkaXN9JwogICAgICAtICdfQVBQX1JFRElTX1BPUlQ9JHtfQVBQX1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICAtICdfQVBQX1JFRElTX1VTRVI9JHtfQVBQX1JFRElTX1VTRVJ9JwogICAgICAtICdfQVBQX1JFRElTX1BBU1M9JHtfQVBQX1JFRElTX1BBU1N9JwogICAgICAtICdfQVBQX0RCX0hPU1Q9JHtfQVBQX0RCX0hPU1Q6LWFwcHdyaXRlLW1hcmlhZGJ9JwogICAgICAtICdfQVBQX0RCX1BPUlQ9JHtfQVBQX0RCX1BPUlQ6LTMzMDZ9JwogICAgICAtICdfQVBQX0RCX1NDSEVNQT0ke19BUFBfREJfU0NIRU1BOi1hcHB3cml0ZX0nCiAgICAgIC0gX0FQUF9EQl9VU0VSPSRTRVJWSUNFX1VTRVJfTUFSSUFEQgogICAgICAtIF9BUFBfREJfUEFTUz0kU0VSVklDRV9QQVNTV09SRF9NQVJJQURCCiAgICAgIC0gJ19BUFBfTE9HR0lOR19DT05GSUc9JHtfQVBQX0xPR0dJTkdfQ09ORklHfScKICBhcHB3cml0ZS13b3JrZXItYnVpbGRzOgogICAgaW1hZ2U6ICdhcHB3cml0ZS9hcHB3cml0ZToxLjcuNCcKICAgIGVudHJ5cG9pbnQ6IHdvcmtlci1idWlsZHMKICAgIGNvbnRhaW5lcl9uYW1lOiBhcHB3cml0ZS13b3JrZXItYnVpbGRzCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwcHdyaXRlLXJlZGlzCiAgICAgIC0gYXBwd3JpdGUtbWFyaWFkYgogICAgdm9sdW1lczoKICAgICAgLSAnYXBwd3JpdGUtZnVuY3Rpb25zOi9zdG9yYWdlL2Z1bmN0aW9uczpydycKICAgICAgLSAnYXBwd3JpdGUtc2l0ZXM6L3N0b3JhZ2Uvc2l0ZXM6cncnCiAgICAgIC0gJ2FwcHdyaXRlLWJ1aWxkczovc3RvcmFnZS9idWlsZHM6cncnCiAgICAgIC0gJ2FwcHdyaXRlLXVwbG9hZHM6L3N0b3JhZ2UvdXBsb2FkczpydycKICAgIGVudmlyb25tZW50OgogICAgICAtICdfQVBQX0VOVj0ke19BUFBfRU5WOi1wcm9kdWN0aW9ufScKICAgICAgLSAnX0FQUF9XT1JLRVJfUEVSX0NPUkU9JHtfQVBQX1dPUktFUl9QRVJfQ09SRTotNn0nCiAgICAgIC0gX0FQUF9PUEVOU1NMX0tFWV9WMT0kU0VSVklDRV9QQVNTV09SRF82NF9BUFBXUklURQogICAgICAtIF9BUFBfRVhFQ1VUT1JfU0VDUkVUPSRTRVJWSUNFX1BBU1NXT1JEXzY0X0FQUFdSSVRFCiAgICAgIC0gJ19BUFBfRVhFQ1VUT1JfSE9TVD0ke19BUFBfRVhFQ1VUT1JfSE9TVDotaHR0cDovL2FwcHdyaXRlLWV4ZWN1dG9yL3YxfScKICAgICAgLSAnX0FQUF9SRURJU19IT1NUPSR7X0FQUF9SRURJU19IT1NUOi1hcHB3cml0ZS1yZWRpc30nCiAgICAgIC0gJ19BUFBfUkVESVNfUE9SVD0ke19BUFBfUkVESVNfUE9SVDotNjM3OX0nCiAgICAgIC0gJ19BUFBfUkVESVNfVVNFUj0ke19BUFBfUkVESVNfVVNFUn0nCiAgICAgIC0gJ19BUFBfUkVESVNfUEFTUz0ke19BUFBfUkVESVNfUEFTU30nCiAgICAgIC0gJ19BUFBfREJfSE9TVD0ke19BUFBfREJfSE9TVDotYXBwd3JpdGUtbWFyaWFkYn0nCiAgICAgIC0gJ19BUFBfREJfUE9SVD0ke19BUFBfREJfUE9SVDotMzMwNn0nCiAgICAgIC0gJ19BUFBfREJfU0NIRU1BPSR7X0FQUF9EQl9TQ0hFTUE6LWFwcHdyaXRlfScKICAgICAgLSBfQVBQX0RCX1VTRVI9JFNFUlZJQ0VfVVNFUl9NQVJJQURCCiAgICAgIC0gX0FQUF9EQl9QQVNTPSRTRVJWSUNFX1BBU1NXT1JEX01BUklBREIKICAgICAgLSAnX0FQUF9MT0dHSU5HX0NPTkZJRz0ke19BUFBfTE9HR0lOR19DT05GSUd9JwogICAgICAtICdfQVBQX1ZDU19HSVRIVUJfQVBQX05BTUU9JHtfQVBQX1ZDU19HSVRIVUJfQVBQX05BTUV9JwogICAgICAtICdfQVBQX1ZDU19HSVRIVUJfUFJJVkFURV9LRVk9JHtfQVBQX1ZDU19HSVRIVUJfUFJJVkFURV9LRVl9JwogICAgICAtICdfQVBQX1ZDU19HSVRIVUJfQVBQX0lEPSR7X0FQUF9WQ1NfR0lUSFVCX0FQUF9JRH0nCiAgICAgIC0gJ19BUFBfRlVOQ1RJT05TX1RJTUVPVVQ9JHtfQVBQX0ZVTkNUSU9OU19USU1FT1VUOi05MDB9JwogICAgICAtICdfQVBQX1NJVEVTX1RJTUVPVVQ9JHtfQVBQX1NJVEVTX1RJTUVPVVQ6LTkwMH0nCiAgICAgIC0gJ19BUFBfQ09NUFVURV9CVUlMRF9USU1FT1VUPSR7X0FQUF9DT01QVVRFX0JVSUxEX1RJTUVPVVQ6LTkwMH0nCiAgICAgIC0gJ19BUFBfQ09NUFVURV9DUFVTPSR7X0FQUF9DT01QVVRFX0NQVVM6LTB9JwogICAgICAtICdfQVBQX0NPTVBVVEVfTUVNT1JZPSR7X0FQUF9DT01QVVRFX01FTU9SWTotMH0nCiAgICAgIC0gJ19BUFBfQ09NUFVURV9TSVpFX0xJTUlUPSR7X0FQUF9DT01QVVRFX1NJWkVfTElNSVQ6LTMwMDAwMDAwfScKICAgICAgLSAnX0FQUF9PUFRJT05TX0ZPUkNFX0hUVFBTPSR7X0FQUF9PUFRJT05TX0ZPUkNFX0hUVFBTOi1kaXNhYmxlZH0nCiAgICAgIC0gJ19BUFBfT1BUSU9OU19ST1VURVJfRk9SQ0VfSFRUUFM9JHtfQVBQX09QVElPTlNfUk9VVEVSX0ZPUkNFX0hUVFBTOi1kaXNhYmxlZH0nCiAgICAgIC0gX0FQUF9ET01BSU49JFNFUlZJQ0VfVVJMX0FQUFdSSVRFCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9ERVZJQ0U9JHtfQVBQX1NUT1JBR0VfREVWSUNFOi1sb2NhbH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9TM19BQ0NFU1NfS0VZPSR7X0FQUF9TVE9SQUdFX1MzX0FDQ0VTU19LRVl9JwogICAgICAtICdfQVBQX1NUT1JBR0VfUzNfU0VDUkVUPSR7X0FQUF9TVE9SQUdFX1MzX1NFQ1JFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9TM19SRUdJT049JHtfQVBQX1NUT1JBR0VfUzNfUkVHSU9OOi11cy1lYXN0LTF9JwogICAgICAtICdfQVBQX1NUT1JBR0VfUzNfQlVDS0VUPSR7X0FQUF9TVE9SQUdFX1MzX0JVQ0tFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9TM19FTkRQT0lOVD0ke19BUFBfU1RPUkFHRV9TM19FTkRQT0lOVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9ET19TUEFDRVNfQUNDRVNTX0tFWT0ke19BUFBfU1RPUkFHRV9ET19TUEFDRVNfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9ET19TUEFDRVNfU0VDUkVUPSR7X0FQUF9TVE9SQUdFX0RPX1NQQUNFU19TRUNSRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX1JFR0lPTj0ke19BUFBfU1RPUkFHRV9ET19TUEFDRVNfUkVHSU9OOi11cy1lYXN0LTF9JwogICAgICAtICdfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX0JVQ0tFVD0ke19BUFBfU1RPUkFHRV9ET19TUEFDRVNfQlVDS0VUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9BQ0NFU1NfS0VZPSR7X0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9BQ0NFU1NfS0VZfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9TRUNSRVQ9JHtfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX1NFQ1JFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfUkVHSU9OPSR7X0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9SRUdJT046LXVzLXdlc3QtMDA0fScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9CVUNLRVQ9JHtfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX0JVQ0tFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9MSU5PREVfQUNDRVNTX0tFWT0ke19BUFBfU1RPUkFHRV9MSU5PREVfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9MSU5PREVfU0VDUkVUPSR7X0FQUF9TVE9SQUdFX0xJTk9ERV9TRUNSRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfTElOT0RFX1JFR0lPTj0ke19BUFBfU1RPUkFHRV9MSU5PREVfUkVHSU9OOi1ldS1jZW50cmFsLTF9JwogICAgICAtICdfQVBQX1NUT1JBR0VfTElOT0RFX0JVQ0tFVD0ke19BUFBfU1RPUkFHRV9MSU5PREVfQlVDS0VUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1dBU0FCSV9BQ0NFU1NfS0VZPSR7X0FQUF9TVE9SQUdFX1dBU0FCSV9BQ0NFU1NfS0VZfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1dBU0FCSV9TRUNSRVQ9JHtfQVBQX1NUT1JBR0VfV0FTQUJJX1NFQ1JFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9XQVNBQklfUkVHSU9OPSR7X0FQUF9TVE9SQUdFX1dBU0FCSV9SRUdJT046LWV1LWNlbnRyYWwtMX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9XQVNBQklfQlVDS0VUPSR7X0FQUF9TVE9SQUdFX1dBU0FCSV9CVUNLRVR9JwogICAgICAtICdfQVBQX0RPTUFJTl9TSVRFUz0ke19BUFBfRE9NQUlOX1NJVEVTfScKICBhcHB3cml0ZS13b3JrZXItY2VydGlmaWNhdGVzOgogICAgaW1hZ2U6ICdhcHB3cml0ZS9hcHB3cml0ZToxLjcuNCcKICAgIGVudHJ5cG9pbnQ6IHdvcmtlci1jZXJ0aWZpY2F0ZXMKICAgIGNvbnRhaW5lcl9uYW1lOiBhcHB3cml0ZS13b3JrZXItY2VydGlmaWNhdGVzCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwcHdyaXRlLXJlZGlzCiAgICAgIC0gYXBwd3JpdGUtbWFyaWFkYgogICAgdm9sdW1lczoKICAgICAgLSAnYXBwd3JpdGUtY29uZmlnOi9zdG9yYWdlL2NvbmZpZzpydycKICAgICAgLSAnYXBwd3JpdGUtY2VydGlmaWNhdGVzOi9zdG9yYWdlL2NlcnRpZmljYXRlczpydycKICAgIGVudmlyb25tZW50OgogICAgICAtICdfQVBQX0VOVj0ke19BUFBfRU5WOi1wcm9kdWN0aW9ufScKICAgICAgLSAnX0FQUF9XT1JLRVJfUEVSX0NPUkU9JHtfQVBQX1dPUktFUl9QRVJfQ09SRTotNn0nCiAgICAgIC0gX0FQUF9PUEVOU1NMX0tFWV9WMT0kU0VSVklDRV9QQVNTV09SRF82NF9BUFBXUklURQogICAgICAtIF9BUFBfRE9NQUlOPSRTRVJWSUNFX1VSTF9BUFBXUklURQogICAgICAtICdfQVBQX0RPTUFJTl9UQVJHRVRfQ05BTUU9JHtfQVBQX0RPTUFJTl9UQVJHRVRfQ05BTUV9JwogICAgICAtICdfQVBQX0RPTUFJTl9UQVJHRVRfQUFBQT0ke19BUFBfRE9NQUlOX1RBUkdFVF9BQUFBfScKICAgICAgLSAnX0FQUF9ET01BSU5fVEFSR0VUX0E9JHtfQVBQX0RPTUFJTl9UQVJHRVRfQX0nCiAgICAgIC0gX0FQUF9ET01BSU5fRlVOQ1RJT05TPSRTRVJWSUNFX1VSTF9BUFBXUklURQogICAgICAtICdfQVBQX0VNQUlMX0NFUlRJRklDQVRFUz0ke19BUFBfRU1BSUxfQ0VSVElGSUNBVEVTOi1lbmFibGVkfScKICAgICAgLSAnX0FQUF9SRURJU19IT1NUPSR7X0FQUF9SRURJU19IT1NUOi1hcHB3cml0ZS1yZWRpc30nCiAgICAgIC0gJ19BUFBfUkVESVNfUE9SVD0ke19BUFBfUkVESVNfUE9SVDotNjM3OX0nCiAgICAgIC0gJ19BUFBfUkVESVNfVVNFUj0ke19BUFBfUkVESVNfVVNFUn0nCiAgICAgIC0gJ19BUFBfUkVESVNfUEFTUz0ke19BUFBfUkVESVNfUEFTU30nCiAgICAgIC0gJ19BUFBfREJfSE9TVD0ke19BUFBfREJfSE9TVDotYXBwd3JpdGUtbWFyaWFkYn0nCiAgICAgIC0gJ19BUFBfREJfUE9SVD0ke19BUFBfREJfUE9SVDotMzMwNn0nCiAgICAgIC0gJ19BUFBfREJfU0NIRU1BPSR7X0FQUF9EQl9TQ0hFTUE6LWFwcHdyaXRlfScKICAgICAgLSBfQVBQX0RCX1VTRVI9JFNFUlZJQ0VfVVNFUl9NQVJJQURCCiAgICAgIC0gX0FQUF9EQl9QQVNTPSRTRVJWSUNFX1BBU1NXT1JEX01BUklBREIKICAgICAgLSAnX0FQUF9MT0dHSU5HX0NPTkZJRz0ke19BUFBfTE9HR0lOR19DT05GSUd9JwogIGFwcHdyaXRlLXdvcmtlci1mdW5jdGlvbnM6CiAgICBpbWFnZTogJ2FwcHdyaXRlL2FwcHdyaXRlOjEuNy40JwogICAgZW50cnlwb2ludDogd29ya2VyLWZ1bmN0aW9ucwogICAgY29udGFpbmVyX25hbWU6IGFwcHdyaXRlLXdvcmtlci1mdW5jdGlvbnMKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBwd3JpdGUtcmVkaXMKICAgICAgLSBhcHB3cml0ZS1tYXJpYWRiCiAgICAgIC0gb3BlbnJ1bnRpbWVzLWV4ZWN1dG9yCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnX0FQUF9FTlY9JHtfQVBQX0VOVjotcHJvZHVjdGlvbn0nCiAgICAgIC0gJ19BUFBfV09SS0VSX1BFUl9DT1JFPSR7X0FQUF9XT1JLRVJfUEVSX0NPUkU6LTZ9JwogICAgICAtIF9BUFBfT1BFTlNTTF9LRVlfVjE9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfQVBQV1JJVEUKICAgICAgLSBfQVBQX0RPTUFJTj0kU0VSVklDRV9VUkxfQVBQV1JJVEUKICAgICAgLSAnX0FQUF9PUFRJT05TX0ZPUkNFX0hUVFBTPSR7X0FQUF9PUFRJT05TX0ZPUkNFX0hUVFBTOi1kaXNhYmxlZH0nCiAgICAgIC0gJ19BUFBfUkVESVNfSE9TVD0ke19BUFBfUkVESVNfSE9TVDotYXBwd3JpdGUtcmVkaXN9JwogICAgICAtICdfQVBQX1JFRElTX1BPUlQ9JHtfQVBQX1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICAtICdfQVBQX1JFRElTX1VTRVI9JHtfQVBQX1JFRElTX1VTRVJ9JwogICAgICAtICdfQVBQX1JFRElTX1BBU1M9JHtfQVBQX1JFRElTX1BBU1N9JwogICAgICAtICdfQVBQX0RCX0hPU1Q9JHtfQVBQX0RCX0hPU1Q6LWFwcHdyaXRlLW1hcmlhZGJ9JwogICAgICAtICdfQVBQX0RCX1BPUlQ9JHtfQVBQX0RCX1BPUlQ6LTMzMDZ9JwogICAgICAtICdfQVBQX0RCX1NDSEVNQT0ke19BUFBfREJfU0NIRU1BOi1hcHB3cml0ZX0nCiAgICAgIC0gX0FQUF9EQl9VU0VSPSRTRVJWSUNFX1VTRVJfTUFSSUFEQgogICAgICAtIF9BUFBfREJfUEFTUz0kU0VSVklDRV9QQVNTV09SRF9NQVJJQURCCiAgICAgIC0gJ19BUFBfRlVOQ1RJT05TX1RJTUVPVVQ9JHtfQVBQX0ZVTkNUSU9OU19USU1FT1VUOi05MDB9JwogICAgICAtICdfQVBQX1NJVEVTX1RJTUVPVVQ9JHtfQVBQX1NJVEVTX1RJTUVPVVQ6LTkwMH0nCiAgICAgIC0gJ19BUFBfQ09NUFVURV9CVUlMRF9USU1FT1VUPSR7X0FQUF9DT01QVVRFX0JVSUxEX1RJTUVPVVQ6LTkwMH0nCiAgICAgIC0gJ19BUFBfQ09NUFVURV9DUFVTPSR7X0FQUF9DT01QVVRFX0NQVVM6LTB9JwogICAgICAtICdfQVBQX0NPTVBVVEVfTUVNT1JZPSR7X0FQUF9DT01QVVRFX01FTU9SWTotMH0nCiAgICAgIC0gX0FQUF9FWEVDVVRPUl9TRUNSRVQ9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfQVBQV1JJVEUKICAgICAgLSAnX0FQUF9FWEVDVVRPUl9IT1NUPSR7X0FQUF9FWEVDVVRPUl9IT1NUOi1odHRwOi8vYXBwd3JpdGUtZXhlY3V0b3IvdjF9JwogICAgICAtICdfQVBQX1VTQUdFX1NUQVRTPSR7X0FQUF9VU0FHRV9TVEFUUzotZW5hYmxlZH0nCiAgICAgIC0gJ19BUFBfRE9DS0VSX0hVQl9VU0VSTkFNRT0ke19BUFBfRE9DS0VSX0hVQl9VU0VSTkFNRX0nCiAgICAgIC0gJ19BUFBfRE9DS0VSX0hVQl9QQVNTV09SRD0ke19BUFBfRE9DS0VSX0hVQl9QQVNTV09SRH0nCiAgICAgIC0gJ19BUFBfTE9HR0lOR19DT05GSUc9JHtfQVBQX0xPR0dJTkdfQ09ORklHfScKICBhcHB3cml0ZS13b3JrZXItbWFpbHM6CiAgICBpbWFnZTogJ2FwcHdyaXRlL2FwcHdyaXRlOjEuNy40JwogICAgZW50cnlwb2ludDogd29ya2VyLW1haWxzCiAgICBjb250YWluZXJfbmFtZTogYXBwd3JpdGUtd29ya2VyLW1haWxzCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwcHdyaXRlLXJlZGlzCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnX0FQUF9FTlY9JHtfQVBQX0VOVjotcHJvZHVjdGlvbn0nCiAgICAgIC0gJ19BUFBfV09SS0VSX1BFUl9DT1JFPSR7X0FQUF9XT1JLRVJfUEVSX0NPUkU6LTZ9JwogICAgICAtIF9BUFBfT1BFTlNTTF9LRVlfVjE9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfQVBQV1JJVEUKICAgICAgLSAnX0FQUF9TWVNURU1fRU1BSUxfTkFNRT0ke19BUFBfU1lTVEVNX0VNQUlMX05BTUU6LUFwcHdyaXRlfScKICAgICAgLSAnX0FQUF9TWVNURU1fRU1BSUxfQUREUkVTUz0ke19BUFBfU1lTVEVNX0VNQUlMX0FERFJFU1M6LXRlYW1AYXBwd3JpdGUuaW99JwogICAgICAtICdfQVBQX0RCX0hPU1Q9JHtfQVBQX0RCX0hPU1Q6LWFwcHdyaXRlLW1hcmlhZGJ9JwogICAgICAtICdfQVBQX0RCX1BPUlQ9JHtfQVBQX0RCX1BPUlQ6LTMzMDZ9JwogICAgICAtICdfQVBQX0RCX1NDSEVNQT0ke19BUFBfREJfU0NIRU1BOi1hcHB3cml0ZX0nCiAgICAgIC0gX0FQUF9EQl9VU0VSPSRTRVJWSUNFX1VTRVJfTUFSSUFEQgogICAgICAtIF9BUFBfREJfUEFTUz0kU0VSVklDRV9QQVNTV09SRF9NQVJJQURCCiAgICAgIC0gJ19BUFBfUkVESVNfSE9TVD0ke19BUFBfUkVESVNfSE9TVDotYXBwd3JpdGUtcmVkaXN9JwogICAgICAtICdfQVBQX1JFRElTX1BPUlQ9JHtfQVBQX1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICAtICdfQVBQX1JFRElTX1VTRVI9JHtfQVBQX1JFRElTX1VTRVJ9JwogICAgICAtICdfQVBQX1JFRElTX1BBU1M9JHtfQVBQX1JFRElTX1BBU1N9JwogICAgICAtICdfQVBQX1NNVFBfSE9TVD0ke19BUFBfU01UUF9IT1NUfScKICAgICAgLSAnX0FQUF9TTVRQX1BPUlQ9JHtfQVBQX1NNVFBfUE9SVH0nCiAgICAgIC0gJ19BUFBfU01UUF9TRUNVUkU9JHtfQVBQX1NNVFBfU0VDVVJFfScKICAgICAgLSAnX0FQUF9TTVRQX1VTRVJOQU1FPSR7X0FQUF9TTVRQX1VTRVJOQU1FfScKICAgICAgLSAnX0FQUF9TTVRQX1BBU1NXT1JEPSR7X0FQUF9TTVRQX1BBU1NXT1JEfScKICAgICAgLSAnX0FQUF9MT0dHSU5HX0NPTkZJRz0ke19BUFBfTE9HR0lOR19DT05GSUd9JwogICAgICAtIF9BUFBfRE9NQUlOPSRTRVJWSUNFX1VSTF9BUFBXUklURQogICAgICAtICdfQVBQX09QVElPTlNfRk9SQ0VfSFRUUFM9JHtfQVBQX09QVElPTlNfRk9SQ0VfSFRUUFM6LWRpc2FibGVkfScKICBhcHB3cml0ZS13b3JrZXItbWVzc2FnaW5nOgogICAgaW1hZ2U6ICdhcHB3cml0ZS9hcHB3cml0ZToxLjcuNCcKICAgIGVudHJ5cG9pbnQ6IHdvcmtlci1tZXNzYWdpbmcKICAgIGNvbnRhaW5lcl9uYW1lOiBhcHB3cml0ZS13b3JrZXItbWVzc2FnaW5nCiAgICB2b2x1bWVzOgogICAgICAtICdhcHB3cml0ZS11cGxvYWRzOi9zdG9yYWdlL3VwbG9hZHM6cncnCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwcHdyaXRlLXJlZGlzCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnX0FQUF9FTlY9JHtfQVBQX0VOVjotcHJvZHVjdGlvbn0nCiAgICAgIC0gJ19BUFBfV09SS0VSX1BFUl9DT1JFPSR7X0FQUF9XT1JLRVJfUEVSX0NPUkU6LTZ9JwogICAgICAtIF9BUFBfT1BFTlNTTF9LRVlfVjE9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfQVBQV1JJVEUKICAgICAgLSAnX0FQUF9SRURJU19IT1NUPSR7X0FQUF9SRURJU19IT1NUOi1hcHB3cml0ZS1yZWRpc30nCiAgICAgIC0gJ19BUFBfUkVESVNfUE9SVD0ke19BUFBfUkVESVNfUE9SVDotNjM3OX0nCiAgICAgIC0gJ19BUFBfUkVESVNfVVNFUj0ke19BUFBfUkVESVNfVVNFUn0nCiAgICAgIC0gJ19BUFBfUkVESVNfUEFTUz0ke19BUFBfUkVESVNfUEFTU30nCiAgICAgIC0gJ19BUFBfREJfSE9TVD0ke19BUFBfREJfSE9TVDotYXBwd3JpdGUtbWFyaWFkYn0nCiAgICAgIC0gJ19BUFBfREJfUE9SVD0ke19BUFBfREJfUE9SVDotMzMwNn0nCiAgICAgIC0gJ19BUFBfREJfU0NIRU1BPSR7X0FQUF9EQl9TQ0hFTUE6LWFwcHdyaXRlfScKICAgICAgLSBfQVBQX0RCX1VTRVI9JFNFUlZJQ0VfVVNFUl9NQVJJQURCCiAgICAgIC0gX0FQUF9EQl9QQVNTPSRTRVJWSUNFX1BBU1NXT1JEX01BUklBREIKICAgICAgLSAnX0FQUF9MT0dHSU5HX0NPTkZJRz0ke19BUFBfTE9HR0lOR19DT05GSUd9JwogICAgICAtICdfQVBQX1NNU19GUk9NPSR7X0FQUF9TTVNfRlJPTX0nCiAgICAgIC0gJ19BUFBfU01TX1BST1ZJREVSPSR7X0FQUF9TTVNfUFJPVklERVJ9JwogICAgICAtICdfQVBQX1NUT1JBR0VfREVWSUNFPSR7X0FQUF9TVE9SQUdFX0RFVklDRTotbG9jYWx9JwogICAgICAtICdfQVBQX1NUT1JBR0VfUzNfQUNDRVNTX0tFWT0ke19BUFBfU1RPUkFHRV9TM19BQ0NFU1NfS0VZfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1MzX1NFQ1JFVD0ke19BUFBfU1RPUkFHRV9TM19TRUNSRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfUzNfUkVHSU9OPSR7X0FQUF9TVE9SQUdFX1MzX1JFR0lPTjotdXMtZWFzdC0xfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1MzX0JVQ0tFVD0ke19BUFBfU1RPUkFHRV9TM19CVUNLRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfUzNfRU5EUE9JTlQ9JHtfQVBQX1NUT1JBR0VfUzNfRU5EUE9JTlR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX0FDQ0VTU19LRVk9JHtfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX0FDQ0VTU19LRVl9JwogICAgICAtICdfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX1NFQ1JFVD0ke19BUFBfU1RPUkFHRV9ET19TUEFDRVNfU0VDUkVUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0RPX1NQQUNFU19SRUdJT049JHtfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX1JFR0lPTjotdXMtZWFzdC0xfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0RPX1NQQUNFU19CVUNLRVQ9JHtfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX0JVQ0tFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfQUNDRVNTX0tFWT0ke19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfU0VDUkVUPSR7X0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9TRUNSRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX1JFR0lPTj0ke19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfUkVHSU9OOi11cy13ZXN0LTAwNH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfQlVDS0VUPSR7X0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9CVUNLRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfTElOT0RFX0FDQ0VTU19LRVk9JHtfQVBQX1NUT1JBR0VfTElOT0RFX0FDQ0VTU19LRVl9JwogICAgICAtICdfQVBQX1NUT1JBR0VfTElOT0RFX1NFQ1JFVD0ke19BUFBfU1RPUkFHRV9MSU5PREVfU0VDUkVUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0xJTk9ERV9SRUdJT049JHtfQVBQX1NUT1JBR0VfTElOT0RFX1JFR0lPTjotZXUtY2VudHJhbC0xfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0xJTk9ERV9CVUNLRVQ9JHtfQVBQX1NUT1JBR0VfTElOT0RFX0JVQ0tFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9XQVNBQklfQUNDRVNTX0tFWT0ke19BUFBfU1RPUkFHRV9XQVNBQklfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9XQVNBQklfU0VDUkVUPSR7X0FQUF9TVE9SQUdFX1dBU0FCSV9TRUNSRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfV0FTQUJJX1JFR0lPTj0ke19BUFBfU1RPUkFHRV9XQVNBQklfUkVHSU9OOi1ldS1jZW50cmFsLTF9JwogICAgICAtICdfQVBQX1NUT1JBR0VfV0FTQUJJX0JVQ0tFVD0ke19BUFBfU1RPUkFHRV9XQVNBQklfQlVDS0VUfScKICBhcHB3cml0ZS13b3JrZXItbWlncmF0aW9uczoKICAgIGltYWdlOiAnYXBwd3JpdGUvYXBwd3JpdGU6MS43LjQnCiAgICBlbnRyeXBvaW50OiB3b3JrZXItbWlncmF0aW9ucwogICAgY29udGFpbmVyX25hbWU6IGFwcHdyaXRlLXdvcmtlci1taWdyYXRpb25zCiAgICB2b2x1bWVzOgogICAgICAtICdhcHB3cml0ZS1pbXBvcnRzOi9zdG9yYWdlL2ltcG9ydHM6cncnCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwcHdyaXRlLW1hcmlhZGIKICAgIGVudmlyb25tZW50OgogICAgICAtICdfQVBQX0VOVj0ke19BUFBfRU5WOi1wcm9kdWN0aW9ufScKICAgICAgLSAnX0FQUF9XT1JLRVJfUEVSX0NPUkU9JHtfQVBQX1dPUktFUl9QRVJfQ09SRTotNn0nCiAgICAgIC0gX0FQUF9PUEVOU1NMX0tFWV9WMT0kU0VSVklDRV9QQVNTV09SRF82NF9BUFBXUklURQogICAgICAtIF9BUFBfRE9NQUlOPSRTRVJWSUNFX1VSTF9BUFBXUklURQogICAgICAtICdfQVBQX0RPTUFJTl9UQVJHRVRfQ05BTUU9JHtfQVBQX0RPTUFJTl9UQVJHRVRfQ05BTUV9JwogICAgICAtICdfQVBQX0RPTUFJTl9UQVJHRVRfQUFBQT0ke19BUFBfRE9NQUlOX1RBUkdFVF9BQUFBfScKICAgICAgLSAnX0FQUF9ET01BSU5fVEFSR0VUX0E9JHtfQVBQX0RPTUFJTl9UQVJHRVRfQX0nCiAgICAgIC0gJ19BUFBfRU1BSUxfU0VDVVJJVFk9JHtfQVBQX0VNQUlMX1NFQ1VSSVRZOi1jZXJ0c0BhcHB3cml0ZS5pb30nCiAgICAgIC0gJ19BUFBfUkVESVNfSE9TVD0ke19BUFBfUkVESVNfSE9TVDotYXBwd3JpdGUtcmVkaXN9JwogICAgICAtICdfQVBQX1JFRElTX1BPUlQ9JHtfQVBQX1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICAtICdfQVBQX1JFRElTX1VTRVI9JHtfQVBQX1JFRElTX1VTRVJ9JwogICAgICAtICdfQVBQX1JFRElTX1BBU1M9JHtfQVBQX1JFRElTX1BBU1N9JwogICAgICAtICdfQVBQX0RCX0hPU1Q9JHtfQVBQX0RCX0hPU1Q6LWFwcHdyaXRlLW1hcmlhZGJ9JwogICAgICAtICdfQVBQX0RCX1BPUlQ9JHtfQVBQX0RCX1BPUlQ6LTMzMDZ9JwogICAgICAtICdfQVBQX0RCX1NDSEVNQT0ke19BUFBfREJfU0NIRU1BOi1hcHB3cml0ZX0nCiAgICAgIC0gX0FQUF9EQl9VU0VSPSRTRVJWSUNFX1VTRVJfTUFSSUFEQgogICAgICAtIF9BUFBfREJfUEFTUz0kU0VSVklDRV9QQVNTV09SRF9NQVJJQURCCiAgICAgIC0gJ19BUFBfTE9HR0lOR19DT05GSUc9JHtfQVBQX0xPR0dJTkdfQ09ORklHfScKICAgICAgLSAnX0FQUF9NSUdSQVRJT05TX0ZJUkVCQVNFX0NMSUVOVF9JRD0ke19BUFBfTUlHUkFUSU9OU19GSVJFQkFTRV9DTElFTlRfSUR9JwogICAgICAtICdfQVBQX01JR1JBVElPTlNfRklSRUJBU0VfQ0xJRU5UX1NFQ1JFVD0ke19BUFBfTUlHUkFUSU9OU19GSVJFQkFTRV9DTElFTlRfU0VDUkVUfScKICBhcHB3cml0ZS10YXNrLW1haW50ZW5hbmNlOgogICAgaW1hZ2U6ICdhcHB3cml0ZS9hcHB3cml0ZToxLjcuNCcKICAgIGVudHJ5cG9pbnQ6IG1haW50ZW5hbmNlCiAgICBjb250YWluZXJfbmFtZTogYXBwd3JpdGUtdGFzay1tYWludGVuYW5jZQogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcHB3cml0ZS1yZWRpcwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ19BUFBfRU5WPSR7X0FQUF9FTlY6LXByb2R1Y3Rpb259JwogICAgICAtICdfQVBQX1dPUktFUl9QRVJfQ09SRT0ke19BUFBfV09SS0VSX1BFUl9DT1JFOi02fScKICAgICAgLSBfQVBQX0RPTUFJTj0kU0VSVklDRV9VUkxfQVBQV1JJVEUKICAgICAgLSAnX0FQUF9ET01BSU5fVEFSR0VUX0NOQU1FPSR7X0FQUF9ET01BSU5fVEFSR0VUX0NOQU1FfScKICAgICAgLSAnX0FQUF9ET01BSU5fVEFSR0VUX0FBQUE9JHtfQVBQX0RPTUFJTl9UQVJHRVRfQUFBQX0nCiAgICAgIC0gJ19BUFBfRE9NQUlOX1RBUkdFVF9BPSR7X0FQUF9ET01BSU5fVEFSR0VUX0F9JwogICAgICAtIF9BUFBfRE9NQUlOX0ZVTkNUSU9OUz0kU0VSVklDRV9VUkxfQVBQV1JJVEUKICAgICAgLSBfQVBQX09QRU5TU0xfS0VZX1YxPSRTRVJWSUNFX1BBU1NXT1JEXzY0X0FQUFdSSVRFCiAgICAgIC0gJ19BUFBfUkVESVNfSE9TVD0ke19BUFBfUkVESVNfSE9TVDotYXBwd3JpdGUtcmVkaXN9JwogICAgICAtICdfQVBQX1JFRElTX1BPUlQ9JHtfQVBQX1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICAtICdfQVBQX1JFRElTX1VTRVI9JHtfQVBQX1JFRElTX1VTRVJ9JwogICAgICAtICdfQVBQX1JFRElTX1BBU1M9JHtfQVBQX1JFRElTX1BBU1N9JwogICAgICAtICdfQVBQX0RCX0hPU1Q9JHtfQVBQX0RCX0hPU1Q6LWFwcHdyaXRlLW1hcmlhZGJ9JwogICAgICAtICdfQVBQX0RCX1BPUlQ9JHtfQVBQX0RCX1BPUlQ6LTMzMDZ9JwogICAgICAtICdfQVBQX0RCX1NDSEVNQT0ke19BUFBfREJfU0NIRU1BOi1hcHB3cml0ZX0nCiAgICAgIC0gX0FQUF9EQl9VU0VSPSRTRVJWSUNFX1VTRVJfTUFSSUFEQgogICAgICAtIF9BUFBfREJfUEFTUz0kU0VSVklDRV9QQVNTV09SRF9NQVJJQURCCiAgICAgIC0gJ19BUFBfTUFJTlRFTkFOQ0VfSU5URVJWQUw9JHtfQVBQX01BSU5URU5BTkNFX0lOVEVSVkFMfScKICAgICAgLSAnX0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fRVhFQ1VUSU9OPSR7X0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fRVhFQ1VUSU9OfScKICAgICAgLSAnX0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fQ0FDSEU9JHtfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9DQUNIRTotMjU5MjAwMH0nCiAgICAgIC0gJ19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0FCVVNFPSR7X0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fQUJVU0U6LTg2NDAwfScKICAgICAgLSAnX0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fQVVESVQ9JHtfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9BVURJVDotMTIwOTYwMH0nCiAgICAgIC0gJ19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0FVRElUX0NPTlNPTEU9JHtfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9BVURJVF9DT05TT0xFfScKICAgICAgLSAnX0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fVVNBR0VfSE9VUkxZPSR7X0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fVVNBR0VfSE9VUkxZOi04NjQwMDAwfScKICAgICAgLSAnX0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fU0NIRURVTEVTPSR7X0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fU0NIRURVTEVTOi04NjQwMH0nCiAgYXBwd3JpdGUtdGFzay1zdGF0cy1yZXNvdXJjZXM6CiAgICBpbWFnZTogJ2FwcHdyaXRlL2FwcHdyaXRlOjEuNy40JwogICAgY29udGFpbmVyX25hbWU6IGFwcHdyaXRlLXRhc2stc3RhdHMtcmVzb3VyY2VzCiAgICBlbnRyeXBvaW50OiBzdGF0cy1yZXNvdXJjZXMKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBwd3JpdGUtcmVkaXMKICAgICAgLSBhcHB3cml0ZS1tYXJpYWRiCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnX0FQUF9FTlY9JHtfQVBQX0VOVjotcHJvZHVjdGlvbn0nCiAgICAgIC0gJ19BUFBfV09SS0VSX1BFUl9DT1JFPSR7X0FQUF9XT1JLRVJfUEVSX0NPUkU6LTZ9JwogICAgICAtIF9BUFBfT1BFTlNTTF9LRVlfVjE9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfQVBQV1JJVEUKICAgICAgLSAnX0FQUF9EQl9IT1NUPSR7X0FQUF9EQl9IT1NUOi1hcHB3cml0ZS1tYXJpYWRifScKICAgICAgLSAnX0FQUF9EQl9QT1JUPSR7X0FQUF9EQl9QT1JUOi0zMzA2fScKICAgICAgLSAnX0FQUF9EQl9TQ0hFTUE9JHtfQVBQX0RCX1NDSEVNQTotYXBwd3JpdGV9JwogICAgICAtIF9BUFBfREJfVVNFUj0kU0VSVklDRV9VU0VSX01BUklBREIKICAgICAgLSBfQVBQX0RCX1BBU1M9JFNFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQgogICAgICAtICdfQVBQX1JFRElTX0hPU1Q9JHtfQVBQX1JFRElTX0hPU1Q6LWFwcHdyaXRlLXJlZGlzfScKICAgICAgLSAnX0FQUF9SRURJU19QT1JUPSR7X0FQUF9SRURJU19QT1JUOi02Mzc5fScKICAgICAgLSAnX0FQUF9SRURJU19VU0VSPSR7X0FQUF9SRURJU19VU0VSfScKICAgICAgLSAnX0FQUF9SRURJU19QQVNTPSR7X0FQUF9SRURJU19QQVNTfScKICAgICAgLSAnX0FQUF9VU0FHRV9TVEFUUz0ke19BUFBfVVNBR0VfU1RBVFM6LWVuYWJsZWR9JwogICAgICAtICdfQVBQX0xPR0dJTkdfQ09ORklHPSR7X0FQUF9MT0dHSU5HX0NPTkZJR30nCiAgICAgIC0gJ19BUFBfREFUQUJBU0VfU0hBUkVEX1RBQkxFUz0ke19BUFBfREFUQUJBU0VfU0hBUkVEX1RBQkxFU30nCiAgICAgIC0gJ19BUFBfU1RBVFNfUkVTT1VSQ0VTX0lOVEVSVkFMPSR7X0FQUF9TVEFUU19SRVNPVVJDRVNfSU5URVJWQUx9JwogIGFwcHdyaXRlLXdvcmtlci1zdGF0cy1yZXNvdXJjZXM6CiAgICBpbWFnZTogJ2FwcHdyaXRlL2FwcHdyaXRlOjEuNy40JwogICAgZW50cnlwb2ludDogd29ya2VyLXN0YXRzLXJlc291cmNlcwogICAgY29udGFpbmVyX25hbWU6IGFwcHdyaXRlLXdvcmtlci1zdGF0cy1yZXNvdXJjZXMKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBwd3JpdGUtcmVkaXMKICAgICAgLSBhcHB3cml0ZS1tYXJpYWRiCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnX0FQUF9FTlY9JHtfQVBQX0VOVjotcHJvZHVjdGlvbn0nCiAgICAgIC0gJ19BUFBfV09SS0VSX1BFUl9DT1JFPSR7X0FQUF9XT1JLRVJfUEVSX0NPUkU6LTZ9JwogICAgICAtIF9BUFBfT1BFTlNTTF9LRVlfVjE9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfQVBQV1JJVEUKICAgICAgLSAnX0FQUF9EQl9IT1NUPSR7X0FQUF9EQl9IT1NUOi1hcHB3cml0ZS1tYXJpYWRifScKICAgICAgLSAnX0FQUF9EQl9QT1JUPSR7X0FQUF9EQl9QT1JUOi0zMzA2fScKICAgICAgLSAnX0FQUF9EQl9TQ0hFTUE9JHtfQVBQX0RCX1NDSEVNQTotYXBwd3JpdGV9JwogICAgICAtIF9BUFBfREJfVVNFUj0kU0VSVklDRV9VU0VSX01BUklBREIKICAgICAgLSBfQVBQX0RCX1BBU1M9JFNFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQgogICAgICAtICdfQVBQX1JFRElTX0hPU1Q9JHtfQVBQX1JFRElTX0hPU1Q6LWFwcHdyaXRlLXJlZGlzfScKICAgICAgLSAnX0FQUF9SRURJU19QT1JUPSR7X0FQUF9SRURJU19QT1JUOi02Mzc5fScKICAgICAgLSAnX0FQUF9SRURJU19VU0VSPSR7X0FQUF9SRURJU19VU0VSfScKICAgICAgLSAnX0FQUF9SRURJU19QQVNTPSR7X0FQUF9SRURJU19QQVNTfScKICAgICAgLSAnX0FQUF9VU0FHRV9TVEFUUz0ke19BUFBfVVNBR0VfU1RBVFM6LWVuYWJsZWR9JwogICAgICAtICdfQVBQX0xPR0dJTkdfQ09ORklHPSR7X0FQUF9MT0dHSU5HX0NPTkZJR30nCiAgICAgIC0gJ19BUFBfU1RBVFNfUkVTT1VSQ0VTX0lOVEVSVkFMPSR7X0FQUF9TVEFUU19SRVNPVVJDRVNfSU5URVJWQUx9JwogIGFwcHdyaXRlLXdvcmtlci1zdGF0cy11c2FnZToKICAgIGltYWdlOiAnYXBwd3JpdGUvYXBwd3JpdGU6MS43LjQnCiAgICBlbnRyeXBvaW50OiB3b3JrZXItc3RhdHMtdXNhZ2UKICAgIGNvbnRhaW5lcl9uYW1lOiBhcHB3cml0ZS13b3JrZXItc3RhdHMtdXNhZ2UKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBwd3JpdGUtcmVkaXMKICAgICAgLSBhcHB3cml0ZS1tYXJpYWRiCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnX0FQUF9FTlY9JHtfQVBQX0VOVjotcHJvZHVjdGlvbn0nCiAgICAgIC0gJ19BUFBfV09SS0VSX1BFUl9DT1JFPSR7X0FQUF9XT1JLRVJfUEVSX0NPUkU6LTZ9JwogICAgICAtIF9BUFBfT1BFTlNTTF9LRVlfVjE9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfQVBQV1JJVEUKICAgICAgLSAnX0FQUF9EQl9IT1NUPSR7X0FQUF9EQl9IT1NUOi1hcHB3cml0ZS1tYXJpYWRifScKICAgICAgLSAnX0FQUF9EQl9QT1JUPSR7X0FQUF9EQl9QT1JUOi0zMzA2fScKICAgICAgLSAnX0FQUF9EQl9TQ0hFTUE9JHtfQVBQX0RCX1NDSEVNQTotYXBwd3JpdGV9JwogICAgICAtIF9BUFBfREJfVVNFUj0kU0VSVklDRV9VU0VSX01BUklBREIKICAgICAgLSBfQVBQX0RCX1BBU1M9JFNFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQgogICAgICAtICdfQVBQX1JFRElTX0hPU1Q9JHtfQVBQX1JFRElTX0hPU1Q6LWFwcHdyaXRlLXJlZGlzfScKICAgICAgLSAnX0FQUF9SRURJU19QT1JUPSR7X0FQUF9SRURJU19QT1JUOi02Mzc5fScKICAgICAgLSAnX0FQUF9SRURJU19VU0VSPSR7X0FQUF9SRURJU19VU0VSfScKICAgICAgLSAnX0FQUF9SRURJU19QQVNTPSR7X0FQUF9SRURJU19QQVNTfScKICAgICAgLSAnX0FQUF9VU0FHRV9TVEFUUz0ke19BUFBfVVNBR0VfU1RBVFM6LWVuYWJsZWR9JwogICAgICAtICdfQVBQX0xPR0dJTkdfQ09ORklHPSR7X0FQUF9MT0dHSU5HX0NPTkZJR30nCiAgICAgIC0gJ19BUFBfVVNBR0VfQUdHUkVHQVRJT05fSU5URVJWQUw9JHtfQVBQX1VTQUdFX0FHR1JFR0FUSU9OX0lOVEVSVkFMOi0zMH0nCiAgYXBwd3JpdGUtdGFzay1zY2hlZHVsZXItZnVuY3Rpb25zOgogICAgaW1hZ2U6ICdhcHB3cml0ZS9hcHB3cml0ZToxLjcuNCcKICAgIGVudHJ5cG9pbnQ6IHNjaGVkdWxlLWZ1bmN0aW9ucwogICAgY29udGFpbmVyX25hbWU6IGFwcHdyaXRlLXRhc2stc2NoZWR1bGVyLWZ1bmN0aW9ucwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcHB3cml0ZS1tYXJpYWRiCiAgICAgIC0gYXBwd3JpdGUtcmVkaXMKICAgIGVudmlyb25tZW50OgogICAgICAtICdfQVBQX0VOVj0ke19BUFBfRU5WOi1wcm9kdWN0aW9ufScKICAgICAgLSAnX0FQUF9XT1JLRVJfUEVSX0NPUkU9JHtfQVBQX1dPUktFUl9QRVJfQ09SRTotNn0nCiAgICAgIC0gX0FQUF9PUEVOU1NMX0tFWV9WMT0kU0VSVklDRV9QQVNTV09SRF82NF9BUFBXUklURQogICAgICAtICdfQVBQX1JFRElTX0hPU1Q9JHtfQVBQX1JFRElTX0hPU1Q6LWFwcHdyaXRlLXJlZGlzfScKICAgICAgLSAnX0FQUF9SRURJU19QT1JUPSR7X0FQUF9SRURJU19QT1JUOi02Mzc5fScKICAgICAgLSAnX0FQUF9SRURJU19VU0VSPSR7X0FQUF9SRURJU19VU0VSfScKICAgICAgLSAnX0FQUF9SRURJU19QQVNTPSR7X0FQUF9SRURJU19QQVNTfScKICAgICAgLSAnX0FQUF9EQl9IT1NUPSR7X0FQUF9EQl9IT1NUOi1hcHB3cml0ZS1tYXJpYWRifScKICAgICAgLSAnX0FQUF9EQl9QT1JUPSR7X0FQUF9EQl9QT1JUOi0zMzA2fScKICAgICAgLSAnX0FQUF9EQl9TQ0hFTUE9JHtfQVBQX0RCX1NDSEVNQTotYXBwd3JpdGV9JwogICAgICAtIF9BUFBfREJfVVNFUj0kU0VSVklDRV9VU0VSX01BUklBREIKICAgICAgLSBfQVBQX0RCX1BBU1M9JFNFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQgogIGFwcHdyaXRlLXRhc2stc2NoZWR1bGVyLWV4ZWN1dGlvbnM6CiAgICBpbWFnZTogJ2FwcHdyaXRlL2FwcHdyaXRlOjEuNy40JwogICAgZW50cnlwb2ludDogc2NoZWR1bGUtZXhlY3V0aW9ucwogICAgY29udGFpbmVyX25hbWU6IGFwcHdyaXRlLXRhc2stc2NoZWR1bGVyLWV4ZWN1dGlvbnMKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBwd3JpdGUtbWFyaWFkYgogICAgICAtIGFwcHdyaXRlLXJlZGlzCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnX0FQUF9FTlY9JHtfQVBQX0VOVjotcHJvZHVjdGlvbn0nCiAgICAgIC0gJ19BUFBfV09SS0VSX1BFUl9DT1JFPSR7X0FQUF9XT1JLRVJfUEVSX0NPUkU6LTZ9JwogICAgICAtIF9BUFBfT1BFTlNTTF9LRVlfVjE9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfQVBQV1JJVEUKICAgICAgLSAnX0FQUF9SRURJU19IT1NUPSR7X0FQUF9SRURJU19IT1NUOi1hcHB3cml0ZS1yZWRpc30nCiAgICAgIC0gJ19BUFBfUkVESVNfUE9SVD0ke19BUFBfUkVESVNfUE9SVDotNjM3OX0nCiAgICAgIC0gJ19BUFBfUkVESVNfVVNFUj0ke19BUFBfUkVESVNfVVNFUn0nCiAgICAgIC0gJ19BUFBfUkVESVNfUEFTUz0ke19BUFBfUkVESVNfUEFTU30nCiAgICAgIC0gJ19BUFBfREJfSE9TVD0ke19BUFBfREJfSE9TVDotYXBwd3JpdGUtbWFyaWFkYn0nCiAgICAgIC0gJ19BUFBfREJfUE9SVD0ke19BUFBfREJfUE9SVDotMzMwNn0nCiAgICAgIC0gJ19BUFBfREJfU0NIRU1BPSR7X0FQUF9EQl9TQ0hFTUE6LWFwcHdyaXRlfScKICAgICAgLSBfQVBQX0RCX1VTRVI9JFNFUlZJQ0VfVVNFUl9NQVJJQURCCiAgICAgIC0gX0FQUF9EQl9QQVNTPSRTRVJWSUNFX1BBU1NXT1JEX01BUklBREIKICBhcHB3cml0ZS10YXNrLXNjaGVkdWxlci1tZXNzYWdlczoKICAgIGltYWdlOiAnYXBwd3JpdGUvYXBwd3JpdGU6MS43LjQnCiAgICBlbnRyeXBvaW50OiBzY2hlZHVsZS1tZXNzYWdlcwogICAgY29udGFpbmVyX25hbWU6IGFwcHdyaXRlLXRhc2stc2NoZWR1bGVyLW1lc3NhZ2VzCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwcHdyaXRlLW1hcmlhZGIKICAgICAgLSBhcHB3cml0ZS1yZWRpcwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ19BUFBfRU5WPSR7X0FQUF9FTlY6LXByb2R1Y3Rpb259JwogICAgICAtICdfQVBQX1dPUktFUl9QRVJfQ09SRT0ke19BUFBfV09SS0VSX1BFUl9DT1JFOi02fScKICAgICAgLSBfQVBQX09QRU5TU0xfS0VZX1YxPSRTRVJWSUNFX1BBU1NXT1JEXzY0X0FQUFdSSVRFCiAgICAgIC0gJ19BUFBfUkVESVNfSE9TVD0ke19BUFBfUkVESVNfSE9TVDotYXBwd3JpdGUtcmVkaXN9JwogICAgICAtICdfQVBQX1JFRElTX1BPUlQ9JHtfQVBQX1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICAtICdfQVBQX1JFRElTX1VTRVI9JHtfQVBQX1JFRElTX1VTRVJ9JwogICAgICAtICdfQVBQX1JFRElTX1BBU1M9JHtfQVBQX1JFRElTX1BBU1N9JwogICAgICAtICdfQVBQX0RCX0hPU1Q9JHtfQVBQX0RCX0hPU1Q6LWFwcHdyaXRlLW1hcmlhZGJ9JwogICAgICAtICdfQVBQX0RCX1BPUlQ9JHtfQVBQX0RCX1BPUlQ6LTMzMDZ9JwogICAgICAtICdfQVBQX0RCX1NDSEVNQT0ke19BUFBfREJfU0NIRU1BOi1hcHB3cml0ZX0nCiAgICAgIC0gX0FQUF9EQl9VU0VSPSRTRVJWSUNFX1VTRVJfTUFSSUFEQgogICAgICAtIF9BUFBfREJfUEFTUz0kU0VSVklDRV9QQVNTV09SRF9NQVJJQURCCiAgYXBwd3JpdGUtYXNzaXN0YW50OgogICAgaW1hZ2U6ICdhcHB3cml0ZS9hc3Npc3RhbnQ6MC40LjAnCiAgICBjb250YWluZXJfbmFtZTogYXBwd3JpdGUtYXNzaXN0YW50CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnX0FQUF9BU1NJU1RBTlRfT1BFTkFJX0FQSV9LRVk9JHtfQVBQX0FTU0lTVEFOVF9PUEVOQUlfQVBJX0tFWX0nCiAgYXBwd3JpdGUtYnJvd3NlcjoKICAgIGltYWdlOiAnYXBwd3JpdGUvYnJvd3NlcjowLjIuNCcKICAgIGNvbnRhaW5lcl9uYW1lOiBhcHB3cml0ZS1icm93c2VyCiAgb3BlbnJ1bnRpbWVzLWV4ZWN1dG9yOgogICAgY29udGFpbmVyX25hbWU6IG9wZW5ydW50aW1lcy1leGVjdXRvcgogICAgaG9zdG5hbWU6IGFwcHdyaXRlLWV4ZWN1dG9yCiAgICBzdG9wX3NpZ25hbDogU0lHSU5UCiAgICBpbWFnZTogJ29wZW5ydW50aW1lcy9leGVjdXRvcjowLjcuMTQnCiAgICBuZXR3b3JrczoKICAgICAgLSBydW50aW1lcwogICAgdm9sdW1lczoKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2snCiAgICAgIC0gJ2FwcHdyaXRlLWJ1aWxkczovc3RvcmFnZS9idWlsZHM6cncnCiAgICAgIC0gJ2FwcHdyaXRlLWZ1bmN0aW9uczovc3RvcmFnZS9mdW5jdGlvbnM6cncnCiAgICAgIC0gJ2FwcHdyaXRlLXNpdGVzOi9zdG9yYWdlL3NpdGVzOnJ3JwogICAgICAtICcvdG1wOi90bXA6cncnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnT1BSX0VYRUNVVE9SX0lOQUNUSVZFX1RSRVNIT0xEPSR7X0FQUF9DT01QVVRFX0lOQUNUSVZFX1RIUkVTSE9MRH0nCiAgICAgIC0gJ09QUl9FWEVDVVRPUl9NQUlOVEVOQU5DRV9JTlRFUlZBTD0ke19BUFBfQ09NUFVURV9NQUlOVEVOQU5DRV9JTlRFUlZBTH0nCiAgICAgIC0gJ09QUl9FWEVDVVRPUl9ORVRXT1JLPSR7X0FQUF9DT01QVVRFX1JVTlRJTUVTX05FVFdPUks6LXJ1bnRpbWVzfScKICAgICAgLSAnT1BSX0VYRUNVVE9SX0RPQ0tFUl9IVUJfVVNFUk5BTUU9JHtfQVBQX0RPQ0tFUl9IVUJfVVNFUk5BTUV9JwogICAgICAtICdPUFJfRVhFQ1VUT1JfRE9DS0VSX0hVQl9QQVNTV09SRD0ke19BUFBfRE9DS0VSX0hVQl9QQVNTV09SRH0nCiAgICAgIC0gJ09QUl9FWEVDVVRPUl9FTlY9JHtfQVBQX0VOVjotcHJvZHVjdGlvbn0nCiAgICAgIC0gJ09QUl9FWEVDVVRPUl9SVU5USU1FUz0ke19BUFBfRlVOQ1RJT05TX1JVTlRJTUVTfSwke19BUFBfU0lURVNfUlVOVElNRVN9JwogICAgICAtIE9QUl9FWEVDVVRPUl9TRUNSRVQ9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfQVBQV1JJVEUKICAgICAgLSBPUFJfRVhFQ1VUT1JfUlVOVElNRV9WRVJTSU9OUz12NQogICAgICAtICdPUFJfRVhFQ1VUT1JfTE9HR0lOR19DT05GSUc9JHtfQVBQX0xPR0dJTkdfQ09ORklHfScKICAgICAgLSAnT1BSX0VYRUNVVE9SX1NUT1JBR0VfREVWSUNFPSR7X0FQUF9TVE9SQUdFX0RFVklDRTotbG9jYWx9JwogICAgICAtICdPUFJfRVhFQ1VUT1JfU1RPUkFHRV9TM19BQ0NFU1NfS0VZPSR7X0FQUF9TVE9SQUdFX1MzX0FDQ0VTU19LRVl9JwogICAgICAtICdPUFJfRVhFQ1VUT1JfU1RPUkFHRV9TM19TRUNSRVQ9JHtfQVBQX1NUT1JBR0VfUzNfU0VDUkVUfScKICAgICAgLSAnT1BSX0VYRUNVVE9SX1NUT1JBR0VfUzNfUkVHSU9OPSR7X0FQUF9TVE9SQUdFX1MzX1JFR0lPTn0nCiAgICAgIC0gJ09QUl9FWEVDVVRPUl9TVE9SQUdFX1MzX0JVQ0tFVD0ke19BUFBfU1RPUkFHRV9TM19CVUNLRVR9JwogICAgICAtICdPUFJfRVhFQ1VUT1JfU1RPUkFHRV9TM19FTkRQT0lOVD0ke19BUFBfU1RPUkFHRV9TM19FTkRQT0lOVH0nCiAgICAgIC0gJ09QUl9FWEVDVVRPUl9TVE9SQUdFX0RPX1NQQUNFU19BQ0NFU1NfS0VZPSR7X0FQUF9TVE9SQUdFX0RPX1NQQUNFU19BQ0NFU1NfS0VZfScKICAgICAgLSAnT1BSX0VYRUNVVE9SX1NUT1JBR0VfRE9fU1BBQ0VTX1NFQ1JFVD0ke19BUFBfU1RPUkFHRV9ET19TUEFDRVNfU0VDUkVUfScKICAgICAgLSAnT1BSX0VYRUNVVE9SX1NUT1JBR0VfRE9fU1BBQ0VTX1JFR0lPTj0ke19BUFBfU1RPUkFHRV9ET19TUEFDRVNfUkVHSU9OfScKICAgICAgLSAnT1BSX0VYRUNVVE9SX1NUT1JBR0VfRE9fU1BBQ0VTX0JVQ0tFVD0ke19BUFBfU1RPUkFHRV9ET19TUEFDRVNfQlVDS0VUfScKICAgICAgLSAnT1BSX0VYRUNVVE9SX1NUT1JBR0VfQkFDS0JMQVpFX0FDQ0VTU19LRVk9JHtfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX0FDQ0VTU19LRVl9JwogICAgICAtICdPUFJfRVhFQ1VUT1JfU1RPUkFHRV9CQUNLQkxBWkVfU0VDUkVUPSR7X0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9TRUNSRVR9JwogICAgICAtICdPUFJfRVhFQ1VUT1JfU1RPUkFHRV9CQUNLQkxBWkVfUkVHSU9OPSR7X0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9SRUdJT059JwogICAgICAtICdPUFJfRVhFQ1VUT1JfU1RPUkFHRV9CQUNLQkxBWkVfQlVDS0VUPSR7X0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9CVUNLRVR9JwogICAgICAtICdPUFJfRVhFQ1VUT1JfU1RPUkFHRV9MSU5PREVfQUNDRVNTX0tFWT0ke19BUFBfU1RPUkFHRV9MSU5PREVfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ09QUl9FWEVDVVRPUl9TVE9SQUdFX0xJTk9ERV9TRUNSRVQ9JHtfQVBQX1NUT1JBR0VfTElOT0RFX1NFQ1JFVH0nCiAgICAgIC0gJ09QUl9FWEVDVVRPUl9TVE9SQUdFX0xJTk9ERV9SRUdJT049JHtfQVBQX1NUT1JBR0VfTElOT0RFX1JFR0lPTn0nCiAgICAgIC0gJ09QUl9FWEVDVVRPUl9TVE9SQUdFX0xJTk9ERV9CVUNLRVQ9JHtfQVBQX1NUT1JBR0VfTElOT0RFX0JVQ0tFVH0nCiAgICAgIC0gJ09QUl9FWEVDVVRPUl9TVE9SQUdFX1dBU0FCSV9BQ0NFU1NfS0VZPSR7X0FQUF9TVE9SQUdFX1dBU0FCSV9BQ0NFU1NfS0VZfScKICAgICAgLSAnT1BSX0VYRUNVVE9SX1NUT1JBR0VfV0FTQUJJX1NFQ1JFVD0ke19BUFBfU1RPUkFHRV9XQVNBQklfU0VDUkVUfScKICAgICAgLSAnT1BSX0VYRUNVVE9SX1NUT1JBR0VfV0FTQUJJX1JFR0lPTj0ke19BUFBfU1RPUkFHRV9XQVNBQklfUkVHSU9OfScKICAgICAgLSAnT1BSX0VYRUNVVE9SX1NUT1JBR0VfV0FTQUJJX0JVQ0tFVD0ke19BUFBfU1RPUkFHRV9XQVNBQklfQlVDS0VUfScKICBhcHB3cml0ZS1tYXJpYWRiOgogICAgaW1hZ2U6ICdtYXJpYWRiOjEwLjExJwogICAgY29udGFpbmVyX25hbWU6IGFwcHdyaXRlLW1hcmlhZGIKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2FwcHdyaXRlLW1hcmlhZGI6L3Zhci9saWIvbXlzcWw6cncnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBNWVNRTF9ST09UX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX01BUklBREJST09UCiAgICAgIC0gJ01ZU1FMX0RBVEFCQVNFPSR7X0FQUF9EQl9TQ0hFTUE6LWFwcHdyaXRlfScKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfTUFSSUFEQgogICAgICAtIE1ZU1FMX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX01BUklBREIKICAgICAgLSBNQVJJQURCX0FVVE9fVVBHUkFERT0xCiAgICBjb21tYW5kOiAnbXlzcWxkIC0taW5ub2RiLWZsdXNoLW1ldGhvZD1mc3luYycKICBhcHB3cml0ZS1yZWRpczoKICAgIGltYWdlOiAncmVkaXM6Ny4yLjQtYWxwaW5lJwogICAgY29udGFpbmVyX25hbWU6IGFwcHdyaXRlLXJlZGlzCiAgICBjb21tYW5kOiAicmVkaXMtc2VydmVyIC0tbWF4bWVtb3J5ICAgICAgICAgICAgNTEybWIgLS1tYXhtZW1vcnktcG9saWN5ICAgICBhbGxrZXlzLWxydSAtLW1heG1lbW9yeS1zYW1wbGVzICAgIDVcbiIKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2FwcHdyaXRlLXJlZGlzOi9kYXRhOnJ3JwpuZXR3b3JrczoKICBydW50aW1lczoKICAgIG5hbWU6IHJ1bnRpbWVzCnZvbHVtZXM6CiAgYXBwd3JpdGUtbWFyaWFkYjogbnVsbAogIGFwcHdyaXRlLXJlZGlzOiBudWxsCiAgYXBwd3JpdGUtY2FjaGU6IG51bGwKICBhcHB3cml0ZS11cGxvYWRzOiBudWxsCiAgYXBwd3JpdGUtaW1wb3J0czogbnVsbAogIGFwcHdyaXRlLWNlcnRpZmljYXRlczogbnVsbAogIGFwcHdyaXRlLWZ1bmN0aW9uczogbnVsbAogIGFwcHdyaXRlLXNpdGVzOiBudWxsCiAgYXBwd3JpdGUtYnVpbGRzOiBudWxsCiAgYXBwd3JpdGUtY29uZmlnOiBudWxsCg==", + "compose": "c2VydmljZXM6CiAgYXBwd3JpdGU6CiAgICBpbWFnZTogJ2FwcHdyaXRlL2FwcHdyaXRlOjEuNy40JwogICAgY29udGFpbmVyX25hbWU6IGFwcHdyaXRlCiAgICB2b2x1bWVzOgogICAgICAtICdhcHB3cml0ZS11cGxvYWRzOi9zdG9yYWdlL3VwbG9hZHM6cncnCiAgICAgIC0gJ2FwcHdyaXRlLWltcG9ydHM6L3N0b3JhZ2UvaW1wb3J0czpydycKICAgICAgLSAnYXBwd3JpdGUtY2FjaGU6L3N0b3JhZ2UvY2FjaGU6cncnCiAgICAgIC0gJ2FwcHdyaXRlLWNvbmZpZzovc3RvcmFnZS9jb25maWc6cncnCiAgICAgIC0gJ2FwcHdyaXRlLWNlcnRpZmljYXRlczovc3RvcmFnZS9jZXJ0aWZpY2F0ZXM6cncnCiAgICAgIC0gJ2FwcHdyaXRlLWZ1bmN0aW9uczovc3RvcmFnZS9mdW5jdGlvbnM6cncnCiAgICAgIC0gJ2FwcHdyaXRlLXNpdGVzOi9zdG9yYWdlL3NpdGVzOnJ3JwogICAgICAtICdhcHB3cml0ZS1idWlsZHM6L3N0b3JhZ2UvYnVpbGRzOnJ3JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcHB3cml0ZS1tYXJpYWRiCiAgICAgIC0gYXBwd3JpdGUtcmVkaXMKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0FQUFdSSVRFPS8KICAgICAgLSAnX0FQUF9FTlY9JHtfQVBQX0VOVjotcHJvZHVjdGlvbn0nCiAgICAgIC0gJ19BUFBfRURJVElPTj0ke19BUFBfRURJVElPTjotc2VsZi1ob3N0ZWR9JwogICAgICAtICdfQVBQX1dPUktFUl9QRVJfQ09SRT0ke19BUFBfV09SS0VSX1BFUl9DT1JFOi02fScKICAgICAgLSAnX0FQUF9MT0NBTEU9JHtfQVBQX0xPQ0FMRTotZW59JwogICAgICAtICdfQVBQX0NPTVBSRVNTSU9OX01JTl9TSVpFX0JZVEVTPSR7X0FQUF9DT01QUkVTU0lPTl9NSU5fU0laRV9CWVRFU30nCiAgICAgIC0gJ19BUFBfQ09OU09MRV9XSElURUxJU1RfUk9PVD0ke19BUFBfQ09OU09MRV9XSElURUxJU1RfUk9PVDotZW5hYmxlZH0nCiAgICAgIC0gJ19BUFBfQ09OU09MRV9XSElURUxJU1RfRU1BSUxTPSR7X0FQUF9DT05TT0xFX1dISVRFTElTVF9FTUFJTFN9JwogICAgICAtICdfQVBQX0NPTlNPTEVfU0VTU0lPTl9BTEVSVFM9JHtfQVBQX0NPTlNPTEVfU0VTU0lPTl9BTEVSVFN9JwogICAgICAtICdfQVBQX0NPTlNPTEVfV0hJVEVMSVNUX0lQUz0ke19BUFBfQ09OU09MRV9XSElURUxJU1RfSVBTfScKICAgICAgLSAnX0FQUF9DT05TT0xFX0hPU1ROQU1FUz0ke19BUFBfQ09OU09MRV9IT1NUTkFNRVN9JwogICAgICAtICdfQVBQX1NZU1RFTV9FTUFJTF9OQU1FPSR7X0FQUF9TWVNURU1fRU1BSUxfTkFNRTotQXBwd3JpdGV9JwogICAgICAtICdfQVBQX1NZU1RFTV9FTUFJTF9BRERSRVNTPSR7X0FQUF9TWVNURU1fRU1BSUxfQUREUkVTUzotdGVhbUBhcHB3cml0ZS5pb30nCiAgICAgIC0gJ19BUFBfU1lTVEVNX1RFQU1fRU1BSUw9JHtfQVBQX1NZU1RFTV9URUFNX0VNQUlMOi10ZWFtQGFwcHdyaXRlLmlvfScKICAgICAgLSAnX0FQUF9FTUFJTF9TRUNVUklUWT0ke19BUFBfRU1BSUxfU0VDVVJJVFk6LWNlcnRzQGFwcHdyaXRlLmlvfScKICAgICAgLSAnX0FQUF9TWVNURU1fUkVTUE9OU0VfRk9STUFUPSR7X0FQUF9TWVNURU1fUkVTUE9OU0VfRk9STUFUfScKICAgICAgLSAnX0FQUF9PUFRJT05TX0FCVVNFPSR7X0FQUF9PUFRJT05TX0FCVVNFOi1lbmFibGVkfScKICAgICAgLSAnX0FQUF9PUFRJT05TX1JPVVRFUl9QUk9URUNUSU9OPSR7X0FQUF9PUFRJT05TX1JPVVRFUl9QUk9URUNUSU9OOi1kaXNhYmxlZH0nCiAgICAgIC0gJ19BUFBfT1BUSU9OU19GT1JDRV9IVFRQUz0ke19BUFBfT1BUSU9OU19GT1JDRV9IVFRQUzotZGlzYWJsZWR9JwogICAgICAtICdfQVBQX09QVElPTlNfUk9VVEVSX0ZPUkNFX0hUVFBTPSR7X0FQUF9PUFRJT05TX1JPVVRFUl9GT1JDRV9IVFRQUzotZGlzYWJsZWR9JwogICAgICAtIF9BUFBfT1BFTlNTTF9LRVlfVjE9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfQVBQV1JJVEUKICAgICAgLSAnX0FQUF9DT05TT0xFX0RPTUFJTj0ke19BUFBfQ09OU09MRV9ET01BSU59JwogICAgICAtICdfQVBQX0RPTUFJTj0ke19BUFBfRE9NQUlOOi0kU0VSVklDRV9GUUROX0FQUFdSSVRFfScKICAgICAgLSAnX0FQUF9ET01BSU5fVEFSR0VUX0NOQU1FPSR7X0FQUF9ET01BSU5fVEFSR0VUX0NOQU1FOi1sb2NhbGhvc3R9JwogICAgICAtICdfQVBQX0RPTUFJTl9UQVJHRVRfQUFBQT0ke19BUFBfRE9NQUlOX1RBUkdFVF9BQUFBOi06OjF9JwogICAgICAtICdfQVBQX0RPTUFJTl9UQVJHRVRfQT0ke19BUFBfRE9NQUlOX1RBUkdFVF9BOi0xMjcuMC4wLjF9JwogICAgICAtICdfQVBQX0RPTUFJTl9UQVJHRVRfQ0FBPSR7X0FQUF9ET01BSU5fVEFSR0VUX0NBQX0nCiAgICAgIC0gJ19BUFBfRE9NQUlOX0ZVTkNUSU9OUz0ke19BUFBfRE9NQUlOX0ZVTkNUSU9OUzotZnVuY3Rpb25zLiRTRVJWSUNFX0ZRRE5fQVBQV1JJVEV9JwogICAgICAtICdfQVBQX0ROUz0ke19BUFBfRE5TfScKICAgICAgLSAnX0FQUF9SRURJU19IT1NUPSR7X0FQUF9SRURJU19IT1NUOi1hcHB3cml0ZS1yZWRpc30nCiAgICAgIC0gJ19BUFBfUkVESVNfUE9SVD0ke19BUFBfUkVESVNfUE9SVDotNjM3OX0nCiAgICAgIC0gJ19BUFBfUkVESVNfVVNFUj0ke19BUFBfUkVESVNfVVNFUn0nCiAgICAgIC0gJ19BUFBfUkVESVNfUEFTUz0ke19BUFBfUkVESVNfUEFTU30nCiAgICAgIC0gJ19BUFBfREJfSE9TVD0ke19BUFBfREJfSE9TVDotYXBwd3JpdGUtbWFyaWFkYn0nCiAgICAgIC0gJ19BUFBfREJfUE9SVD0ke19BUFBfREJfUE9SVDotMzMwNn0nCiAgICAgIC0gJ19BUFBfREJfU0NIRU1BPSR7X0FQUF9EQl9TQ0hFTUE6LWFwcHdyaXRlfScKICAgICAgLSBfQVBQX0RCX1VTRVI9JFNFUlZJQ0VfVVNFUl9NQVJJQURCCiAgICAgIC0gX0FQUF9EQl9QQVNTPSRTRVJWSUNFX1BBU1NXT1JEX01BUklBREIKICAgICAgLSAnX0FQUF9TTVRQX0hPU1Q9JHtfQVBQX1NNVFBfSE9TVH0nCiAgICAgIC0gJ19BUFBfU01UUF9QT1JUPSR7X0FQUF9TTVRQX1BPUlR9JwogICAgICAtICdfQVBQX1NNVFBfU0VDVVJFPSR7X0FQUF9TTVRQX1NFQ1VSRX0nCiAgICAgIC0gJ19BUFBfU01UUF9VU0VSTkFNRT0ke19BUFBfU01UUF9VU0VSTkFNRX0nCiAgICAgIC0gJ19BUFBfU01UUF9QQVNTV09SRD0ke19BUFBfU01UUF9QQVNTV09SRH0nCiAgICAgIC0gJ19BUFBfVVNBR0VfU1RBVFM9JHtfQVBQX1VTQUdFX1NUQVRTOi1lbmFibGVkfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0xJTUlUPSR7X0FQUF9TVE9SQUdFX0xJTUlUOi0zMDAwMDAwMH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9QUkVWSUVXX0xJTUlUPSR7X0FQUF9TVE9SQUdFX1BSRVZJRVdfTElNSVQ6LTIwMDAwMDAwfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0FOVElWSVJVUz0ke19BUFBfU1RPUkFHRV9BTlRJVklSVVM6LWRpc2FibGVkfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0FOVElWSVJVU19IT1NUPSR7X0FQUF9TVE9SQUdFX0FOVElWSVJVU19IT1NUOi1hcHB3cml0ZS1jbGFtYXZ9JwogICAgICAtICdfQVBQX1NUT1JBR0VfQU5USVZJUlVTX1BPUlQ9JHtfQVBQX1NUT1JBR0VfQU5USVZJUlVTX1BPUlQ6LTMzMTB9JwogICAgICAtICdfQVBQX1NUT1JBR0VfREVWSUNFPSR7X0FQUF9TVE9SQUdFX0RFVklDRTotbG9jYWx9JwogICAgICAtICdfQVBQX1NUT1JBR0VfUzNfQUNDRVNTX0tFWT0ke19BUFBfU1RPUkFHRV9TM19BQ0NFU1NfS0VZfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1MzX1NFQ1JFVD0ke19BUFBfU1RPUkFHRV9TM19TRUNSRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfUzNfUkVHSU9OPSR7X0FQUF9TVE9SQUdFX1MzX1JFR0lPTjotdXMtZWFzdC0xfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1MzX0JVQ0tFVD0ke19BUFBfU1RPUkFHRV9TM19CVUNLRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfUzNfRU5EUE9JTlQ9JHtfQVBQX1NUT1JBR0VfUzNfRU5EUE9JTlR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX0FDQ0VTU19LRVk9JHtfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX0FDQ0VTU19LRVl9JwogICAgICAtICdfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX1NFQ1JFVD0ke19BUFBfU1RPUkFHRV9ET19TUEFDRVNfU0VDUkVUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0RPX1NQQUNFU19SRUdJT049JHtfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX1JFR0lPTjotdXMtZWFzdC0xfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0RPX1NQQUNFU19CVUNLRVQ9JHtfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX0JVQ0tFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfQUNDRVNTX0tFWT0ke19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfU0VDUkVUPSR7X0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9TRUNSRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX1JFR0lPTj0ke19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfUkVHSU9OOi11cy13ZXN0LTAwNH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfQlVDS0VUPSR7X0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9CVUNLRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfTElOT0RFX0FDQ0VTU19LRVk9JHtfQVBQX1NUT1JBR0VfTElOT0RFX0FDQ0VTU19LRVl9JwogICAgICAtICdfQVBQX1NUT1JBR0VfTElOT0RFX1NFQ1JFVD0ke19BUFBfU1RPUkFHRV9MSU5PREVfU0VDUkVUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0xJTk9ERV9SRUdJT049JHtfQVBQX1NUT1JBR0VfTElOT0RFX1JFR0lPTjotZXUtY2VudHJhbC0xfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0xJTk9ERV9CVUNLRVQ9JHtfQVBQX1NUT1JBR0VfTElOT0RFX0JVQ0tFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9XQVNBQklfQUNDRVNTX0tFWT0ke19BUFBfU1RPUkFHRV9XQVNBQklfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9XQVNBQklfU0VDUkVUPSR7X0FQUF9TVE9SQUdFX1dBU0FCSV9TRUNSRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfV0FTQUJJX1JFR0lPTj0ke19BUFBfU1RPUkFHRV9XQVNBQklfUkVHSU9OOi1ldS1jZW50cmFsLTF9JwogICAgICAtICdfQVBQX1NUT1JBR0VfV0FTQUJJX0JVQ0tFVD0ke19BUFBfU1RPUkFHRV9XQVNBQklfQlVDS0VUfScKICAgICAgLSAnX0FQUF9DT01QVVRFX1NJWkVfTElNSVQ9JHtfQVBQX0NPTVBVVEVfU0laRV9MSU1JVDotMzAwMDAwMDB9JwogICAgICAtICdfQVBQX0ZVTkNUSU9OU19USU1FT1VUPSR7X0FQUF9GVU5DVElPTlNfVElNRU9VVDotOTAwfScKICAgICAgLSAnX0FQUF9TSVRFU19USU1FT1VUPSR7X0FQUF9TSVRFU19USU1FT1VUOi05MDB9JwogICAgICAtICdfQVBQX0NPTVBVVEVfQlVJTERfVElNRU9VVD0ke19BUFBfQ09NUFVURV9CVUlMRF9USU1FT1VUOi05MDB9JwogICAgICAtICdfQVBQX0NPTVBVVEVfQ1BVUz0ke19BUFBfQ09NUFVURV9DUFVTOi0wfScKICAgICAgLSAnX0FQUF9DT01QVVRFX01FTU9SWT0ke19BUFBfQ09NUFVURV9NRU1PUlk6LTB9JwogICAgICAtICdfQVBQX0ZVTkNUSU9OU19SVU5USU1FUz0ke19BUFBfRlVOQ1RJT05TX1JVTlRJTUVTOi1ub2RlLTIwLjAscGhwLTguMixweXRob24tMy4xMSxydWJ5LTMuMn0nCiAgICAgIC0gJ19BUFBfU0lURVNfUlVOVElNRVM9JHtfQVBQX1NJVEVTX1JVTlRJTUVTfScKICAgICAgLSAnX0FQUF9ET01BSU5fU0lURVM9JHtfQVBQX0RPTUFJTl9TSVRFUzotc2l0ZXMuJFNFUlZJQ0VfRlFETl9BUFBXUklURX0nCiAgICAgIC0gX0FQUF9FWEVDVVRPUl9TRUNSRVQ9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfQVBQV1JJVEUKICAgICAgLSAnX0FQUF9FWEVDVVRPUl9IT1NUPSR7X0FQUF9FWEVDVVRPUl9IT1NUOi1odHRwOi8vYXBwd3JpdGUtZXhlY3V0b3IvdjF9JwogICAgICAtICdfQVBQX0xPR0dJTkdfQ09ORklHPSR7X0FQUF9MT0dHSU5HX0NPTkZJR30nCiAgICAgIC0gJ19BUFBfTUFJTlRFTkFOQ0VfSU5URVJWQUw9JHtfQVBQX01BSU5URU5BTkNFX0lOVEVSVkFMOi04NjQwMH0nCiAgICAgIC0gJ19BUFBfTUFJTlRFTkFOQ0VfREVMQVk9JHtfQVBQX01BSU5URU5BTkNFX0RFTEFZfScKICAgICAgLSAnX0FQUF9NQUlOVEVOQU5DRV9TVEFSVF9USU1FPSR7X0FQUF9NQUlOVEVOQU5DRV9TVEFSVF9USU1FfScKICAgICAgLSAnX0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fRVhFQ1VUSU9OPSR7X0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fRVhFQ1VUSU9OOi0xMjA5NjAwfScKICAgICAgLSAnX0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fQ0FDSEU9JHtfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9DQUNIRTotMjU5MjAwMH0nCiAgICAgIC0gJ19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0FCVVNFPSR7X0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fQUJVU0U6LTg2NDAwfScKICAgICAgLSAnX0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fQVVESVQ9JHtfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9BVURJVDotMTIwOTYwMH0nCiAgICAgIC0gJ19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0FVRElUX0NPTlNPTEU9JHtfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9BVURJVF9DT05TT0xFfScKICAgICAgLSAnX0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fVVNBR0VfSE9VUkxZPSR7X0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fVVNBR0VfSE9VUkxZOi04NjQwMDAwfScKICAgICAgLSAnX0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fU0NIRURVTEVTPSR7X0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fU0NIRURVTEVTOi04NjQwMH0nCiAgICAgIC0gJ19BUFBfU01TX1BST1ZJREVSPSR7X0FQUF9TTVNfUFJPVklERVJ9JwogICAgICAtICdfQVBQX1NNU19GUk9NPSR7X0FQUF9TTVNfRlJPTX0nCiAgICAgIC0gJ19BUFBfR1JBUEhRTF9NQVhfQkFUQ0hfU0laRT0ke19BUFBfR1JBUEhRTF9NQVhfQkFUQ0hfU0laRTotMTB9JwogICAgICAtICdfQVBQX0dSQVBIUUxfTUFYX0NPTVBMRVhJVFk9JHtfQVBQX0dSQVBIUUxfTUFYX0NPTVBMRVhJVFk6LTI1MH0nCiAgICAgIC0gJ19BUFBfR1JBUEhRTF9NQVhfREVQVEg9JHtfQVBQX0dSQVBIUUxfTUFYX0RFUFRIOi0zfScKICAgICAgLSAnX0FQUF9WQ1NfR0lUSFVCX0FQUF9OQU1FPSR7X0FQUF9WQ1NfR0lUSFVCX0FQUF9OQU1FfScKICAgICAgLSAnX0FQUF9WQ1NfR0lUSFVCX1BSSVZBVEVfS0VZPSR7X0FQUF9WQ1NfR0lUSFVCX1BSSVZBVEVfS0VZfScKICAgICAgLSAnX0FQUF9WQ1NfR0lUSFVCX0FQUF9JRD0ke19BUFBfVkNTX0dJVEhVQl9BUFBfSUR9JwogICAgICAtICdfQVBQX1ZDU19HSVRIVUJfV0VCSE9PS19TRUNSRVQ9JHtfQVBQX1ZDU19HSVRIVUJfV0VCSE9PS19TRUNSRVR9JwogICAgICAtICdfQVBQX1ZDU19HSVRIVUJfQ0xJRU5UX1NFQ1JFVD0ke19BUFBfVkNTX0dJVEhVQl9DTElFTlRfU0VDUkVUfScKICAgICAgLSAnX0FQUF9WQ1NfR0lUSFVCX0NMSUVOVF9JRD0ke19BUFBfVkNTX0dJVEhVQl9DTElFTlRfSUR9JwogICAgICAtICdfQVBQX01JR1JBVElPTlNfRklSRUJBU0VfQ0xJRU5UX0lEPSR7X0FQUF9NSUdSQVRJT05TX0ZJUkVCQVNFX0NMSUVOVF9JRH0nCiAgICAgIC0gJ19BUFBfTUlHUkFUSU9OU19GSVJFQkFTRV9DTElFTlRfU0VDUkVUPSR7X0FQUF9NSUdSQVRJT05TX0ZJUkVCQVNFX0NMSUVOVF9TRUNSRVR9JwogICAgICAtICdfQVBQX0FTU0lTVEFOVF9PUEVOQUlfQVBJX0tFWT0ke19BUFBfQVNTSVNUQU5UX09QRU5BSV9BUElfS0VZfScKICAgICAgLSAnX0FQUF9NRVNTQUdFX1NNU19URVNUX0RTTj0ke19BUFBfTUVTU0FHRV9TTVNfVEVTVF9EU059JwogICAgICAtICdfQVBQX01FU1NBR0VfRU1BSUxfVEVTVF9EU049JHtfQVBQX01FU1NBR0VfRU1BSUxfVEVTVF9EU059JwogICAgICAtICdfQVBQX01FU1NBR0VfUFVTSF9URVNUX0RTTj0ke19BUFBfTUVTU0FHRV9QVVNIX1RFU1RfRFNOfScKICAgICAgLSAnX0FQUF9DT05TT0xFX0NPVU5UUklFU19ERU5ZTElTVD0ke19BUFBfQ09OU09MRV9DT1VOVFJJRVNfREVOWUxJU1R9JwogICAgICAtICdfQVBQX0VYUEVSSU1FTlRfTE9HR0lOR19QUk9WSURFUj0ke19BUFBfRVhQRVJJTUVOVF9MT0dHSU5HX1BST1ZJREVSfScKICAgICAgLSAnX0FQUF9FWFBFUklNRU5UX0xPR0dJTkdfQ09ORklHPSR7X0FQUF9FWFBFUklNRU5UX0xPR0dJTkdfQ09ORklHfScKICAgICAgLSAnX0FQUF9EQVRBQkFTRV9TSEFSRURfVEFCTEVTPSR7X0FQUF9EQVRBQkFTRV9TSEFSRURfVEFCTEVTfScKICAgICAgLSAnX0FQUF9EQVRBQkFTRV9TSEFSRURfVEFCTEVTX1YxPSR7X0FQUF9EQVRBQkFTRV9TSEFSRURfVEFCTEVTX1YxfScKICAgICAgLSAnX0FQUF9EQVRBQkFTRV9TSEFSRURfTkFNRVNQQUNFPSR7X0FQUF9EQVRBQkFTRV9TSEFSRURfTkFNRVNQQUNFfScKICAgICAgLSAnX0FQUF9GVU5DVElPTlNfQ1JFQVRJT05fQUJVU0VfTElNSVQ9JHtfQVBQX0ZVTkNUSU9OU19DUkVBVElPTl9BQlVTRV9MSU1JVH0nCiAgICAgIC0gJ19BUFBfQ1VTVE9NX0RPTUFJTl9ERU5ZX0xJU1Q9JHtfQVBQX0NVU1RPTV9ET01BSU5fREVOWV9MSVNUfScKICBhcHB3cml0ZS1jb25zb2xlOgogICAgaW1hZ2U6ICdhcHB3cml0ZS9jb25zb2xlOjYuMS4yOCcKICAgIGNvbnRhaW5lcl9uYW1lOiBhcHB3cml0ZS1jb25zb2xlCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9BUFBXUklURT0vY29uc29sZQogIGFwcHdyaXRlLXJlYWx0aW1lOgogICAgaW1hZ2U6ICdhcHB3cml0ZS9hcHB3cml0ZToxLjcuNCcKICAgIGVudHJ5cG9pbnQ6IHJlYWx0aW1lCiAgICBjb250YWluZXJfbmFtZTogYXBwd3JpdGUtcmVhbHRpbWUKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBwd3JpdGUtbWFyaWFkYgogICAgICAtIGFwcHdyaXRlLXJlZGlzCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9BUFBXUklURT0vdjEvcmVhbHRpbWUKICAgICAgLSAnX0FQUF9FTlY9JHtfQVBQX0VOVjotcHJvZHVjdGlvbn0nCiAgICAgIC0gJ19BUFBfV09SS0VSX1BFUl9DT1JFPSR7X0FQUF9XT1JLRVJfUEVSX0NPUkU6LTZ9JwogICAgICAtICdfQVBQX09QVElPTlNfQUJVU0U9JHtfQVBQX09QVElPTlNfQUJVU0U6LWVuYWJsZWR9JwogICAgICAtICdfQVBQX09QVElPTlNfUk9VVEVSX1BST1RFQ1RJT049JHtfQVBQX09QVElPTlNfUk9VVEVSX1BST1RFQ1RJT046LWRpc2FibGVkfScKICAgICAgLSBfQVBQX09QRU5TU0xfS0VZX1YxPSRTRVJWSUNFX1BBU1NXT1JEXzY0X0FQUFdSSVRFCiAgICAgIC0gJ19BUFBfUkVESVNfSE9TVD0ke19BUFBfUkVESVNfSE9TVDotYXBwd3JpdGUtcmVkaXN9JwogICAgICAtICdfQVBQX1JFRElTX1BPUlQ9JHtfQVBQX1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICAtICdfQVBQX1JFRElTX1VTRVI9JHtfQVBQX1JFRElTX1VTRVJ9JwogICAgICAtICdfQVBQX1JFRElTX1BBU1M9JHtfQVBQX1JFRElTX1BBU1N9JwogICAgICAtICdfQVBQX0RCX0hPU1Q9JHtfQVBQX0RCX0hPU1Q6LWFwcHdyaXRlLW1hcmlhZGJ9JwogICAgICAtICdfQVBQX0RCX1BPUlQ9JHtfQVBQX0RCX1BPUlQ6LTMzMDZ9JwogICAgICAtICdfQVBQX0RCX1NDSEVNQT0ke19BUFBfREJfU0NIRU1BOi1hcHB3cml0ZX0nCiAgICAgIC0gX0FQUF9EQl9VU0VSPSRTRVJWSUNFX1VTRVJfTUFSSUFEQgogICAgICAtIF9BUFBfREJfUEFTUz0kU0VSVklDRV9QQVNTV09SRF9NQVJJQURCCiAgICAgIC0gJ19BUFBfVVNBR0VfU1RBVFM9JHtfQVBQX1VTQUdFX1NUQVRTOi1lbmFibGVkfScKICAgICAgLSAnX0FQUF9MT0dHSU5HX0NPTkZJRz0ke19BUFBfTE9HR0lOR19DT05GSUd9JwogICAgICAtICdfQVBQX0RBVEFCQVNFX1NIQVJFRF9UQUJMRVM9JHtfQVBQX0RBVEFCQVNFX1NIQVJFRF9UQUJMRVN9JwogIGFwcHdyaXRlLXdvcmtlci1hdWRpdHM6CiAgICBpbWFnZTogJ2FwcHdyaXRlL2FwcHdyaXRlOjEuNy40JwogICAgZW50cnlwb2ludDogd29ya2VyLWF1ZGl0cwogICAgY29udGFpbmVyX25hbWU6IGFwcHdyaXRlLXdvcmtlci1hdWRpdHMKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBwd3JpdGUtcmVkaXMKICAgICAgLSBhcHB3cml0ZS1tYXJpYWRiCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnX0FQUF9FTlY9JHtfQVBQX0VOVjotcHJvZHVjdGlvbn0nCiAgICAgIC0gJ19BUFBfV09SS0VSX1BFUl9DT1JFPSR7X0FQUF9XT1JLRVJfUEVSX0NPUkU6LTZ9JwogICAgICAtIF9BUFBfT1BFTlNTTF9LRVlfVjE9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfQVBQV1JJVEUKICAgICAgLSAnX0FQUF9SRURJU19IT1NUPSR7X0FQUF9SRURJU19IT1NUOi1hcHB3cml0ZS1yZWRpc30nCiAgICAgIC0gJ19BUFBfUkVESVNfUE9SVD0ke19BUFBfUkVESVNfUE9SVDotNjM3OX0nCiAgICAgIC0gJ19BUFBfUkVESVNfVVNFUj0ke19BUFBfUkVESVNfVVNFUn0nCiAgICAgIC0gJ19BUFBfUkVESVNfUEFTUz0ke19BUFBfUkVESVNfUEFTU30nCiAgICAgIC0gJ19BUFBfREJfSE9TVD0ke19BUFBfREJfSE9TVDotYXBwd3JpdGUtbWFyaWFkYn0nCiAgICAgIC0gJ19BUFBfREJfUE9SVD0ke19BUFBfREJfUE9SVDotMzMwNn0nCiAgICAgIC0gJ19BUFBfREJfU0NIRU1BPSR7X0FQUF9EQl9TQ0hFTUE6LWFwcHdyaXRlfScKICAgICAgLSBfQVBQX0RCX1VTRVI9JFNFUlZJQ0VfVVNFUl9NQVJJQURCCiAgICAgIC0gX0FQUF9EQl9QQVNTPSRTRVJWSUNFX1BBU1NXT1JEX01BUklBREIKICAgICAgLSAnX0FQUF9MT0dHSU5HX0NPTkZJRz0ke19BUFBfTE9HR0lOR19DT05GSUd9JwogICAgICAtICdfQVBQX0RBVEFCQVNFX1NIQVJFRF9UQUJMRVM9JHtfQVBQX0RBVEFCQVNFX1NIQVJFRF9UQUJMRVN9JwogIGFwcHdyaXRlLXdvcmtlci13ZWJob29rczoKICAgIGltYWdlOiAnYXBwd3JpdGUvYXBwd3JpdGU6MS43LjQnCiAgICBlbnRyeXBvaW50OiB3b3JrZXItd2ViaG9va3MKICAgIGNvbnRhaW5lcl9uYW1lOiBhcHB3cml0ZS13b3JrZXItd2ViaG9va3MKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBwd3JpdGUtcmVkaXMKICAgICAgLSBhcHB3cml0ZS1tYXJpYWRiCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnX0FQUF9FTlY9JHtfQVBQX0VOVjotcHJvZHVjdGlvbn0nCiAgICAgIC0gJ19BUFBfV09SS0VSX1BFUl9DT1JFPSR7X0FQUF9XT1JLRVJfUEVSX0NPUkU6LTZ9JwogICAgICAtIF9BUFBfT1BFTlNTTF9LRVlfVjE9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfQVBQV1JJVEUKICAgICAgLSAnX0FQUF9FTUFJTF9TRUNVUklUWT0ke19BUFBfRU1BSUxfU0VDVVJJVFk6LWNlcnRzQGFwcHdyaXRlLmlvfScKICAgICAgLSAnX0FQUF9TWVNURU1fU0VDVVJJVFlfRU1BSUxfQUREUkVTUz0ke19BUFBfU1lTVEVNX1NFQ1VSSVRZX0VNQUlMX0FERFJFU1N9JwogICAgICAtICdfQVBQX0RCX0hPU1Q9JHtfQVBQX0RCX0hPU1Q6LWFwcHdyaXRlLW1hcmlhZGJ9JwogICAgICAtICdfQVBQX0RCX1BPUlQ9JHtfQVBQX0RCX1BPUlQ6LTMzMDZ9JwogICAgICAtICdfQVBQX0RCX1NDSEVNQT0ke19BUFBfREJfU0NIRU1BOi1hcHB3cml0ZX0nCiAgICAgIC0gX0FQUF9EQl9VU0VSPSRTRVJWSUNFX1VTRVJfTUFSSUFEQgogICAgICAtIF9BUFBfREJfUEFTUz0kU0VSVklDRV9QQVNTV09SRF9NQVJJQURCCiAgICAgIC0gJ19BUFBfUkVESVNfSE9TVD0ke19BUFBfUkVESVNfSE9TVDotYXBwd3JpdGUtcmVkaXN9JwogICAgICAtICdfQVBQX1JFRElTX1BPUlQ9JHtfQVBQX1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICAtICdfQVBQX1JFRElTX1VTRVI9JHtfQVBQX1JFRElTX1VTRVJ9JwogICAgICAtICdfQVBQX1JFRElTX1BBU1M9JHtfQVBQX1JFRElTX1BBU1N9JwogICAgICAtICdfQVBQX0xPR0dJTkdfQ09ORklHPSR7X0FQUF9MT0dHSU5HX0NPTkZJR30nCiAgICAgIC0gJ19BUFBfV0VCSE9PS19NQVhfRkFJTEVEX0FUVEVNUFRTPSR7X0FQUF9XRUJIT09LX01BWF9GQUlMRURfQVRURU1QVFN9JwogICAgICAtICdfQVBQX0RBVEFCQVNFX1NIQVJFRF9UQUJMRVM9JHtfQVBQX0RBVEFCQVNFX1NIQVJFRF9UQUJMRVN9JwogIGFwcHdyaXRlLXdvcmtlci1kZWxldGVzOgogICAgaW1hZ2U6ICdhcHB3cml0ZS9hcHB3cml0ZToxLjcuNCcKICAgIGVudHJ5cG9pbnQ6IHdvcmtlci1kZWxldGVzCiAgICBjb250YWluZXJfbmFtZTogYXBwd3JpdGUtd29ya2VyLWRlbGV0ZXMKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBwd3JpdGUtcmVkaXMKICAgICAgLSBhcHB3cml0ZS1tYXJpYWRiCiAgICB2b2x1bWVzOgogICAgICAtICdhcHB3cml0ZS11cGxvYWRzOi9zdG9yYWdlL3VwbG9hZHM6cncnCiAgICAgIC0gJ2FwcHdyaXRlLWNhY2hlOi9zdG9yYWdlL2NhY2hlOnJ3JwogICAgICAtICdhcHB3cml0ZS1mdW5jdGlvbnM6L3N0b3JhZ2UvZnVuY3Rpb25zOnJ3JwogICAgICAtICdhcHB3cml0ZS1zaXRlczovc3RvcmFnZS9zaXRlczpydycKICAgICAgLSAnYXBwd3JpdGUtYnVpbGRzOi9zdG9yYWdlL2J1aWxkczpydycKICAgICAgLSAnYXBwd3JpdGUtY2VydGlmaWNhdGVzOi9zdG9yYWdlL2NlcnRpZmljYXRlczpydycKICAgIGVudmlyb25tZW50OgogICAgICAtICdfQVBQX0VOVj0ke19BUFBfRU5WOi1wcm9kdWN0aW9ufScKICAgICAgLSAnX0FQUF9XT1JLRVJfUEVSX0NPUkU9JHtfQVBQX1dPUktFUl9QRVJfQ09SRTotNn0nCiAgICAgIC0gX0FQUF9PUEVOU1NMX0tFWV9WMT0kU0VSVklDRV9QQVNTV09SRF82NF9BUFBXUklURQogICAgICAtICdfQVBQX1JFRElTX0hPU1Q9JHtfQVBQX1JFRElTX0hPU1Q6LWFwcHdyaXRlLXJlZGlzfScKICAgICAgLSAnX0FQUF9SRURJU19QT1JUPSR7X0FQUF9SRURJU19QT1JUOi02Mzc5fScKICAgICAgLSAnX0FQUF9SRURJU19VU0VSPSR7X0FQUF9SRURJU19VU0VSfScKICAgICAgLSAnX0FQUF9SRURJU19QQVNTPSR7X0FQUF9SRURJU19QQVNTfScKICAgICAgLSAnX0FQUF9EQl9IT1NUPSR7X0FQUF9EQl9IT1NUOi1hcHB3cml0ZS1tYXJpYWRifScKICAgICAgLSAnX0FQUF9EQl9QT1JUPSR7X0FQUF9EQl9QT1JUOi0zMzA2fScKICAgICAgLSAnX0FQUF9EQl9TQ0hFTUE9JHtfQVBQX0RCX1NDSEVNQTotYXBwd3JpdGV9JwogICAgICAtIF9BUFBfREJfVVNFUj0kU0VSVklDRV9VU0VSX01BUklBREIKICAgICAgLSBfQVBQX0RCX1BBU1M9JFNFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQgogICAgICAtICdfQVBQX1NUT1JBR0VfREVWSUNFPSR7X0FQUF9TVE9SQUdFX0RFVklDRTotbG9jYWx9JwogICAgICAtICdfQVBQX1NUT1JBR0VfUzNfQUNDRVNTX0tFWT0ke19BUFBfU1RPUkFHRV9TM19BQ0NFU1NfS0VZfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1MzX1NFQ1JFVD0ke19BUFBfU1RPUkFHRV9TM19TRUNSRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfUzNfUkVHSU9OPSR7X0FQUF9TVE9SQUdFX1MzX1JFR0lPTjotdXMtZWFzdC0xfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1MzX0JVQ0tFVD0ke19BUFBfU1RPUkFHRV9TM19CVUNLRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfUzNfRU5EUE9JTlQ9JHtfQVBQX1NUT1JBR0VfUzNfRU5EUE9JTlR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX0FDQ0VTU19LRVk9JHtfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX0FDQ0VTU19LRVl9JwogICAgICAtICdfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX1NFQ1JFVD0ke19BUFBfU1RPUkFHRV9ET19TUEFDRVNfU0VDUkVUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0RPX1NQQUNFU19SRUdJT049JHtfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX1JFR0lPTjotdXMtZWFzdC0xfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0RPX1NQQUNFU19CVUNLRVQ9JHtfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX0JVQ0tFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfQUNDRVNTX0tFWT0ke19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfU0VDUkVUPSR7X0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9TRUNSRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX1JFR0lPTj0ke19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfUkVHSU9OOi11cy13ZXN0LTAwNH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfQlVDS0VUPSR7X0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9CVUNLRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfTElOT0RFX0FDQ0VTU19LRVk9JHtfQVBQX1NUT1JBR0VfTElOT0RFX0FDQ0VTU19LRVl9JwogICAgICAtICdfQVBQX1NUT1JBR0VfTElOT0RFX1NFQ1JFVD0ke19BUFBfU1RPUkFHRV9MSU5PREVfU0VDUkVUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0xJTk9ERV9SRUdJT049JHtfQVBQX1NUT1JBR0VfTElOT0RFX1JFR0lPTjotZXUtY2VudHJhbC0xfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0xJTk9ERV9CVUNLRVQ9JHtfQVBQX1NUT1JBR0VfTElOT0RFX0JVQ0tFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9XQVNBQklfQUNDRVNTX0tFWT0ke19BUFBfU1RPUkFHRV9XQVNBQklfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9XQVNBQklfU0VDUkVUPSR7X0FQUF9TVE9SQUdFX1dBU0FCSV9TRUNSRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfV0FTQUJJX1JFR0lPTj0ke19BUFBfU1RPUkFHRV9XQVNBQklfUkVHSU9OOi1ldS1jZW50cmFsLTF9JwogICAgICAtICdfQVBQX1NUT1JBR0VfV0FTQUJJX0JVQ0tFVD0ke19BUFBfU1RPUkFHRV9XQVNBQklfQlVDS0VUfScKICAgICAgLSAnX0FQUF9MT0dHSU5HX0NPTkZJRz0ke19BUFBfTE9HR0lOR19DT05GSUd9JwogICAgICAtIF9BUFBfRVhFQ1VUT1JfU0VDUkVUPSRTRVJWSUNFX1BBU1NXT1JEXzY0X0FQUFdSSVRFCiAgICAgIC0gJ19BUFBfRVhFQ1VUT1JfSE9TVD0ke19BUFBfRVhFQ1VUT1JfSE9TVDotaHR0cDovL2FwcHdyaXRlLWV4ZWN1dG9yL3YxfScKICAgICAgLSAnX0FQUF9EQVRBQkFTRV9TSEFSRURfVEFCTEVTPSR7X0FQUF9EQVRBQkFTRV9TSEFSRURfVEFCTEVTfScKICAgICAgLSAnX0FQUF9EQVRBQkFTRV9TSEFSRURfVEFCTEVTX1YxPSR7X0FQUF9EQVRBQkFTRV9TSEFSRURfVEFCTEVTX1YxfScKICAgICAgLSAnX0FQUF9FTUFJTF9DRVJUSUZJQ0FURVM9JHtfQVBQX0VNQUlMX0NFUlRJRklDQVRFU30nCiAgICAgIC0gJ19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0FVRElUPSR7X0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fQVVESVQ6LTEyMDk2MDB9JwogICAgICAtICdfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9BVURJVF9DT05TT0xFPSR7X0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fQVVESVRfQ09OU09MRX0nCiAgYXBwd3JpdGUtd29ya2VyLWRhdGFiYXNlczoKICAgIGltYWdlOiAnYXBwd3JpdGUvYXBwd3JpdGU6MS43LjQnCiAgICBlbnRyeXBvaW50OiB3b3JrZXItZGF0YWJhc2VzCiAgICBjb250YWluZXJfbmFtZTogYXBwd3JpdGUtd29ya2VyLWRhdGFiYXNlcwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcHB3cml0ZS1yZWRpcwogICAgICAtIGFwcHdyaXRlLW1hcmlhZGIKICAgIGVudmlyb25tZW50OgogICAgICAtICdfQVBQX0VOVj0ke19BUFBfRU5WOi1wcm9kdWN0aW9ufScKICAgICAgLSAnX0FQUF9XT1JLRVJfUEVSX0NPUkU9JHtfQVBQX1dPUktFUl9QRVJfQ09SRTotNn0nCiAgICAgIC0gX0FQUF9PUEVOU1NMX0tFWV9WMT0kU0VSVklDRV9QQVNTV09SRF82NF9BUFBXUklURQogICAgICAtICdfQVBQX1JFRElTX0hPU1Q9JHtfQVBQX1JFRElTX0hPU1Q6LWFwcHdyaXRlLXJlZGlzfScKICAgICAgLSAnX0FQUF9SRURJU19QT1JUPSR7X0FQUF9SRURJU19QT1JUOi02Mzc5fScKICAgICAgLSAnX0FQUF9SRURJU19VU0VSPSR7X0FQUF9SRURJU19VU0VSfScKICAgICAgLSAnX0FQUF9SRURJU19QQVNTPSR7X0FQUF9SRURJU19QQVNTfScKICAgICAgLSAnX0FQUF9EQl9IT1NUPSR7X0FQUF9EQl9IT1NUOi1hcHB3cml0ZS1tYXJpYWRifScKICAgICAgLSAnX0FQUF9EQl9QT1JUPSR7X0FQUF9EQl9QT1JUOi0zMzA2fScKICAgICAgLSAnX0FQUF9EQl9TQ0hFTUE9JHtfQVBQX0RCX1NDSEVNQTotYXBwd3JpdGV9JwogICAgICAtIF9BUFBfREJfVVNFUj0kU0VSVklDRV9VU0VSX01BUklBREIKICAgICAgLSBfQVBQX0RCX1BBU1M9JFNFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQgogICAgICAtICdfQVBQX0xPR0dJTkdfQ09ORklHPSR7X0FQUF9MT0dHSU5HX0NPTkZJR30nCiAgICAgIC0gJ19BUFBfV09SS0VSU19OVU09JHtfQVBQX1dPUktFUlNfTlVNfScKICAgICAgLSAnX0FQUF9RVUVVRV9OQU1FPSR7X0FQUF9RVUVVRV9OQU1FfScKICAgICAgLSAnX0FQUF9EQVRBQkFTRV9TSEFSRURfVEFCTEVTPSR7X0FQUF9EQVRBQkFTRV9TSEFSRURfVEFCTEVTfScKICBhcHB3cml0ZS13b3JrZXItYnVpbGRzOgogICAgaW1hZ2U6ICdhcHB3cml0ZS9hcHB3cml0ZToxLjcuNCcKICAgIGVudHJ5cG9pbnQ6IHdvcmtlci1idWlsZHMKICAgIGNvbnRhaW5lcl9uYW1lOiBhcHB3cml0ZS13b3JrZXItYnVpbGRzCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwcHdyaXRlLXJlZGlzCiAgICAgIC0gYXBwd3JpdGUtbWFyaWFkYgogICAgdm9sdW1lczoKICAgICAgLSAnYXBwd3JpdGUtZnVuY3Rpb25zOi9zdG9yYWdlL2Z1bmN0aW9uczpydycKICAgICAgLSAnYXBwd3JpdGUtc2l0ZXM6L3N0b3JhZ2Uvc2l0ZXM6cncnCiAgICAgIC0gJ2FwcHdyaXRlLWJ1aWxkczovc3RvcmFnZS9idWlsZHM6cncnCiAgICAgIC0gJ2FwcHdyaXRlLXVwbG9hZHM6L3N0b3JhZ2UvdXBsb2FkczpydycKICAgIGVudmlyb25tZW50OgogICAgICAtICdfQVBQX0VOVj0ke19BUFBfRU5WOi1wcm9kdWN0aW9ufScKICAgICAgLSAnX0FQUF9XT1JLRVJfUEVSX0NPUkU9JHtfQVBQX1dPUktFUl9QRVJfQ09SRTotNn0nCiAgICAgIC0gX0FQUF9PUEVOU1NMX0tFWV9WMT0kU0VSVklDRV9QQVNTV09SRF82NF9BUFBXUklURQogICAgICAtIF9BUFBfRVhFQ1VUT1JfU0VDUkVUPSRTRVJWSUNFX1BBU1NXT1JEXzY0X0FQUFdSSVRFCiAgICAgIC0gJ19BUFBfRVhFQ1VUT1JfSE9TVD0ke19BUFBfRVhFQ1VUT1JfSE9TVDotaHR0cDovL2FwcHdyaXRlLWV4ZWN1dG9yL3YxfScKICAgICAgLSAnX0FQUF9SRURJU19IT1NUPSR7X0FQUF9SRURJU19IT1NUOi1hcHB3cml0ZS1yZWRpc30nCiAgICAgIC0gJ19BUFBfUkVESVNfUE9SVD0ke19BUFBfUkVESVNfUE9SVDotNjM3OX0nCiAgICAgIC0gJ19BUFBfUkVESVNfVVNFUj0ke19BUFBfUkVESVNfVVNFUn0nCiAgICAgIC0gJ19BUFBfUkVESVNfUEFTUz0ke19BUFBfUkVESVNfUEFTU30nCiAgICAgIC0gJ19BUFBfREJfSE9TVD0ke19BUFBfREJfSE9TVDotYXBwd3JpdGUtbWFyaWFkYn0nCiAgICAgIC0gJ19BUFBfREJfUE9SVD0ke19BUFBfREJfUE9SVDotMzMwNn0nCiAgICAgIC0gJ19BUFBfREJfU0NIRU1BPSR7X0FQUF9EQl9TQ0hFTUE6LWFwcHdyaXRlfScKICAgICAgLSBfQVBQX0RCX1VTRVI9JFNFUlZJQ0VfVVNFUl9NQVJJQURCCiAgICAgIC0gX0FQUF9EQl9QQVNTPSRTRVJWSUNFX1BBU1NXT1JEX01BUklBREIKICAgICAgLSAnX0FQUF9MT0dHSU5HX0NPTkZJRz0ke19BUFBfTE9HR0lOR19DT05GSUd9JwogICAgICAtICdfQVBQX1ZDU19HSVRIVUJfQVBQX05BTUU9JHtfQVBQX1ZDU19HSVRIVUJfQVBQX05BTUV9JwogICAgICAtICdfQVBQX1ZDU19HSVRIVUJfUFJJVkFURV9LRVk9JHtfQVBQX1ZDU19HSVRIVUJfUFJJVkFURV9LRVl9JwogICAgICAtICdfQVBQX1ZDU19HSVRIVUJfQVBQX0lEPSR7X0FQUF9WQ1NfR0lUSFVCX0FQUF9JRH0nCiAgICAgIC0gJ19BUFBfRlVOQ1RJT05TX1RJTUVPVVQ9JHtfQVBQX0ZVTkNUSU9OU19USU1FT1VUOi05MDB9JwogICAgICAtICdfQVBQX1NJVEVTX1RJTUVPVVQ9JHtfQVBQX1NJVEVTX1RJTUVPVVQ6LTkwMH0nCiAgICAgIC0gJ19BUFBfQ09NUFVURV9CVUlMRF9USU1FT1VUPSR7X0FQUF9DT01QVVRFX0JVSUxEX1RJTUVPVVQ6LTkwMH0nCiAgICAgIC0gJ19BUFBfQ09NUFVURV9DUFVTPSR7X0FQUF9DT01QVVRFX0NQVVM6LTB9JwogICAgICAtICdfQVBQX0NPTVBVVEVfTUVNT1JZPSR7X0FQUF9DT01QVVRFX01FTU9SWTotMH0nCiAgICAgIC0gJ19BUFBfQ09NUFVURV9TSVpFX0xJTUlUPSR7X0FQUF9DT01QVVRFX1NJWkVfTElNSVQ6LTMwMDAwMDAwfScKICAgICAgLSAnX0FQUF9PUFRJT05TX0ZPUkNFX0hUVFBTPSR7X0FQUF9PUFRJT05TX0ZPUkNFX0hUVFBTOi1kaXNhYmxlZH0nCiAgICAgIC0gJ19BUFBfT1BUSU9OU19ST1VURVJfRk9SQ0VfSFRUUFM9JHtfQVBQX09QVElPTlNfUk9VVEVSX0ZPUkNFX0hUVFBTOi1kaXNhYmxlZH0nCiAgICAgIC0gJ19BUFBfRE9NQUlOPSR7X0FQUF9ET01BSU46LSRTRVJWSUNFX0ZRRE5fQVBQV1JJVEV9JwogICAgICAtICdfQVBQX1NUT1JBR0VfREVWSUNFPSR7X0FQUF9TVE9SQUdFX0RFVklDRTotbG9jYWx9JwogICAgICAtICdfQVBQX1NUT1JBR0VfUzNfQUNDRVNTX0tFWT0ke19BUFBfU1RPUkFHRV9TM19BQ0NFU1NfS0VZfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1MzX1NFQ1JFVD0ke19BUFBfU1RPUkFHRV9TM19TRUNSRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfUzNfUkVHSU9OPSR7X0FQUF9TVE9SQUdFX1MzX1JFR0lPTjotdXMtZWFzdC0xfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1MzX0JVQ0tFVD0ke19BUFBfU1RPUkFHRV9TM19CVUNLRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfUzNfRU5EUE9JTlQ9JHtfQVBQX1NUT1JBR0VfUzNfRU5EUE9JTlR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX0FDQ0VTU19LRVk9JHtfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX0FDQ0VTU19LRVl9JwogICAgICAtICdfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX1NFQ1JFVD0ke19BUFBfU1RPUkFHRV9ET19TUEFDRVNfU0VDUkVUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0RPX1NQQUNFU19SRUdJT049JHtfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX1JFR0lPTjotdXMtZWFzdC0xfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0RPX1NQQUNFU19CVUNLRVQ9JHtfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX0JVQ0tFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfQUNDRVNTX0tFWT0ke19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfU0VDUkVUPSR7X0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9TRUNSRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX1JFR0lPTj0ke19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfUkVHSU9OOi11cy13ZXN0LTAwNH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfQlVDS0VUPSR7X0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9CVUNLRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfTElOT0RFX0FDQ0VTU19LRVk9JHtfQVBQX1NUT1JBR0VfTElOT0RFX0FDQ0VTU19LRVl9JwogICAgICAtICdfQVBQX1NUT1JBR0VfTElOT0RFX1NFQ1JFVD0ke19BUFBfU1RPUkFHRV9MSU5PREVfU0VDUkVUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0xJTk9ERV9SRUdJT049JHtfQVBQX1NUT1JBR0VfTElOT0RFX1JFR0lPTjotZXUtY2VudHJhbC0xfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0xJTk9ERV9CVUNLRVQ9JHtfQVBQX1NUT1JBR0VfTElOT0RFX0JVQ0tFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9XQVNBQklfQUNDRVNTX0tFWT0ke19BUFBfU1RPUkFHRV9XQVNBQklfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9XQVNBQklfU0VDUkVUPSR7X0FQUF9TVE9SQUdFX1dBU0FCSV9TRUNSRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfV0FTQUJJX1JFR0lPTj0ke19BUFBfU1RPUkFHRV9XQVNBQklfUkVHSU9OOi1ldS1jZW50cmFsLTF9JwogICAgICAtICdfQVBQX1NUT1JBR0VfV0FTQUJJX0JVQ0tFVD0ke19BUFBfU1RPUkFHRV9XQVNBQklfQlVDS0VUfScKICAgICAgLSAnX0FQUF9EQVRBQkFTRV9TSEFSRURfVEFCTEVTPSR7X0FQUF9EQVRBQkFTRV9TSEFSRURfVEFCTEVTfScKICAgICAgLSAnX0FQUF9ET01BSU5fU0lURVM9JHtfQVBQX0RPTUFJTl9TSVRFUzotc2l0ZXMuJFNFUlZJQ0VfRlFETl9BUFBXUklURX0nCiAgICAgIC0gJ19BUFBfQlJPV1NFUl9IT1NUPSR7X0FQUF9CUk9XU0VSX0hPU1R9JwogICAgICAtICdfQVBQX0NPTlNPTEVfRE9NQUlOPSR7X0FQUF9DT05TT0xFX0RPTUFJTn0nCiAgYXBwd3JpdGUtd29ya2VyLWNlcnRpZmljYXRlczoKICAgIGltYWdlOiAnYXBwd3JpdGUvYXBwd3JpdGU6MS43LjQnCiAgICBlbnRyeXBvaW50OiB3b3JrZXItY2VydGlmaWNhdGVzCiAgICBjb250YWluZXJfbmFtZTogYXBwd3JpdGUtd29ya2VyLWNlcnRpZmljYXRlcwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcHB3cml0ZS1yZWRpcwogICAgICAtIGFwcHdyaXRlLW1hcmlhZGIKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2FwcHdyaXRlLWNvbmZpZzovc3RvcmFnZS9jb25maWc6cncnCiAgICAgIC0gJ2FwcHdyaXRlLWNlcnRpZmljYXRlczovc3RvcmFnZS9jZXJ0aWZpY2F0ZXM6cncnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnX0FQUF9FTlY9JHtfQVBQX0VOVjotcHJvZHVjdGlvbn0nCiAgICAgIC0gJ19BUFBfV09SS0VSX1BFUl9DT1JFPSR7X0FQUF9XT1JLRVJfUEVSX0NPUkU6LTZ9JwogICAgICAtIF9BUFBfT1BFTlNTTF9LRVlfVjE9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfQVBQV1JJVEUKICAgICAgLSAnX0FQUF9ET01BSU49JHtfQVBQX0RPTUFJTjotJFNFUlZJQ0VfRlFETl9BUFBXUklURX0nCiAgICAgIC0gJ19BUFBfRE9NQUlOX1RBUkdFVF9DTkFNRT0ke19BUFBfRE9NQUlOX1RBUkdFVF9DTkFNRX0nCiAgICAgIC0gJ19BUFBfRE9NQUlOX1RBUkdFVF9BQUFBPSR7X0FQUF9ET01BSU5fVEFSR0VUX0FBQUF9JwogICAgICAtICdfQVBQX0RPTUFJTl9UQVJHRVRfQT0ke19BUFBfRE9NQUlOX1RBUkdFVF9BfScKICAgICAgLSAnX0FQUF9ET01BSU5fVEFSR0VUX0NBQT0ke19BUFBfRE9NQUlOX1RBUkdFVF9DQUF9JwogICAgICAtICdfQVBQX0RPTUFJTl9GVU5DVElPTlM9JHtfQVBQX0RPTUFJTl9GVU5DVElPTlM6LWZ1bmN0aW9ucy4kU0VSVklDRV9GUUROX0FQUFdSSVRFfScKICAgICAgLSAnX0FQUF9ETlM9JHtfQVBQX0ROU30nCiAgICAgIC0gJ19BUFBfRU1BSUxfQ0VSVElGSUNBVEVTPSR7X0FQUF9FTUFJTF9DRVJUSUZJQ0FURVM6LWVuYWJsZWR9JwogICAgICAtICdfQVBQX1JFRElTX0hPU1Q9JHtfQVBQX1JFRElTX0hPU1Q6LWFwcHdyaXRlLXJlZGlzfScKICAgICAgLSAnX0FQUF9SRURJU19QT1JUPSR7X0FQUF9SRURJU19QT1JUOi02Mzc5fScKICAgICAgLSAnX0FQUF9SRURJU19VU0VSPSR7X0FQUF9SRURJU19VU0VSfScKICAgICAgLSAnX0FQUF9SRURJU19QQVNTPSR7X0FQUF9SRURJU19QQVNTfScKICAgICAgLSAnX0FQUF9EQl9IT1NUPSR7X0FQUF9EQl9IT1NUOi1hcHB3cml0ZS1tYXJpYWRifScKICAgICAgLSAnX0FQUF9EQl9QT1JUPSR7X0FQUF9EQl9QT1JUOi0zMzA2fScKICAgICAgLSAnX0FQUF9EQl9TQ0hFTUE9JHtfQVBQX0RCX1NDSEVNQTotYXBwd3JpdGV9JwogICAgICAtIF9BUFBfREJfVVNFUj0kU0VSVklDRV9VU0VSX01BUklBREIKICAgICAgLSBfQVBQX0RCX1BBU1M9JFNFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQgogICAgICAtICdfQVBQX0xPR0dJTkdfQ09ORklHPSR7X0FQUF9MT0dHSU5HX0NPTkZJR30nCiAgICAgIC0gJ19BUFBfREFUQUJBU0VfU0hBUkVEX1RBQkxFUz0ke19BUFBfREFUQUJBU0VfU0hBUkVEX1RBQkxFU30nCiAgYXBwd3JpdGUtd29ya2VyLWZ1bmN0aW9uczoKICAgIGltYWdlOiAnYXBwd3JpdGUvYXBwd3JpdGU6MS43LjQnCiAgICBlbnRyeXBvaW50OiB3b3JrZXItZnVuY3Rpb25zCiAgICBjb250YWluZXJfbmFtZTogYXBwd3JpdGUtd29ya2VyLWZ1bmN0aW9ucwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcHB3cml0ZS1yZWRpcwogICAgICAtIGFwcHdyaXRlLW1hcmlhZGIKICAgICAgLSBvcGVucnVudGltZXMtZXhlY3V0b3IKICAgIGVudmlyb25tZW50OgogICAgICAtICdfQVBQX0VOVj0ke19BUFBfRU5WOi1wcm9kdWN0aW9ufScKICAgICAgLSAnX0FQUF9XT1JLRVJfUEVSX0NPUkU9JHtfQVBQX1dPUktFUl9QRVJfQ09SRTotNn0nCiAgICAgIC0gX0FQUF9PUEVOU1NMX0tFWV9WMT0kU0VSVklDRV9QQVNTV09SRF82NF9BUFBXUklURQogICAgICAtICdfQVBQX0RPTUFJTj0ke19BUFBfRE9NQUlOOi0kU0VSVklDRV9GUUROX0FQUFdSSVRFfScKICAgICAgLSAnX0FQUF9PUFRJT05TX0ZPUkNFX0hUVFBTPSR7X0FQUF9PUFRJT05TX0ZPUkNFX0hUVFBTOi1kaXNhYmxlZH0nCiAgICAgIC0gJ19BUFBfUkVESVNfSE9TVD0ke19BUFBfUkVESVNfSE9TVDotYXBwd3JpdGUtcmVkaXN9JwogICAgICAtICdfQVBQX1JFRElTX1BPUlQ9JHtfQVBQX1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICAtICdfQVBQX1JFRElTX1VTRVI9JHtfQVBQX1JFRElTX1VTRVJ9JwogICAgICAtICdfQVBQX1JFRElTX1BBU1M9JHtfQVBQX1JFRElTX1BBU1N9JwogICAgICAtICdfQVBQX0RCX0hPU1Q9JHtfQVBQX0RCX0hPU1Q6LWFwcHdyaXRlLW1hcmlhZGJ9JwogICAgICAtICdfQVBQX0RCX1BPUlQ9JHtfQVBQX0RCX1BPUlQ6LTMzMDZ9JwogICAgICAtICdfQVBQX0RCX1NDSEVNQT0ke19BUFBfREJfU0NIRU1BOi1hcHB3cml0ZX0nCiAgICAgIC0gX0FQUF9EQl9VU0VSPSRTRVJWSUNFX1VTRVJfTUFSSUFEQgogICAgICAtIF9BUFBfREJfUEFTUz0kU0VSVklDRV9QQVNTV09SRF9NQVJJQURCCiAgICAgIC0gJ19BUFBfRlVOQ1RJT05TX1RJTUVPVVQ9JHtfQVBQX0ZVTkNUSU9OU19USU1FT1VUOi05MDB9JwogICAgICAtICdfQVBQX1NJVEVTX1RJTUVPVVQ9JHtfQVBQX1NJVEVTX1RJTUVPVVQ6LTkwMH0nCiAgICAgIC0gJ19BUFBfQ09NUFVURV9CVUlMRF9USU1FT1VUPSR7X0FQUF9DT01QVVRFX0JVSUxEX1RJTUVPVVQ6LTkwMH0nCiAgICAgIC0gJ19BUFBfQ09NUFVURV9DUFVTPSR7X0FQUF9DT01QVVRFX0NQVVM6LTB9JwogICAgICAtICdfQVBQX0NPTVBVVEVfTUVNT1JZPSR7X0FQUF9DT01QVVRFX01FTU9SWTotMH0nCiAgICAgIC0gX0FQUF9FWEVDVVRPUl9TRUNSRVQ9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfQVBQV1JJVEUKICAgICAgLSAnX0FQUF9FWEVDVVRPUl9IT1NUPSR7X0FQUF9FWEVDVVRPUl9IT1NUOi1odHRwOi8vYXBwd3JpdGUtZXhlY3V0b3IvdjF9JwogICAgICAtICdfQVBQX1VTQUdFX1NUQVRTPSR7X0FQUF9VU0FHRV9TVEFUUzotZW5hYmxlZH0nCiAgICAgIC0gJ19BUFBfRE9DS0VSX0hVQl9VU0VSTkFNRT0ke19BUFBfRE9DS0VSX0hVQl9VU0VSTkFNRX0nCiAgICAgIC0gJ19BUFBfRE9DS0VSX0hVQl9QQVNTV09SRD0ke19BUFBfRE9DS0VSX0hVQl9QQVNTV09SRH0nCiAgICAgIC0gJ19BUFBfTE9HR0lOR19DT05GSUc9JHtfQVBQX0xPR0dJTkdfQ09ORklHfScKICAgICAgLSAnX0FQUF9MT0dHSU5HX1BST1ZJREVSPSR7X0FQUF9MT0dHSU5HX1BST1ZJREVSfScKICAgICAgLSAnX0FQUF9EQVRBQkFTRV9TSEFSRURfVEFCTEVTPSR7X0FQUF9EQVRBQkFTRV9TSEFSRURfVEFCTEVTfScKICBhcHB3cml0ZS13b3JrZXItbWFpbHM6CiAgICBpbWFnZTogJ2FwcHdyaXRlL2FwcHdyaXRlOjEuNy40JwogICAgZW50cnlwb2ludDogd29ya2VyLW1haWxzCiAgICBjb250YWluZXJfbmFtZTogYXBwd3JpdGUtd29ya2VyLW1haWxzCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwcHdyaXRlLXJlZGlzCiAgICAgIC0gYXBwd3JpdGUtbWFyaWFkYgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ19BUFBfRU5WPSR7X0FQUF9FTlY6LXByb2R1Y3Rpb259JwogICAgICAtICdfQVBQX1dPUktFUl9QRVJfQ09SRT0ke19BUFBfV09SS0VSX1BFUl9DT1JFOi02fScKICAgICAgLSBfQVBQX09QRU5TU0xfS0VZX1YxPSRTRVJWSUNFX1BBU1NXT1JEXzY0X0FQUFdSSVRFCiAgICAgIC0gJ19BUFBfU1lTVEVNX0VNQUlMX05BTUU9JHtfQVBQX1NZU1RFTV9FTUFJTF9OQU1FOi1BcHB3cml0ZX0nCiAgICAgIC0gJ19BUFBfU1lTVEVNX0VNQUlMX0FERFJFU1M9JHtfQVBQX1NZU1RFTV9FTUFJTF9BRERSRVNTOi10ZWFtQGFwcHdyaXRlLmlvfScKICAgICAgLSAnX0FQUF9EQl9IT1NUPSR7X0FQUF9EQl9IT1NUOi1hcHB3cml0ZS1tYXJpYWRifScKICAgICAgLSAnX0FQUF9EQl9QT1JUPSR7X0FQUF9EQl9QT1JUOi0zMzA2fScKICAgICAgLSAnX0FQUF9EQl9TQ0hFTUE9JHtfQVBQX0RCX1NDSEVNQTotYXBwd3JpdGV9JwogICAgICAtIF9BUFBfREJfVVNFUj0kU0VSVklDRV9VU0VSX01BUklBREIKICAgICAgLSBfQVBQX0RCX1BBU1M9JFNFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQgogICAgICAtICdfQVBQX1JFRElTX0hPU1Q9JHtfQVBQX1JFRElTX0hPU1Q6LWFwcHdyaXRlLXJlZGlzfScKICAgICAgLSAnX0FQUF9SRURJU19QT1JUPSR7X0FQUF9SRURJU19QT1JUOi02Mzc5fScKICAgICAgLSAnX0FQUF9SRURJU19VU0VSPSR7X0FQUF9SRURJU19VU0VSfScKICAgICAgLSAnX0FQUF9SRURJU19QQVNTPSR7X0FQUF9SRURJU19QQVNTfScKICAgICAgLSAnX0FQUF9TTVRQX0hPU1Q9JHtfQVBQX1NNVFBfSE9TVH0nCiAgICAgIC0gJ19BUFBfU01UUF9QT1JUPSR7X0FQUF9TTVRQX1BPUlR9JwogICAgICAtICdfQVBQX1NNVFBfU0VDVVJFPSR7X0FQUF9TTVRQX1NFQ1VSRX0nCiAgICAgIC0gJ19BUFBfU01UUF9VU0VSTkFNRT0ke19BUFBfU01UUF9VU0VSTkFNRX0nCiAgICAgIC0gJ19BUFBfU01UUF9QQVNTV09SRD0ke19BUFBfU01UUF9QQVNTV09SRH0nCiAgICAgIC0gJ19BUFBfTE9HR0lOR19DT05GSUc9JHtfQVBQX0xPR0dJTkdfQ09ORklHfScKICAgICAgLSAnX0FQUF9ET01BSU49JHtfQVBQX0RPTUFJTjotJFNFUlZJQ0VfRlFETl9BUFBXUklURX0nCiAgICAgIC0gJ19BUFBfT1BUSU9OU19GT1JDRV9IVFRQUz0ke19BUFBfT1BUSU9OU19GT1JDRV9IVFRQUzotZGlzYWJsZWR9JwogICAgICAtICdfQVBQX0RBVEFCQVNFX1NIQVJFRF9UQUJMRVM9JHtfQVBQX0RBVEFCQVNFX1NIQVJFRF9UQUJMRVN9JwogIGFwcHdyaXRlLXdvcmtlci1tZXNzYWdpbmc6CiAgICBpbWFnZTogJ2FwcHdyaXRlL2FwcHdyaXRlOjEuNy40JwogICAgZW50cnlwb2ludDogd29ya2VyLW1lc3NhZ2luZwogICAgY29udGFpbmVyX25hbWU6IGFwcHdyaXRlLXdvcmtlci1tZXNzYWdpbmcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2FwcHdyaXRlLXVwbG9hZHM6L3N0b3JhZ2UvdXBsb2FkczpydycKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBwd3JpdGUtcmVkaXMKICAgIGVudmlyb25tZW50OgogICAgICAtICdfQVBQX0VOVj0ke19BUFBfRU5WOi1wcm9kdWN0aW9ufScKICAgICAgLSAnX0FQUF9XT1JLRVJfUEVSX0NPUkU9JHtfQVBQX1dPUktFUl9QRVJfQ09SRTotNn0nCiAgICAgIC0gX0FQUF9PUEVOU1NMX0tFWV9WMT0kU0VSVklDRV9QQVNTV09SRF82NF9BUFBXUklURQogICAgICAtICdfQVBQX1JFRElTX0hPU1Q9JHtfQVBQX1JFRElTX0hPU1Q6LWFwcHdyaXRlLXJlZGlzfScKICAgICAgLSAnX0FQUF9SRURJU19QT1JUPSR7X0FQUF9SRURJU19QT1JUOi02Mzc5fScKICAgICAgLSAnX0FQUF9SRURJU19VU0VSPSR7X0FQUF9SRURJU19VU0VSfScKICAgICAgLSAnX0FQUF9SRURJU19QQVNTPSR7X0FQUF9SRURJU19QQVNTfScKICAgICAgLSAnX0FQUF9EQl9IT1NUPSR7X0FQUF9EQl9IT1NUOi1hcHB3cml0ZS1tYXJpYWRifScKICAgICAgLSAnX0FQUF9EQl9QT1JUPSR7X0FQUF9EQl9QT1JUOi0zMzA2fScKICAgICAgLSAnX0FQUF9EQl9TQ0hFTUE9JHtfQVBQX0RCX1NDSEVNQTotYXBwd3JpdGV9JwogICAgICAtIF9BUFBfREJfVVNFUj0kU0VSVklDRV9VU0VSX01BUklBREIKICAgICAgLSBfQVBQX0RCX1BBU1M9JFNFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQgogICAgICAtICdfQVBQX0xPR0dJTkdfQ09ORklHPSR7X0FQUF9MT0dHSU5HX0NPTkZJR30nCiAgICAgIC0gJ19BUFBfU01TX0ZST009JHtfQVBQX1NNU19GUk9NfScKICAgICAgLSAnX0FQUF9TTVNfUFJPVklERVI9JHtfQVBQX1NNU19QUk9WSURFUn0nCiAgICAgIC0gJ19BUFBfU01TX1BST0pFQ1RTX0RFTllfTElTVD0ke19BUFBfU01TX1BST0pFQ1RTX0RFTllfTElTVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9ERVZJQ0U9JHtfQVBQX1NUT1JBR0VfREVWSUNFOi1sb2NhbH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9TM19BQ0NFU1NfS0VZPSR7X0FQUF9TVE9SQUdFX1MzX0FDQ0VTU19LRVl9JwogICAgICAtICdfQVBQX1NUT1JBR0VfUzNfU0VDUkVUPSR7X0FQUF9TVE9SQUdFX1MzX1NFQ1JFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9TM19SRUdJT049JHtfQVBQX1NUT1JBR0VfUzNfUkVHSU9OOi11cy1lYXN0LTF9JwogICAgICAtICdfQVBQX1NUT1JBR0VfUzNfQlVDS0VUPSR7X0FQUF9TVE9SQUdFX1MzX0JVQ0tFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9TM19FTkRQT0lOVD0ke19BUFBfU1RPUkFHRV9TM19FTkRQT0lOVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9ET19TUEFDRVNfQUNDRVNTX0tFWT0ke19BUFBfU1RPUkFHRV9ET19TUEFDRVNfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9ET19TUEFDRVNfU0VDUkVUPSR7X0FQUF9TVE9SQUdFX0RPX1NQQUNFU19TRUNSRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX1JFR0lPTj0ke19BUFBfU1RPUkFHRV9ET19TUEFDRVNfUkVHSU9OOi11cy1lYXN0LTF9JwogICAgICAtICdfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX0JVQ0tFVD0ke19BUFBfU1RPUkFHRV9ET19TUEFDRVNfQlVDS0VUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9BQ0NFU1NfS0VZPSR7X0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9BQ0NFU1NfS0VZfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9TRUNSRVQ9JHtfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX1NFQ1JFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfUkVHSU9OPSR7X0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9SRUdJT046LXVzLXdlc3QtMDA0fScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9CVUNLRVQ9JHtfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX0JVQ0tFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9MSU5PREVfQUNDRVNTX0tFWT0ke19BUFBfU1RPUkFHRV9MSU5PREVfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9MSU5PREVfU0VDUkVUPSR7X0FQUF9TVE9SQUdFX0xJTk9ERV9TRUNSRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfTElOT0RFX1JFR0lPTj0ke19BUFBfU1RPUkFHRV9MSU5PREVfUkVHSU9OOi1ldS1jZW50cmFsLTF9JwogICAgICAtICdfQVBQX1NUT1JBR0VfTElOT0RFX0JVQ0tFVD0ke19BUFBfU1RPUkFHRV9MSU5PREVfQlVDS0VUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1dBU0FCSV9BQ0NFU1NfS0VZPSR7X0FQUF9TVE9SQUdFX1dBU0FCSV9BQ0NFU1NfS0VZfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1dBU0FCSV9TRUNSRVQ9JHtfQVBQX1NUT1JBR0VfV0FTQUJJX1NFQ1JFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9XQVNBQklfUkVHSU9OPSR7X0FQUF9TVE9SQUdFX1dBU0FCSV9SRUdJT046LWV1LWNlbnRyYWwtMX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9XQVNBQklfQlVDS0VUPSR7X0FQUF9TVE9SQUdFX1dBU0FCSV9CVUNLRVR9JwogICAgICAtICdfQVBQX0RBVEFCQVNFX1NIQVJFRF9UQUJMRVM9JHtfQVBQX0RBVEFCQVNFX1NIQVJFRF9UQUJMRVN9JwogIGFwcHdyaXRlLXdvcmtlci1taWdyYXRpb25zOgogICAgaW1hZ2U6ICdhcHB3cml0ZS9hcHB3cml0ZToxLjcuNCcKICAgIGVudHJ5cG9pbnQ6IHdvcmtlci1taWdyYXRpb25zCiAgICBjb250YWluZXJfbmFtZTogYXBwd3JpdGUtd29ya2VyLW1pZ3JhdGlvbnMKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2FwcHdyaXRlLWltcG9ydHM6L3N0b3JhZ2UvaW1wb3J0czpydycKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBwd3JpdGUtbWFyaWFkYgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ19BUFBfRU5WPSR7X0FQUF9FTlY6LXByb2R1Y3Rpb259JwogICAgICAtICdfQVBQX1dPUktFUl9QRVJfQ09SRT0ke19BUFBfV09SS0VSX1BFUl9DT1JFOi02fScKICAgICAgLSBfQVBQX09QRU5TU0xfS0VZX1YxPSRTRVJWSUNFX1BBU1NXT1JEXzY0X0FQUFdSSVRFCiAgICAgIC0gJ19BUFBfRE9NQUlOPSR7X0FQUF9ET01BSU46LSRTRVJWSUNFX0ZRRE5fQVBQV1JJVEV9JwogICAgICAtICdfQVBQX0RPTUFJTl9UQVJHRVRfQ05BTUU9JHtfQVBQX0RPTUFJTl9UQVJHRVRfQ05BTUV9JwogICAgICAtICdfQVBQX0RPTUFJTl9UQVJHRVRfQUFBQT0ke19BUFBfRE9NQUlOX1RBUkdFVF9BQUFBfScKICAgICAgLSAnX0FQUF9ET01BSU5fVEFSR0VUX0E9JHtfQVBQX0RPTUFJTl9UQVJHRVRfQX0nCiAgICAgIC0gJ19BUFBfRE9NQUlOX1RBUkdFVF9DQUE9JHtfQVBQX0RPTUFJTl9UQVJHRVRfQ0FBfScKICAgICAgLSAnX0FQUF9ETlM9JHtfQVBQX0ROU30nCiAgICAgIC0gJ19BUFBfRU1BSUxfU0VDVVJJVFk9JHtfQVBQX0VNQUlMX1NFQ1VSSVRZOi1jZXJ0c0BhcHB3cml0ZS5pb30nCiAgICAgIC0gJ19BUFBfUkVESVNfSE9TVD0ke19BUFBfUkVESVNfSE9TVDotYXBwd3JpdGUtcmVkaXN9JwogICAgICAtICdfQVBQX1JFRElTX1BPUlQ9JHtfQVBQX1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICAtICdfQVBQX1JFRElTX1VTRVI9JHtfQVBQX1JFRElTX1VTRVJ9JwogICAgICAtICdfQVBQX1JFRElTX1BBU1M9JHtfQVBQX1JFRElTX1BBU1N9JwogICAgICAtICdfQVBQX0RCX0hPU1Q9JHtfQVBQX0RCX0hPU1Q6LWFwcHdyaXRlLW1hcmlhZGJ9JwogICAgICAtICdfQVBQX0RCX1BPUlQ9JHtfQVBQX0RCX1BPUlQ6LTMzMDZ9JwogICAgICAtICdfQVBQX0RCX1NDSEVNQT0ke19BUFBfREJfU0NIRU1BOi1hcHB3cml0ZX0nCiAgICAgIC0gX0FQUF9EQl9VU0VSPSRTRVJWSUNFX1VTRVJfTUFSSUFEQgogICAgICAtIF9BUFBfREJfUEFTUz0kU0VSVklDRV9QQVNTV09SRF9NQVJJQURCCiAgICAgIC0gJ19BUFBfTE9HR0lOR19DT05GSUc9JHtfQVBQX0xPR0dJTkdfQ09ORklHfScKICAgICAgLSAnX0FQUF9NSUdSQVRJT05TX0ZJUkVCQVNFX0NMSUVOVF9JRD0ke19BUFBfTUlHUkFUSU9OU19GSVJFQkFTRV9DTElFTlRfSUR9JwogICAgICAtICdfQVBQX01JR1JBVElPTlNfRklSRUJBU0VfQ0xJRU5UX1NFQ1JFVD0ke19BUFBfTUlHUkFUSU9OU19GSVJFQkFTRV9DTElFTlRfU0VDUkVUfScKICAgICAgLSAnX0FQUF9EQVRBQkFTRV9TSEFSRURfVEFCTEVTPSR7X0FQUF9EQVRBQkFTRV9TSEFSRURfVEFCTEVTfScKICBhcHB3cml0ZS10YXNrLW1haW50ZW5hbmNlOgogICAgaW1hZ2U6ICdhcHB3cml0ZS9hcHB3cml0ZToxLjcuNCcKICAgIGVudHJ5cG9pbnQ6IG1haW50ZW5hbmNlCiAgICBjb250YWluZXJfbmFtZTogYXBwd3JpdGUtdGFzay1tYWludGVuYW5jZQogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcHB3cml0ZS1yZWRpcwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ19BUFBfRU5WPSR7X0FQUF9FTlY6LXByb2R1Y3Rpb259JwogICAgICAtICdfQVBQX1dPUktFUl9QRVJfQ09SRT0ke19BUFBfV09SS0VSX1BFUl9DT1JFOi02fScKICAgICAgLSAnX0FQUF9ET01BSU49JHtfQVBQX0RPTUFJTjotJFNFUlZJQ0VfRlFETl9BUFBXUklURX0nCiAgICAgIC0gJ19BUFBfRE9NQUlOX1RBUkdFVF9DTkFNRT0ke19BUFBfRE9NQUlOX1RBUkdFVF9DTkFNRX0nCiAgICAgIC0gJ19BUFBfRE9NQUlOX1RBUkdFVF9BQUFBPSR7X0FQUF9ET01BSU5fVEFSR0VUX0FBQUF9JwogICAgICAtICdfQVBQX0RPTUFJTl9UQVJHRVRfQT0ke19BUFBfRE9NQUlOX1RBUkdFVF9BfScKICAgICAgLSAnX0FQUF9ET01BSU5fVEFSR0VUX0NBQT0ke19BUFBfRE9NQUlOX1RBUkdFVF9DQUF9JwogICAgICAtICdfQVBQX0RPTUFJTl9GVU5DVElPTlM9JHtfQVBQX0RPTUFJTl9GVU5DVElPTlM6LWZ1bmN0aW9ucy4kU0VSVklDRV9GUUROX0FQUFdSSVRFfScKICAgICAgLSAnX0FQUF9ETlM9JHtfQVBQX0ROU30nCiAgICAgIC0gX0FQUF9PUEVOU1NMX0tFWV9WMT0kU0VSVklDRV9QQVNTV09SRF82NF9BUFBXUklURQogICAgICAtICdfQVBQX1JFRElTX0hPU1Q9JHtfQVBQX1JFRElTX0hPU1Q6LWFwcHdyaXRlLXJlZGlzfScKICAgICAgLSAnX0FQUF9SRURJU19QT1JUPSR7X0FQUF9SRURJU19QT1JUOi02Mzc5fScKICAgICAgLSAnX0FQUF9SRURJU19VU0VSPSR7X0FQUF9SRURJU19VU0VSfScKICAgICAgLSAnX0FQUF9SRURJU19QQVNTPSR7X0FQUF9SRURJU19QQVNTfScKICAgICAgLSAnX0FQUF9EQl9IT1NUPSR7X0FQUF9EQl9IT1NUOi1hcHB3cml0ZS1tYXJpYWRifScKICAgICAgLSAnX0FQUF9EQl9QT1JUPSR7X0FQUF9EQl9QT1JUOi0zMzA2fScKICAgICAgLSAnX0FQUF9EQl9TQ0hFTUE9JHtfQVBQX0RCX1NDSEVNQTotYXBwd3JpdGV9JwogICAgICAtIF9BUFBfREJfVVNFUj0kU0VSVklDRV9VU0VSX01BUklBREIKICAgICAgLSBfQVBQX0RCX1BBU1M9JFNFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQgogICAgICAtICdfQVBQX01BSU5URU5BTkNFX0lOVEVSVkFMPSR7X0FQUF9NQUlOVEVOQU5DRV9JTlRFUlZBTDotODY0MDB9JwogICAgICAtICdfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9FWEVDVVRJT049JHtfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9FWEVDVVRJT046LTEyMDk2MDB9JwogICAgICAtICdfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9DQUNIRT0ke19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0NBQ0hFOi0yNTkyMDAwfScKICAgICAgLSAnX0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fQUJVU0U9JHtfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9BQlVTRTotODY0MDB9JwogICAgICAtICdfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9BVURJVD0ke19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0FVRElUOi0xMjA5NjAwfScKICAgICAgLSAnX0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fQVVESVRfQ09OU09MRT0ke19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0FVRElUX0NPTlNPTEV9JwogICAgICAtICdfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9VU0FHRV9IT1VSTFk9JHtfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9VU0FHRV9IT1VSTFk6LTg2NDAwMDB9JwogICAgICAtICdfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9TQ0hFRFVMRVM9JHtfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9TQ0hFRFVMRVM6LTg2NDAwfScKICAgICAgLSAnX0FQUF9NQUlOVEVOQU5DRV9TVEFSVF9USU1FPSR7X0FQUF9NQUlOVEVOQU5DRV9TVEFSVF9USU1FfScKICAgICAgLSAnX0FQUF9EQVRBQkFTRV9TSEFSRURfVEFCTEVTPSR7X0FQUF9EQVRBQkFTRV9TSEFSRURfVEFCTEVTfScKICBhcHB3cml0ZS10YXNrLXN0YXRzLXJlc291cmNlczoKICAgIGltYWdlOiAnYXBwd3JpdGUvYXBwd3JpdGU6MS43LjQnCiAgICBjb250YWluZXJfbmFtZTogYXBwd3JpdGUtdGFzay1zdGF0cy1yZXNvdXJjZXMKICAgIGVudHJ5cG9pbnQ6IHN0YXRzLXJlc291cmNlcwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcHB3cml0ZS1yZWRpcwogICAgICAtIGFwcHdyaXRlLW1hcmlhZGIKICAgIGVudmlyb25tZW50OgogICAgICAtICdfQVBQX0VOVj0ke19BUFBfRU5WOi1wcm9kdWN0aW9ufScKICAgICAgLSAnX0FQUF9XT1JLRVJfUEVSX0NPUkU9JHtfQVBQX1dPUktFUl9QRVJfQ09SRTotNn0nCiAgICAgIC0gX0FQUF9PUEVOU1NMX0tFWV9WMT0kU0VSVklDRV9QQVNTV09SRF82NF9BUFBXUklURQogICAgICAtICdfQVBQX0RCX0hPU1Q9JHtfQVBQX0RCX0hPU1Q6LWFwcHdyaXRlLW1hcmlhZGJ9JwogICAgICAtICdfQVBQX0RCX1BPUlQ9JHtfQVBQX0RCX1BPUlQ6LTMzMDZ9JwogICAgICAtICdfQVBQX0RCX1NDSEVNQT0ke19BUFBfREJfU0NIRU1BOi1hcHB3cml0ZX0nCiAgICAgIC0gX0FQUF9EQl9VU0VSPSRTRVJWSUNFX1VTRVJfTUFSSUFEQgogICAgICAtIF9BUFBfREJfUEFTUz0kU0VSVklDRV9QQVNTV09SRF9NQVJJQURCCiAgICAgIC0gJ19BUFBfUkVESVNfSE9TVD0ke19BUFBfUkVESVNfSE9TVDotYXBwd3JpdGUtcmVkaXN9JwogICAgICAtICdfQVBQX1JFRElTX1BPUlQ9JHtfQVBQX1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICAtICdfQVBQX1JFRElTX1VTRVI9JHtfQVBQX1JFRElTX1VTRVJ9JwogICAgICAtICdfQVBQX1JFRElTX1BBU1M9JHtfQVBQX1JFRElTX1BBU1N9JwogICAgICAtICdfQVBQX1VTQUdFX1NUQVRTPSR7X0FQUF9VU0FHRV9TVEFUUzotZW5hYmxlZH0nCiAgICAgIC0gJ19BUFBfTE9HR0lOR19DT05GSUc9JHtfQVBQX0xPR0dJTkdfQ09ORklHfScKICAgICAgLSAnX0FQUF9EQVRBQkFTRV9TSEFSRURfVEFCTEVTPSR7X0FQUF9EQVRBQkFTRV9TSEFSRURfVEFCTEVTfScKICAgICAgLSAnX0FQUF9TVEFUU19SRVNPVVJDRVNfSU5URVJWQUw9JHtfQVBQX1NUQVRTX1JFU09VUkNFU19JTlRFUlZBTH0nCiAgYXBwd3JpdGUtd29ya2VyLXN0YXRzLXJlc291cmNlczoKICAgIGltYWdlOiAnYXBwd3JpdGUvYXBwd3JpdGU6MS43LjQnCiAgICBlbnRyeXBvaW50OiB3b3JrZXItc3RhdHMtcmVzb3VyY2VzCiAgICBjb250YWluZXJfbmFtZTogYXBwd3JpdGUtd29ya2VyLXN0YXRzLXJlc291cmNlcwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcHB3cml0ZS1yZWRpcwogICAgICAtIGFwcHdyaXRlLW1hcmlhZGIKICAgIGVudmlyb25tZW50OgogICAgICAtICdfQVBQX0VOVj0ke19BUFBfRU5WOi1wcm9kdWN0aW9ufScKICAgICAgLSAnX0FQUF9XT1JLRVJfUEVSX0NPUkU9JHtfQVBQX1dPUktFUl9QRVJfQ09SRTotNn0nCiAgICAgIC0gX0FQUF9PUEVOU1NMX0tFWV9WMT0kU0VSVklDRV9QQVNTV09SRF82NF9BUFBXUklURQogICAgICAtICdfQVBQX0RCX0hPU1Q9JHtfQVBQX0RCX0hPU1Q6LWFwcHdyaXRlLW1hcmlhZGJ9JwogICAgICAtICdfQVBQX0RCX1BPUlQ9JHtfQVBQX0RCX1BPUlQ6LTMzMDZ9JwogICAgICAtICdfQVBQX0RCX1NDSEVNQT0ke19BUFBfREJfU0NIRU1BOi1hcHB3cml0ZX0nCiAgICAgIC0gX0FQUF9EQl9VU0VSPSRTRVJWSUNFX1VTRVJfTUFSSUFEQgogICAgICAtIF9BUFBfREJfUEFTUz0kU0VSVklDRV9QQVNTV09SRF9NQVJJQURCCiAgICAgIC0gJ19BUFBfUkVESVNfSE9TVD0ke19BUFBfUkVESVNfSE9TVDotYXBwd3JpdGUtcmVkaXN9JwogICAgICAtICdfQVBQX1JFRElTX1BPUlQ9JHtfQVBQX1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICAtICdfQVBQX1JFRElTX1VTRVI9JHtfQVBQX1JFRElTX1VTRVJ9JwogICAgICAtICdfQVBQX1JFRElTX1BBU1M9JHtfQVBQX1JFRElTX1BBU1N9JwogICAgICAtICdfQVBQX1VTQUdFX1NUQVRTPSR7X0FQUF9VU0FHRV9TVEFUUzotZW5hYmxlZH0nCiAgICAgIC0gJ19BUFBfTE9HR0lOR19DT05GSUc9JHtfQVBQX0xPR0dJTkdfQ09ORklHfScKICAgICAgLSAnX0FQUF9TVEFUU19SRVNPVVJDRVNfSU5URVJWQUw9JHtfQVBQX1NUQVRTX1JFU09VUkNFU19JTlRFUlZBTH0nCiAgYXBwd3JpdGUtd29ya2VyLXN0YXRzLXVzYWdlOgogICAgaW1hZ2U6ICdhcHB3cml0ZS9hcHB3cml0ZToxLjcuNCcKICAgIGVudHJ5cG9pbnQ6IHdvcmtlci1zdGF0cy11c2FnZQogICAgY29udGFpbmVyX25hbWU6IGFwcHdyaXRlLXdvcmtlci1zdGF0cy11c2FnZQogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcHB3cml0ZS1yZWRpcwogICAgICAtIGFwcHdyaXRlLW1hcmlhZGIKICAgIGVudmlyb25tZW50OgogICAgICAtICdfQVBQX0VOVj0ke19BUFBfRU5WOi1wcm9kdWN0aW9ufScKICAgICAgLSAnX0FQUF9XT1JLRVJfUEVSX0NPUkU9JHtfQVBQX1dPUktFUl9QRVJfQ09SRTotNn0nCiAgICAgIC0gX0FQUF9PUEVOU1NMX0tFWV9WMT0kU0VSVklDRV9QQVNTV09SRF82NF9BUFBXUklURQogICAgICAtICdfQVBQX0RCX0hPU1Q9JHtfQVBQX0RCX0hPU1Q6LWFwcHdyaXRlLW1hcmlhZGJ9JwogICAgICAtICdfQVBQX0RCX1BPUlQ9JHtfQVBQX0RCX1BPUlQ6LTMzMDZ9JwogICAgICAtICdfQVBQX0RCX1NDSEVNQT0ke19BUFBfREJfU0NIRU1BOi1hcHB3cml0ZX0nCiAgICAgIC0gX0FQUF9EQl9VU0VSPSRTRVJWSUNFX1VTRVJfTUFSSUFEQgogICAgICAtIF9BUFBfREJfUEFTUz0kU0VSVklDRV9QQVNTV09SRF9NQVJJQURCCiAgICAgIC0gJ19BUFBfUkVESVNfSE9TVD0ke19BUFBfUkVESVNfSE9TVDotYXBwd3JpdGUtcmVkaXN9JwogICAgICAtICdfQVBQX1JFRElTX1BPUlQ9JHtfQVBQX1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICAtICdfQVBQX1JFRElTX1VTRVI9JHtfQVBQX1JFRElTX1VTRVJ9JwogICAgICAtICdfQVBQX1JFRElTX1BBU1M9JHtfQVBQX1JFRElTX1BBU1N9JwogICAgICAtICdfQVBQX1VTQUdFX1NUQVRTPSR7X0FQUF9VU0FHRV9TVEFUUzotZW5hYmxlZH0nCiAgICAgIC0gJ19BUFBfTE9HR0lOR19DT05GSUc9JHtfQVBQX0xPR0dJTkdfQ09ORklHfScKICAgICAgLSAnX0FQUF9VU0FHRV9BR0dSRUdBVElPTl9JTlRFUlZBTD0ke19BUFBfVVNBR0VfQUdHUkVHQVRJT05fSU5URVJWQUw6LTMwfScKICAgICAgLSAnX0FQUF9EQVRBQkFTRV9TSEFSRURfVEFCTEVTPSR7X0FQUF9EQVRBQkFTRV9TSEFSRURfVEFCTEVTfScKICBhcHB3cml0ZS10YXNrLXNjaGVkdWxlci1mdW5jdGlvbnM6CiAgICBpbWFnZTogJ2FwcHdyaXRlL2FwcHdyaXRlOjEuNy40JwogICAgZW50cnlwb2ludDogc2NoZWR1bGUtZnVuY3Rpb25zCiAgICBjb250YWluZXJfbmFtZTogYXBwd3JpdGUtdGFzay1zY2hlZHVsZXItZnVuY3Rpb25zCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwcHdyaXRlLW1hcmlhZGIKICAgICAgLSBhcHB3cml0ZS1yZWRpcwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ19BUFBfRU5WPSR7X0FQUF9FTlY6LXByb2R1Y3Rpb259JwogICAgICAtICdfQVBQX1dPUktFUl9QRVJfQ09SRT0ke19BUFBfV09SS0VSX1BFUl9DT1JFOi02fScKICAgICAgLSBfQVBQX09QRU5TU0xfS0VZX1YxPSRTRVJWSUNFX1BBU1NXT1JEXzY0X0FQUFdSSVRFCiAgICAgIC0gJ19BUFBfUkVESVNfSE9TVD0ke19BUFBfUkVESVNfSE9TVDotYXBwd3JpdGUtcmVkaXN9JwogICAgICAtICdfQVBQX1JFRElTX1BPUlQ9JHtfQVBQX1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICAtICdfQVBQX1JFRElTX1VTRVI9JHtfQVBQX1JFRElTX1VTRVJ9JwogICAgICAtICdfQVBQX1JFRElTX1BBU1M9JHtfQVBQX1JFRElTX1BBU1N9JwogICAgICAtICdfQVBQX0RCX0hPU1Q9JHtfQVBQX0RCX0hPU1Q6LWFwcHdyaXRlLW1hcmlhZGJ9JwogICAgICAtICdfQVBQX0RCX1BPUlQ9JHtfQVBQX0RCX1BPUlQ6LTMzMDZ9JwogICAgICAtICdfQVBQX0RCX1NDSEVNQT0ke19BUFBfREJfU0NIRU1BOi1hcHB3cml0ZX0nCiAgICAgIC0gX0FQUF9EQl9VU0VSPSRTRVJWSUNFX1VTRVJfTUFSSUFEQgogICAgICAtIF9BUFBfREJfUEFTUz0kU0VSVklDRV9QQVNTV09SRF9NQVJJQURCCiAgICAgIC0gJ19BUFBfREFUQUJBU0VfU0hBUkVEX1RBQkxFUz0ke19BUFBfREFUQUJBU0VfU0hBUkVEX1RBQkxFU30nCiAgYXBwd3JpdGUtdGFzay1zY2hlZHVsZXItZXhlY3V0aW9uczoKICAgIGltYWdlOiAnYXBwd3JpdGUvYXBwd3JpdGU6MS43LjQnCiAgICBlbnRyeXBvaW50OiBzY2hlZHVsZS1leGVjdXRpb25zCiAgICBjb250YWluZXJfbmFtZTogYXBwd3JpdGUtdGFzay1zY2hlZHVsZXItZXhlY3V0aW9ucwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcHB3cml0ZS1tYXJpYWRiCiAgICAgIC0gYXBwd3JpdGUtcmVkaXMKICAgIGVudmlyb25tZW50OgogICAgICAtICdfQVBQX0VOVj0ke19BUFBfRU5WOi1wcm9kdWN0aW9ufScKICAgICAgLSAnX0FQUF9XT1JLRVJfUEVSX0NPUkU9JHtfQVBQX1dPUktFUl9QRVJfQ09SRTotNn0nCiAgICAgIC0gX0FQUF9PUEVOU1NMX0tFWV9WMT0kU0VSVklDRV9QQVNTV09SRF82NF9BUFBXUklURQogICAgICAtICdfQVBQX1JFRElTX0hPU1Q9JHtfQVBQX1JFRElTX0hPU1Q6LWFwcHdyaXRlLXJlZGlzfScKICAgICAgLSAnX0FQUF9SRURJU19QT1JUPSR7X0FQUF9SRURJU19QT1JUOi02Mzc5fScKICAgICAgLSAnX0FQUF9SRURJU19VU0VSPSR7X0FQUF9SRURJU19VU0VSfScKICAgICAgLSAnX0FQUF9SRURJU19QQVNTPSR7X0FQUF9SRURJU19QQVNTfScKICAgICAgLSAnX0FQUF9EQl9IT1NUPSR7X0FQUF9EQl9IT1NUOi1hcHB3cml0ZS1tYXJpYWRifScKICAgICAgLSAnX0FQUF9EQl9QT1JUPSR7X0FQUF9EQl9QT1JUOi0zMzA2fScKICAgICAgLSAnX0FQUF9EQl9TQ0hFTUE9JHtfQVBQX0RCX1NDSEVNQTotYXBwd3JpdGV9JwogICAgICAtIF9BUFBfREJfVVNFUj0kU0VSVklDRV9VU0VSX01BUklBREIKICAgICAgLSBfQVBQX0RCX1BBU1M9JFNFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQgogICAgICAtICdfQVBQX0RBVEFCQVNFX1NIQVJFRF9UQUJMRVM9JHtfQVBQX0RBVEFCQVNFX1NIQVJFRF9UQUJMRVN9JwogIGFwcHdyaXRlLXRhc2stc2NoZWR1bGVyLW1lc3NhZ2VzOgogICAgaW1hZ2U6ICdhcHB3cml0ZS9hcHB3cml0ZToxLjcuNCcKICAgIGVudHJ5cG9pbnQ6IHNjaGVkdWxlLW1lc3NhZ2VzCiAgICBjb250YWluZXJfbmFtZTogYXBwd3JpdGUtdGFzay1zY2hlZHVsZXItbWVzc2FnZXMKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBwd3JpdGUtbWFyaWFkYgogICAgICAtIGFwcHdyaXRlLXJlZGlzCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnX0FQUF9FTlY9JHtfQVBQX0VOVjotcHJvZHVjdGlvbn0nCiAgICAgIC0gJ19BUFBfV09SS0VSX1BFUl9DT1JFPSR7X0FQUF9XT1JLRVJfUEVSX0NPUkU6LTZ9JwogICAgICAtIF9BUFBfT1BFTlNTTF9LRVlfVjE9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfQVBQV1JJVEUKICAgICAgLSAnX0FQUF9SRURJU19IT1NUPSR7X0FQUF9SRURJU19IT1NUOi1hcHB3cml0ZS1yZWRpc30nCiAgICAgIC0gJ19BUFBfUkVESVNfUE9SVD0ke19BUFBfUkVESVNfUE9SVDotNjM3OX0nCiAgICAgIC0gJ19BUFBfUkVESVNfVVNFUj0ke19BUFBfUkVESVNfVVNFUn0nCiAgICAgIC0gJ19BUFBfUkVESVNfUEFTUz0ke19BUFBfUkVESVNfUEFTU30nCiAgICAgIC0gJ19BUFBfREJfSE9TVD0ke19BUFBfREJfSE9TVDotYXBwd3JpdGUtbWFyaWFkYn0nCiAgICAgIC0gJ19BUFBfREJfUE9SVD0ke19BUFBfREJfUE9SVDotMzMwNn0nCiAgICAgIC0gJ19BUFBfREJfU0NIRU1BPSR7X0FQUF9EQl9TQ0hFTUE6LWFwcHdyaXRlfScKICAgICAgLSBfQVBQX0RCX1VTRVI9JFNFUlZJQ0VfVVNFUl9NQVJJQURCCiAgICAgIC0gX0FQUF9EQl9QQVNTPSRTRVJWSUNFX1BBU1NXT1JEX01BUklBREIKICAgICAgLSAnX0FQUF9EQVRBQkFTRV9TSEFSRURfVEFCTEVTPSR7X0FQUF9EQVRBQkFTRV9TSEFSRURfVEFCTEVTfScKICBhcHB3cml0ZS1hc3Npc3RhbnQ6CiAgICBpbWFnZTogJ2FwcHdyaXRlL2Fzc2lzdGFudDowLjguMycKICAgIGNvbnRhaW5lcl9uYW1lOiBhcHB3cml0ZS1hc3Npc3RhbnQKICAgIGVudmlyb25tZW50OgogICAgICAtICdfQVBQX0FTU0lTVEFOVF9PUEVOQUlfQVBJX0tFWT0ke19BUFBfQVNTSVNUQU5UX09QRU5BSV9BUElfS0VZfScKICBhcHB3cml0ZS1icm93c2VyOgogICAgaW1hZ2U6ICdhcHB3cml0ZS9icm93c2VyOjAuMi40JwogICAgY29udGFpbmVyX25hbWU6IGFwcHdyaXRlLWJyb3dzZXIKICAgIGhvc3RuYW1lOiBhcHB3cml0ZS1icm93c2VyCiAgb3BlbnJ1bnRpbWVzLWV4ZWN1dG9yOgogICAgY29udGFpbmVyX25hbWU6IG9wZW5ydW50aW1lcy1leGVjdXRvcgogICAgaG9zdG5hbWU6IGFwcHdyaXRlLWV4ZWN1dG9yCiAgICBzdG9wX3NpZ25hbDogU0lHSU5UCiAgICBpbWFnZTogJ29wZW5ydW50aW1lcy9leGVjdXRvcjowLjguNicKICAgIG5ldHdvcmtzOgogICAgICAtIHJ1bnRpbWVzCiAgICB2b2x1bWVzOgogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jaycKICAgICAgLSAnYXBwd3JpdGUtYnVpbGRzOi9zdG9yYWdlL2J1aWxkczpydycKICAgICAgLSAnYXBwd3JpdGUtZnVuY3Rpb25zOi9zdG9yYWdlL2Z1bmN0aW9uczpydycKICAgICAgLSAnYXBwd3JpdGUtc2l0ZXM6L3N0b3JhZ2Uvc2l0ZXM6cncnCiAgICAgIC0gJy90bXA6L3RtcDpydycKICAgIGVudmlyb25tZW50OgogICAgICAtIE9QUl9FWEVDVVRPUl9JTUFHRV9QVUxMPWRpc2FibGVkCiAgICAgIC0gJ09QUl9FWEVDVVRPUl9JTkFDVElWRV9UUkVTSE9MRD0ke19BUFBfQ09NUFVURV9JTkFDVElWRV9USFJFU0hPTER9JwogICAgICAtICdPUFJfRVhFQ1VUT1JfTUFJTlRFTkFOQ0VfSU5URVJWQUw9JHtfQVBQX0NPTVBVVEVfTUFJTlRFTkFOQ0VfSU5URVJWQUx9JwogICAgICAtICdPUFJfRVhFQ1VUT1JfTkVUV09SSz0ke19BUFBfQ09NUFVURV9SVU5USU1FU19ORVRXT1JLOi1ydW50aW1lc30nCiAgICAgIC0gJ09QUl9FWEVDVVRPUl9ET0NLRVJfSFVCX1VTRVJOQU1FPSR7X0FQUF9ET0NLRVJfSFVCX1VTRVJOQU1FfScKICAgICAgLSAnT1BSX0VYRUNVVE9SX0RPQ0tFUl9IVUJfUEFTU1dPUkQ9JHtfQVBQX0RPQ0tFUl9IVUJfUEFTU1dPUkR9JwogICAgICAtICdPUFJfRVhFQ1VUT1JfRU5WPSR7X0FQUF9FTlY6LXByb2R1Y3Rpb259JwogICAgICAtICdPUFJfRVhFQ1VUT1JfUlVOVElNRVM9JHtfQVBQX0ZVTkNUSU9OU19SVU5USU1FU30sJHtfQVBQX1NJVEVTX1JVTlRJTUVTfScKICAgICAgLSBPUFJfRVhFQ1VUT1JfU0VDUkVUPSRTRVJWSUNFX1BBU1NXT1JEXzY0X0FQUFdSSVRFCiAgICAgIC0gT1BSX0VYRUNVVE9SX1JVTlRJTUVfVkVSU0lPTlM9djUKICAgICAgLSAnT1BSX0VYRUNVVE9SX0xPR0dJTkdfQ09ORklHPSR7X0FQUF9MT0dHSU5HX0NPTkZJR30nCiAgICAgIC0gJ09QUl9FWEVDVVRPUl9TVE9SQUdFX0RFVklDRT0ke19BUFBfU1RPUkFHRV9ERVZJQ0U6LWxvY2FsfScKICAgICAgLSAnT1BSX0VYRUNVVE9SX1NUT1JBR0VfUzNfQUNDRVNTX0tFWT0ke19BUFBfU1RPUkFHRV9TM19BQ0NFU1NfS0VZfScKICAgICAgLSAnT1BSX0VYRUNVVE9SX1NUT1JBR0VfUzNfU0VDUkVUPSR7X0FQUF9TVE9SQUdFX1MzX1NFQ1JFVH0nCiAgICAgIC0gJ09QUl9FWEVDVVRPUl9TVE9SQUdFX1MzX1JFR0lPTj0ke19BUFBfU1RPUkFHRV9TM19SRUdJT059JwogICAgICAtICdPUFJfRVhFQ1VUT1JfU1RPUkFHRV9TM19CVUNLRVQ9JHtfQVBQX1NUT1JBR0VfUzNfQlVDS0VUfScKICAgICAgLSAnT1BSX0VYRUNVVE9SX1NUT1JBR0VfUzNfRU5EUE9JTlQ9JHtfQVBQX1NUT1JBR0VfUzNfRU5EUE9JTlR9JwogICAgICAtICdPUFJfRVhFQ1VUT1JfU1RPUkFHRV9ET19TUEFDRVNfQUNDRVNTX0tFWT0ke19BUFBfU1RPUkFHRV9ET19TUEFDRVNfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ09QUl9FWEVDVVRPUl9TVE9SQUdFX0RPX1NQQUNFU19TRUNSRVQ9JHtfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX1NFQ1JFVH0nCiAgICAgIC0gJ09QUl9FWEVDVVRPUl9TVE9SQUdFX0RPX1NQQUNFU19SRUdJT049JHtfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX1JFR0lPTn0nCiAgICAgIC0gJ09QUl9FWEVDVVRPUl9TVE9SQUdFX0RPX1NQQUNFU19CVUNLRVQ9JHtfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX0JVQ0tFVH0nCiAgICAgIC0gJ09QUl9FWEVDVVRPUl9TVE9SQUdFX0JBQ0tCTEFaRV9BQ0NFU1NfS0VZPSR7X0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9BQ0NFU1NfS0VZfScKICAgICAgLSAnT1BSX0VYRUNVVE9SX1NUT1JBR0VfQkFDS0JMQVpFX1NFQ1JFVD0ke19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfU0VDUkVUfScKICAgICAgLSAnT1BSX0VYRUNVVE9SX1NUT1JBR0VfQkFDS0JMQVpFX1JFR0lPTj0ke19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfUkVHSU9OfScKICAgICAgLSAnT1BSX0VYRUNVVE9SX1NUT1JBR0VfQkFDS0JMQVpFX0JVQ0tFVD0ke19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfQlVDS0VUfScKICAgICAgLSAnT1BSX0VYRUNVVE9SX1NUT1JBR0VfTElOT0RFX0FDQ0VTU19LRVk9JHtfQVBQX1NUT1JBR0VfTElOT0RFX0FDQ0VTU19LRVl9JwogICAgICAtICdPUFJfRVhFQ1VUT1JfU1RPUkFHRV9MSU5PREVfU0VDUkVUPSR7X0FQUF9TVE9SQUdFX0xJTk9ERV9TRUNSRVR9JwogICAgICAtICdPUFJfRVhFQ1VUT1JfU1RPUkFHRV9MSU5PREVfUkVHSU9OPSR7X0FQUF9TVE9SQUdFX0xJTk9ERV9SRUdJT059JwogICAgICAtICdPUFJfRVhFQ1VUT1JfU1RPUkFHRV9MSU5PREVfQlVDS0VUPSR7X0FQUF9TVE9SQUdFX0xJTk9ERV9CVUNLRVR9JwogICAgICAtICdPUFJfRVhFQ1VUT1JfU1RPUkFHRV9XQVNBQklfQUNDRVNTX0tFWT0ke19BUFBfU1RPUkFHRV9XQVNBQklfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ09QUl9FWEVDVVRPUl9TVE9SQUdFX1dBU0FCSV9TRUNSRVQ9JHtfQVBQX1NUT1JBR0VfV0FTQUJJX1NFQ1JFVH0nCiAgICAgIC0gJ09QUl9FWEVDVVRPUl9TVE9SQUdFX1dBU0FCSV9SRUdJT049JHtfQVBQX1NUT1JBR0VfV0FTQUJJX1JFR0lPTn0nCiAgICAgIC0gJ09QUl9FWEVDVVRPUl9TVE9SQUdFX1dBU0FCSV9CVUNLRVQ9JHtfQVBQX1NUT1JBR0VfV0FTQUJJX0JVQ0tFVH0nCiAgYXBwd3JpdGUtbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMC4xMScKICAgIGNvbnRhaW5lcl9uYW1lOiBhcHB3cml0ZS1tYXJpYWRiCiAgICB2b2x1bWVzOgogICAgICAtICdhcHB3cml0ZS1tYXJpYWRiOi92YXIvbGliL215c3FsOnJ3JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTVlTUUxfUk9PVF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NQVJJQURCUk9PVAogICAgICAtICdNWVNRTF9EQVRBQkFTRT0ke19BUFBfREJfU0NIRU1BOi1hcHB3cml0ZX0nCiAgICAgIC0gTVlTUUxfVVNFUj0kU0VSVklDRV9VU0VSX01BUklBREIKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NQVJJQURCCiAgICAgIC0gTUFSSUFEQl9BVVRPX1VQR1JBREU9MQogICAgY29tbWFuZDogJ215c3FsZCAtLWlubm9kYi1mbHVzaC1tZXRob2Q9ZnN5bmMnCiAgYXBwd3JpdGUtcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjcuMi40LWFscGluZScKICAgIGNvbnRhaW5lcl9uYW1lOiBhcHB3cml0ZS1yZWRpcwogICAgY29tbWFuZDogInJlZGlzLXNlcnZlciAtLW1heG1lbW9yeSAgICAgICAgICAgIDUxMm1iIC0tbWF4bWVtb3J5LXBvbGljeSAgICAgYWxsa2V5cy1scnUgLS1tYXhtZW1vcnktc2FtcGxlcyAgICA1XG4iCiAgICB2b2x1bWVzOgogICAgICAtICdhcHB3cml0ZS1yZWRpczovZGF0YTpydycKbmV0d29ya3M6CiAgcnVudGltZXM6CiAgICBuYW1lOiBydW50aW1lcwp2b2x1bWVzOgogIGFwcHdyaXRlLW1hcmlhZGI6IG51bGwKICBhcHB3cml0ZS1yZWRpczogbnVsbAogIGFwcHdyaXRlLWNhY2hlOiBudWxsCiAgYXBwd3JpdGUtdXBsb2FkczogbnVsbAogIGFwcHdyaXRlLWltcG9ydHM6IG51bGwKICBhcHB3cml0ZS1jZXJ0aWZpY2F0ZXM6IG51bGwKICBhcHB3cml0ZS1mdW5jdGlvbnM6IG51bGwKICBhcHB3cml0ZS1zaXRlczogbnVsbAogIGFwcHdyaXRlLWJ1aWxkczogbnVsbAogIGFwcHdyaXRlLWNvbmZpZzogbnVsbAo=", "tags": [ "backend", "backend-as-a-service", @@ -1307,7 +1307,7 @@ "getoutline": { "documentation": "https://docs.getoutline.com/s/hosting/doc/hosting-outline-nipGaCRBDu?utm_source=coolify.io", "slogan": "Your team\u2019s knowledge base", - "compose": "c2VydmljZXM6CiAgb3V0bGluZToKICAgIGltYWdlOiAnZG9ja2VyLmdldG91dGxpbmUuY29tL291dGxpbmV3aWtpL291dGxpbmU6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnc3RvcmFnZS1kYXRhOi92YXIvbGliL291dGxpbmUvZGF0YScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9PVVRMSU5FXzMwMDAKICAgICAgLSBOT0RFX0VOVj1wcm9kdWN0aW9uCiAgICAgIC0gJ1NFQ1JFVF9LRVk9JHtTRVJWSUNFX0JBU0U2NF9PVVRMSU5FfScKICAgICAgLSAnVVRJTFNfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF82NF9PVVRMSU5FfScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF82NF9QT1NUR1JFU31AcG9zdGdyZXM6NTQzMi8ke1BPU1RHUkVTX0RBVEFCQVNFOi1vdXRsaW5lfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vOiR7U0VSVklDRV9QQVNTV09SRF82NF9SRURJU31AcmVkaXM6NjM3OScKICAgICAgLSAnVVJMPSR7U0VSVklDRV9VUkxfT1VUTElORV8zMDAwfScKICAgICAgLSAnUE9SVD0ke09VVExJTkVfUE9SVDotMzAwMH0nCiAgICAgIC0gJ0ZJTEVfU1RPUkFHRT0ke0ZJTEVfU1RPUkFHRTotbG9jYWx9JwogICAgICAtICdGSUxFX1NUT1JBR0VfTE9DQUxfUk9PVF9ESVI9JHtGSUxFX1NUT1JBR0VfTE9DQUxfUk9PVF9ESVI6LS92YXIvbGliL291dGxpbmUvZGF0YX0nCiAgICAgIC0gJ0ZJTEVfU1RPUkFHRV9VUExPQURfTUFYX1NJWkU9JHtGSUxFX1NUT1JBR0VfVVBMT0FEX01BWF9TSVpFOi0yMDAwfScKICAgICAgLSAnRklMRV9TVE9SQUdFX0lNUE9SVF9NQVhfU0laRT0ke0ZJTEVfU1RPUkFHRV9JTVBPUlRfTUFYX1NJWkU6LTEwMH0nCiAgICAgIC0gJ0ZJTEVfU1RPUkFHRV9XT1JLU1BBQ0VfSU1QT1JUX01BWF9TSVpFPSR7RklMRV9TVE9SQUdFX1dPUktTUEFDRV9JTVBPUlRfTUFYX1NJWkV9JwogICAgICAtICdBV1NfQUNDRVNTX0tFWV9JRD0ke0FXU19BQ0NFU1NfS0VZX0lEfScKICAgICAgLSAnQVdTX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7QVdTX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAgICAgLSAnQVdTX1JFR0lPTj0ke0FXU19SRUdJT059JwogICAgICAtICdBV1NfUzNfQUNDRUxFUkFURV9VUkw9JHtBV1NfUzNfQUNDRUxFUkFURV9VUkx9JwogICAgICAtICdBV1NfUzNfVVBMT0FEX0JVQ0tFVF9VUkw9JHtBV1NfUzNfVVBMT0FEX0JVQ0tFVF9VUkx9JwogICAgICAtICdBV1NfUzNfVVBMT0FEX0JVQ0tFVF9OQU1FPSR7QVdTX1MzX1VQTE9BRF9CVUNLRVRfTkFNRX0nCiAgICAgIC0gJ0FXU19TM19GT1JDRV9QQVRIX1NUWUxFPSR7QVdTX1MzX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogICAgICAtICdBV1NfUzNfQUNMPSR7QVdTX1MzX0FDTDotcHJpdmF0ZX0nCiAgICAgIC0gJ1NMQUNLX0NMSUVOVF9JRD0ke1NMQUNLX0NMSUVOVF9JRH0nCiAgICAgIC0gJ1NMQUNLX0NMSUVOVF9TRUNSRVQ9JHtTTEFDS19DTElFTlRfU0VDUkVUfScKICAgICAgLSAnR09PR0xFX0NMSUVOVF9JRD0ke0dPT0dMRV9DTElFTlRfSUR9JwogICAgICAtICdHT09HTEVfQ0xJRU5UX1NFQ1JFVD0ke0dPT0dMRV9DTElFTlRfU0VDUkVUfScKICAgICAgLSAnQVpVUkVfQ0xJRU5UX0lEPSR7QVpVUkVfQ0xJRU5UX0lEfScKICAgICAgLSAnQVpVUkVfQ0xJRU5UX1NFQ1JFVD0ke0FaVVJFX0NMSUVOVF9TRUNSRVR9JwogICAgICAtICdBWlVSRV9SRVNPVVJDRV9BUFBfSUQ9JHtBWlVSRV9SRVNPVVJDRV9BUFBfSUR9JwogICAgICAtICdPSURDX0NMSUVOVF9JRD0ke09JRENfQ0xJRU5UX0lEfScKICAgICAgLSAnT0lEQ19DTElFTlRfU0VDUkVUPSR7T0lEQ19DTElFTlRfU0VDUkVUfScKICAgICAgLSAnT0lEQ19BVVRIX1VSST0ke09JRENfQVVUSF9VUkl9JwogICAgICAtICdPSURDX1RPS0VOX1VSST0ke09JRENfVE9LRU5fVVJJfScKICAgICAgLSAnT0lEQ19VU0VSSU5GT19VUkk9JHtPSURDX1VTRVJJTkZPX1VSSX0nCiAgICAgIC0gJ09JRENfTE9HT1VUX1VSST0ke09JRENfTE9HT1VUX1VSSX0nCiAgICAgIC0gJ09JRENfVVNFUk5BTUVfQ0xBSU09JHtPSURDX1VTRVJOQU1FX0NMQUlNfScKICAgICAgLSAnT0lEQ19ESVNQTEFZX05BTUU9JHtPSURDX0RJU1BMQVlfTkFNRX0nCiAgICAgIC0gJ09JRENfU0NPUEVTPSR7T0lEQ19TQ09QRVN9JwogICAgICAtICdHSVRIVUJfQ0xJRU5UX0lEPSR7R0lUSFVCX0NMSUVOVF9JRH0nCiAgICAgIC0gJ0dJVEhVQl9DTElFTlRfU0VDUkVUPSR7R0lUSFVCX0NMSUVOVF9TRUNSRVR9JwogICAgICAtICdHSVRIVUJfQVBQX05BTUU9JHtHSVRIVUJfQVBQX05BTUV9JwogICAgICAtICdHSVRIVUJfQVBQX0lEPSR7R0lUSFVCX0FQUF9JRH0nCiAgICAgIC0gJ0dJVEhVQl9BUFBfUFJJVkFURV9LRVk9JHtHSVRIVUJfQVBQX1BSSVZBVEVfS0VZfScKICAgICAgLSAnRElTQ09SRF9DTElFTlRfSUQ9JHtESVNDT1JEX0NMSUVOVF9JRH0nCiAgICAgIC0gJ0RJU0NPUkRfQ0xJRU5UX1NFQ1JFVD0ke0RJU0NPUkRfQ0xJRU5UX1NFQ1JFVH0nCiAgICAgIC0gJ0RJU0NPUkRfU0VSVkVSX0lEPSR7RElTQ09SRF9TRVJWRVJfSUR9JwogICAgICAtICdESVNDT1JEX1NFUlZFUl9ST0xFUz0ke0RJU0NPUkRfU0VSVkVSX1JPTEVTfScKICAgICAgLSAnUEdTU0xNT0RFPSR7UEdTU0xNT0RFOi1kaXNhYmxlfScKICAgICAgLSAnRk9SQ0VfSFRUUFM9JHtGT1JDRV9IVFRQUzotdHJ1ZX0nCiAgICAgIC0gJ1NNVFBfSE9TVD0ke1NNVFBfSE9TVH0nCiAgICAgIC0gJ1NNVFBfUE9SVD0ke1NNVFBfUE9SVH0nCiAgICAgIC0gJ1NNVFBfVVNFUk5BTUU9JHtTTVRQX1VTRVJOQU1FfScKICAgICAgLSAnU01UUF9QQVNTV09SRD0ke1NNVFBfUEFTU1dPUkR9JwogICAgICAtICdTTVRQX0ZST01fRU1BSUw9JHtTTVRQX0ZST01fRU1BSUx9JwogICAgICAtICdTTVRQX1JFUExZX0VNQUlMPSR7U01UUF9SRVBMWV9FTUFJTH0nCiAgICAgIC0gJ1NNVFBfVExTX0NJUEhFUlM9JHtTTVRQX1RMU19DSVBIRVJTfScKICAgICAgLSAnU01UUF9TRUNVUkU9JHtTTVRQX1NFQ1VSRX0nCiAgICAgIC0gJ1NNVFBfTkFNRT0ke1NNVFBfTkFNRX0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgZGlzYWJsZTogdHJ1ZQogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczphbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUkVESVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEXzY0X1JFRElTfScKICAgIGNvbW1hbmQ6CiAgICAgIC0gcmVkaXMtc2VydmVyCiAgICAgIC0gJy0tcmVxdWlyZXBhc3MnCiAgICAgIC0gJyR7U0VSVklDRV9QQVNTV09SRF82NF9SRURJU30nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSAnLWEnCiAgICAgICAgLSAnJHtTRVJWSUNFX1BBU1NXT1JEXzY0X1JFRElTfScKICAgICAgICAtIFBJTkcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiAzMHMKICAgICAgcmV0cmllczogMwogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxMi1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdkYXRhYmFzZS1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEXzY0X1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQVRBQkFTRTotb3V0bGluZX0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcGdfaXNyZWFkeQogICAgICAgIC0gJy1VJwogICAgICAgIC0gJyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgICAtICctZCcKICAgICAgICAtICcke1BPU1RHUkVTX0RBVEFCQVNFOi1vdXRsaW5lfScKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMwo=", + "compose": "c2VydmljZXM6CiAgb3V0bGluZToKICAgIGltYWdlOiAnZG9ja2VyLmdldG91dGxpbmUuY29tL291dGxpbmV3aWtpL291dGxpbmU6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnc3RvcmFnZS1kYXRhOi92YXIvbGliL291dGxpbmUvZGF0YScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9PVVRMSU5FXzMwMDAKICAgICAgLSBOT0RFX0VOVj1wcm9kdWN0aW9uCiAgICAgIC0gJ1NFQ1JFVF9LRVk9JHtTRVJWSUNFX0hFWF8zMl9PVVRMSU5FfScKICAgICAgLSAnVVRJTFNfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF82NF9PVVRMSU5FfScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF82NF9QT1NUR1JFU31AcG9zdGdyZXM6NTQzMi8ke1BPU1RHUkVTX0RBVEFCQVNFOi1vdXRsaW5lfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vOiR7U0VSVklDRV9QQVNTV09SRF82NF9SRURJU31AcmVkaXM6NjM3OScKICAgICAgLSAnVVJMPSR7U0VSVklDRV9VUkxfT1VUTElORV8zMDAwfScKICAgICAgLSAnUE9SVD0ke09VVExJTkVfUE9SVDotMzAwMH0nCiAgICAgIC0gJ0ZJTEVfU1RPUkFHRT0ke0ZJTEVfU1RPUkFHRTotbG9jYWx9JwogICAgICAtICdGSUxFX1NUT1JBR0VfTE9DQUxfUk9PVF9ESVI9JHtGSUxFX1NUT1JBR0VfTE9DQUxfUk9PVF9ESVI6LS92YXIvbGliL291dGxpbmUvZGF0YX0nCiAgICAgIC0gJ0ZJTEVfU1RPUkFHRV9VUExPQURfTUFYX1NJWkU9JHtGSUxFX1NUT1JBR0VfVVBMT0FEX01BWF9TSVpFOi0yMDAwfScKICAgICAgLSAnRklMRV9TVE9SQUdFX0lNUE9SVF9NQVhfU0laRT0ke0ZJTEVfU1RPUkFHRV9JTVBPUlRfTUFYX1NJWkU6LTEwMH0nCiAgICAgIC0gJ0ZJTEVfU1RPUkFHRV9XT1JLU1BBQ0VfSU1QT1JUX01BWF9TSVpFPSR7RklMRV9TVE9SQUdFX1dPUktTUEFDRV9JTVBPUlRfTUFYX1NJWkV9JwogICAgICAtICdBV1NfQUNDRVNTX0tFWV9JRD0ke0FXU19BQ0NFU1NfS0VZX0lEfScKICAgICAgLSAnQVdTX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7QVdTX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAgICAgLSAnQVdTX1JFR0lPTj0ke0FXU19SRUdJT059JwogICAgICAtICdBV1NfUzNfQUNDRUxFUkFURV9VUkw9JHtBV1NfUzNfQUNDRUxFUkFURV9VUkx9JwogICAgICAtICdBV1NfUzNfVVBMT0FEX0JVQ0tFVF9VUkw9JHtBV1NfUzNfVVBMT0FEX0JVQ0tFVF9VUkx9JwogICAgICAtICdBV1NfUzNfVVBMT0FEX0JVQ0tFVF9OQU1FPSR7QVdTX1MzX1VQTE9BRF9CVUNLRVRfTkFNRX0nCiAgICAgIC0gJ0FXU19TM19GT1JDRV9QQVRIX1NUWUxFPSR7QVdTX1MzX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogICAgICAtICdBV1NfUzNfQUNMPSR7QVdTX1MzX0FDTDotcHJpdmF0ZX0nCiAgICAgIC0gJ1NMQUNLX0NMSUVOVF9JRD0ke1NMQUNLX0NMSUVOVF9JRH0nCiAgICAgIC0gJ1NMQUNLX0NMSUVOVF9TRUNSRVQ9JHtTTEFDS19DTElFTlRfU0VDUkVUfScKICAgICAgLSAnR09PR0xFX0NMSUVOVF9JRD0ke0dPT0dMRV9DTElFTlRfSUR9JwogICAgICAtICdHT09HTEVfQ0xJRU5UX1NFQ1JFVD0ke0dPT0dMRV9DTElFTlRfU0VDUkVUfScKICAgICAgLSAnQVpVUkVfQ0xJRU5UX0lEPSR7QVpVUkVfQ0xJRU5UX0lEfScKICAgICAgLSAnQVpVUkVfQ0xJRU5UX1NFQ1JFVD0ke0FaVVJFX0NMSUVOVF9TRUNSRVR9JwogICAgICAtICdBWlVSRV9SRVNPVVJDRV9BUFBfSUQ9JHtBWlVSRV9SRVNPVVJDRV9BUFBfSUR9JwogICAgICAtICdPSURDX0NMSUVOVF9JRD0ke09JRENfQ0xJRU5UX0lEfScKICAgICAgLSAnT0lEQ19DTElFTlRfU0VDUkVUPSR7T0lEQ19DTElFTlRfU0VDUkVUfScKICAgICAgLSAnT0lEQ19BVVRIX1VSST0ke09JRENfQVVUSF9VUkl9JwogICAgICAtICdPSURDX1RPS0VOX1VSST0ke09JRENfVE9LRU5fVVJJfScKICAgICAgLSAnT0lEQ19VU0VSSU5GT19VUkk9JHtPSURDX1VTRVJJTkZPX1VSSX0nCiAgICAgIC0gJ09JRENfTE9HT1VUX1VSST0ke09JRENfTE9HT1VUX1VSSX0nCiAgICAgIC0gJ09JRENfVVNFUk5BTUVfQ0xBSU09JHtPSURDX1VTRVJOQU1FX0NMQUlNfScKICAgICAgLSAnT0lEQ19ESVNQTEFZX05BTUU9JHtPSURDX0RJU1BMQVlfTkFNRX0nCiAgICAgIC0gJ09JRENfU0NPUEVTPSR7T0lEQ19TQ09QRVN9JwogICAgICAtICdHSVRIVUJfQ0xJRU5UX0lEPSR7R0lUSFVCX0NMSUVOVF9JRH0nCiAgICAgIC0gJ0dJVEhVQl9DTElFTlRfU0VDUkVUPSR7R0lUSFVCX0NMSUVOVF9TRUNSRVR9JwogICAgICAtICdHSVRIVUJfQVBQX05BTUU9JHtHSVRIVUJfQVBQX05BTUV9JwogICAgICAtICdHSVRIVUJfQVBQX0lEPSR7R0lUSFVCX0FQUF9JRH0nCiAgICAgIC0gJ0dJVEhVQl9BUFBfUFJJVkFURV9LRVk9JHtHSVRIVUJfQVBQX1BSSVZBVEVfS0VZfScKICAgICAgLSAnRElTQ09SRF9DTElFTlRfSUQ9JHtESVNDT1JEX0NMSUVOVF9JRH0nCiAgICAgIC0gJ0RJU0NPUkRfQ0xJRU5UX1NFQ1JFVD0ke0RJU0NPUkRfQ0xJRU5UX1NFQ1JFVH0nCiAgICAgIC0gJ0RJU0NPUkRfU0VSVkVSX0lEPSR7RElTQ09SRF9TRVJWRVJfSUR9JwogICAgICAtICdESVNDT1JEX1NFUlZFUl9ST0xFUz0ke0RJU0NPUkRfU0VSVkVSX1JPTEVTfScKICAgICAgLSAnUEdTU0xNT0RFPSR7UEdTU0xNT0RFOi1kaXNhYmxlfScKICAgICAgLSAnRk9SQ0VfSFRUUFM9JHtGT1JDRV9IVFRQUzotdHJ1ZX0nCiAgICAgIC0gJ1NNVFBfSE9TVD0ke1NNVFBfSE9TVH0nCiAgICAgIC0gJ1NNVFBfUE9SVD0ke1NNVFBfUE9SVH0nCiAgICAgIC0gJ1NNVFBfVVNFUk5BTUU9JHtTTVRQX1VTRVJOQU1FfScKICAgICAgLSAnU01UUF9QQVNTV09SRD0ke1NNVFBfUEFTU1dPUkR9JwogICAgICAtICdTTVRQX0ZST01fRU1BSUw9JHtTTVRQX0ZST01fRU1BSUx9JwogICAgICAtICdTTVRQX1JFUExZX0VNQUlMPSR7U01UUF9SRVBMWV9FTUFJTH0nCiAgICAgIC0gJ1NNVFBfVExTX0NJUEhFUlM9JHtTTVRQX1RMU19DSVBIRVJTfScKICAgICAgLSAnU01UUF9TRUNVUkU9JHtTTVRQX1NFQ1VSRX0nCiAgICAgIC0gJ1NNVFBfTkFNRT0ke1NNVFBfTkFNRX0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgZGlzYWJsZTogdHJ1ZQogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczphbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUkVESVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEXzY0X1JFRElTfScKICAgIGNvbW1hbmQ6CiAgICAgIC0gcmVkaXMtc2VydmVyCiAgICAgIC0gJy0tcmVxdWlyZXBhc3MnCiAgICAgIC0gJyR7U0VSVklDRV9QQVNTV09SRF82NF9SRURJU30nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSAnLWEnCiAgICAgICAgLSAnJHtTRVJWSUNFX1BBU1NXT1JEXzY0X1JFRElTfScKICAgICAgICAtIFBJTkcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiAzMHMKICAgICAgcmV0cmllczogMwogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxMi1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdkYXRhYmFzZS1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEXzY0X1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQVRBQkFTRTotb3V0bGluZX0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcGdfaXNyZWFkeQogICAgICAgIC0gJy1VJwogICAgICAgIC0gJyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgICAtICctZCcKICAgICAgICAtICcke1BPU1RHUkVTX0RBVEFCQVNFOi1vdXRsaW5lfScKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMwo=", "tags": [ "knowledge base", "documentation" @@ -2443,6 +2443,27 @@ "minversion": "0.0.0", "port": "1883" }, + "n8n-with-postgres-and-worker": { + "documentation": "https://n8n.io?utm_source=coolify.io", + "slogan": "n8n is an extendable workflow automation tool with queue mode and workers.", + "compose": "c2VydmljZXM6CiAgbjhuOgogICAgaW1hZ2U6IGRvY2tlci5uOG4uaW8vbjhuaW8vbjhuCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9OOE5fNTY3OAogICAgICAtICdOOE5fRURJVE9SX0JBU0VfVVJMPSR7U0VSVklDRV9VUkxfTjhOfScKICAgICAgLSAnV0VCSE9PS19VUkw9JHtTRVJWSUNFX1VSTF9OOE59JwogICAgICAtICdOOE5fSE9TVD0ke1NFUlZJQ0VfVVJMX044Tn0nCiAgICAgIC0gJ0dFTkVSSUNfVElNRVpPTkU9JHtHRU5FUklDX1RJTUVaT05FOi1FdXJvcGUvQmVybGlufScKICAgICAgLSAnVFo9JHtUWjotRXVyb3BlL0Jlcmxpbn0nCiAgICAgIC0gREJfVFlQRT1wb3N0Z3Jlc2RiCiAgICAgIC0gJ0RCX1BPU1RHUkVTREJfREFUQUJBU0U9JHtQT1NUR1JFU19EQjotbjhufScKICAgICAgLSBEQl9QT1NUR1JFU0RCX0hPU1Q9cG9zdGdyZXNxbAogICAgICAtIERCX1BPU1RHUkVTREJfUE9SVD01NDMyCiAgICAgIC0gREJfUE9TVEdSRVNEQl9VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1NDSEVNQT1wdWJsaWMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gRVhFQ1VUSU9OU19NT0RFPXF1ZXVlCiAgICAgIC0gUVVFVUVfQlVMTF9SRURJU19IT1NUPXJlZGlzCiAgICAgIC0gUVVFVUVfSEVBTFRIX0NIRUNLX0FDVElWRT10cnVlCiAgICAgIC0gJ044Tl9FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfRU5DUllQVElPTn0nCiAgICAgIC0gTjhOX1JVTk5FUlNfRU5BQkxFRD10cnVlCiAgICAgIC0gT0ZGTE9BRF9NQU5VQUxfRVhFQ1VUSU9OU19UT19XT1JLRVJTPXRydWUKICAgICAgLSAnTjhOX0JMT0NLX0VOVl9BQ0NFU1NfSU5fTk9ERT0ke044Tl9CTE9DS19FTlZfQUNDRVNTX0lOX05PREU6LXRydWV9JwogICAgICAtICdOOE5fRU5GT1JDRV9TRVRUSU5HU19GSUxFX1BFUk1JU1NJT05TPSR7TjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUzotdHJ1ZX0nCiAgICB2b2x1bWVzOgogICAgICAtICduOG4tZGF0YTovaG9tZS9ub2RlLy5uOG4nCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3Jlc3FsOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjU2NzgvJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgbjhuLXdvcmtlcjoKICAgIGltYWdlOiBkb2NrZXIubjhuLmlvL244bmlvL244bgogICAgY29tbWFuZDogd29ya2VyCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnR0VORVJJQ19USU1FWk9ORT0ke0dFTkVSSUNfVElNRVpPTkU6LUV1cm9wZS9CZXJsaW59JwogICAgICAtICdUWj0ke1RaOi1FdXJvcGUvQmVybGlufScKICAgICAgLSBEQl9UWVBFPXBvc3RncmVzZGIKICAgICAgLSAnREJfUE9TVEdSRVNEQl9EQVRBQkFTRT0ke1BPU1RHUkVTX0RCOi1uOG59JwogICAgICAtIERCX1BPU1RHUkVTREJfSE9TVD1wb3N0Z3Jlc3FsCiAgICAgIC0gREJfUE9TVEdSRVNEQl9QT1JUPTU0MzIKICAgICAgLSBEQl9QT1NUR1JFU0RCX1VTRVI9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAtIERCX1BPU1RHUkVTREJfU0NIRU1BPXB1YmxpYwogICAgICAtIERCX1BPU1RHUkVTREJfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgLSBFWEVDVVRJT05TX01PREU9cXVldWUKICAgICAgLSBRVUVVRV9CVUxMX1JFRElTX0hPU1Q9cmVkaXMKICAgICAgLSBRVUVVRV9IRUFMVEhfQ0hFQ0tfQUNUSVZFPXRydWUKICAgICAgLSAnTjhOX0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9QQVNTV09SRF9FTkNSWVBUSU9OfScKICAgICAgLSBOOE5fUlVOTkVSU19FTkFCTEVEPXRydWUKICAgICAgLSAnTjhOX0JMT0NLX0VOVl9BQ0NFU1NfSU5fTk9ERT0ke044Tl9CTE9DS19FTlZfQUNDRVNTX0lOX05PREU6LXRydWV9JwogICAgICAtICdOOE5fRU5GT1JDRV9TRVRUSU5HU19GSUxFX1BFUk1JU1NJT05TPSR7TjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUzotdHJ1ZX0nCiAgICB2b2x1bWVzOgogICAgICAtICduOG4tZGF0YTovaG9tZS9ub2RlLy5uOG4nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjU2NzgvaGVhbHRoeicKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgZGVwZW5kc19vbjoKICAgICAgbjhuOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHBvc3RncmVzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICBwb3N0Z3Jlc3FsOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNi1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdwb3N0Z3Jlc3FsLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gUE9TVEdSRVNfVVNFUj0kU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIC0gUE9TVEdSRVNfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotbjhufScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6Ni1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdyZWRpcy1kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAK", + "tags": [ + "n8n", + "workflow", + "automation", + "open", + "source", + "low", + "code", + "queue", + "worker", + "scalable" + ], + "category": "automation", + "logo": "svgs/n8n.png", + "minversion": "0.0.0", + "port": "5678" + }, "n8n-with-postgresql": { "documentation": "https://n8n.io?utm_source=coolify.io", "slogan": "n8n is an extendable workflow automation tool.", diff --git a/templates/service-templates.json b/templates/service-templates.json index 19d5e0560..34154ad0f 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -101,7 +101,7 @@ "appwrite": { "documentation": "https://appwrite.io?utm_source=coolify.io", "slogan": "A backend-as-a-service platform that simplifies the web & mobile app development.", - "compose": "c2VydmljZXM6CiAgYXBwd3JpdGU6CiAgICBpbWFnZTogJ2FwcHdyaXRlL2FwcHdyaXRlOjEuNy40JwogICAgY29udGFpbmVyX25hbWU6IGFwcHdyaXRlCiAgICB2b2x1bWVzOgogICAgICAtICdhcHB3cml0ZS11cGxvYWRzOi9zdG9yYWdlL3VwbG9hZHM6cncnCiAgICAgIC0gJ2FwcHdyaXRlLWltcG9ydHM6L3N0b3JhZ2UvaW1wb3J0czpydycKICAgICAgLSAnYXBwd3JpdGUtY2FjaGU6L3N0b3JhZ2UvY2FjaGU6cncnCiAgICAgIC0gJ2FwcHdyaXRlLWNvbmZpZzovc3RvcmFnZS9jb25maWc6cncnCiAgICAgIC0gJ2FwcHdyaXRlLWNlcnRpZmljYXRlczovc3RvcmFnZS9jZXJ0aWZpY2F0ZXM6cncnCiAgICAgIC0gJ2FwcHdyaXRlLWZ1bmN0aW9uczovc3RvcmFnZS9mdW5jdGlvbnM6cncnCiAgICAgIC0gJ2FwcHdyaXRlLXNpdGVzOi9zdG9yYWdlL3NpdGVzOnJ3JwogICAgICAtICdhcHB3cml0ZS1idWlsZHM6L3N0b3JhZ2UvYnVpbGRzOnJ3JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcHB3cml0ZS1tYXJpYWRiCiAgICAgIC0gYXBwd3JpdGUtcmVkaXMKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9BUFBXUklURT0vCiAgICAgIC0gJ19BUFBfRU5WPSR7X0FQUF9FTlY6LXByb2R1Y3Rpb259JwogICAgICAtICdfQVBQX1dPUktFUl9QRVJfQ09SRT0ke19BUFBfV09SS0VSX1BFUl9DT1JFOi02fScKICAgICAgLSAnX0FQUF9MT0NBTEU9JHtfQVBQX0xPQ0FMRTotZW59JwogICAgICAtICdfQVBQX0NPTVBSRVNTSU9OX01JTl9TSVpFX0JZVEVTPSR7X0FQUF9DT01QUkVTU0lPTl9NSU5fU0laRV9CWVRFU30nCiAgICAgIC0gJ19BUFBfQ09OU09MRV9XSElURUxJU1RfUk9PVD0ke19BUFBfQ09OU09MRV9XSElURUxJU1RfUk9PVDotZW5hYmxlZH0nCiAgICAgIC0gJ19BUFBfQ09OU09MRV9XSElURUxJU1RfRU1BSUxTPSR7X0FQUF9DT05TT0xFX1dISVRFTElTVF9FTUFJTFN9JwogICAgICAtICdfQVBQX0NPTlNPTEVfU0VTU0lPTl9BTEVSVFM9JHtfQVBQX0NPTlNPTEVfU0VTU0lPTl9BTEVSVFN9JwogICAgICAtICdfQVBQX0NPTlNPTEVfV0hJVEVMSVNUX0lQUz0ke19BUFBfQ09OU09MRV9XSElURUxJU1RfSVBTfScKICAgICAgLSAnX0FQUF9DT05TT0xFX0hPU1ROQU1FUz0ke19BUFBfQ09OU09MRV9IT1NUTkFNRVN9JwogICAgICAtICdfQVBQX1NZU1RFTV9FTUFJTF9OQU1FPSR7X0FQUF9TWVNURU1fRU1BSUxfTkFNRTotQXBwd3JpdGV9JwogICAgICAtICdfQVBQX1NZU1RFTV9FTUFJTF9BRERSRVNTPSR7X0FQUF9TWVNURU1fRU1BSUxfQUREUkVTUzotdGVhbUBhcHB3cml0ZS5pb30nCiAgICAgIC0gJ19BUFBfU1lTVEVNX1RFQU1fRU1BSUw9JHtfQVBQX1NZU1RFTV9URUFNX0VNQUlMOi10ZWFtQGFwcHdyaXRlLmlvfScKICAgICAgLSAnX0FQUF9FTUFJTF9TRUNVUklUWT0ke19BUFBfRU1BSUxfU0VDVVJJVFk6LWNlcnRzQGFwcHdyaXRlLmlvfScKICAgICAgLSAnX0FQUF9TWVNURU1fUkVTUE9OU0VfRk9STUFUPSR7X0FQUF9TWVNURU1fUkVTUE9OU0VfRk9STUFUfScKICAgICAgLSAnX0FQUF9PUFRJT05TX0FCVVNFPSR7X0FQUF9PUFRJT05TX0FCVVNFOi1lbmFibGVkfScKICAgICAgLSAnX0FQUF9PUFRJT05TX1JPVVRFUl9QUk9URUNUSU9OPSR7X0FQUF9PUFRJT05TX1JPVVRFUl9QUk9URUNUSU9OOi1kaXNhYmxlZH0nCiAgICAgIC0gJ19BUFBfT1BUSU9OU19GT1JDRV9IVFRQUz0ke19BUFBfT1BUSU9OU19GT1JDRV9IVFRQUzotZGlzYWJsZWR9JwogICAgICAtICdfQVBQX09QVElPTlNfUk9VVEVSX0ZPUkNFX0hUVFBTPSR7X0FQUF9PUFRJT05TX1JPVVRFUl9GT1JDRV9IVFRQUzotZGlzYWJsZWR9JwogICAgICAtIF9BUFBfT1BFTlNTTF9LRVlfVjE9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfQVBQV1JJVEUKICAgICAgLSBfQVBQX0RPTUFJTj0kU0VSVklDRV9GUUROX0FQUFdSSVRFCiAgICAgIC0gJ19BUFBfRE9NQUlOX1RBUkdFVF9DTkFNRT0ke19BUFBfRE9NQUlOX1RBUkdFVF9DTkFNRTotbG9jYWxob3N0fScKICAgICAgLSAnX0FQUF9ET01BSU5fVEFSR0VUX0FBQUE9JHtfQVBQX0RPTUFJTl9UQVJHRVRfQUFBQTotOjoxfScKICAgICAgLSAnX0FQUF9ET01BSU5fVEFSR0VUX0E9JHtfQVBQX0RPTUFJTl9UQVJHRVRfQTotMTI3LjAuMC4xfScKICAgICAgLSBfQVBQX0RPTUFJTl9GVU5DVElPTlM9JFNFUlZJQ0VfRlFETl9BUFBXUklURQogICAgICAtICdfQVBQX1JFRElTX0hPU1Q9JHtfQVBQX1JFRElTX0hPU1Q6LWFwcHdyaXRlLXJlZGlzfScKICAgICAgLSAnX0FQUF9SRURJU19QT1JUPSR7X0FQUF9SRURJU19QT1JUOi02Mzc5fScKICAgICAgLSAnX0FQUF9SRURJU19VU0VSPSR7X0FQUF9SRURJU19VU0VSfScKICAgICAgLSAnX0FQUF9SRURJU19QQVNTPSR7X0FQUF9SRURJU19QQVNTfScKICAgICAgLSAnX0FQUF9EQl9IT1NUPSR7X0FQUF9EQl9IT1NUOi1hcHB3cml0ZS1tYXJpYWRifScKICAgICAgLSAnX0FQUF9EQl9QT1JUPSR7X0FQUF9EQl9QT1JUOi0zMzA2fScKICAgICAgLSAnX0FQUF9EQl9TQ0hFTUE9JHtfQVBQX0RCX1NDSEVNQTotYXBwd3JpdGV9JwogICAgICAtIF9BUFBfREJfVVNFUj0kU0VSVklDRV9VU0VSX01BUklBREIKICAgICAgLSBfQVBQX0RCX1BBU1M9JFNFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQgogICAgICAtICdfQVBQX1NNVFBfSE9TVD0ke19BUFBfU01UUF9IT1NUfScKICAgICAgLSAnX0FQUF9TTVRQX1BPUlQ9JHtfQVBQX1NNVFBfUE9SVH0nCiAgICAgIC0gJ19BUFBfU01UUF9TRUNVUkU9JHtfQVBQX1NNVFBfU0VDVVJFfScKICAgICAgLSAnX0FQUF9TTVRQX1VTRVJOQU1FPSR7X0FQUF9TTVRQX1VTRVJOQU1FfScKICAgICAgLSAnX0FQUF9TTVRQX1BBU1NXT1JEPSR7X0FQUF9TTVRQX1BBU1NXT1JEfScKICAgICAgLSAnX0FQUF9VU0FHRV9TVEFUUz0ke19BUFBfVVNBR0VfU1RBVFM6LWVuYWJsZWR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfTElNSVQ9JHtfQVBQX1NUT1JBR0VfTElNSVQ6LTMwMDAwMDAwfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1BSRVZJRVdfTElNSVQ9JHtfQVBQX1NUT1JBR0VfUFJFVklFV19MSU1JVDotMjAwMDAwMDB9JwogICAgICAtICdfQVBQX1NUT1JBR0VfQU5USVZJUlVTPSR7X0FQUF9TVE9SQUdFX0FOVElWSVJVUzotZGlzYWJsZWR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfQU5USVZJUlVTX0hPU1Q9JHtfQVBQX1NUT1JBR0VfQU5USVZJUlVTX0hPU1Q6LWFwcHdyaXRlLWNsYW1hdn0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9BTlRJVklSVVNfUE9SVD0ke19BUFBfU1RPUkFHRV9BTlRJVklSVVNfUE9SVDotMzMxMH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9ERVZJQ0U9JHtfQVBQX1NUT1JBR0VfREVWSUNFOi1sb2NhbH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9TM19BQ0NFU1NfS0VZPSR7X0FQUF9TVE9SQUdFX1MzX0FDQ0VTU19LRVl9JwogICAgICAtICdfQVBQX1NUT1JBR0VfUzNfU0VDUkVUPSR7X0FQUF9TVE9SQUdFX1MzX1NFQ1JFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9TM19SRUdJT049JHtfQVBQX1NUT1JBR0VfUzNfUkVHSU9OOi11cy1lYXN0LTF9JwogICAgICAtICdfQVBQX1NUT1JBR0VfUzNfQlVDS0VUPSR7X0FQUF9TVE9SQUdFX1MzX0JVQ0tFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9TM19FTkRQT0lOVD0ke19BUFBfU1RPUkFHRV9TM19FTkRQT0lOVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9ET19TUEFDRVNfQUNDRVNTX0tFWT0ke19BUFBfU1RPUkFHRV9ET19TUEFDRVNfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9ET19TUEFDRVNfU0VDUkVUPSR7X0FQUF9TVE9SQUdFX0RPX1NQQUNFU19TRUNSRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX1JFR0lPTj0ke19BUFBfU1RPUkFHRV9ET19TUEFDRVNfUkVHSU9OOi11cy1lYXN0LTF9JwogICAgICAtICdfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX0JVQ0tFVD0ke19BUFBfU1RPUkFHRV9ET19TUEFDRVNfQlVDS0VUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9BQ0NFU1NfS0VZPSR7X0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9BQ0NFU1NfS0VZfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9TRUNSRVQ9JHtfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX1NFQ1JFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfUkVHSU9OPSR7X0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9SRUdJT046LXVzLXdlc3QtMDA0fScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9CVUNLRVQ9JHtfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX0JVQ0tFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9MSU5PREVfQUNDRVNTX0tFWT0ke19BUFBfU1RPUkFHRV9MSU5PREVfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9MSU5PREVfU0VDUkVUPSR7X0FQUF9TVE9SQUdFX0xJTk9ERV9TRUNSRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfTElOT0RFX1JFR0lPTj0ke19BUFBfU1RPUkFHRV9MSU5PREVfUkVHSU9OOi1ldS1jZW50cmFsLTF9JwogICAgICAtICdfQVBQX1NUT1JBR0VfTElOT0RFX0JVQ0tFVD0ke19BUFBfU1RPUkFHRV9MSU5PREVfQlVDS0VUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1dBU0FCSV9BQ0NFU1NfS0VZPSR7X0FQUF9TVE9SQUdFX1dBU0FCSV9BQ0NFU1NfS0VZfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1dBU0FCSV9TRUNSRVQ9JHtfQVBQX1NUT1JBR0VfV0FTQUJJX1NFQ1JFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9XQVNBQklfUkVHSU9OPSR7X0FQUF9TVE9SQUdFX1dBU0FCSV9SRUdJT046LWV1LWNlbnRyYWwtMX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9XQVNBQklfQlVDS0VUPSR7X0FQUF9TVE9SQUdFX1dBU0FCSV9CVUNLRVR9JwogICAgICAtICdfQVBQX0NPTVBVVEVfU0laRV9MSU1JVD0ke19BUFBfQ09NUFVURV9TSVpFX0xJTUlUOi0zMDAwMDAwMH0nCiAgICAgIC0gJ19BUFBfRlVOQ1RJT05TX1RJTUVPVVQ9JHtfQVBQX0ZVTkNUSU9OU19USU1FT1VUOi05MDB9JwogICAgICAtICdfQVBQX1NJVEVTX1RJTUVPVVQ9JHtfQVBQX1NJVEVTX1RJTUVPVVQ6LTkwMH0nCiAgICAgIC0gJ19BUFBfQ09NUFVURV9CVUlMRF9USU1FT1VUPSR7X0FQUF9DT01QVVRFX0JVSUxEX1RJTUVPVVQ6LTkwMH0nCiAgICAgIC0gJ19BUFBfQ09NUFVURV9DUFVTPSR7X0FQUF9DT01QVVRFX0NQVVM6LTB9JwogICAgICAtICdfQVBQX0NPTVBVVEVfTUVNT1JZPSR7X0FQUF9DT01QVVRFX01FTU9SWTotMH0nCiAgICAgIC0gJ19BUFBfRlVOQ1RJT05TX1JVTlRJTUVTPSR7X0FQUF9GVU5DVElPTlNfUlVOVElNRVM6LW5vZGUtMjAuMCxwaHAtOC4yLHB5dGhvbi0zLjExLHJ1YnktMy4yfScKICAgICAgLSAnX0FQUF9TSVRFU19SVU5USU1FUz0ke19BUFBfU0lURVNfUlVOVElNRVN9JwogICAgICAtICdfQVBQX0RPTUFJTl9TSVRFUz0ke19BUFBfRE9NQUlOX1NJVEVTOi1hcHB3cml0ZS5uZXR3b3JrfScKICAgICAgLSBfQVBQX0VYRUNVVE9SX1NFQ1JFVD0kU0VSVklDRV9QQVNTV09SRF82NF9BUFBXUklURQogICAgICAtICdfQVBQX0VYRUNVVE9SX0hPU1Q9JHtfQVBQX0VYRUNVVE9SX0hPU1Q6LWh0dHA6Ly9hcHB3cml0ZS1leGVjdXRvci92MX0nCiAgICAgIC0gJ19BUFBfTE9HR0lOR19DT05GSUc9JHtfQVBQX0xPR0dJTkdfQ09ORklHfScKICAgICAgLSAnX0FQUF9NQUlOVEVOQU5DRV9JTlRFUlZBTD0ke19BUFBfTUFJTlRFTkFOQ0VfSU5URVJWQUw6LTg2NDAwfScKICAgICAgLSAnX0FQUF9NQUlOVEVOQU5DRV9ERUxBWT0ke19BUFBfTUFJTlRFTkFOQ0VfREVMQVl9JwogICAgICAtICdfQVBQX01BSU5URU5BTkNFX1NUQVJUX1RJTUU9JHtfQVBQX01BSU5URU5BTkNFX1NUQVJUX1RJTUV9JwogICAgICAtICdfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9FWEVDVVRJT049JHtfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9FWEVDVVRJT046LTEyMDk2MDB9JwogICAgICAtICdfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9DQUNIRT0ke19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0NBQ0hFOi0yNTkyMDAwfScKICAgICAgLSAnX0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fQUJVU0U9JHtfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9BQlVTRTotODY0MDB9JwogICAgICAtICdfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9BVURJVD0ke19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0FVRElUOi0xMjA5NjAwfScKICAgICAgLSAnX0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fQVVESVRfQ09OU09MRT0ke19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0FVRElUX0NPTlNPTEV9JwogICAgICAtICdfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9VU0FHRV9IT1VSTFk9JHtfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9VU0FHRV9IT1VSTFk6LTg2NDAwMDB9JwogICAgICAtICdfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9TQ0hFRFVMRVM9JHtfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9TQ0hFRFVMRVM6LTg2NDAwfScKICAgICAgLSAnX0FQUF9TTVNfUFJPVklERVI9JHtfQVBQX1NNU19QUk9WSURFUn0nCiAgICAgIC0gJ19BUFBfU01TX0ZST009JHtfQVBQX1NNU19GUk9NfScKICAgICAgLSAnX0FQUF9HUkFQSFFMX01BWF9CQVRDSF9TSVpFPSR7X0FQUF9HUkFQSFFMX01BWF9CQVRDSF9TSVpFOi0xMH0nCiAgICAgIC0gJ19BUFBfR1JBUEhRTF9NQVhfQ09NUExFWElUWT0ke19BUFBfR1JBUEhRTF9NQVhfQ09NUExFWElUWTotMjUwfScKICAgICAgLSAnX0FQUF9HUkFQSFFMX01BWF9ERVBUSD0ke19BUFBfR1JBUEhRTF9NQVhfREVQVEg6LTN9JwogICAgICAtICdfQVBQX1ZDU19HSVRIVUJfQVBQX05BTUU9JHtfQVBQX1ZDU19HSVRIVUJfQVBQX05BTUV9JwogICAgICAtICdfQVBQX1ZDU19HSVRIVUJfUFJJVkFURV9LRVk9JHtfQVBQX1ZDU19HSVRIVUJfUFJJVkFURV9LRVl9JwogICAgICAtICdfQVBQX1ZDU19HSVRIVUJfQVBQX0lEPSR7X0FQUF9WQ1NfR0lUSFVCX0FQUF9JRH0nCiAgICAgIC0gJ19BUFBfVkNTX0dJVEhVQl9XRUJIT09LX1NFQ1JFVD0ke19BUFBfVkNTX0dJVEhVQl9XRUJIT09LX1NFQ1JFVH0nCiAgICAgIC0gJ19BUFBfVkNTX0dJVEhVQl9DTElFTlRfU0VDUkVUPSR7X0FQUF9WQ1NfR0lUSFVCX0NMSUVOVF9TRUNSRVR9JwogICAgICAtICdfQVBQX1ZDU19HSVRIVUJfQ0xJRU5UX0lEPSR7X0FQUF9WQ1NfR0lUSFVCX0NMSUVOVF9JRH0nCiAgICAgIC0gJ19BUFBfTUlHUkFUSU9OU19GSVJFQkFTRV9DTElFTlRfSUQ9JHtfQVBQX01JR1JBVElPTlNfRklSRUJBU0VfQ0xJRU5UX0lEfScKICAgICAgLSAnX0FQUF9NSUdSQVRJT05TX0ZJUkVCQVNFX0NMSUVOVF9TRUNSRVQ9JHtfQVBQX01JR1JBVElPTlNfRklSRUJBU0VfQ0xJRU5UX1NFQ1JFVH0nCiAgICAgIC0gJ19BUFBfQVNTSVNUQU5UX09QRU5BSV9BUElfS0VZPSR7X0FQUF9BU1NJU1RBTlRfT1BFTkFJX0FQSV9LRVl9JwogIGFwcHdyaXRlLWNvbnNvbGU6CiAgICBpbWFnZTogJ2FwcHdyaXRlL2NvbnNvbGU6Ni4wLjEzJwogICAgY29udGFpbmVyX25hbWU6IGFwcHdyaXRlLWNvbnNvbGUKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9BUFBXUklURT0vY29uc29sZQogIGFwcHdyaXRlLXJlYWx0aW1lOgogICAgaW1hZ2U6ICdhcHB3cml0ZS9hcHB3cml0ZToxLjcuNCcKICAgIGVudHJ5cG9pbnQ6IHJlYWx0aW1lCiAgICBjb250YWluZXJfbmFtZTogYXBwd3JpdGUtcmVhbHRpbWUKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBwd3JpdGUtbWFyaWFkYgogICAgICAtIGFwcHdyaXRlLXJlZGlzCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fQVBQV1JJVEU9L3YxL3JlYWx0aW1lCiAgICAgIC0gJ19BUFBfRU5WPSR7X0FQUF9FTlY6LXByb2R1Y3Rpb259JwogICAgICAtICdfQVBQX1dPUktFUl9QRVJfQ09SRT0ke19BUFBfV09SS0VSX1BFUl9DT1JFOi02fScKICAgICAgLSAnX0FQUF9PUFRJT05TX0FCVVNFPSR7X0FQUF9PUFRJT05TX0FCVVNFOi1lbmFibGVkfScKICAgICAgLSAnX0FQUF9PUFRJT05TX1JPVVRFUl9QUk9URUNUSU9OPSR7X0FQUF9PUFRJT05TX1JPVVRFUl9QUk9URUNUSU9OOi1kaXNhYmxlZH0nCiAgICAgIC0gX0FQUF9PUEVOU1NMX0tFWV9WMT0kU0VSVklDRV9QQVNTV09SRF82NF9BUFBXUklURQogICAgICAtICdfQVBQX1JFRElTX0hPU1Q9JHtfQVBQX1JFRElTX0hPU1Q6LWFwcHdyaXRlLXJlZGlzfScKICAgICAgLSAnX0FQUF9SRURJU19QT1JUPSR7X0FQUF9SRURJU19QT1JUOi02Mzc5fScKICAgICAgLSAnX0FQUF9SRURJU19VU0VSPSR7X0FQUF9SRURJU19VU0VSfScKICAgICAgLSAnX0FQUF9SRURJU19QQVNTPSR7X0FQUF9SRURJU19QQVNTfScKICAgICAgLSAnX0FQUF9EQl9IT1NUPSR7X0FQUF9EQl9IT1NUOi1hcHB3cml0ZS1tYXJpYWRifScKICAgICAgLSAnX0FQUF9EQl9QT1JUPSR7X0FQUF9EQl9QT1JUOi0zMzA2fScKICAgICAgLSAnX0FQUF9EQl9TQ0hFTUE9JHtfQVBQX0RCX1NDSEVNQTotYXBwd3JpdGV9JwogICAgICAtIF9BUFBfREJfVVNFUj0kU0VSVklDRV9VU0VSX01BUklBREIKICAgICAgLSBfQVBQX0RCX1BBU1M9JFNFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQgogICAgICAtICdfQVBQX1VTQUdFX1NUQVRTPSR7X0FQUF9VU0FHRV9TVEFUUzotZW5hYmxlZH0nCiAgICAgIC0gJ19BUFBfTE9HR0lOR19DT05GSUc9JHtfQVBQX0xPR0dJTkdfQ09ORklHfScKICBhcHB3cml0ZS13b3JrZXItYXVkaXRzOgogICAgaW1hZ2U6ICdhcHB3cml0ZS9hcHB3cml0ZToxLjcuNCcKICAgIGVudHJ5cG9pbnQ6IHdvcmtlci1hdWRpdHMKICAgIGNvbnRhaW5lcl9uYW1lOiBhcHB3cml0ZS13b3JrZXItYXVkaXRzCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwcHdyaXRlLXJlZGlzCiAgICAgIC0gYXBwd3JpdGUtbWFyaWFkYgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ19BUFBfRU5WPSR7X0FQUF9FTlY6LXByb2R1Y3Rpb259JwogICAgICAtICdfQVBQX1dPUktFUl9QRVJfQ09SRT0ke19BUFBfV09SS0VSX1BFUl9DT1JFOi02fScKICAgICAgLSBfQVBQX09QRU5TU0xfS0VZX1YxPSRTRVJWSUNFX1BBU1NXT1JEXzY0X0FQUFdSSVRFCiAgICAgIC0gJ19BUFBfUkVESVNfSE9TVD0ke19BUFBfUkVESVNfSE9TVDotYXBwd3JpdGUtcmVkaXN9JwogICAgICAtICdfQVBQX1JFRElTX1BPUlQ9JHtfQVBQX1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICAtICdfQVBQX1JFRElTX1VTRVI9JHtfQVBQX1JFRElTX1VTRVJ9JwogICAgICAtICdfQVBQX1JFRElTX1BBU1M9JHtfQVBQX1JFRElTX1BBU1N9JwogICAgICAtICdfQVBQX0RCX0hPU1Q9JHtfQVBQX0RCX0hPU1Q6LWFwcHdyaXRlLW1hcmlhZGJ9JwogICAgICAtICdfQVBQX0RCX1BPUlQ9JHtfQVBQX0RCX1BPUlQ6LTMzMDZ9JwogICAgICAtICdfQVBQX0RCX1NDSEVNQT0ke19BUFBfREJfU0NIRU1BOi1hcHB3cml0ZX0nCiAgICAgIC0gX0FQUF9EQl9VU0VSPSRTRVJWSUNFX1VTRVJfTUFSSUFEQgogICAgICAtIF9BUFBfREJfUEFTUz0kU0VSVklDRV9QQVNTV09SRF9NQVJJQURCCiAgICAgIC0gJ19BUFBfTE9HR0lOR19DT05GSUc9JHtfQVBQX0xPR0dJTkdfQ09ORklHfScKICBhcHB3cml0ZS13b3JrZXItd2ViaG9va3M6CiAgICBpbWFnZTogJ2FwcHdyaXRlL2FwcHdyaXRlOjEuNy40JwogICAgZW50cnlwb2ludDogd29ya2VyLXdlYmhvb2tzCiAgICBjb250YWluZXJfbmFtZTogYXBwd3JpdGUtd29ya2VyLXdlYmhvb2tzCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwcHdyaXRlLXJlZGlzCiAgICAgIC0gYXBwd3JpdGUtbWFyaWFkYgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ19BUFBfRU5WPSR7X0FQUF9FTlY6LXByb2R1Y3Rpb259JwogICAgICAtICdfQVBQX1dPUktFUl9QRVJfQ09SRT0ke19BUFBfV09SS0VSX1BFUl9DT1JFOi02fScKICAgICAgLSBfQVBQX09QRU5TU0xfS0VZX1YxPSRTRVJWSUNFX1BBU1NXT1JEXzY0X0FQUFdSSVRFCiAgICAgIC0gJ19BUFBfRU1BSUxfU0VDVVJJVFk9JHtfQVBQX0VNQUlMX1NFQ1VSSVRZOi1jZXJ0c0BhcHB3cml0ZS5pb30nCiAgICAgIC0gJ19BUFBfU1lTVEVNX1NFQ1VSSVRZX0VNQUlMX0FERFJFU1M9JHtfQVBQX1NZU1RFTV9TRUNVUklUWV9FTUFJTF9BRERSRVNTfScKICAgICAgLSAnX0FQUF9EQl9IT1NUPSR7X0FQUF9EQl9IT1NUOi1hcHB3cml0ZS1tYXJpYWRifScKICAgICAgLSAnX0FQUF9EQl9QT1JUPSR7X0FQUF9EQl9QT1JUOi0zMzA2fScKICAgICAgLSAnX0FQUF9EQl9TQ0hFTUE9JHtfQVBQX0RCX1NDSEVNQTotYXBwd3JpdGV9JwogICAgICAtIF9BUFBfREJfVVNFUj0kU0VSVklDRV9VU0VSX01BUklBREIKICAgICAgLSBfQVBQX0RCX1BBU1M9JFNFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQgogICAgICAtICdfQVBQX1JFRElTX0hPU1Q9JHtfQVBQX1JFRElTX0hPU1Q6LWFwcHdyaXRlLXJlZGlzfScKICAgICAgLSAnX0FQUF9SRURJU19QT1JUPSR7X0FQUF9SRURJU19QT1JUOi02Mzc5fScKICAgICAgLSAnX0FQUF9SRURJU19VU0VSPSR7X0FQUF9SRURJU19VU0VSfScKICAgICAgLSAnX0FQUF9SRURJU19QQVNTPSR7X0FQUF9SRURJU19QQVNTfScKICAgICAgLSAnX0FQUF9MT0dHSU5HX0NPTkZJRz0ke19BUFBfTE9HR0lOR19DT05GSUd9JwogIGFwcHdyaXRlLXdvcmtlci1kZWxldGVzOgogICAgaW1hZ2U6ICdhcHB3cml0ZS9hcHB3cml0ZToxLjcuNCcKICAgIGVudHJ5cG9pbnQ6IHdvcmtlci1kZWxldGVzCiAgICBjb250YWluZXJfbmFtZTogYXBwd3JpdGUtd29ya2VyLWRlbGV0ZXMKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBwd3JpdGUtcmVkaXMKICAgICAgLSBhcHB3cml0ZS1tYXJpYWRiCiAgICB2b2x1bWVzOgogICAgICAtICdhcHB3cml0ZS11cGxvYWRzOi9zdG9yYWdlL3VwbG9hZHM6cncnCiAgICAgIC0gJ2FwcHdyaXRlLWNhY2hlOi9zdG9yYWdlL2NhY2hlOnJ3JwogICAgICAtICdhcHB3cml0ZS1mdW5jdGlvbnM6L3N0b3JhZ2UvZnVuY3Rpb25zOnJ3JwogICAgICAtICdhcHB3cml0ZS1zaXRlczovc3RvcmFnZS9zaXRlczpydycKICAgICAgLSAnYXBwd3JpdGUtYnVpbGRzOi9zdG9yYWdlL2J1aWxkczpydycKICAgICAgLSAnYXBwd3JpdGUtY2VydGlmaWNhdGVzOi9zdG9yYWdlL2NlcnRpZmljYXRlczpydycKICAgIGVudmlyb25tZW50OgogICAgICAtICdfQVBQX0VOVj0ke19BUFBfRU5WOi1wcm9kdWN0aW9ufScKICAgICAgLSAnX0FQUF9XT1JLRVJfUEVSX0NPUkU9JHtfQVBQX1dPUktFUl9QRVJfQ09SRTotNn0nCiAgICAgIC0gX0FQUF9PUEVOU1NMX0tFWV9WMT0kU0VSVklDRV9QQVNTV09SRF82NF9BUFBXUklURQogICAgICAtICdfQVBQX1JFRElTX0hPU1Q9JHtfQVBQX1JFRElTX0hPU1Q6LWFwcHdyaXRlLXJlZGlzfScKICAgICAgLSAnX0FQUF9SRURJU19QT1JUPSR7X0FQUF9SRURJU19QT1JUOi02Mzc5fScKICAgICAgLSAnX0FQUF9SRURJU19VU0VSPSR7X0FQUF9SRURJU19VU0VSfScKICAgICAgLSAnX0FQUF9SRURJU19QQVNTPSR7X0FQUF9SRURJU19QQVNTfScKICAgICAgLSAnX0FQUF9EQl9IT1NUPSR7X0FQUF9EQl9IT1NUOi1hcHB3cml0ZS1tYXJpYWRifScKICAgICAgLSAnX0FQUF9EQl9QT1JUPSR7X0FQUF9EQl9QT1JUOi0zMzA2fScKICAgICAgLSAnX0FQUF9EQl9TQ0hFTUE9JHtfQVBQX0RCX1NDSEVNQTotYXBwd3JpdGV9JwogICAgICAtIF9BUFBfREJfVVNFUj0kU0VSVklDRV9VU0VSX01BUklBREIKICAgICAgLSBfQVBQX0RCX1BBU1M9JFNFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQgogICAgICAtICdfQVBQX1NUT1JBR0VfREVWSUNFPSR7X0FQUF9TVE9SQUdFX0RFVklDRTotbG9jYWx9JwogICAgICAtICdfQVBQX1NUT1JBR0VfUzNfQUNDRVNTX0tFWT0ke19BUFBfU1RPUkFHRV9TM19BQ0NFU1NfS0VZfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1MzX1NFQ1JFVD0ke19BUFBfU1RPUkFHRV9TM19TRUNSRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfUzNfUkVHSU9OPSR7X0FQUF9TVE9SQUdFX1MzX1JFR0lPTjotdXMtZWFzdC0xfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1MzX0JVQ0tFVD0ke19BUFBfU1RPUkFHRV9TM19CVUNLRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfUzNfRU5EUE9JTlQ9JHtfQVBQX1NUT1JBR0VfUzNfRU5EUE9JTlR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX0FDQ0VTU19LRVk9JHtfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX0FDQ0VTU19LRVl9JwogICAgICAtICdfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX1NFQ1JFVD0ke19BUFBfU1RPUkFHRV9ET19TUEFDRVNfU0VDUkVUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0RPX1NQQUNFU19SRUdJT049JHtfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX1JFR0lPTjotdXMtZWFzdC0xfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0RPX1NQQUNFU19CVUNLRVQ9JHtfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX0JVQ0tFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfQUNDRVNTX0tFWT0ke19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfU0VDUkVUPSR7X0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9TRUNSRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX1JFR0lPTj0ke19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfUkVHSU9OOi11cy13ZXN0LTAwNH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfQlVDS0VUPSR7X0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9CVUNLRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfTElOT0RFX0FDQ0VTU19LRVk9JHtfQVBQX1NUT1JBR0VfTElOT0RFX0FDQ0VTU19LRVl9JwogICAgICAtICdfQVBQX1NUT1JBR0VfTElOT0RFX1NFQ1JFVD0ke19BUFBfU1RPUkFHRV9MSU5PREVfU0VDUkVUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0xJTk9ERV9SRUdJT049JHtfQVBQX1NUT1JBR0VfTElOT0RFX1JFR0lPTjotZXUtY2VudHJhbC0xfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0xJTk9ERV9CVUNLRVQ9JHtfQVBQX1NUT1JBR0VfTElOT0RFX0JVQ0tFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9XQVNBQklfQUNDRVNTX0tFWT0ke19BUFBfU1RPUkFHRV9XQVNBQklfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9XQVNBQklfU0VDUkVUPSR7X0FQUF9TVE9SQUdFX1dBU0FCSV9TRUNSRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfV0FTQUJJX1JFR0lPTj0ke19BUFBfU1RPUkFHRV9XQVNBQklfUkVHSU9OOi1ldS1jZW50cmFsLTF9JwogICAgICAtICdfQVBQX1NUT1JBR0VfV0FTQUJJX0JVQ0tFVD0ke19BUFBfU1RPUkFHRV9XQVNBQklfQlVDS0VUfScKICAgICAgLSAnX0FQUF9MT0dHSU5HX0NPTkZJRz0ke19BUFBfTE9HR0lOR19DT05GSUd9JwogICAgICAtIF9BUFBfRVhFQ1VUT1JfU0VDUkVUPSRTRVJWSUNFX1BBU1NXT1JEXzY0X0FQUFdSSVRFCiAgICAgIC0gJ19BUFBfRVhFQ1VUT1JfSE9TVD0ke19BUFBfRVhFQ1VUT1JfSE9TVDotaHR0cDovL2FwcHdyaXRlLWV4ZWN1dG9yL3YxfScKICAgICAgLSAnX0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fQUJVU0U9JHtfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9BQlVTRTotODY0MDB9JwogICAgICAtICdfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9BVURJVD0ke19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0FVRElUOi0xMjA5NjAwfScKICAgICAgLSAnX0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fQVVESVRfQ09OU09MRT0ke19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0FVRElUX0NPTlNPTEV9JwogICAgICAtICdfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9FWEVDVVRJT049JHtfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9FWEVDVVRJT046LTEyMDk2MDB9JwogICAgICAtICdfQVBQX1NZU1RFTV9TRUNVUklUWV9FTUFJTF9BRERSRVNTPSR7X0FQUF9TWVNURU1fU0VDVVJJVFlfRU1BSUxfQUREUkVTU30nCiAgICAgIC0gJ19BUFBfRU1BSUxfQ0VSVElGSUNBVEVTPSR7X0FQUF9FTUFJTF9DRVJUSUZJQ0FURVN9JwogIGFwcHdyaXRlLXdvcmtlci1kYXRhYmFzZXM6CiAgICBpbWFnZTogJ2FwcHdyaXRlL2FwcHdyaXRlOjEuNy40JwogICAgZW50cnlwb2ludDogd29ya2VyLWRhdGFiYXNlcwogICAgY29udGFpbmVyX25hbWU6IGFwcHdyaXRlLXdvcmtlci1kYXRhYmFzZXMKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBwd3JpdGUtcmVkaXMKICAgICAgLSBhcHB3cml0ZS1tYXJpYWRiCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnX0FQUF9FTlY9JHtfQVBQX0VOVjotcHJvZHVjdGlvbn0nCiAgICAgIC0gJ19BUFBfV09SS0VSX1BFUl9DT1JFPSR7X0FQUF9XT1JLRVJfUEVSX0NPUkU6LTZ9JwogICAgICAtIF9BUFBfT1BFTlNTTF9LRVlfVjE9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfQVBQV1JJVEUKICAgICAgLSAnX0FQUF9SRURJU19IT1NUPSR7X0FQUF9SRURJU19IT1NUOi1hcHB3cml0ZS1yZWRpc30nCiAgICAgIC0gJ19BUFBfUkVESVNfUE9SVD0ke19BUFBfUkVESVNfUE9SVDotNjM3OX0nCiAgICAgIC0gJ19BUFBfUkVESVNfVVNFUj0ke19BUFBfUkVESVNfVVNFUn0nCiAgICAgIC0gJ19BUFBfUkVESVNfUEFTUz0ke19BUFBfUkVESVNfUEFTU30nCiAgICAgIC0gJ19BUFBfREJfSE9TVD0ke19BUFBfREJfSE9TVDotYXBwd3JpdGUtbWFyaWFkYn0nCiAgICAgIC0gJ19BUFBfREJfUE9SVD0ke19BUFBfREJfUE9SVDotMzMwNn0nCiAgICAgIC0gJ19BUFBfREJfU0NIRU1BPSR7X0FQUF9EQl9TQ0hFTUE6LWFwcHdyaXRlfScKICAgICAgLSBfQVBQX0RCX1VTRVI9JFNFUlZJQ0VfVVNFUl9NQVJJQURCCiAgICAgIC0gX0FQUF9EQl9QQVNTPSRTRVJWSUNFX1BBU1NXT1JEX01BUklBREIKICAgICAgLSAnX0FQUF9MT0dHSU5HX0NPTkZJRz0ke19BUFBfTE9HR0lOR19DT05GSUd9JwogIGFwcHdyaXRlLXdvcmtlci1idWlsZHM6CiAgICBpbWFnZTogJ2FwcHdyaXRlL2FwcHdyaXRlOjEuNy40JwogICAgZW50cnlwb2ludDogd29ya2VyLWJ1aWxkcwogICAgY29udGFpbmVyX25hbWU6IGFwcHdyaXRlLXdvcmtlci1idWlsZHMKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBwd3JpdGUtcmVkaXMKICAgICAgLSBhcHB3cml0ZS1tYXJpYWRiCiAgICB2b2x1bWVzOgogICAgICAtICdhcHB3cml0ZS1mdW5jdGlvbnM6L3N0b3JhZ2UvZnVuY3Rpb25zOnJ3JwogICAgICAtICdhcHB3cml0ZS1zaXRlczovc3RvcmFnZS9zaXRlczpydycKICAgICAgLSAnYXBwd3JpdGUtYnVpbGRzOi9zdG9yYWdlL2J1aWxkczpydycKICAgICAgLSAnYXBwd3JpdGUtdXBsb2Fkczovc3RvcmFnZS91cGxvYWRzOnJ3JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ19BUFBfRU5WPSR7X0FQUF9FTlY6LXByb2R1Y3Rpb259JwogICAgICAtICdfQVBQX1dPUktFUl9QRVJfQ09SRT0ke19BUFBfV09SS0VSX1BFUl9DT1JFOi02fScKICAgICAgLSBfQVBQX09QRU5TU0xfS0VZX1YxPSRTRVJWSUNFX1BBU1NXT1JEXzY0X0FQUFdSSVRFCiAgICAgIC0gX0FQUF9FWEVDVVRPUl9TRUNSRVQ9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfQVBQV1JJVEUKICAgICAgLSAnX0FQUF9FWEVDVVRPUl9IT1NUPSR7X0FQUF9FWEVDVVRPUl9IT1NUOi1odHRwOi8vYXBwd3JpdGUtZXhlY3V0b3IvdjF9JwogICAgICAtICdfQVBQX1JFRElTX0hPU1Q9JHtfQVBQX1JFRElTX0hPU1Q6LWFwcHdyaXRlLXJlZGlzfScKICAgICAgLSAnX0FQUF9SRURJU19QT1JUPSR7X0FQUF9SRURJU19QT1JUOi02Mzc5fScKICAgICAgLSAnX0FQUF9SRURJU19VU0VSPSR7X0FQUF9SRURJU19VU0VSfScKICAgICAgLSAnX0FQUF9SRURJU19QQVNTPSR7X0FQUF9SRURJU19QQVNTfScKICAgICAgLSAnX0FQUF9EQl9IT1NUPSR7X0FQUF9EQl9IT1NUOi1hcHB3cml0ZS1tYXJpYWRifScKICAgICAgLSAnX0FQUF9EQl9QT1JUPSR7X0FQUF9EQl9QT1JUOi0zMzA2fScKICAgICAgLSAnX0FQUF9EQl9TQ0hFTUE9JHtfQVBQX0RCX1NDSEVNQTotYXBwd3JpdGV9JwogICAgICAtIF9BUFBfREJfVVNFUj0kU0VSVklDRV9VU0VSX01BUklBREIKICAgICAgLSBfQVBQX0RCX1BBU1M9JFNFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQgogICAgICAtICdfQVBQX0xPR0dJTkdfQ09ORklHPSR7X0FQUF9MT0dHSU5HX0NPTkZJR30nCiAgICAgIC0gJ19BUFBfVkNTX0dJVEhVQl9BUFBfTkFNRT0ke19BUFBfVkNTX0dJVEhVQl9BUFBfTkFNRX0nCiAgICAgIC0gJ19BUFBfVkNTX0dJVEhVQl9QUklWQVRFX0tFWT0ke19BUFBfVkNTX0dJVEhVQl9QUklWQVRFX0tFWX0nCiAgICAgIC0gJ19BUFBfVkNTX0dJVEhVQl9BUFBfSUQ9JHtfQVBQX1ZDU19HSVRIVUJfQVBQX0lEfScKICAgICAgLSAnX0FQUF9GVU5DVElPTlNfVElNRU9VVD0ke19BUFBfRlVOQ1RJT05TX1RJTUVPVVQ6LTkwMH0nCiAgICAgIC0gJ19BUFBfU0lURVNfVElNRU9VVD0ke19BUFBfU0lURVNfVElNRU9VVDotOTAwfScKICAgICAgLSAnX0FQUF9DT01QVVRFX0JVSUxEX1RJTUVPVVQ9JHtfQVBQX0NPTVBVVEVfQlVJTERfVElNRU9VVDotOTAwfScKICAgICAgLSAnX0FQUF9DT01QVVRFX0NQVVM9JHtfQVBQX0NPTVBVVEVfQ1BVUzotMH0nCiAgICAgIC0gJ19BUFBfQ09NUFVURV9NRU1PUlk9JHtfQVBQX0NPTVBVVEVfTUVNT1JZOi0wfScKICAgICAgLSAnX0FQUF9DT01QVVRFX1NJWkVfTElNSVQ9JHtfQVBQX0NPTVBVVEVfU0laRV9MSU1JVDotMzAwMDAwMDB9JwogICAgICAtICdfQVBQX09QVElPTlNfRk9SQ0VfSFRUUFM9JHtfQVBQX09QVElPTlNfRk9SQ0VfSFRUUFM6LWRpc2FibGVkfScKICAgICAgLSAnX0FQUF9PUFRJT05TX1JPVVRFUl9GT1JDRV9IVFRQUz0ke19BUFBfT1BUSU9OU19ST1VURVJfRk9SQ0VfSFRUUFM6LWRpc2FibGVkfScKICAgICAgLSBfQVBQX0RPTUFJTj0kU0VSVklDRV9GUUROX0FQUFdSSVRFCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9ERVZJQ0U9JHtfQVBQX1NUT1JBR0VfREVWSUNFOi1sb2NhbH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9TM19BQ0NFU1NfS0VZPSR7X0FQUF9TVE9SQUdFX1MzX0FDQ0VTU19LRVl9JwogICAgICAtICdfQVBQX1NUT1JBR0VfUzNfU0VDUkVUPSR7X0FQUF9TVE9SQUdFX1MzX1NFQ1JFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9TM19SRUdJT049JHtfQVBQX1NUT1JBR0VfUzNfUkVHSU9OOi11cy1lYXN0LTF9JwogICAgICAtICdfQVBQX1NUT1JBR0VfUzNfQlVDS0VUPSR7X0FQUF9TVE9SQUdFX1MzX0JVQ0tFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9TM19FTkRQT0lOVD0ke19BUFBfU1RPUkFHRV9TM19FTkRQT0lOVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9ET19TUEFDRVNfQUNDRVNTX0tFWT0ke19BUFBfU1RPUkFHRV9ET19TUEFDRVNfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9ET19TUEFDRVNfU0VDUkVUPSR7X0FQUF9TVE9SQUdFX0RPX1NQQUNFU19TRUNSRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX1JFR0lPTj0ke19BUFBfU1RPUkFHRV9ET19TUEFDRVNfUkVHSU9OOi11cy1lYXN0LTF9JwogICAgICAtICdfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX0JVQ0tFVD0ke19BUFBfU1RPUkFHRV9ET19TUEFDRVNfQlVDS0VUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9BQ0NFU1NfS0VZPSR7X0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9BQ0NFU1NfS0VZfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9TRUNSRVQ9JHtfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX1NFQ1JFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfUkVHSU9OPSR7X0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9SRUdJT046LXVzLXdlc3QtMDA0fScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9CVUNLRVQ9JHtfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX0JVQ0tFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9MSU5PREVfQUNDRVNTX0tFWT0ke19BUFBfU1RPUkFHRV9MSU5PREVfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9MSU5PREVfU0VDUkVUPSR7X0FQUF9TVE9SQUdFX0xJTk9ERV9TRUNSRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfTElOT0RFX1JFR0lPTj0ke19BUFBfU1RPUkFHRV9MSU5PREVfUkVHSU9OOi1ldS1jZW50cmFsLTF9JwogICAgICAtICdfQVBQX1NUT1JBR0VfTElOT0RFX0JVQ0tFVD0ke19BUFBfU1RPUkFHRV9MSU5PREVfQlVDS0VUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1dBU0FCSV9BQ0NFU1NfS0VZPSR7X0FQUF9TVE9SQUdFX1dBU0FCSV9BQ0NFU1NfS0VZfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1dBU0FCSV9TRUNSRVQ9JHtfQVBQX1NUT1JBR0VfV0FTQUJJX1NFQ1JFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9XQVNBQklfUkVHSU9OPSR7X0FQUF9TVE9SQUdFX1dBU0FCSV9SRUdJT046LWV1LWNlbnRyYWwtMX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9XQVNBQklfQlVDS0VUPSR7X0FQUF9TVE9SQUdFX1dBU0FCSV9CVUNLRVR9JwogICAgICAtICdfQVBQX0RPTUFJTl9TSVRFUz0ke19BUFBfRE9NQUlOX1NJVEVTfScKICBhcHB3cml0ZS13b3JrZXItY2VydGlmaWNhdGVzOgogICAgaW1hZ2U6ICdhcHB3cml0ZS9hcHB3cml0ZToxLjcuNCcKICAgIGVudHJ5cG9pbnQ6IHdvcmtlci1jZXJ0aWZpY2F0ZXMKICAgIGNvbnRhaW5lcl9uYW1lOiBhcHB3cml0ZS13b3JrZXItY2VydGlmaWNhdGVzCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwcHdyaXRlLXJlZGlzCiAgICAgIC0gYXBwd3JpdGUtbWFyaWFkYgogICAgdm9sdW1lczoKICAgICAgLSAnYXBwd3JpdGUtY29uZmlnOi9zdG9yYWdlL2NvbmZpZzpydycKICAgICAgLSAnYXBwd3JpdGUtY2VydGlmaWNhdGVzOi9zdG9yYWdlL2NlcnRpZmljYXRlczpydycKICAgIGVudmlyb25tZW50OgogICAgICAtICdfQVBQX0VOVj0ke19BUFBfRU5WOi1wcm9kdWN0aW9ufScKICAgICAgLSAnX0FQUF9XT1JLRVJfUEVSX0NPUkU9JHtfQVBQX1dPUktFUl9QRVJfQ09SRTotNn0nCiAgICAgIC0gX0FQUF9PUEVOU1NMX0tFWV9WMT0kU0VSVklDRV9QQVNTV09SRF82NF9BUFBXUklURQogICAgICAtIF9BUFBfRE9NQUlOPSRTRVJWSUNFX0ZRRE5fQVBQV1JJVEUKICAgICAgLSAnX0FQUF9ET01BSU5fVEFSR0VUX0NOQU1FPSR7X0FQUF9ET01BSU5fVEFSR0VUX0NOQU1FfScKICAgICAgLSAnX0FQUF9ET01BSU5fVEFSR0VUX0FBQUE9JHtfQVBQX0RPTUFJTl9UQVJHRVRfQUFBQX0nCiAgICAgIC0gJ19BUFBfRE9NQUlOX1RBUkdFVF9BPSR7X0FQUF9ET01BSU5fVEFSR0VUX0F9JwogICAgICAtIF9BUFBfRE9NQUlOX0ZVTkNUSU9OUz0kU0VSVklDRV9GUUROX0FQUFdSSVRFCiAgICAgIC0gJ19BUFBfRU1BSUxfQ0VSVElGSUNBVEVTPSR7X0FQUF9FTUFJTF9DRVJUSUZJQ0FURVM6LWVuYWJsZWR9JwogICAgICAtICdfQVBQX1JFRElTX0hPU1Q9JHtfQVBQX1JFRElTX0hPU1Q6LWFwcHdyaXRlLXJlZGlzfScKICAgICAgLSAnX0FQUF9SRURJU19QT1JUPSR7X0FQUF9SRURJU19QT1JUOi02Mzc5fScKICAgICAgLSAnX0FQUF9SRURJU19VU0VSPSR7X0FQUF9SRURJU19VU0VSfScKICAgICAgLSAnX0FQUF9SRURJU19QQVNTPSR7X0FQUF9SRURJU19QQVNTfScKICAgICAgLSAnX0FQUF9EQl9IT1NUPSR7X0FQUF9EQl9IT1NUOi1hcHB3cml0ZS1tYXJpYWRifScKICAgICAgLSAnX0FQUF9EQl9QT1JUPSR7X0FQUF9EQl9QT1JUOi0zMzA2fScKICAgICAgLSAnX0FQUF9EQl9TQ0hFTUE9JHtfQVBQX0RCX1NDSEVNQTotYXBwd3JpdGV9JwogICAgICAtIF9BUFBfREJfVVNFUj0kU0VSVklDRV9VU0VSX01BUklBREIKICAgICAgLSBfQVBQX0RCX1BBU1M9JFNFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQgogICAgICAtICdfQVBQX0xPR0dJTkdfQ09ORklHPSR7X0FQUF9MT0dHSU5HX0NPTkZJR30nCiAgYXBwd3JpdGUtd29ya2VyLWZ1bmN0aW9uczoKICAgIGltYWdlOiAnYXBwd3JpdGUvYXBwd3JpdGU6MS43LjQnCiAgICBlbnRyeXBvaW50OiB3b3JrZXItZnVuY3Rpb25zCiAgICBjb250YWluZXJfbmFtZTogYXBwd3JpdGUtd29ya2VyLWZ1bmN0aW9ucwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcHB3cml0ZS1yZWRpcwogICAgICAtIGFwcHdyaXRlLW1hcmlhZGIKICAgICAgLSBvcGVucnVudGltZXMtZXhlY3V0b3IKICAgIGVudmlyb25tZW50OgogICAgICAtICdfQVBQX0VOVj0ke19BUFBfRU5WOi1wcm9kdWN0aW9ufScKICAgICAgLSAnX0FQUF9XT1JLRVJfUEVSX0NPUkU9JHtfQVBQX1dPUktFUl9QRVJfQ09SRTotNn0nCiAgICAgIC0gX0FQUF9PUEVOU1NMX0tFWV9WMT0kU0VSVklDRV9QQVNTV09SRF82NF9BUFBXUklURQogICAgICAtIF9BUFBfRE9NQUlOPSRTRVJWSUNFX0ZRRE5fQVBQV1JJVEUKICAgICAgLSAnX0FQUF9PUFRJT05TX0ZPUkNFX0hUVFBTPSR7X0FQUF9PUFRJT05TX0ZPUkNFX0hUVFBTOi1kaXNhYmxlZH0nCiAgICAgIC0gJ19BUFBfUkVESVNfSE9TVD0ke19BUFBfUkVESVNfSE9TVDotYXBwd3JpdGUtcmVkaXN9JwogICAgICAtICdfQVBQX1JFRElTX1BPUlQ9JHtfQVBQX1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICAtICdfQVBQX1JFRElTX1VTRVI9JHtfQVBQX1JFRElTX1VTRVJ9JwogICAgICAtICdfQVBQX1JFRElTX1BBU1M9JHtfQVBQX1JFRElTX1BBU1N9JwogICAgICAtICdfQVBQX0RCX0hPU1Q9JHtfQVBQX0RCX0hPU1Q6LWFwcHdyaXRlLW1hcmlhZGJ9JwogICAgICAtICdfQVBQX0RCX1BPUlQ9JHtfQVBQX0RCX1BPUlQ6LTMzMDZ9JwogICAgICAtICdfQVBQX0RCX1NDSEVNQT0ke19BUFBfREJfU0NIRU1BOi1hcHB3cml0ZX0nCiAgICAgIC0gX0FQUF9EQl9VU0VSPSRTRVJWSUNFX1VTRVJfTUFSSUFEQgogICAgICAtIF9BUFBfREJfUEFTUz0kU0VSVklDRV9QQVNTV09SRF9NQVJJQURCCiAgICAgIC0gJ19BUFBfRlVOQ1RJT05TX1RJTUVPVVQ9JHtfQVBQX0ZVTkNUSU9OU19USU1FT1VUOi05MDB9JwogICAgICAtICdfQVBQX1NJVEVTX1RJTUVPVVQ9JHtfQVBQX1NJVEVTX1RJTUVPVVQ6LTkwMH0nCiAgICAgIC0gJ19BUFBfQ09NUFVURV9CVUlMRF9USU1FT1VUPSR7X0FQUF9DT01QVVRFX0JVSUxEX1RJTUVPVVQ6LTkwMH0nCiAgICAgIC0gJ19BUFBfQ09NUFVURV9DUFVTPSR7X0FQUF9DT01QVVRFX0NQVVM6LTB9JwogICAgICAtICdfQVBQX0NPTVBVVEVfTUVNT1JZPSR7X0FQUF9DT01QVVRFX01FTU9SWTotMH0nCiAgICAgIC0gX0FQUF9FWEVDVVRPUl9TRUNSRVQ9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfQVBQV1JJVEUKICAgICAgLSAnX0FQUF9FWEVDVVRPUl9IT1NUPSR7X0FQUF9FWEVDVVRPUl9IT1NUOi1odHRwOi8vYXBwd3JpdGUtZXhlY3V0b3IvdjF9JwogICAgICAtICdfQVBQX1VTQUdFX1NUQVRTPSR7X0FQUF9VU0FHRV9TVEFUUzotZW5hYmxlZH0nCiAgICAgIC0gJ19BUFBfRE9DS0VSX0hVQl9VU0VSTkFNRT0ke19BUFBfRE9DS0VSX0hVQl9VU0VSTkFNRX0nCiAgICAgIC0gJ19BUFBfRE9DS0VSX0hVQl9QQVNTV09SRD0ke19BUFBfRE9DS0VSX0hVQl9QQVNTV09SRH0nCiAgICAgIC0gJ19BUFBfTE9HR0lOR19DT05GSUc9JHtfQVBQX0xPR0dJTkdfQ09ORklHfScKICBhcHB3cml0ZS13b3JrZXItbWFpbHM6CiAgICBpbWFnZTogJ2FwcHdyaXRlL2FwcHdyaXRlOjEuNy40JwogICAgZW50cnlwb2ludDogd29ya2VyLW1haWxzCiAgICBjb250YWluZXJfbmFtZTogYXBwd3JpdGUtd29ya2VyLW1haWxzCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwcHdyaXRlLXJlZGlzCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnX0FQUF9FTlY9JHtfQVBQX0VOVjotcHJvZHVjdGlvbn0nCiAgICAgIC0gJ19BUFBfV09SS0VSX1BFUl9DT1JFPSR7X0FQUF9XT1JLRVJfUEVSX0NPUkU6LTZ9JwogICAgICAtIF9BUFBfT1BFTlNTTF9LRVlfVjE9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfQVBQV1JJVEUKICAgICAgLSAnX0FQUF9TWVNURU1fRU1BSUxfTkFNRT0ke19BUFBfU1lTVEVNX0VNQUlMX05BTUU6LUFwcHdyaXRlfScKICAgICAgLSAnX0FQUF9TWVNURU1fRU1BSUxfQUREUkVTUz0ke19BUFBfU1lTVEVNX0VNQUlMX0FERFJFU1M6LXRlYW1AYXBwd3JpdGUuaW99JwogICAgICAtICdfQVBQX0RCX0hPU1Q9JHtfQVBQX0RCX0hPU1Q6LWFwcHdyaXRlLW1hcmlhZGJ9JwogICAgICAtICdfQVBQX0RCX1BPUlQ9JHtfQVBQX0RCX1BPUlQ6LTMzMDZ9JwogICAgICAtICdfQVBQX0RCX1NDSEVNQT0ke19BUFBfREJfU0NIRU1BOi1hcHB3cml0ZX0nCiAgICAgIC0gX0FQUF9EQl9VU0VSPSRTRVJWSUNFX1VTRVJfTUFSSUFEQgogICAgICAtIF9BUFBfREJfUEFTUz0kU0VSVklDRV9QQVNTV09SRF9NQVJJQURCCiAgICAgIC0gJ19BUFBfUkVESVNfSE9TVD0ke19BUFBfUkVESVNfSE9TVDotYXBwd3JpdGUtcmVkaXN9JwogICAgICAtICdfQVBQX1JFRElTX1BPUlQ9JHtfQVBQX1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICAtICdfQVBQX1JFRElTX1VTRVI9JHtfQVBQX1JFRElTX1VTRVJ9JwogICAgICAtICdfQVBQX1JFRElTX1BBU1M9JHtfQVBQX1JFRElTX1BBU1N9JwogICAgICAtICdfQVBQX1NNVFBfSE9TVD0ke19BUFBfU01UUF9IT1NUfScKICAgICAgLSAnX0FQUF9TTVRQX1BPUlQ9JHtfQVBQX1NNVFBfUE9SVH0nCiAgICAgIC0gJ19BUFBfU01UUF9TRUNVUkU9JHtfQVBQX1NNVFBfU0VDVVJFfScKICAgICAgLSAnX0FQUF9TTVRQX1VTRVJOQU1FPSR7X0FQUF9TTVRQX1VTRVJOQU1FfScKICAgICAgLSAnX0FQUF9TTVRQX1BBU1NXT1JEPSR7X0FQUF9TTVRQX1BBU1NXT1JEfScKICAgICAgLSAnX0FQUF9MT0dHSU5HX0NPTkZJRz0ke19BUFBfTE9HR0lOR19DT05GSUd9JwogICAgICAtIF9BUFBfRE9NQUlOPSRTRVJWSUNFX0ZRRE5fQVBQV1JJVEUKICAgICAgLSAnX0FQUF9PUFRJT05TX0ZPUkNFX0hUVFBTPSR7X0FQUF9PUFRJT05TX0ZPUkNFX0hUVFBTOi1kaXNhYmxlZH0nCiAgYXBwd3JpdGUtd29ya2VyLW1lc3NhZ2luZzoKICAgIGltYWdlOiAnYXBwd3JpdGUvYXBwd3JpdGU6MS43LjQnCiAgICBlbnRyeXBvaW50OiB3b3JrZXItbWVzc2FnaW5nCiAgICBjb250YWluZXJfbmFtZTogYXBwd3JpdGUtd29ya2VyLW1lc3NhZ2luZwogICAgdm9sdW1lczoKICAgICAgLSAnYXBwd3JpdGUtdXBsb2Fkczovc3RvcmFnZS91cGxvYWRzOnJ3JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcHB3cml0ZS1yZWRpcwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ19BUFBfRU5WPSR7X0FQUF9FTlY6LXByb2R1Y3Rpb259JwogICAgICAtICdfQVBQX1dPUktFUl9QRVJfQ09SRT0ke19BUFBfV09SS0VSX1BFUl9DT1JFOi02fScKICAgICAgLSBfQVBQX09QRU5TU0xfS0VZX1YxPSRTRVJWSUNFX1BBU1NXT1JEXzY0X0FQUFdSSVRFCiAgICAgIC0gJ19BUFBfUkVESVNfSE9TVD0ke19BUFBfUkVESVNfSE9TVDotYXBwd3JpdGUtcmVkaXN9JwogICAgICAtICdfQVBQX1JFRElTX1BPUlQ9JHtfQVBQX1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICAtICdfQVBQX1JFRElTX1VTRVI9JHtfQVBQX1JFRElTX1VTRVJ9JwogICAgICAtICdfQVBQX1JFRElTX1BBU1M9JHtfQVBQX1JFRElTX1BBU1N9JwogICAgICAtICdfQVBQX0RCX0hPU1Q9JHtfQVBQX0RCX0hPU1Q6LWFwcHdyaXRlLW1hcmlhZGJ9JwogICAgICAtICdfQVBQX0RCX1BPUlQ9JHtfQVBQX0RCX1BPUlQ6LTMzMDZ9JwogICAgICAtICdfQVBQX0RCX1NDSEVNQT0ke19BUFBfREJfU0NIRU1BOi1hcHB3cml0ZX0nCiAgICAgIC0gX0FQUF9EQl9VU0VSPSRTRVJWSUNFX1VTRVJfTUFSSUFEQgogICAgICAtIF9BUFBfREJfUEFTUz0kU0VSVklDRV9QQVNTV09SRF9NQVJJQURCCiAgICAgIC0gJ19BUFBfTE9HR0lOR19DT05GSUc9JHtfQVBQX0xPR0dJTkdfQ09ORklHfScKICAgICAgLSAnX0FQUF9TTVNfRlJPTT0ke19BUFBfU01TX0ZST019JwogICAgICAtICdfQVBQX1NNU19QUk9WSURFUj0ke19BUFBfU01TX1BST1ZJREVSfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0RFVklDRT0ke19BUFBfU1RPUkFHRV9ERVZJQ0U6LWxvY2FsfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1MzX0FDQ0VTU19LRVk9JHtfQVBQX1NUT1JBR0VfUzNfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9TM19TRUNSRVQ9JHtfQVBQX1NUT1JBR0VfUzNfU0VDUkVUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1MzX1JFR0lPTj0ke19BUFBfU1RPUkFHRV9TM19SRUdJT046LXVzLWVhc3QtMX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9TM19CVUNLRVQ9JHtfQVBQX1NUT1JBR0VfUzNfQlVDS0VUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1MzX0VORFBPSU5UPSR7X0FQUF9TVE9SQUdFX1MzX0VORFBPSU5UfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0RPX1NQQUNFU19BQ0NFU1NfS0VZPSR7X0FQUF9TVE9SQUdFX0RPX1NQQUNFU19BQ0NFU1NfS0VZfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0RPX1NQQUNFU19TRUNSRVQ9JHtfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX1NFQ1JFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9ET19TUEFDRVNfUkVHSU9OPSR7X0FQUF9TVE9SQUdFX0RPX1NQQUNFU19SRUdJT046LXVzLWVhc3QtMX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9ET19TUEFDRVNfQlVDS0VUPSR7X0FQUF9TVE9SQUdFX0RPX1NQQUNFU19CVUNLRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX0FDQ0VTU19LRVk9JHtfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX0FDQ0VTU19LRVl9JwogICAgICAtICdfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX1NFQ1JFVD0ke19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfU0VDUkVUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9SRUdJT049JHtfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX1JFR0lPTjotdXMtd2VzdC0wMDR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX0JVQ0tFVD0ke19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfQlVDS0VUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0xJTk9ERV9BQ0NFU1NfS0VZPSR7X0FQUF9TVE9SQUdFX0xJTk9ERV9BQ0NFU1NfS0VZfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0xJTk9ERV9TRUNSRVQ9JHtfQVBQX1NUT1JBR0VfTElOT0RFX1NFQ1JFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9MSU5PREVfUkVHSU9OPSR7X0FQUF9TVE9SQUdFX0xJTk9ERV9SRUdJT046LWV1LWNlbnRyYWwtMX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9MSU5PREVfQlVDS0VUPSR7X0FQUF9TVE9SQUdFX0xJTk9ERV9CVUNLRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfV0FTQUJJX0FDQ0VTU19LRVk9JHtfQVBQX1NUT1JBR0VfV0FTQUJJX0FDQ0VTU19LRVl9JwogICAgICAtICdfQVBQX1NUT1JBR0VfV0FTQUJJX1NFQ1JFVD0ke19BUFBfU1RPUkFHRV9XQVNBQklfU0VDUkVUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1dBU0FCSV9SRUdJT049JHtfQVBQX1NUT1JBR0VfV0FTQUJJX1JFR0lPTjotZXUtY2VudHJhbC0xfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1dBU0FCSV9CVUNLRVQ9JHtfQVBQX1NUT1JBR0VfV0FTQUJJX0JVQ0tFVH0nCiAgYXBwd3JpdGUtd29ya2VyLW1pZ3JhdGlvbnM6CiAgICBpbWFnZTogJ2FwcHdyaXRlL2FwcHdyaXRlOjEuNy40JwogICAgZW50cnlwb2ludDogd29ya2VyLW1pZ3JhdGlvbnMKICAgIGNvbnRhaW5lcl9uYW1lOiBhcHB3cml0ZS13b3JrZXItbWlncmF0aW9ucwogICAgdm9sdW1lczoKICAgICAgLSAnYXBwd3JpdGUtaW1wb3J0czovc3RvcmFnZS9pbXBvcnRzOnJ3JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcHB3cml0ZS1tYXJpYWRiCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnX0FQUF9FTlY9JHtfQVBQX0VOVjotcHJvZHVjdGlvbn0nCiAgICAgIC0gJ19BUFBfV09SS0VSX1BFUl9DT1JFPSR7X0FQUF9XT1JLRVJfUEVSX0NPUkU6LTZ9JwogICAgICAtIF9BUFBfT1BFTlNTTF9LRVlfVjE9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfQVBQV1JJVEUKICAgICAgLSBfQVBQX0RPTUFJTj0kU0VSVklDRV9GUUROX0FQUFdSSVRFCiAgICAgIC0gJ19BUFBfRE9NQUlOX1RBUkdFVF9DTkFNRT0ke19BUFBfRE9NQUlOX1RBUkdFVF9DTkFNRX0nCiAgICAgIC0gJ19BUFBfRE9NQUlOX1RBUkdFVF9BQUFBPSR7X0FQUF9ET01BSU5fVEFSR0VUX0FBQUF9JwogICAgICAtICdfQVBQX0RPTUFJTl9UQVJHRVRfQT0ke19BUFBfRE9NQUlOX1RBUkdFVF9BfScKICAgICAgLSAnX0FQUF9FTUFJTF9TRUNVUklUWT0ke19BUFBfRU1BSUxfU0VDVVJJVFk6LWNlcnRzQGFwcHdyaXRlLmlvfScKICAgICAgLSAnX0FQUF9SRURJU19IT1NUPSR7X0FQUF9SRURJU19IT1NUOi1hcHB3cml0ZS1yZWRpc30nCiAgICAgIC0gJ19BUFBfUkVESVNfUE9SVD0ke19BUFBfUkVESVNfUE9SVDotNjM3OX0nCiAgICAgIC0gJ19BUFBfUkVESVNfVVNFUj0ke19BUFBfUkVESVNfVVNFUn0nCiAgICAgIC0gJ19BUFBfUkVESVNfUEFTUz0ke19BUFBfUkVESVNfUEFTU30nCiAgICAgIC0gJ19BUFBfREJfSE9TVD0ke19BUFBfREJfSE9TVDotYXBwd3JpdGUtbWFyaWFkYn0nCiAgICAgIC0gJ19BUFBfREJfUE9SVD0ke19BUFBfREJfUE9SVDotMzMwNn0nCiAgICAgIC0gJ19BUFBfREJfU0NIRU1BPSR7X0FQUF9EQl9TQ0hFTUE6LWFwcHdyaXRlfScKICAgICAgLSBfQVBQX0RCX1VTRVI9JFNFUlZJQ0VfVVNFUl9NQVJJQURCCiAgICAgIC0gX0FQUF9EQl9QQVNTPSRTRVJWSUNFX1BBU1NXT1JEX01BUklBREIKICAgICAgLSAnX0FQUF9MT0dHSU5HX0NPTkZJRz0ke19BUFBfTE9HR0lOR19DT05GSUd9JwogICAgICAtICdfQVBQX01JR1JBVElPTlNfRklSRUJBU0VfQ0xJRU5UX0lEPSR7X0FQUF9NSUdSQVRJT05TX0ZJUkVCQVNFX0NMSUVOVF9JRH0nCiAgICAgIC0gJ19BUFBfTUlHUkFUSU9OU19GSVJFQkFTRV9DTElFTlRfU0VDUkVUPSR7X0FQUF9NSUdSQVRJT05TX0ZJUkVCQVNFX0NMSUVOVF9TRUNSRVR9JwogIGFwcHdyaXRlLXRhc2stbWFpbnRlbmFuY2U6CiAgICBpbWFnZTogJ2FwcHdyaXRlL2FwcHdyaXRlOjEuNy40JwogICAgZW50cnlwb2ludDogbWFpbnRlbmFuY2UKICAgIGNvbnRhaW5lcl9uYW1lOiBhcHB3cml0ZS10YXNrLW1haW50ZW5hbmNlCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwcHdyaXRlLXJlZGlzCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnX0FQUF9FTlY9JHtfQVBQX0VOVjotcHJvZHVjdGlvbn0nCiAgICAgIC0gJ19BUFBfV09SS0VSX1BFUl9DT1JFPSR7X0FQUF9XT1JLRVJfUEVSX0NPUkU6LTZ9JwogICAgICAtIF9BUFBfRE9NQUlOPSRTRVJWSUNFX0ZRRE5fQVBQV1JJVEUKICAgICAgLSAnX0FQUF9ET01BSU5fVEFSR0VUX0NOQU1FPSR7X0FQUF9ET01BSU5fVEFSR0VUX0NOQU1FfScKICAgICAgLSAnX0FQUF9ET01BSU5fVEFSR0VUX0FBQUE9JHtfQVBQX0RPTUFJTl9UQVJHRVRfQUFBQX0nCiAgICAgIC0gJ19BUFBfRE9NQUlOX1RBUkdFVF9BPSR7X0FQUF9ET01BSU5fVEFSR0VUX0F9JwogICAgICAtIF9BUFBfRE9NQUlOX0ZVTkNUSU9OUz0kU0VSVklDRV9GUUROX0FQUFdSSVRFCiAgICAgIC0gX0FQUF9PUEVOU1NMX0tFWV9WMT0kU0VSVklDRV9QQVNTV09SRF82NF9BUFBXUklURQogICAgICAtICdfQVBQX1JFRElTX0hPU1Q9JHtfQVBQX1JFRElTX0hPU1Q6LWFwcHdyaXRlLXJlZGlzfScKICAgICAgLSAnX0FQUF9SRURJU19QT1JUPSR7X0FQUF9SRURJU19QT1JUOi02Mzc5fScKICAgICAgLSAnX0FQUF9SRURJU19VU0VSPSR7X0FQUF9SRURJU19VU0VSfScKICAgICAgLSAnX0FQUF9SRURJU19QQVNTPSR7X0FQUF9SRURJU19QQVNTfScKICAgICAgLSAnX0FQUF9EQl9IT1NUPSR7X0FQUF9EQl9IT1NUOi1hcHB3cml0ZS1tYXJpYWRifScKICAgICAgLSAnX0FQUF9EQl9QT1JUPSR7X0FQUF9EQl9QT1JUOi0zMzA2fScKICAgICAgLSAnX0FQUF9EQl9TQ0hFTUE9JHtfQVBQX0RCX1NDSEVNQTotYXBwd3JpdGV9JwogICAgICAtIF9BUFBfREJfVVNFUj0kU0VSVklDRV9VU0VSX01BUklBREIKICAgICAgLSBfQVBQX0RCX1BBU1M9JFNFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQgogICAgICAtICdfQVBQX01BSU5URU5BTkNFX0lOVEVSVkFMPSR7X0FQUF9NQUlOVEVOQU5DRV9JTlRFUlZBTH0nCiAgICAgIC0gJ19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0VYRUNVVElPTj0ke19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0VYRUNVVElPTn0nCiAgICAgIC0gJ19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0NBQ0hFPSR7X0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fQ0FDSEU6LTI1OTIwMDB9JwogICAgICAtICdfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9BQlVTRT0ke19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0FCVVNFOi04NjQwMH0nCiAgICAgIC0gJ19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0FVRElUPSR7X0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fQVVESVQ6LTEyMDk2MDB9JwogICAgICAtICdfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9BVURJVF9DT05TT0xFPSR7X0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fQVVESVRfQ09OU09MRX0nCiAgICAgIC0gJ19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX1VTQUdFX0hPVVJMWT0ke19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX1VTQUdFX0hPVVJMWTotODY0MDAwMH0nCiAgICAgIC0gJ19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX1NDSEVEVUxFUz0ke19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX1NDSEVEVUxFUzotODY0MDB9JwogIGFwcHdyaXRlLXRhc2stc3RhdHMtcmVzb3VyY2VzOgogICAgaW1hZ2U6ICdhcHB3cml0ZS9hcHB3cml0ZToxLjcuNCcKICAgIGNvbnRhaW5lcl9uYW1lOiBhcHB3cml0ZS10YXNrLXN0YXRzLXJlc291cmNlcwogICAgZW50cnlwb2ludDogc3RhdHMtcmVzb3VyY2VzCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwcHdyaXRlLXJlZGlzCiAgICAgIC0gYXBwd3JpdGUtbWFyaWFkYgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ19BUFBfRU5WPSR7X0FQUF9FTlY6LXByb2R1Y3Rpb259JwogICAgICAtICdfQVBQX1dPUktFUl9QRVJfQ09SRT0ke19BUFBfV09SS0VSX1BFUl9DT1JFOi02fScKICAgICAgLSBfQVBQX09QRU5TU0xfS0VZX1YxPSRTRVJWSUNFX1BBU1NXT1JEXzY0X0FQUFdSSVRFCiAgICAgIC0gJ19BUFBfREJfSE9TVD0ke19BUFBfREJfSE9TVDotYXBwd3JpdGUtbWFyaWFkYn0nCiAgICAgIC0gJ19BUFBfREJfUE9SVD0ke19BUFBfREJfUE9SVDotMzMwNn0nCiAgICAgIC0gJ19BUFBfREJfU0NIRU1BPSR7X0FQUF9EQl9TQ0hFTUE6LWFwcHdyaXRlfScKICAgICAgLSBfQVBQX0RCX1VTRVI9JFNFUlZJQ0VfVVNFUl9NQVJJQURCCiAgICAgIC0gX0FQUF9EQl9QQVNTPSRTRVJWSUNFX1BBU1NXT1JEX01BUklBREIKICAgICAgLSAnX0FQUF9SRURJU19IT1NUPSR7X0FQUF9SRURJU19IT1NUOi1hcHB3cml0ZS1yZWRpc30nCiAgICAgIC0gJ19BUFBfUkVESVNfUE9SVD0ke19BUFBfUkVESVNfUE9SVDotNjM3OX0nCiAgICAgIC0gJ19BUFBfUkVESVNfVVNFUj0ke19BUFBfUkVESVNfVVNFUn0nCiAgICAgIC0gJ19BUFBfUkVESVNfUEFTUz0ke19BUFBfUkVESVNfUEFTU30nCiAgICAgIC0gJ19BUFBfVVNBR0VfU1RBVFM9JHtfQVBQX1VTQUdFX1NUQVRTOi1lbmFibGVkfScKICAgICAgLSAnX0FQUF9MT0dHSU5HX0NPTkZJRz0ke19BUFBfTE9HR0lOR19DT05GSUd9JwogICAgICAtICdfQVBQX0RBVEFCQVNFX1NIQVJFRF9UQUJMRVM9JHtfQVBQX0RBVEFCQVNFX1NIQVJFRF9UQUJMRVN9JwogICAgICAtICdfQVBQX1NUQVRTX1JFU09VUkNFU19JTlRFUlZBTD0ke19BUFBfU1RBVFNfUkVTT1VSQ0VTX0lOVEVSVkFMfScKICBhcHB3cml0ZS13b3JrZXItc3RhdHMtcmVzb3VyY2VzOgogICAgaW1hZ2U6ICdhcHB3cml0ZS9hcHB3cml0ZToxLjcuNCcKICAgIGVudHJ5cG9pbnQ6IHdvcmtlci1zdGF0cy1yZXNvdXJjZXMKICAgIGNvbnRhaW5lcl9uYW1lOiBhcHB3cml0ZS13b3JrZXItc3RhdHMtcmVzb3VyY2VzCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwcHdyaXRlLXJlZGlzCiAgICAgIC0gYXBwd3JpdGUtbWFyaWFkYgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ19BUFBfRU5WPSR7X0FQUF9FTlY6LXByb2R1Y3Rpb259JwogICAgICAtICdfQVBQX1dPUktFUl9QRVJfQ09SRT0ke19BUFBfV09SS0VSX1BFUl9DT1JFOi02fScKICAgICAgLSBfQVBQX09QRU5TU0xfS0VZX1YxPSRTRVJWSUNFX1BBU1NXT1JEXzY0X0FQUFdSSVRFCiAgICAgIC0gJ19BUFBfREJfSE9TVD0ke19BUFBfREJfSE9TVDotYXBwd3JpdGUtbWFyaWFkYn0nCiAgICAgIC0gJ19BUFBfREJfUE9SVD0ke19BUFBfREJfUE9SVDotMzMwNn0nCiAgICAgIC0gJ19BUFBfREJfU0NIRU1BPSR7X0FQUF9EQl9TQ0hFTUE6LWFwcHdyaXRlfScKICAgICAgLSBfQVBQX0RCX1VTRVI9JFNFUlZJQ0VfVVNFUl9NQVJJQURCCiAgICAgIC0gX0FQUF9EQl9QQVNTPSRTRVJWSUNFX1BBU1NXT1JEX01BUklBREIKICAgICAgLSAnX0FQUF9SRURJU19IT1NUPSR7X0FQUF9SRURJU19IT1NUOi1hcHB3cml0ZS1yZWRpc30nCiAgICAgIC0gJ19BUFBfUkVESVNfUE9SVD0ke19BUFBfUkVESVNfUE9SVDotNjM3OX0nCiAgICAgIC0gJ19BUFBfUkVESVNfVVNFUj0ke19BUFBfUkVESVNfVVNFUn0nCiAgICAgIC0gJ19BUFBfUkVESVNfUEFTUz0ke19BUFBfUkVESVNfUEFTU30nCiAgICAgIC0gJ19BUFBfVVNBR0VfU1RBVFM9JHtfQVBQX1VTQUdFX1NUQVRTOi1lbmFibGVkfScKICAgICAgLSAnX0FQUF9MT0dHSU5HX0NPTkZJRz0ke19BUFBfTE9HR0lOR19DT05GSUd9JwogICAgICAtICdfQVBQX1NUQVRTX1JFU09VUkNFU19JTlRFUlZBTD0ke19BUFBfU1RBVFNfUkVTT1VSQ0VTX0lOVEVSVkFMfScKICBhcHB3cml0ZS13b3JrZXItc3RhdHMtdXNhZ2U6CiAgICBpbWFnZTogJ2FwcHdyaXRlL2FwcHdyaXRlOjEuNy40JwogICAgZW50cnlwb2ludDogd29ya2VyLXN0YXRzLXVzYWdlCiAgICBjb250YWluZXJfbmFtZTogYXBwd3JpdGUtd29ya2VyLXN0YXRzLXVzYWdlCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwcHdyaXRlLXJlZGlzCiAgICAgIC0gYXBwd3JpdGUtbWFyaWFkYgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ19BUFBfRU5WPSR7X0FQUF9FTlY6LXByb2R1Y3Rpb259JwogICAgICAtICdfQVBQX1dPUktFUl9QRVJfQ09SRT0ke19BUFBfV09SS0VSX1BFUl9DT1JFOi02fScKICAgICAgLSBfQVBQX09QRU5TU0xfS0VZX1YxPSRTRVJWSUNFX1BBU1NXT1JEXzY0X0FQUFdSSVRFCiAgICAgIC0gJ19BUFBfREJfSE9TVD0ke19BUFBfREJfSE9TVDotYXBwd3JpdGUtbWFyaWFkYn0nCiAgICAgIC0gJ19BUFBfREJfUE9SVD0ke19BUFBfREJfUE9SVDotMzMwNn0nCiAgICAgIC0gJ19BUFBfREJfU0NIRU1BPSR7X0FQUF9EQl9TQ0hFTUE6LWFwcHdyaXRlfScKICAgICAgLSBfQVBQX0RCX1VTRVI9JFNFUlZJQ0VfVVNFUl9NQVJJQURCCiAgICAgIC0gX0FQUF9EQl9QQVNTPSRTRVJWSUNFX1BBU1NXT1JEX01BUklBREIKICAgICAgLSAnX0FQUF9SRURJU19IT1NUPSR7X0FQUF9SRURJU19IT1NUOi1hcHB3cml0ZS1yZWRpc30nCiAgICAgIC0gJ19BUFBfUkVESVNfUE9SVD0ke19BUFBfUkVESVNfUE9SVDotNjM3OX0nCiAgICAgIC0gJ19BUFBfUkVESVNfVVNFUj0ke19BUFBfUkVESVNfVVNFUn0nCiAgICAgIC0gJ19BUFBfUkVESVNfUEFTUz0ke19BUFBfUkVESVNfUEFTU30nCiAgICAgIC0gJ19BUFBfVVNBR0VfU1RBVFM9JHtfQVBQX1VTQUdFX1NUQVRTOi1lbmFibGVkfScKICAgICAgLSAnX0FQUF9MT0dHSU5HX0NPTkZJRz0ke19BUFBfTE9HR0lOR19DT05GSUd9JwogICAgICAtICdfQVBQX1VTQUdFX0FHR1JFR0FUSU9OX0lOVEVSVkFMPSR7X0FQUF9VU0FHRV9BR0dSRUdBVElPTl9JTlRFUlZBTDotMzB9JwogIGFwcHdyaXRlLXRhc2stc2NoZWR1bGVyLWZ1bmN0aW9uczoKICAgIGltYWdlOiAnYXBwd3JpdGUvYXBwd3JpdGU6MS43LjQnCiAgICBlbnRyeXBvaW50OiBzY2hlZHVsZS1mdW5jdGlvbnMKICAgIGNvbnRhaW5lcl9uYW1lOiBhcHB3cml0ZS10YXNrLXNjaGVkdWxlci1mdW5jdGlvbnMKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBwd3JpdGUtbWFyaWFkYgogICAgICAtIGFwcHdyaXRlLXJlZGlzCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnX0FQUF9FTlY9JHtfQVBQX0VOVjotcHJvZHVjdGlvbn0nCiAgICAgIC0gJ19BUFBfV09SS0VSX1BFUl9DT1JFPSR7X0FQUF9XT1JLRVJfUEVSX0NPUkU6LTZ9JwogICAgICAtIF9BUFBfT1BFTlNTTF9LRVlfVjE9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfQVBQV1JJVEUKICAgICAgLSAnX0FQUF9SRURJU19IT1NUPSR7X0FQUF9SRURJU19IT1NUOi1hcHB3cml0ZS1yZWRpc30nCiAgICAgIC0gJ19BUFBfUkVESVNfUE9SVD0ke19BUFBfUkVESVNfUE9SVDotNjM3OX0nCiAgICAgIC0gJ19BUFBfUkVESVNfVVNFUj0ke19BUFBfUkVESVNfVVNFUn0nCiAgICAgIC0gJ19BUFBfUkVESVNfUEFTUz0ke19BUFBfUkVESVNfUEFTU30nCiAgICAgIC0gJ19BUFBfREJfSE9TVD0ke19BUFBfREJfSE9TVDotYXBwd3JpdGUtbWFyaWFkYn0nCiAgICAgIC0gJ19BUFBfREJfUE9SVD0ke19BUFBfREJfUE9SVDotMzMwNn0nCiAgICAgIC0gJ19BUFBfREJfU0NIRU1BPSR7X0FQUF9EQl9TQ0hFTUE6LWFwcHdyaXRlfScKICAgICAgLSBfQVBQX0RCX1VTRVI9JFNFUlZJQ0VfVVNFUl9NQVJJQURCCiAgICAgIC0gX0FQUF9EQl9QQVNTPSRTRVJWSUNFX1BBU1NXT1JEX01BUklBREIKICBhcHB3cml0ZS10YXNrLXNjaGVkdWxlci1leGVjdXRpb25zOgogICAgaW1hZ2U6ICdhcHB3cml0ZS9hcHB3cml0ZToxLjcuNCcKICAgIGVudHJ5cG9pbnQ6IHNjaGVkdWxlLWV4ZWN1dGlvbnMKICAgIGNvbnRhaW5lcl9uYW1lOiBhcHB3cml0ZS10YXNrLXNjaGVkdWxlci1leGVjdXRpb25zCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwcHdyaXRlLW1hcmlhZGIKICAgICAgLSBhcHB3cml0ZS1yZWRpcwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ19BUFBfRU5WPSR7X0FQUF9FTlY6LXByb2R1Y3Rpb259JwogICAgICAtICdfQVBQX1dPUktFUl9QRVJfQ09SRT0ke19BUFBfV09SS0VSX1BFUl9DT1JFOi02fScKICAgICAgLSBfQVBQX09QRU5TU0xfS0VZX1YxPSRTRVJWSUNFX1BBU1NXT1JEXzY0X0FQUFdSSVRFCiAgICAgIC0gJ19BUFBfUkVESVNfSE9TVD0ke19BUFBfUkVESVNfSE9TVDotYXBwd3JpdGUtcmVkaXN9JwogICAgICAtICdfQVBQX1JFRElTX1BPUlQ9JHtfQVBQX1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICAtICdfQVBQX1JFRElTX1VTRVI9JHtfQVBQX1JFRElTX1VTRVJ9JwogICAgICAtICdfQVBQX1JFRElTX1BBU1M9JHtfQVBQX1JFRElTX1BBU1N9JwogICAgICAtICdfQVBQX0RCX0hPU1Q9JHtfQVBQX0RCX0hPU1Q6LWFwcHdyaXRlLW1hcmlhZGJ9JwogICAgICAtICdfQVBQX0RCX1BPUlQ9JHtfQVBQX0RCX1BPUlQ6LTMzMDZ9JwogICAgICAtICdfQVBQX0RCX1NDSEVNQT0ke19BUFBfREJfU0NIRU1BOi1hcHB3cml0ZX0nCiAgICAgIC0gX0FQUF9EQl9VU0VSPSRTRVJWSUNFX1VTRVJfTUFSSUFEQgogICAgICAtIF9BUFBfREJfUEFTUz0kU0VSVklDRV9QQVNTV09SRF9NQVJJQURCCiAgYXBwd3JpdGUtdGFzay1zY2hlZHVsZXItbWVzc2FnZXM6CiAgICBpbWFnZTogJ2FwcHdyaXRlL2FwcHdyaXRlOjEuNy40JwogICAgZW50cnlwb2ludDogc2NoZWR1bGUtbWVzc2FnZXMKICAgIGNvbnRhaW5lcl9uYW1lOiBhcHB3cml0ZS10YXNrLXNjaGVkdWxlci1tZXNzYWdlcwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcHB3cml0ZS1tYXJpYWRiCiAgICAgIC0gYXBwd3JpdGUtcmVkaXMKICAgIGVudmlyb25tZW50OgogICAgICAtICdfQVBQX0VOVj0ke19BUFBfRU5WOi1wcm9kdWN0aW9ufScKICAgICAgLSAnX0FQUF9XT1JLRVJfUEVSX0NPUkU9JHtfQVBQX1dPUktFUl9QRVJfQ09SRTotNn0nCiAgICAgIC0gX0FQUF9PUEVOU1NMX0tFWV9WMT0kU0VSVklDRV9QQVNTV09SRF82NF9BUFBXUklURQogICAgICAtICdfQVBQX1JFRElTX0hPU1Q9JHtfQVBQX1JFRElTX0hPU1Q6LWFwcHdyaXRlLXJlZGlzfScKICAgICAgLSAnX0FQUF9SRURJU19QT1JUPSR7X0FQUF9SRURJU19QT1JUOi02Mzc5fScKICAgICAgLSAnX0FQUF9SRURJU19VU0VSPSR7X0FQUF9SRURJU19VU0VSfScKICAgICAgLSAnX0FQUF9SRURJU19QQVNTPSR7X0FQUF9SRURJU19QQVNTfScKICAgICAgLSAnX0FQUF9EQl9IT1NUPSR7X0FQUF9EQl9IT1NUOi1hcHB3cml0ZS1tYXJpYWRifScKICAgICAgLSAnX0FQUF9EQl9QT1JUPSR7X0FQUF9EQl9QT1JUOi0zMzA2fScKICAgICAgLSAnX0FQUF9EQl9TQ0hFTUE9JHtfQVBQX0RCX1NDSEVNQTotYXBwd3JpdGV9JwogICAgICAtIF9BUFBfREJfVVNFUj0kU0VSVklDRV9VU0VSX01BUklBREIKICAgICAgLSBfQVBQX0RCX1BBU1M9JFNFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQgogIGFwcHdyaXRlLWFzc2lzdGFudDoKICAgIGltYWdlOiAnYXBwd3JpdGUvYXNzaXN0YW50OjAuNC4wJwogICAgY29udGFpbmVyX25hbWU6IGFwcHdyaXRlLWFzc2lzdGFudAogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ19BUFBfQVNTSVNUQU5UX09QRU5BSV9BUElfS0VZPSR7X0FQUF9BU1NJU1RBTlRfT1BFTkFJX0FQSV9LRVl9JwogIGFwcHdyaXRlLWJyb3dzZXI6CiAgICBpbWFnZTogJ2FwcHdyaXRlL2Jyb3dzZXI6MC4yLjQnCiAgICBjb250YWluZXJfbmFtZTogYXBwd3JpdGUtYnJvd3NlcgogIG9wZW5ydW50aW1lcy1leGVjdXRvcjoKICAgIGNvbnRhaW5lcl9uYW1lOiBvcGVucnVudGltZXMtZXhlY3V0b3IKICAgIGhvc3RuYW1lOiBhcHB3cml0ZS1leGVjdXRvcgogICAgc3RvcF9zaWduYWw6IFNJR0lOVAogICAgaW1hZ2U6ICdvcGVucnVudGltZXMvZXhlY3V0b3I6MC43LjE0JwogICAgbmV0d29ya3M6CiAgICAgIC0gcnVudGltZXMKICAgIHZvbHVtZXM6CiAgICAgIC0gJy92YXIvcnVuL2RvY2tlci5zb2NrOi92YXIvcnVuL2RvY2tlci5zb2NrJwogICAgICAtICdhcHB3cml0ZS1idWlsZHM6L3N0b3JhZ2UvYnVpbGRzOnJ3JwogICAgICAtICdhcHB3cml0ZS1mdW5jdGlvbnM6L3N0b3JhZ2UvZnVuY3Rpb25zOnJ3JwogICAgICAtICdhcHB3cml0ZS1zaXRlczovc3RvcmFnZS9zaXRlczpydycKICAgICAgLSAnL3RtcDovdG1wOnJ3JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ09QUl9FWEVDVVRPUl9JTkFDVElWRV9UUkVTSE9MRD0ke19BUFBfQ09NUFVURV9JTkFDVElWRV9USFJFU0hPTER9JwogICAgICAtICdPUFJfRVhFQ1VUT1JfTUFJTlRFTkFOQ0VfSU5URVJWQUw9JHtfQVBQX0NPTVBVVEVfTUFJTlRFTkFOQ0VfSU5URVJWQUx9JwogICAgICAtICdPUFJfRVhFQ1VUT1JfTkVUV09SSz0ke19BUFBfQ09NUFVURV9SVU5USU1FU19ORVRXT1JLOi1ydW50aW1lc30nCiAgICAgIC0gJ09QUl9FWEVDVVRPUl9ET0NLRVJfSFVCX1VTRVJOQU1FPSR7X0FQUF9ET0NLRVJfSFVCX1VTRVJOQU1FfScKICAgICAgLSAnT1BSX0VYRUNVVE9SX0RPQ0tFUl9IVUJfUEFTU1dPUkQ9JHtfQVBQX0RPQ0tFUl9IVUJfUEFTU1dPUkR9JwogICAgICAtICdPUFJfRVhFQ1VUT1JfRU5WPSR7X0FQUF9FTlY6LXByb2R1Y3Rpb259JwogICAgICAtICdPUFJfRVhFQ1VUT1JfUlVOVElNRVM9JHtfQVBQX0ZVTkNUSU9OU19SVU5USU1FU30sJHtfQVBQX1NJVEVTX1JVTlRJTUVTfScKICAgICAgLSBPUFJfRVhFQ1VUT1JfU0VDUkVUPSRTRVJWSUNFX1BBU1NXT1JEXzY0X0FQUFdSSVRFCiAgICAgIC0gT1BSX0VYRUNVVE9SX1JVTlRJTUVfVkVSU0lPTlM9djUKICAgICAgLSAnT1BSX0VYRUNVVE9SX0xPR0dJTkdfQ09ORklHPSR7X0FQUF9MT0dHSU5HX0NPTkZJR30nCiAgICAgIC0gJ09QUl9FWEVDVVRPUl9TVE9SQUdFX0RFVklDRT0ke19BUFBfU1RPUkFHRV9ERVZJQ0U6LWxvY2FsfScKICAgICAgLSAnT1BSX0VYRUNVVE9SX1NUT1JBR0VfUzNfQUNDRVNTX0tFWT0ke19BUFBfU1RPUkFHRV9TM19BQ0NFU1NfS0VZfScKICAgICAgLSAnT1BSX0VYRUNVVE9SX1NUT1JBR0VfUzNfU0VDUkVUPSR7X0FQUF9TVE9SQUdFX1MzX1NFQ1JFVH0nCiAgICAgIC0gJ09QUl9FWEVDVVRPUl9TVE9SQUdFX1MzX1JFR0lPTj0ke19BUFBfU1RPUkFHRV9TM19SRUdJT059JwogICAgICAtICdPUFJfRVhFQ1VUT1JfU1RPUkFHRV9TM19CVUNLRVQ9JHtfQVBQX1NUT1JBR0VfUzNfQlVDS0VUfScKICAgICAgLSAnT1BSX0VYRUNVVE9SX1NUT1JBR0VfUzNfRU5EUE9JTlQ9JHtfQVBQX1NUT1JBR0VfUzNfRU5EUE9JTlR9JwogICAgICAtICdPUFJfRVhFQ1VUT1JfU1RPUkFHRV9ET19TUEFDRVNfQUNDRVNTX0tFWT0ke19BUFBfU1RPUkFHRV9ET19TUEFDRVNfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ09QUl9FWEVDVVRPUl9TVE9SQUdFX0RPX1NQQUNFU19TRUNSRVQ9JHtfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX1NFQ1JFVH0nCiAgICAgIC0gJ09QUl9FWEVDVVRPUl9TVE9SQUdFX0RPX1NQQUNFU19SRUdJT049JHtfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX1JFR0lPTn0nCiAgICAgIC0gJ09QUl9FWEVDVVRPUl9TVE9SQUdFX0RPX1NQQUNFU19CVUNLRVQ9JHtfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX0JVQ0tFVH0nCiAgICAgIC0gJ09QUl9FWEVDVVRPUl9TVE9SQUdFX0JBQ0tCTEFaRV9BQ0NFU1NfS0VZPSR7X0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9BQ0NFU1NfS0VZfScKICAgICAgLSAnT1BSX0VYRUNVVE9SX1NUT1JBR0VfQkFDS0JMQVpFX1NFQ1JFVD0ke19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfU0VDUkVUfScKICAgICAgLSAnT1BSX0VYRUNVVE9SX1NUT1JBR0VfQkFDS0JMQVpFX1JFR0lPTj0ke19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfUkVHSU9OfScKICAgICAgLSAnT1BSX0VYRUNVVE9SX1NUT1JBR0VfQkFDS0JMQVpFX0JVQ0tFVD0ke19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfQlVDS0VUfScKICAgICAgLSAnT1BSX0VYRUNVVE9SX1NUT1JBR0VfTElOT0RFX0FDQ0VTU19LRVk9JHtfQVBQX1NUT1JBR0VfTElOT0RFX0FDQ0VTU19LRVl9JwogICAgICAtICdPUFJfRVhFQ1VUT1JfU1RPUkFHRV9MSU5PREVfU0VDUkVUPSR7X0FQUF9TVE9SQUdFX0xJTk9ERV9TRUNSRVR9JwogICAgICAtICdPUFJfRVhFQ1VUT1JfU1RPUkFHRV9MSU5PREVfUkVHSU9OPSR7X0FQUF9TVE9SQUdFX0xJTk9ERV9SRUdJT059JwogICAgICAtICdPUFJfRVhFQ1VUT1JfU1RPUkFHRV9MSU5PREVfQlVDS0VUPSR7X0FQUF9TVE9SQUdFX0xJTk9ERV9CVUNLRVR9JwogICAgICAtICdPUFJfRVhFQ1VUT1JfU1RPUkFHRV9XQVNBQklfQUNDRVNTX0tFWT0ke19BUFBfU1RPUkFHRV9XQVNBQklfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ09QUl9FWEVDVVRPUl9TVE9SQUdFX1dBU0FCSV9TRUNSRVQ9JHtfQVBQX1NUT1JBR0VfV0FTQUJJX1NFQ1JFVH0nCiAgICAgIC0gJ09QUl9FWEVDVVRPUl9TVE9SQUdFX1dBU0FCSV9SRUdJT049JHtfQVBQX1NUT1JBR0VfV0FTQUJJX1JFR0lPTn0nCiAgICAgIC0gJ09QUl9FWEVDVVRPUl9TVE9SQUdFX1dBU0FCSV9CVUNLRVQ9JHtfQVBQX1NUT1JBR0VfV0FTQUJJX0JVQ0tFVH0nCiAgYXBwd3JpdGUtbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMC4xMScKICAgIGNvbnRhaW5lcl9uYW1lOiBhcHB3cml0ZS1tYXJpYWRiCiAgICB2b2x1bWVzOgogICAgICAtICdhcHB3cml0ZS1tYXJpYWRiOi92YXIvbGliL215c3FsOnJ3JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTVlTUUxfUk9PVF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NQVJJQURCUk9PVAogICAgICAtICdNWVNRTF9EQVRBQkFTRT0ke19BUFBfREJfU0NIRU1BOi1hcHB3cml0ZX0nCiAgICAgIC0gTVlTUUxfVVNFUj0kU0VSVklDRV9VU0VSX01BUklBREIKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NQVJJQURCCiAgICAgIC0gTUFSSUFEQl9BVVRPX1VQR1JBREU9MQogICAgY29tbWFuZDogJ215c3FsZCAtLWlubm9kYi1mbHVzaC1tZXRob2Q9ZnN5bmMnCiAgYXBwd3JpdGUtcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjcuMi40LWFscGluZScKICAgIGNvbnRhaW5lcl9uYW1lOiBhcHB3cml0ZS1yZWRpcwogICAgY29tbWFuZDogInJlZGlzLXNlcnZlciAtLW1heG1lbW9yeSAgICAgICAgICAgIDUxMm1iIC0tbWF4bWVtb3J5LXBvbGljeSAgICAgYWxsa2V5cy1scnUgLS1tYXhtZW1vcnktc2FtcGxlcyAgICA1XG4iCiAgICB2b2x1bWVzOgogICAgICAtICdhcHB3cml0ZS1yZWRpczovZGF0YTpydycKbmV0d29ya3M6CiAgcnVudGltZXM6CiAgICBuYW1lOiBydW50aW1lcwp2b2x1bWVzOgogIGFwcHdyaXRlLW1hcmlhZGI6IG51bGwKICBhcHB3cml0ZS1yZWRpczogbnVsbAogIGFwcHdyaXRlLWNhY2hlOiBudWxsCiAgYXBwd3JpdGUtdXBsb2FkczogbnVsbAogIGFwcHdyaXRlLWltcG9ydHM6IG51bGwKICBhcHB3cml0ZS1jZXJ0aWZpY2F0ZXM6IG51bGwKICBhcHB3cml0ZS1mdW5jdGlvbnM6IG51bGwKICBhcHB3cml0ZS1zaXRlczogbnVsbAogIGFwcHdyaXRlLWJ1aWxkczogbnVsbAogIGFwcHdyaXRlLWNvbmZpZzogbnVsbAo=", + "compose": "c2VydmljZXM6CiAgYXBwd3JpdGU6CiAgICBpbWFnZTogJ2FwcHdyaXRlL2FwcHdyaXRlOjEuNy40JwogICAgY29udGFpbmVyX25hbWU6IGFwcHdyaXRlCiAgICB2b2x1bWVzOgogICAgICAtICdhcHB3cml0ZS11cGxvYWRzOi9zdG9yYWdlL3VwbG9hZHM6cncnCiAgICAgIC0gJ2FwcHdyaXRlLWltcG9ydHM6L3N0b3JhZ2UvaW1wb3J0czpydycKICAgICAgLSAnYXBwd3JpdGUtY2FjaGU6L3N0b3JhZ2UvY2FjaGU6cncnCiAgICAgIC0gJ2FwcHdyaXRlLWNvbmZpZzovc3RvcmFnZS9jb25maWc6cncnCiAgICAgIC0gJ2FwcHdyaXRlLWNlcnRpZmljYXRlczovc3RvcmFnZS9jZXJ0aWZpY2F0ZXM6cncnCiAgICAgIC0gJ2FwcHdyaXRlLWZ1bmN0aW9uczovc3RvcmFnZS9mdW5jdGlvbnM6cncnCiAgICAgIC0gJ2FwcHdyaXRlLXNpdGVzOi9zdG9yYWdlL3NpdGVzOnJ3JwogICAgICAtICdhcHB3cml0ZS1idWlsZHM6L3N0b3JhZ2UvYnVpbGRzOnJ3JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcHB3cml0ZS1tYXJpYWRiCiAgICAgIC0gYXBwd3JpdGUtcmVkaXMKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9BUFBXUklURT0vCiAgICAgIC0gJ19BUFBfRU5WPSR7X0FQUF9FTlY6LXByb2R1Y3Rpb259JwogICAgICAtICdfQVBQX0VESVRJT049JHtfQVBQX0VESVRJT046LXNlbGYtaG9zdGVkfScKICAgICAgLSAnX0FQUF9XT1JLRVJfUEVSX0NPUkU9JHtfQVBQX1dPUktFUl9QRVJfQ09SRTotNn0nCiAgICAgIC0gJ19BUFBfTE9DQUxFPSR7X0FQUF9MT0NBTEU6LWVufScKICAgICAgLSAnX0FQUF9DT01QUkVTU0lPTl9NSU5fU0laRV9CWVRFUz0ke19BUFBfQ09NUFJFU1NJT05fTUlOX1NJWkVfQllURVN9JwogICAgICAtICdfQVBQX0NPTlNPTEVfV0hJVEVMSVNUX1JPT1Q9JHtfQVBQX0NPTlNPTEVfV0hJVEVMSVNUX1JPT1Q6LWVuYWJsZWR9JwogICAgICAtICdfQVBQX0NPTlNPTEVfV0hJVEVMSVNUX0VNQUlMUz0ke19BUFBfQ09OU09MRV9XSElURUxJU1RfRU1BSUxTfScKICAgICAgLSAnX0FQUF9DT05TT0xFX1NFU1NJT05fQUxFUlRTPSR7X0FQUF9DT05TT0xFX1NFU1NJT05fQUxFUlRTfScKICAgICAgLSAnX0FQUF9DT05TT0xFX1dISVRFTElTVF9JUFM9JHtfQVBQX0NPTlNPTEVfV0hJVEVMSVNUX0lQU30nCiAgICAgIC0gJ19BUFBfQ09OU09MRV9IT1NUTkFNRVM9JHtfQVBQX0NPTlNPTEVfSE9TVE5BTUVTfScKICAgICAgLSAnX0FQUF9TWVNURU1fRU1BSUxfTkFNRT0ke19BUFBfU1lTVEVNX0VNQUlMX05BTUU6LUFwcHdyaXRlfScKICAgICAgLSAnX0FQUF9TWVNURU1fRU1BSUxfQUREUkVTUz0ke19BUFBfU1lTVEVNX0VNQUlMX0FERFJFU1M6LXRlYW1AYXBwd3JpdGUuaW99JwogICAgICAtICdfQVBQX1NZU1RFTV9URUFNX0VNQUlMPSR7X0FQUF9TWVNURU1fVEVBTV9FTUFJTDotdGVhbUBhcHB3cml0ZS5pb30nCiAgICAgIC0gJ19BUFBfRU1BSUxfU0VDVVJJVFk9JHtfQVBQX0VNQUlMX1NFQ1VSSVRZOi1jZXJ0c0BhcHB3cml0ZS5pb30nCiAgICAgIC0gJ19BUFBfU1lTVEVNX1JFU1BPTlNFX0ZPUk1BVD0ke19BUFBfU1lTVEVNX1JFU1BPTlNFX0ZPUk1BVH0nCiAgICAgIC0gJ19BUFBfT1BUSU9OU19BQlVTRT0ke19BUFBfT1BUSU9OU19BQlVTRTotZW5hYmxlZH0nCiAgICAgIC0gJ19BUFBfT1BUSU9OU19ST1VURVJfUFJPVEVDVElPTj0ke19BUFBfT1BUSU9OU19ST1VURVJfUFJPVEVDVElPTjotZGlzYWJsZWR9JwogICAgICAtICdfQVBQX09QVElPTlNfRk9SQ0VfSFRUUFM9JHtfQVBQX09QVElPTlNfRk9SQ0VfSFRUUFM6LWRpc2FibGVkfScKICAgICAgLSAnX0FQUF9PUFRJT05TX1JPVVRFUl9GT1JDRV9IVFRQUz0ke19BUFBfT1BUSU9OU19ST1VURVJfRk9SQ0VfSFRUUFM6LWRpc2FibGVkfScKICAgICAgLSBfQVBQX09QRU5TU0xfS0VZX1YxPSRTRVJWSUNFX1BBU1NXT1JEXzY0X0FQUFdSSVRFCiAgICAgIC0gJ19BUFBfQ09OU09MRV9ET01BSU49JHtfQVBQX0NPTlNPTEVfRE9NQUlOfScKICAgICAgLSAnX0FQUF9ET01BSU49JHtfQVBQX0RPTUFJTjotJFNFUlZJQ0VfRlFETl9BUFBXUklURX0nCiAgICAgIC0gJ19BUFBfRE9NQUlOX1RBUkdFVF9DTkFNRT0ke19BUFBfRE9NQUlOX1RBUkdFVF9DTkFNRTotbG9jYWxob3N0fScKICAgICAgLSAnX0FQUF9ET01BSU5fVEFSR0VUX0FBQUE9JHtfQVBQX0RPTUFJTl9UQVJHRVRfQUFBQTotOjoxfScKICAgICAgLSAnX0FQUF9ET01BSU5fVEFSR0VUX0E9JHtfQVBQX0RPTUFJTl9UQVJHRVRfQTotMTI3LjAuMC4xfScKICAgICAgLSAnX0FQUF9ET01BSU5fVEFSR0VUX0NBQT0ke19BUFBfRE9NQUlOX1RBUkdFVF9DQUF9JwogICAgICAtICdfQVBQX0RPTUFJTl9GVU5DVElPTlM9JHtfQVBQX0RPTUFJTl9GVU5DVElPTlM6LWZ1bmN0aW9ucy4kU0VSVklDRV9GUUROX0FQUFdSSVRFfScKICAgICAgLSAnX0FQUF9ETlM9JHtfQVBQX0ROU30nCiAgICAgIC0gJ19BUFBfUkVESVNfSE9TVD0ke19BUFBfUkVESVNfSE9TVDotYXBwd3JpdGUtcmVkaXN9JwogICAgICAtICdfQVBQX1JFRElTX1BPUlQ9JHtfQVBQX1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICAtICdfQVBQX1JFRElTX1VTRVI9JHtfQVBQX1JFRElTX1VTRVJ9JwogICAgICAtICdfQVBQX1JFRElTX1BBU1M9JHtfQVBQX1JFRElTX1BBU1N9JwogICAgICAtICdfQVBQX0RCX0hPU1Q9JHtfQVBQX0RCX0hPU1Q6LWFwcHdyaXRlLW1hcmlhZGJ9JwogICAgICAtICdfQVBQX0RCX1BPUlQ9JHtfQVBQX0RCX1BPUlQ6LTMzMDZ9JwogICAgICAtICdfQVBQX0RCX1NDSEVNQT0ke19BUFBfREJfU0NIRU1BOi1hcHB3cml0ZX0nCiAgICAgIC0gX0FQUF9EQl9VU0VSPSRTRVJWSUNFX1VTRVJfTUFSSUFEQgogICAgICAtIF9BUFBfREJfUEFTUz0kU0VSVklDRV9QQVNTV09SRF9NQVJJQURCCiAgICAgIC0gJ19BUFBfU01UUF9IT1NUPSR7X0FQUF9TTVRQX0hPU1R9JwogICAgICAtICdfQVBQX1NNVFBfUE9SVD0ke19BUFBfU01UUF9QT1JUfScKICAgICAgLSAnX0FQUF9TTVRQX1NFQ1VSRT0ke19BUFBfU01UUF9TRUNVUkV9JwogICAgICAtICdfQVBQX1NNVFBfVVNFUk5BTUU9JHtfQVBQX1NNVFBfVVNFUk5BTUV9JwogICAgICAtICdfQVBQX1NNVFBfUEFTU1dPUkQ9JHtfQVBQX1NNVFBfUEFTU1dPUkR9JwogICAgICAtICdfQVBQX1VTQUdFX1NUQVRTPSR7X0FQUF9VU0FHRV9TVEFUUzotZW5hYmxlZH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9MSU1JVD0ke19BUFBfU1RPUkFHRV9MSU1JVDotMzAwMDAwMDB9JwogICAgICAtICdfQVBQX1NUT1JBR0VfUFJFVklFV19MSU1JVD0ke19BUFBfU1RPUkFHRV9QUkVWSUVXX0xJTUlUOi0yMDAwMDAwMH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9BTlRJVklSVVM9JHtfQVBQX1NUT1JBR0VfQU5USVZJUlVTOi1kaXNhYmxlZH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9BTlRJVklSVVNfSE9TVD0ke19BUFBfU1RPUkFHRV9BTlRJVklSVVNfSE9TVDotYXBwd3JpdGUtY2xhbWF2fScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0FOVElWSVJVU19QT1JUPSR7X0FQUF9TVE9SQUdFX0FOVElWSVJVU19QT1JUOi0zMzEwfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0RFVklDRT0ke19BUFBfU1RPUkFHRV9ERVZJQ0U6LWxvY2FsfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1MzX0FDQ0VTU19LRVk9JHtfQVBQX1NUT1JBR0VfUzNfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9TM19TRUNSRVQ9JHtfQVBQX1NUT1JBR0VfUzNfU0VDUkVUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1MzX1JFR0lPTj0ke19BUFBfU1RPUkFHRV9TM19SRUdJT046LXVzLWVhc3QtMX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9TM19CVUNLRVQ9JHtfQVBQX1NUT1JBR0VfUzNfQlVDS0VUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1MzX0VORFBPSU5UPSR7X0FQUF9TVE9SQUdFX1MzX0VORFBPSU5UfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0RPX1NQQUNFU19BQ0NFU1NfS0VZPSR7X0FQUF9TVE9SQUdFX0RPX1NQQUNFU19BQ0NFU1NfS0VZfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0RPX1NQQUNFU19TRUNSRVQ9JHtfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX1NFQ1JFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9ET19TUEFDRVNfUkVHSU9OPSR7X0FQUF9TVE9SQUdFX0RPX1NQQUNFU19SRUdJT046LXVzLWVhc3QtMX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9ET19TUEFDRVNfQlVDS0VUPSR7X0FQUF9TVE9SQUdFX0RPX1NQQUNFU19CVUNLRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX0FDQ0VTU19LRVk9JHtfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX0FDQ0VTU19LRVl9JwogICAgICAtICdfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX1NFQ1JFVD0ke19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfU0VDUkVUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9SRUdJT049JHtfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX1JFR0lPTjotdXMtd2VzdC0wMDR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX0JVQ0tFVD0ke19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfQlVDS0VUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0xJTk9ERV9BQ0NFU1NfS0VZPSR7X0FQUF9TVE9SQUdFX0xJTk9ERV9BQ0NFU1NfS0VZfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0xJTk9ERV9TRUNSRVQ9JHtfQVBQX1NUT1JBR0VfTElOT0RFX1NFQ1JFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9MSU5PREVfUkVHSU9OPSR7X0FQUF9TVE9SQUdFX0xJTk9ERV9SRUdJT046LWV1LWNlbnRyYWwtMX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9MSU5PREVfQlVDS0VUPSR7X0FQUF9TVE9SQUdFX0xJTk9ERV9CVUNLRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfV0FTQUJJX0FDQ0VTU19LRVk9JHtfQVBQX1NUT1JBR0VfV0FTQUJJX0FDQ0VTU19LRVl9JwogICAgICAtICdfQVBQX1NUT1JBR0VfV0FTQUJJX1NFQ1JFVD0ke19BUFBfU1RPUkFHRV9XQVNBQklfU0VDUkVUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1dBU0FCSV9SRUdJT049JHtfQVBQX1NUT1JBR0VfV0FTQUJJX1JFR0lPTjotZXUtY2VudHJhbC0xfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1dBU0FCSV9CVUNLRVQ9JHtfQVBQX1NUT1JBR0VfV0FTQUJJX0JVQ0tFVH0nCiAgICAgIC0gJ19BUFBfQ09NUFVURV9TSVpFX0xJTUlUPSR7X0FQUF9DT01QVVRFX1NJWkVfTElNSVQ6LTMwMDAwMDAwfScKICAgICAgLSAnX0FQUF9GVU5DVElPTlNfVElNRU9VVD0ke19BUFBfRlVOQ1RJT05TX1RJTUVPVVQ6LTkwMH0nCiAgICAgIC0gJ19BUFBfU0lURVNfVElNRU9VVD0ke19BUFBfU0lURVNfVElNRU9VVDotOTAwfScKICAgICAgLSAnX0FQUF9DT01QVVRFX0JVSUxEX1RJTUVPVVQ9JHtfQVBQX0NPTVBVVEVfQlVJTERfVElNRU9VVDotOTAwfScKICAgICAgLSAnX0FQUF9DT01QVVRFX0NQVVM9JHtfQVBQX0NPTVBVVEVfQ1BVUzotMH0nCiAgICAgIC0gJ19BUFBfQ09NUFVURV9NRU1PUlk9JHtfQVBQX0NPTVBVVEVfTUVNT1JZOi0wfScKICAgICAgLSAnX0FQUF9GVU5DVElPTlNfUlVOVElNRVM9JHtfQVBQX0ZVTkNUSU9OU19SVU5USU1FUzotbm9kZS0yMC4wLHBocC04LjIscHl0aG9uLTMuMTEscnVieS0zLjJ9JwogICAgICAtICdfQVBQX1NJVEVTX1JVTlRJTUVTPSR7X0FQUF9TSVRFU19SVU5USU1FU30nCiAgICAgIC0gJ19BUFBfRE9NQUlOX1NJVEVTPSR7X0FQUF9ET01BSU5fU0lURVM6LXNpdGVzLiRTRVJWSUNFX0ZRRE5fQVBQV1JJVEV9JwogICAgICAtIF9BUFBfRVhFQ1VUT1JfU0VDUkVUPSRTRVJWSUNFX1BBU1NXT1JEXzY0X0FQUFdSSVRFCiAgICAgIC0gJ19BUFBfRVhFQ1VUT1JfSE9TVD0ke19BUFBfRVhFQ1VUT1JfSE9TVDotaHR0cDovL2FwcHdyaXRlLWV4ZWN1dG9yL3YxfScKICAgICAgLSAnX0FQUF9MT0dHSU5HX0NPTkZJRz0ke19BUFBfTE9HR0lOR19DT05GSUd9JwogICAgICAtICdfQVBQX01BSU5URU5BTkNFX0lOVEVSVkFMPSR7X0FQUF9NQUlOVEVOQU5DRV9JTlRFUlZBTDotODY0MDB9JwogICAgICAtICdfQVBQX01BSU5URU5BTkNFX0RFTEFZPSR7X0FQUF9NQUlOVEVOQU5DRV9ERUxBWX0nCiAgICAgIC0gJ19BUFBfTUFJTlRFTkFOQ0VfU1RBUlRfVElNRT0ke19BUFBfTUFJTlRFTkFOQ0VfU1RBUlRfVElNRX0nCiAgICAgIC0gJ19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0VYRUNVVElPTj0ke19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0VYRUNVVElPTjotMTIwOTYwMH0nCiAgICAgIC0gJ19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0NBQ0hFPSR7X0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fQ0FDSEU6LTI1OTIwMDB9JwogICAgICAtICdfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9BQlVTRT0ke19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0FCVVNFOi04NjQwMH0nCiAgICAgIC0gJ19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0FVRElUPSR7X0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fQVVESVQ6LTEyMDk2MDB9JwogICAgICAtICdfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9BVURJVF9DT05TT0xFPSR7X0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fQVVESVRfQ09OU09MRX0nCiAgICAgIC0gJ19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX1VTQUdFX0hPVVJMWT0ke19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX1VTQUdFX0hPVVJMWTotODY0MDAwMH0nCiAgICAgIC0gJ19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX1NDSEVEVUxFUz0ke19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX1NDSEVEVUxFUzotODY0MDB9JwogICAgICAtICdfQVBQX1NNU19QUk9WSURFUj0ke19BUFBfU01TX1BST1ZJREVSfScKICAgICAgLSAnX0FQUF9TTVNfRlJPTT0ke19BUFBfU01TX0ZST019JwogICAgICAtICdfQVBQX0dSQVBIUUxfTUFYX0JBVENIX1NJWkU9JHtfQVBQX0dSQVBIUUxfTUFYX0JBVENIX1NJWkU6LTEwfScKICAgICAgLSAnX0FQUF9HUkFQSFFMX01BWF9DT01QTEVYSVRZPSR7X0FQUF9HUkFQSFFMX01BWF9DT01QTEVYSVRZOi0yNTB9JwogICAgICAtICdfQVBQX0dSQVBIUUxfTUFYX0RFUFRIPSR7X0FQUF9HUkFQSFFMX01BWF9ERVBUSDotM30nCiAgICAgIC0gJ19BUFBfVkNTX0dJVEhVQl9BUFBfTkFNRT0ke19BUFBfVkNTX0dJVEhVQl9BUFBfTkFNRX0nCiAgICAgIC0gJ19BUFBfVkNTX0dJVEhVQl9QUklWQVRFX0tFWT0ke19BUFBfVkNTX0dJVEhVQl9QUklWQVRFX0tFWX0nCiAgICAgIC0gJ19BUFBfVkNTX0dJVEhVQl9BUFBfSUQ9JHtfQVBQX1ZDU19HSVRIVUJfQVBQX0lEfScKICAgICAgLSAnX0FQUF9WQ1NfR0lUSFVCX1dFQkhPT0tfU0VDUkVUPSR7X0FQUF9WQ1NfR0lUSFVCX1dFQkhPT0tfU0VDUkVUfScKICAgICAgLSAnX0FQUF9WQ1NfR0lUSFVCX0NMSUVOVF9TRUNSRVQ9JHtfQVBQX1ZDU19HSVRIVUJfQ0xJRU5UX1NFQ1JFVH0nCiAgICAgIC0gJ19BUFBfVkNTX0dJVEhVQl9DTElFTlRfSUQ9JHtfQVBQX1ZDU19HSVRIVUJfQ0xJRU5UX0lEfScKICAgICAgLSAnX0FQUF9NSUdSQVRJT05TX0ZJUkVCQVNFX0NMSUVOVF9JRD0ke19BUFBfTUlHUkFUSU9OU19GSVJFQkFTRV9DTElFTlRfSUR9JwogICAgICAtICdfQVBQX01JR1JBVElPTlNfRklSRUJBU0VfQ0xJRU5UX1NFQ1JFVD0ke19BUFBfTUlHUkFUSU9OU19GSVJFQkFTRV9DTElFTlRfU0VDUkVUfScKICAgICAgLSAnX0FQUF9BU1NJU1RBTlRfT1BFTkFJX0FQSV9LRVk9JHtfQVBQX0FTU0lTVEFOVF9PUEVOQUlfQVBJX0tFWX0nCiAgICAgIC0gJ19BUFBfTUVTU0FHRV9TTVNfVEVTVF9EU049JHtfQVBQX01FU1NBR0VfU01TX1RFU1RfRFNOfScKICAgICAgLSAnX0FQUF9NRVNTQUdFX0VNQUlMX1RFU1RfRFNOPSR7X0FQUF9NRVNTQUdFX0VNQUlMX1RFU1RfRFNOfScKICAgICAgLSAnX0FQUF9NRVNTQUdFX1BVU0hfVEVTVF9EU049JHtfQVBQX01FU1NBR0VfUFVTSF9URVNUX0RTTn0nCiAgICAgIC0gJ19BUFBfQ09OU09MRV9DT1VOVFJJRVNfREVOWUxJU1Q9JHtfQVBQX0NPTlNPTEVfQ09VTlRSSUVTX0RFTllMSVNUfScKICAgICAgLSAnX0FQUF9FWFBFUklNRU5UX0xPR0dJTkdfUFJPVklERVI9JHtfQVBQX0VYUEVSSU1FTlRfTE9HR0lOR19QUk9WSURFUn0nCiAgICAgIC0gJ19BUFBfRVhQRVJJTUVOVF9MT0dHSU5HX0NPTkZJRz0ke19BUFBfRVhQRVJJTUVOVF9MT0dHSU5HX0NPTkZJR30nCiAgICAgIC0gJ19BUFBfREFUQUJBU0VfU0hBUkVEX1RBQkxFUz0ke19BUFBfREFUQUJBU0VfU0hBUkVEX1RBQkxFU30nCiAgICAgIC0gJ19BUFBfREFUQUJBU0VfU0hBUkVEX1RBQkxFU19WMT0ke19BUFBfREFUQUJBU0VfU0hBUkVEX1RBQkxFU19WMX0nCiAgICAgIC0gJ19BUFBfREFUQUJBU0VfU0hBUkVEX05BTUVTUEFDRT0ke19BUFBfREFUQUJBU0VfU0hBUkVEX05BTUVTUEFDRX0nCiAgICAgIC0gJ19BUFBfRlVOQ1RJT05TX0NSRUFUSU9OX0FCVVNFX0xJTUlUPSR7X0FQUF9GVU5DVElPTlNfQ1JFQVRJT05fQUJVU0VfTElNSVR9JwogICAgICAtICdfQVBQX0NVU1RPTV9ET01BSU5fREVOWV9MSVNUPSR7X0FQUF9DVVNUT01fRE9NQUlOX0RFTllfTElTVH0nCiAgYXBwd3JpdGUtY29uc29sZToKICAgIGltYWdlOiAnYXBwd3JpdGUvY29uc29sZTo2LjEuMjgnCiAgICBjb250YWluZXJfbmFtZTogYXBwd3JpdGUtY29uc29sZQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0FQUFdSSVRFPS9jb25zb2xlCiAgYXBwd3JpdGUtcmVhbHRpbWU6CiAgICBpbWFnZTogJ2FwcHdyaXRlL2FwcHdyaXRlOjEuNy40JwogICAgZW50cnlwb2ludDogcmVhbHRpbWUKICAgIGNvbnRhaW5lcl9uYW1lOiBhcHB3cml0ZS1yZWFsdGltZQogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcHB3cml0ZS1tYXJpYWRiCiAgICAgIC0gYXBwd3JpdGUtcmVkaXMKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9BUFBXUklURT0vdjEvcmVhbHRpbWUKICAgICAgLSAnX0FQUF9FTlY9JHtfQVBQX0VOVjotcHJvZHVjdGlvbn0nCiAgICAgIC0gJ19BUFBfV09SS0VSX1BFUl9DT1JFPSR7X0FQUF9XT1JLRVJfUEVSX0NPUkU6LTZ9JwogICAgICAtICdfQVBQX09QVElPTlNfQUJVU0U9JHtfQVBQX09QVElPTlNfQUJVU0U6LWVuYWJsZWR9JwogICAgICAtICdfQVBQX09QVElPTlNfUk9VVEVSX1BST1RFQ1RJT049JHtfQVBQX09QVElPTlNfUk9VVEVSX1BST1RFQ1RJT046LWRpc2FibGVkfScKICAgICAgLSBfQVBQX09QRU5TU0xfS0VZX1YxPSRTRVJWSUNFX1BBU1NXT1JEXzY0X0FQUFdSSVRFCiAgICAgIC0gJ19BUFBfUkVESVNfSE9TVD0ke19BUFBfUkVESVNfSE9TVDotYXBwd3JpdGUtcmVkaXN9JwogICAgICAtICdfQVBQX1JFRElTX1BPUlQ9JHtfQVBQX1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICAtICdfQVBQX1JFRElTX1VTRVI9JHtfQVBQX1JFRElTX1VTRVJ9JwogICAgICAtICdfQVBQX1JFRElTX1BBU1M9JHtfQVBQX1JFRElTX1BBU1N9JwogICAgICAtICdfQVBQX0RCX0hPU1Q9JHtfQVBQX0RCX0hPU1Q6LWFwcHdyaXRlLW1hcmlhZGJ9JwogICAgICAtICdfQVBQX0RCX1BPUlQ9JHtfQVBQX0RCX1BPUlQ6LTMzMDZ9JwogICAgICAtICdfQVBQX0RCX1NDSEVNQT0ke19BUFBfREJfU0NIRU1BOi1hcHB3cml0ZX0nCiAgICAgIC0gX0FQUF9EQl9VU0VSPSRTRVJWSUNFX1VTRVJfTUFSSUFEQgogICAgICAtIF9BUFBfREJfUEFTUz0kU0VSVklDRV9QQVNTV09SRF9NQVJJQURCCiAgICAgIC0gJ19BUFBfVVNBR0VfU1RBVFM9JHtfQVBQX1VTQUdFX1NUQVRTOi1lbmFibGVkfScKICAgICAgLSAnX0FQUF9MT0dHSU5HX0NPTkZJRz0ke19BUFBfTE9HR0lOR19DT05GSUd9JwogICAgICAtICdfQVBQX0RBVEFCQVNFX1NIQVJFRF9UQUJMRVM9JHtfQVBQX0RBVEFCQVNFX1NIQVJFRF9UQUJMRVN9JwogIGFwcHdyaXRlLXdvcmtlci1hdWRpdHM6CiAgICBpbWFnZTogJ2FwcHdyaXRlL2FwcHdyaXRlOjEuNy40JwogICAgZW50cnlwb2ludDogd29ya2VyLWF1ZGl0cwogICAgY29udGFpbmVyX25hbWU6IGFwcHdyaXRlLXdvcmtlci1hdWRpdHMKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBwd3JpdGUtcmVkaXMKICAgICAgLSBhcHB3cml0ZS1tYXJpYWRiCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnX0FQUF9FTlY9JHtfQVBQX0VOVjotcHJvZHVjdGlvbn0nCiAgICAgIC0gJ19BUFBfV09SS0VSX1BFUl9DT1JFPSR7X0FQUF9XT1JLRVJfUEVSX0NPUkU6LTZ9JwogICAgICAtIF9BUFBfT1BFTlNTTF9LRVlfVjE9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfQVBQV1JJVEUKICAgICAgLSAnX0FQUF9SRURJU19IT1NUPSR7X0FQUF9SRURJU19IT1NUOi1hcHB3cml0ZS1yZWRpc30nCiAgICAgIC0gJ19BUFBfUkVESVNfUE9SVD0ke19BUFBfUkVESVNfUE9SVDotNjM3OX0nCiAgICAgIC0gJ19BUFBfUkVESVNfVVNFUj0ke19BUFBfUkVESVNfVVNFUn0nCiAgICAgIC0gJ19BUFBfUkVESVNfUEFTUz0ke19BUFBfUkVESVNfUEFTU30nCiAgICAgIC0gJ19BUFBfREJfSE9TVD0ke19BUFBfREJfSE9TVDotYXBwd3JpdGUtbWFyaWFkYn0nCiAgICAgIC0gJ19BUFBfREJfUE9SVD0ke19BUFBfREJfUE9SVDotMzMwNn0nCiAgICAgIC0gJ19BUFBfREJfU0NIRU1BPSR7X0FQUF9EQl9TQ0hFTUE6LWFwcHdyaXRlfScKICAgICAgLSBfQVBQX0RCX1VTRVI9JFNFUlZJQ0VfVVNFUl9NQVJJQURCCiAgICAgIC0gX0FQUF9EQl9QQVNTPSRTRVJWSUNFX1BBU1NXT1JEX01BUklBREIKICAgICAgLSAnX0FQUF9MT0dHSU5HX0NPTkZJRz0ke19BUFBfTE9HR0lOR19DT05GSUd9JwogICAgICAtICdfQVBQX0RBVEFCQVNFX1NIQVJFRF9UQUJMRVM9JHtfQVBQX0RBVEFCQVNFX1NIQVJFRF9UQUJMRVN9JwogIGFwcHdyaXRlLXdvcmtlci13ZWJob29rczoKICAgIGltYWdlOiAnYXBwd3JpdGUvYXBwd3JpdGU6MS43LjQnCiAgICBlbnRyeXBvaW50OiB3b3JrZXItd2ViaG9va3MKICAgIGNvbnRhaW5lcl9uYW1lOiBhcHB3cml0ZS13b3JrZXItd2ViaG9va3MKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBwd3JpdGUtcmVkaXMKICAgICAgLSBhcHB3cml0ZS1tYXJpYWRiCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnX0FQUF9FTlY9JHtfQVBQX0VOVjotcHJvZHVjdGlvbn0nCiAgICAgIC0gJ19BUFBfV09SS0VSX1BFUl9DT1JFPSR7X0FQUF9XT1JLRVJfUEVSX0NPUkU6LTZ9JwogICAgICAtIF9BUFBfT1BFTlNTTF9LRVlfVjE9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfQVBQV1JJVEUKICAgICAgLSAnX0FQUF9FTUFJTF9TRUNVUklUWT0ke19BUFBfRU1BSUxfU0VDVVJJVFk6LWNlcnRzQGFwcHdyaXRlLmlvfScKICAgICAgLSAnX0FQUF9TWVNURU1fU0VDVVJJVFlfRU1BSUxfQUREUkVTUz0ke19BUFBfU1lTVEVNX1NFQ1VSSVRZX0VNQUlMX0FERFJFU1N9JwogICAgICAtICdfQVBQX0RCX0hPU1Q9JHtfQVBQX0RCX0hPU1Q6LWFwcHdyaXRlLW1hcmlhZGJ9JwogICAgICAtICdfQVBQX0RCX1BPUlQ9JHtfQVBQX0RCX1BPUlQ6LTMzMDZ9JwogICAgICAtICdfQVBQX0RCX1NDSEVNQT0ke19BUFBfREJfU0NIRU1BOi1hcHB3cml0ZX0nCiAgICAgIC0gX0FQUF9EQl9VU0VSPSRTRVJWSUNFX1VTRVJfTUFSSUFEQgogICAgICAtIF9BUFBfREJfUEFTUz0kU0VSVklDRV9QQVNTV09SRF9NQVJJQURCCiAgICAgIC0gJ19BUFBfUkVESVNfSE9TVD0ke19BUFBfUkVESVNfSE9TVDotYXBwd3JpdGUtcmVkaXN9JwogICAgICAtICdfQVBQX1JFRElTX1BPUlQ9JHtfQVBQX1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICAtICdfQVBQX1JFRElTX1VTRVI9JHtfQVBQX1JFRElTX1VTRVJ9JwogICAgICAtICdfQVBQX1JFRElTX1BBU1M9JHtfQVBQX1JFRElTX1BBU1N9JwogICAgICAtICdfQVBQX0xPR0dJTkdfQ09ORklHPSR7X0FQUF9MT0dHSU5HX0NPTkZJR30nCiAgICAgIC0gJ19BUFBfV0VCSE9PS19NQVhfRkFJTEVEX0FUVEVNUFRTPSR7X0FQUF9XRUJIT09LX01BWF9GQUlMRURfQVRURU1QVFN9JwogICAgICAtICdfQVBQX0RBVEFCQVNFX1NIQVJFRF9UQUJMRVM9JHtfQVBQX0RBVEFCQVNFX1NIQVJFRF9UQUJMRVN9JwogIGFwcHdyaXRlLXdvcmtlci1kZWxldGVzOgogICAgaW1hZ2U6ICdhcHB3cml0ZS9hcHB3cml0ZToxLjcuNCcKICAgIGVudHJ5cG9pbnQ6IHdvcmtlci1kZWxldGVzCiAgICBjb250YWluZXJfbmFtZTogYXBwd3JpdGUtd29ya2VyLWRlbGV0ZXMKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBwd3JpdGUtcmVkaXMKICAgICAgLSBhcHB3cml0ZS1tYXJpYWRiCiAgICB2b2x1bWVzOgogICAgICAtICdhcHB3cml0ZS11cGxvYWRzOi9zdG9yYWdlL3VwbG9hZHM6cncnCiAgICAgIC0gJ2FwcHdyaXRlLWNhY2hlOi9zdG9yYWdlL2NhY2hlOnJ3JwogICAgICAtICdhcHB3cml0ZS1mdW5jdGlvbnM6L3N0b3JhZ2UvZnVuY3Rpb25zOnJ3JwogICAgICAtICdhcHB3cml0ZS1zaXRlczovc3RvcmFnZS9zaXRlczpydycKICAgICAgLSAnYXBwd3JpdGUtYnVpbGRzOi9zdG9yYWdlL2J1aWxkczpydycKICAgICAgLSAnYXBwd3JpdGUtY2VydGlmaWNhdGVzOi9zdG9yYWdlL2NlcnRpZmljYXRlczpydycKICAgIGVudmlyb25tZW50OgogICAgICAtICdfQVBQX0VOVj0ke19BUFBfRU5WOi1wcm9kdWN0aW9ufScKICAgICAgLSAnX0FQUF9XT1JLRVJfUEVSX0NPUkU9JHtfQVBQX1dPUktFUl9QRVJfQ09SRTotNn0nCiAgICAgIC0gX0FQUF9PUEVOU1NMX0tFWV9WMT0kU0VSVklDRV9QQVNTV09SRF82NF9BUFBXUklURQogICAgICAtICdfQVBQX1JFRElTX0hPU1Q9JHtfQVBQX1JFRElTX0hPU1Q6LWFwcHdyaXRlLXJlZGlzfScKICAgICAgLSAnX0FQUF9SRURJU19QT1JUPSR7X0FQUF9SRURJU19QT1JUOi02Mzc5fScKICAgICAgLSAnX0FQUF9SRURJU19VU0VSPSR7X0FQUF9SRURJU19VU0VSfScKICAgICAgLSAnX0FQUF9SRURJU19QQVNTPSR7X0FQUF9SRURJU19QQVNTfScKICAgICAgLSAnX0FQUF9EQl9IT1NUPSR7X0FQUF9EQl9IT1NUOi1hcHB3cml0ZS1tYXJpYWRifScKICAgICAgLSAnX0FQUF9EQl9QT1JUPSR7X0FQUF9EQl9QT1JUOi0zMzA2fScKICAgICAgLSAnX0FQUF9EQl9TQ0hFTUE9JHtfQVBQX0RCX1NDSEVNQTotYXBwd3JpdGV9JwogICAgICAtIF9BUFBfREJfVVNFUj0kU0VSVklDRV9VU0VSX01BUklBREIKICAgICAgLSBfQVBQX0RCX1BBU1M9JFNFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQgogICAgICAtICdfQVBQX1NUT1JBR0VfREVWSUNFPSR7X0FQUF9TVE9SQUdFX0RFVklDRTotbG9jYWx9JwogICAgICAtICdfQVBQX1NUT1JBR0VfUzNfQUNDRVNTX0tFWT0ke19BUFBfU1RPUkFHRV9TM19BQ0NFU1NfS0VZfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1MzX1NFQ1JFVD0ke19BUFBfU1RPUkFHRV9TM19TRUNSRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfUzNfUkVHSU9OPSR7X0FQUF9TVE9SQUdFX1MzX1JFR0lPTjotdXMtZWFzdC0xfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1MzX0JVQ0tFVD0ke19BUFBfU1RPUkFHRV9TM19CVUNLRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfUzNfRU5EUE9JTlQ9JHtfQVBQX1NUT1JBR0VfUzNfRU5EUE9JTlR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX0FDQ0VTU19LRVk9JHtfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX0FDQ0VTU19LRVl9JwogICAgICAtICdfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX1NFQ1JFVD0ke19BUFBfU1RPUkFHRV9ET19TUEFDRVNfU0VDUkVUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0RPX1NQQUNFU19SRUdJT049JHtfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX1JFR0lPTjotdXMtZWFzdC0xfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0RPX1NQQUNFU19CVUNLRVQ9JHtfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX0JVQ0tFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfQUNDRVNTX0tFWT0ke19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfU0VDUkVUPSR7X0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9TRUNSRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX1JFR0lPTj0ke19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfUkVHSU9OOi11cy13ZXN0LTAwNH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfQlVDS0VUPSR7X0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9CVUNLRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfTElOT0RFX0FDQ0VTU19LRVk9JHtfQVBQX1NUT1JBR0VfTElOT0RFX0FDQ0VTU19LRVl9JwogICAgICAtICdfQVBQX1NUT1JBR0VfTElOT0RFX1NFQ1JFVD0ke19BUFBfU1RPUkFHRV9MSU5PREVfU0VDUkVUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0xJTk9ERV9SRUdJT049JHtfQVBQX1NUT1JBR0VfTElOT0RFX1JFR0lPTjotZXUtY2VudHJhbC0xfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0xJTk9ERV9CVUNLRVQ9JHtfQVBQX1NUT1JBR0VfTElOT0RFX0JVQ0tFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9XQVNBQklfQUNDRVNTX0tFWT0ke19BUFBfU1RPUkFHRV9XQVNBQklfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9XQVNBQklfU0VDUkVUPSR7X0FQUF9TVE9SQUdFX1dBU0FCSV9TRUNSRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfV0FTQUJJX1JFR0lPTj0ke19BUFBfU1RPUkFHRV9XQVNBQklfUkVHSU9OOi1ldS1jZW50cmFsLTF9JwogICAgICAtICdfQVBQX1NUT1JBR0VfV0FTQUJJX0JVQ0tFVD0ke19BUFBfU1RPUkFHRV9XQVNBQklfQlVDS0VUfScKICAgICAgLSAnX0FQUF9MT0dHSU5HX0NPTkZJRz0ke19BUFBfTE9HR0lOR19DT05GSUd9JwogICAgICAtIF9BUFBfRVhFQ1VUT1JfU0VDUkVUPSRTRVJWSUNFX1BBU1NXT1JEXzY0X0FQUFdSSVRFCiAgICAgIC0gJ19BUFBfRVhFQ1VUT1JfSE9TVD0ke19BUFBfRVhFQ1VUT1JfSE9TVDotaHR0cDovL2FwcHdyaXRlLWV4ZWN1dG9yL3YxfScKICAgICAgLSAnX0FQUF9EQVRBQkFTRV9TSEFSRURfVEFCTEVTPSR7X0FQUF9EQVRBQkFTRV9TSEFSRURfVEFCTEVTfScKICAgICAgLSAnX0FQUF9EQVRBQkFTRV9TSEFSRURfVEFCTEVTX1YxPSR7X0FQUF9EQVRBQkFTRV9TSEFSRURfVEFCTEVTX1YxfScKICAgICAgLSAnX0FQUF9FTUFJTF9DRVJUSUZJQ0FURVM9JHtfQVBQX0VNQUlMX0NFUlRJRklDQVRFU30nCiAgICAgIC0gJ19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0FVRElUPSR7X0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fQVVESVQ6LTEyMDk2MDB9JwogICAgICAtICdfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9BVURJVF9DT05TT0xFPSR7X0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fQVVESVRfQ09OU09MRX0nCiAgYXBwd3JpdGUtd29ya2VyLWRhdGFiYXNlczoKICAgIGltYWdlOiAnYXBwd3JpdGUvYXBwd3JpdGU6MS43LjQnCiAgICBlbnRyeXBvaW50OiB3b3JrZXItZGF0YWJhc2VzCiAgICBjb250YWluZXJfbmFtZTogYXBwd3JpdGUtd29ya2VyLWRhdGFiYXNlcwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcHB3cml0ZS1yZWRpcwogICAgICAtIGFwcHdyaXRlLW1hcmlhZGIKICAgIGVudmlyb25tZW50OgogICAgICAtICdfQVBQX0VOVj0ke19BUFBfRU5WOi1wcm9kdWN0aW9ufScKICAgICAgLSAnX0FQUF9XT1JLRVJfUEVSX0NPUkU9JHtfQVBQX1dPUktFUl9QRVJfQ09SRTotNn0nCiAgICAgIC0gX0FQUF9PUEVOU1NMX0tFWV9WMT0kU0VSVklDRV9QQVNTV09SRF82NF9BUFBXUklURQogICAgICAtICdfQVBQX1JFRElTX0hPU1Q9JHtfQVBQX1JFRElTX0hPU1Q6LWFwcHdyaXRlLXJlZGlzfScKICAgICAgLSAnX0FQUF9SRURJU19QT1JUPSR7X0FQUF9SRURJU19QT1JUOi02Mzc5fScKICAgICAgLSAnX0FQUF9SRURJU19VU0VSPSR7X0FQUF9SRURJU19VU0VSfScKICAgICAgLSAnX0FQUF9SRURJU19QQVNTPSR7X0FQUF9SRURJU19QQVNTfScKICAgICAgLSAnX0FQUF9EQl9IT1NUPSR7X0FQUF9EQl9IT1NUOi1hcHB3cml0ZS1tYXJpYWRifScKICAgICAgLSAnX0FQUF9EQl9QT1JUPSR7X0FQUF9EQl9QT1JUOi0zMzA2fScKICAgICAgLSAnX0FQUF9EQl9TQ0hFTUE9JHtfQVBQX0RCX1NDSEVNQTotYXBwd3JpdGV9JwogICAgICAtIF9BUFBfREJfVVNFUj0kU0VSVklDRV9VU0VSX01BUklBREIKICAgICAgLSBfQVBQX0RCX1BBU1M9JFNFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQgogICAgICAtICdfQVBQX0xPR0dJTkdfQ09ORklHPSR7X0FQUF9MT0dHSU5HX0NPTkZJR30nCiAgICAgIC0gJ19BUFBfV09SS0VSU19OVU09JHtfQVBQX1dPUktFUlNfTlVNfScKICAgICAgLSAnX0FQUF9RVUVVRV9OQU1FPSR7X0FQUF9RVUVVRV9OQU1FfScKICAgICAgLSAnX0FQUF9EQVRBQkFTRV9TSEFSRURfVEFCTEVTPSR7X0FQUF9EQVRBQkFTRV9TSEFSRURfVEFCTEVTfScKICBhcHB3cml0ZS13b3JrZXItYnVpbGRzOgogICAgaW1hZ2U6ICdhcHB3cml0ZS9hcHB3cml0ZToxLjcuNCcKICAgIGVudHJ5cG9pbnQ6IHdvcmtlci1idWlsZHMKICAgIGNvbnRhaW5lcl9uYW1lOiBhcHB3cml0ZS13b3JrZXItYnVpbGRzCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwcHdyaXRlLXJlZGlzCiAgICAgIC0gYXBwd3JpdGUtbWFyaWFkYgogICAgdm9sdW1lczoKICAgICAgLSAnYXBwd3JpdGUtZnVuY3Rpb25zOi9zdG9yYWdlL2Z1bmN0aW9uczpydycKICAgICAgLSAnYXBwd3JpdGUtc2l0ZXM6L3N0b3JhZ2Uvc2l0ZXM6cncnCiAgICAgIC0gJ2FwcHdyaXRlLWJ1aWxkczovc3RvcmFnZS9idWlsZHM6cncnCiAgICAgIC0gJ2FwcHdyaXRlLXVwbG9hZHM6L3N0b3JhZ2UvdXBsb2FkczpydycKICAgIGVudmlyb25tZW50OgogICAgICAtICdfQVBQX0VOVj0ke19BUFBfRU5WOi1wcm9kdWN0aW9ufScKICAgICAgLSAnX0FQUF9XT1JLRVJfUEVSX0NPUkU9JHtfQVBQX1dPUktFUl9QRVJfQ09SRTotNn0nCiAgICAgIC0gX0FQUF9PUEVOU1NMX0tFWV9WMT0kU0VSVklDRV9QQVNTV09SRF82NF9BUFBXUklURQogICAgICAtIF9BUFBfRVhFQ1VUT1JfU0VDUkVUPSRTRVJWSUNFX1BBU1NXT1JEXzY0X0FQUFdSSVRFCiAgICAgIC0gJ19BUFBfRVhFQ1VUT1JfSE9TVD0ke19BUFBfRVhFQ1VUT1JfSE9TVDotaHR0cDovL2FwcHdyaXRlLWV4ZWN1dG9yL3YxfScKICAgICAgLSAnX0FQUF9SRURJU19IT1NUPSR7X0FQUF9SRURJU19IT1NUOi1hcHB3cml0ZS1yZWRpc30nCiAgICAgIC0gJ19BUFBfUkVESVNfUE9SVD0ke19BUFBfUkVESVNfUE9SVDotNjM3OX0nCiAgICAgIC0gJ19BUFBfUkVESVNfVVNFUj0ke19BUFBfUkVESVNfVVNFUn0nCiAgICAgIC0gJ19BUFBfUkVESVNfUEFTUz0ke19BUFBfUkVESVNfUEFTU30nCiAgICAgIC0gJ19BUFBfREJfSE9TVD0ke19BUFBfREJfSE9TVDotYXBwd3JpdGUtbWFyaWFkYn0nCiAgICAgIC0gJ19BUFBfREJfUE9SVD0ke19BUFBfREJfUE9SVDotMzMwNn0nCiAgICAgIC0gJ19BUFBfREJfU0NIRU1BPSR7X0FQUF9EQl9TQ0hFTUE6LWFwcHdyaXRlfScKICAgICAgLSBfQVBQX0RCX1VTRVI9JFNFUlZJQ0VfVVNFUl9NQVJJQURCCiAgICAgIC0gX0FQUF9EQl9QQVNTPSRTRVJWSUNFX1BBU1NXT1JEX01BUklBREIKICAgICAgLSAnX0FQUF9MT0dHSU5HX0NPTkZJRz0ke19BUFBfTE9HR0lOR19DT05GSUd9JwogICAgICAtICdfQVBQX1ZDU19HSVRIVUJfQVBQX05BTUU9JHtfQVBQX1ZDU19HSVRIVUJfQVBQX05BTUV9JwogICAgICAtICdfQVBQX1ZDU19HSVRIVUJfUFJJVkFURV9LRVk9JHtfQVBQX1ZDU19HSVRIVUJfUFJJVkFURV9LRVl9JwogICAgICAtICdfQVBQX1ZDU19HSVRIVUJfQVBQX0lEPSR7X0FQUF9WQ1NfR0lUSFVCX0FQUF9JRH0nCiAgICAgIC0gJ19BUFBfRlVOQ1RJT05TX1RJTUVPVVQ9JHtfQVBQX0ZVTkNUSU9OU19USU1FT1VUOi05MDB9JwogICAgICAtICdfQVBQX1NJVEVTX1RJTUVPVVQ9JHtfQVBQX1NJVEVTX1RJTUVPVVQ6LTkwMH0nCiAgICAgIC0gJ19BUFBfQ09NUFVURV9CVUlMRF9USU1FT1VUPSR7X0FQUF9DT01QVVRFX0JVSUxEX1RJTUVPVVQ6LTkwMH0nCiAgICAgIC0gJ19BUFBfQ09NUFVURV9DUFVTPSR7X0FQUF9DT01QVVRFX0NQVVM6LTB9JwogICAgICAtICdfQVBQX0NPTVBVVEVfTUVNT1JZPSR7X0FQUF9DT01QVVRFX01FTU9SWTotMH0nCiAgICAgIC0gJ19BUFBfQ09NUFVURV9TSVpFX0xJTUlUPSR7X0FQUF9DT01QVVRFX1NJWkVfTElNSVQ6LTMwMDAwMDAwfScKICAgICAgLSAnX0FQUF9PUFRJT05TX0ZPUkNFX0hUVFBTPSR7X0FQUF9PUFRJT05TX0ZPUkNFX0hUVFBTOi1kaXNhYmxlZH0nCiAgICAgIC0gJ19BUFBfT1BUSU9OU19ST1VURVJfRk9SQ0VfSFRUUFM9JHtfQVBQX09QVElPTlNfUk9VVEVSX0ZPUkNFX0hUVFBTOi1kaXNhYmxlZH0nCiAgICAgIC0gJ19BUFBfRE9NQUlOPSR7X0FQUF9ET01BSU46LSRTRVJWSUNFX0ZRRE5fQVBQV1JJVEV9JwogICAgICAtICdfQVBQX1NUT1JBR0VfREVWSUNFPSR7X0FQUF9TVE9SQUdFX0RFVklDRTotbG9jYWx9JwogICAgICAtICdfQVBQX1NUT1JBR0VfUzNfQUNDRVNTX0tFWT0ke19BUFBfU1RPUkFHRV9TM19BQ0NFU1NfS0VZfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1MzX1NFQ1JFVD0ke19BUFBfU1RPUkFHRV9TM19TRUNSRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfUzNfUkVHSU9OPSR7X0FQUF9TVE9SQUdFX1MzX1JFR0lPTjotdXMtZWFzdC0xfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1MzX0JVQ0tFVD0ke19BUFBfU1RPUkFHRV9TM19CVUNLRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfUzNfRU5EUE9JTlQ9JHtfQVBQX1NUT1JBR0VfUzNfRU5EUE9JTlR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX0FDQ0VTU19LRVk9JHtfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX0FDQ0VTU19LRVl9JwogICAgICAtICdfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX1NFQ1JFVD0ke19BUFBfU1RPUkFHRV9ET19TUEFDRVNfU0VDUkVUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0RPX1NQQUNFU19SRUdJT049JHtfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX1JFR0lPTjotdXMtZWFzdC0xfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0RPX1NQQUNFU19CVUNLRVQ9JHtfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX0JVQ0tFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfQUNDRVNTX0tFWT0ke19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfU0VDUkVUPSR7X0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9TRUNSRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX1JFR0lPTj0ke19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfUkVHSU9OOi11cy13ZXN0LTAwNH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfQlVDS0VUPSR7X0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9CVUNLRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfTElOT0RFX0FDQ0VTU19LRVk9JHtfQVBQX1NUT1JBR0VfTElOT0RFX0FDQ0VTU19LRVl9JwogICAgICAtICdfQVBQX1NUT1JBR0VfTElOT0RFX1NFQ1JFVD0ke19BUFBfU1RPUkFHRV9MSU5PREVfU0VDUkVUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0xJTk9ERV9SRUdJT049JHtfQVBQX1NUT1JBR0VfTElOT0RFX1JFR0lPTjotZXUtY2VudHJhbC0xfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0xJTk9ERV9CVUNLRVQ9JHtfQVBQX1NUT1JBR0VfTElOT0RFX0JVQ0tFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9XQVNBQklfQUNDRVNTX0tFWT0ke19BUFBfU1RPUkFHRV9XQVNBQklfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9XQVNBQklfU0VDUkVUPSR7X0FQUF9TVE9SQUdFX1dBU0FCSV9TRUNSRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfV0FTQUJJX1JFR0lPTj0ke19BUFBfU1RPUkFHRV9XQVNBQklfUkVHSU9OOi1ldS1jZW50cmFsLTF9JwogICAgICAtICdfQVBQX1NUT1JBR0VfV0FTQUJJX0JVQ0tFVD0ke19BUFBfU1RPUkFHRV9XQVNBQklfQlVDS0VUfScKICAgICAgLSAnX0FQUF9EQVRBQkFTRV9TSEFSRURfVEFCTEVTPSR7X0FQUF9EQVRBQkFTRV9TSEFSRURfVEFCTEVTfScKICAgICAgLSAnX0FQUF9ET01BSU5fU0lURVM9JHtfQVBQX0RPTUFJTl9TSVRFUzotc2l0ZXMuJFNFUlZJQ0VfRlFETl9BUFBXUklURX0nCiAgICAgIC0gJ19BUFBfQlJPV1NFUl9IT1NUPSR7X0FQUF9CUk9XU0VSX0hPU1R9JwogICAgICAtICdfQVBQX0NPTlNPTEVfRE9NQUlOPSR7X0FQUF9DT05TT0xFX0RPTUFJTn0nCiAgYXBwd3JpdGUtd29ya2VyLWNlcnRpZmljYXRlczoKICAgIGltYWdlOiAnYXBwd3JpdGUvYXBwd3JpdGU6MS43LjQnCiAgICBlbnRyeXBvaW50OiB3b3JrZXItY2VydGlmaWNhdGVzCiAgICBjb250YWluZXJfbmFtZTogYXBwd3JpdGUtd29ya2VyLWNlcnRpZmljYXRlcwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcHB3cml0ZS1yZWRpcwogICAgICAtIGFwcHdyaXRlLW1hcmlhZGIKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2FwcHdyaXRlLWNvbmZpZzovc3RvcmFnZS9jb25maWc6cncnCiAgICAgIC0gJ2FwcHdyaXRlLWNlcnRpZmljYXRlczovc3RvcmFnZS9jZXJ0aWZpY2F0ZXM6cncnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnX0FQUF9FTlY9JHtfQVBQX0VOVjotcHJvZHVjdGlvbn0nCiAgICAgIC0gJ19BUFBfV09SS0VSX1BFUl9DT1JFPSR7X0FQUF9XT1JLRVJfUEVSX0NPUkU6LTZ9JwogICAgICAtIF9BUFBfT1BFTlNTTF9LRVlfVjE9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfQVBQV1JJVEUKICAgICAgLSAnX0FQUF9ET01BSU49JHtfQVBQX0RPTUFJTjotJFNFUlZJQ0VfRlFETl9BUFBXUklURX0nCiAgICAgIC0gJ19BUFBfRE9NQUlOX1RBUkdFVF9DTkFNRT0ke19BUFBfRE9NQUlOX1RBUkdFVF9DTkFNRX0nCiAgICAgIC0gJ19BUFBfRE9NQUlOX1RBUkdFVF9BQUFBPSR7X0FQUF9ET01BSU5fVEFSR0VUX0FBQUF9JwogICAgICAtICdfQVBQX0RPTUFJTl9UQVJHRVRfQT0ke19BUFBfRE9NQUlOX1RBUkdFVF9BfScKICAgICAgLSAnX0FQUF9ET01BSU5fVEFSR0VUX0NBQT0ke19BUFBfRE9NQUlOX1RBUkdFVF9DQUF9JwogICAgICAtICdfQVBQX0RPTUFJTl9GVU5DVElPTlM9JHtfQVBQX0RPTUFJTl9GVU5DVElPTlM6LWZ1bmN0aW9ucy4kU0VSVklDRV9GUUROX0FQUFdSSVRFfScKICAgICAgLSAnX0FQUF9ETlM9JHtfQVBQX0ROU30nCiAgICAgIC0gJ19BUFBfRU1BSUxfQ0VSVElGSUNBVEVTPSR7X0FQUF9FTUFJTF9DRVJUSUZJQ0FURVM6LWVuYWJsZWR9JwogICAgICAtICdfQVBQX1JFRElTX0hPU1Q9JHtfQVBQX1JFRElTX0hPU1Q6LWFwcHdyaXRlLXJlZGlzfScKICAgICAgLSAnX0FQUF9SRURJU19QT1JUPSR7X0FQUF9SRURJU19QT1JUOi02Mzc5fScKICAgICAgLSAnX0FQUF9SRURJU19VU0VSPSR7X0FQUF9SRURJU19VU0VSfScKICAgICAgLSAnX0FQUF9SRURJU19QQVNTPSR7X0FQUF9SRURJU19QQVNTfScKICAgICAgLSAnX0FQUF9EQl9IT1NUPSR7X0FQUF9EQl9IT1NUOi1hcHB3cml0ZS1tYXJpYWRifScKICAgICAgLSAnX0FQUF9EQl9QT1JUPSR7X0FQUF9EQl9QT1JUOi0zMzA2fScKICAgICAgLSAnX0FQUF9EQl9TQ0hFTUE9JHtfQVBQX0RCX1NDSEVNQTotYXBwd3JpdGV9JwogICAgICAtIF9BUFBfREJfVVNFUj0kU0VSVklDRV9VU0VSX01BUklBREIKICAgICAgLSBfQVBQX0RCX1BBU1M9JFNFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQgogICAgICAtICdfQVBQX0xPR0dJTkdfQ09ORklHPSR7X0FQUF9MT0dHSU5HX0NPTkZJR30nCiAgICAgIC0gJ19BUFBfREFUQUJBU0VfU0hBUkVEX1RBQkxFUz0ke19BUFBfREFUQUJBU0VfU0hBUkVEX1RBQkxFU30nCiAgYXBwd3JpdGUtd29ya2VyLWZ1bmN0aW9uczoKICAgIGltYWdlOiAnYXBwd3JpdGUvYXBwd3JpdGU6MS43LjQnCiAgICBlbnRyeXBvaW50OiB3b3JrZXItZnVuY3Rpb25zCiAgICBjb250YWluZXJfbmFtZTogYXBwd3JpdGUtd29ya2VyLWZ1bmN0aW9ucwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcHB3cml0ZS1yZWRpcwogICAgICAtIGFwcHdyaXRlLW1hcmlhZGIKICAgICAgLSBvcGVucnVudGltZXMtZXhlY3V0b3IKICAgIGVudmlyb25tZW50OgogICAgICAtICdfQVBQX0VOVj0ke19BUFBfRU5WOi1wcm9kdWN0aW9ufScKICAgICAgLSAnX0FQUF9XT1JLRVJfUEVSX0NPUkU9JHtfQVBQX1dPUktFUl9QRVJfQ09SRTotNn0nCiAgICAgIC0gX0FQUF9PUEVOU1NMX0tFWV9WMT0kU0VSVklDRV9QQVNTV09SRF82NF9BUFBXUklURQogICAgICAtICdfQVBQX0RPTUFJTj0ke19BUFBfRE9NQUlOOi0kU0VSVklDRV9GUUROX0FQUFdSSVRFfScKICAgICAgLSAnX0FQUF9PUFRJT05TX0ZPUkNFX0hUVFBTPSR7X0FQUF9PUFRJT05TX0ZPUkNFX0hUVFBTOi1kaXNhYmxlZH0nCiAgICAgIC0gJ19BUFBfUkVESVNfSE9TVD0ke19BUFBfUkVESVNfSE9TVDotYXBwd3JpdGUtcmVkaXN9JwogICAgICAtICdfQVBQX1JFRElTX1BPUlQ9JHtfQVBQX1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICAtICdfQVBQX1JFRElTX1VTRVI9JHtfQVBQX1JFRElTX1VTRVJ9JwogICAgICAtICdfQVBQX1JFRElTX1BBU1M9JHtfQVBQX1JFRElTX1BBU1N9JwogICAgICAtICdfQVBQX0RCX0hPU1Q9JHtfQVBQX0RCX0hPU1Q6LWFwcHdyaXRlLW1hcmlhZGJ9JwogICAgICAtICdfQVBQX0RCX1BPUlQ9JHtfQVBQX0RCX1BPUlQ6LTMzMDZ9JwogICAgICAtICdfQVBQX0RCX1NDSEVNQT0ke19BUFBfREJfU0NIRU1BOi1hcHB3cml0ZX0nCiAgICAgIC0gX0FQUF9EQl9VU0VSPSRTRVJWSUNFX1VTRVJfTUFSSUFEQgogICAgICAtIF9BUFBfREJfUEFTUz0kU0VSVklDRV9QQVNTV09SRF9NQVJJQURCCiAgICAgIC0gJ19BUFBfRlVOQ1RJT05TX1RJTUVPVVQ9JHtfQVBQX0ZVTkNUSU9OU19USU1FT1VUOi05MDB9JwogICAgICAtICdfQVBQX1NJVEVTX1RJTUVPVVQ9JHtfQVBQX1NJVEVTX1RJTUVPVVQ6LTkwMH0nCiAgICAgIC0gJ19BUFBfQ09NUFVURV9CVUlMRF9USU1FT1VUPSR7X0FQUF9DT01QVVRFX0JVSUxEX1RJTUVPVVQ6LTkwMH0nCiAgICAgIC0gJ19BUFBfQ09NUFVURV9DUFVTPSR7X0FQUF9DT01QVVRFX0NQVVM6LTB9JwogICAgICAtICdfQVBQX0NPTVBVVEVfTUVNT1JZPSR7X0FQUF9DT01QVVRFX01FTU9SWTotMH0nCiAgICAgIC0gX0FQUF9FWEVDVVRPUl9TRUNSRVQ9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfQVBQV1JJVEUKICAgICAgLSAnX0FQUF9FWEVDVVRPUl9IT1NUPSR7X0FQUF9FWEVDVVRPUl9IT1NUOi1odHRwOi8vYXBwd3JpdGUtZXhlY3V0b3IvdjF9JwogICAgICAtICdfQVBQX1VTQUdFX1NUQVRTPSR7X0FQUF9VU0FHRV9TVEFUUzotZW5hYmxlZH0nCiAgICAgIC0gJ19BUFBfRE9DS0VSX0hVQl9VU0VSTkFNRT0ke19BUFBfRE9DS0VSX0hVQl9VU0VSTkFNRX0nCiAgICAgIC0gJ19BUFBfRE9DS0VSX0hVQl9QQVNTV09SRD0ke19BUFBfRE9DS0VSX0hVQl9QQVNTV09SRH0nCiAgICAgIC0gJ19BUFBfTE9HR0lOR19DT05GSUc9JHtfQVBQX0xPR0dJTkdfQ09ORklHfScKICAgICAgLSAnX0FQUF9MT0dHSU5HX1BST1ZJREVSPSR7X0FQUF9MT0dHSU5HX1BST1ZJREVSfScKICAgICAgLSAnX0FQUF9EQVRBQkFTRV9TSEFSRURfVEFCTEVTPSR7X0FQUF9EQVRBQkFTRV9TSEFSRURfVEFCTEVTfScKICBhcHB3cml0ZS13b3JrZXItbWFpbHM6CiAgICBpbWFnZTogJ2FwcHdyaXRlL2FwcHdyaXRlOjEuNy40JwogICAgZW50cnlwb2ludDogd29ya2VyLW1haWxzCiAgICBjb250YWluZXJfbmFtZTogYXBwd3JpdGUtd29ya2VyLW1haWxzCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwcHdyaXRlLXJlZGlzCiAgICAgIC0gYXBwd3JpdGUtbWFyaWFkYgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ19BUFBfRU5WPSR7X0FQUF9FTlY6LXByb2R1Y3Rpb259JwogICAgICAtICdfQVBQX1dPUktFUl9QRVJfQ09SRT0ke19BUFBfV09SS0VSX1BFUl9DT1JFOi02fScKICAgICAgLSBfQVBQX09QRU5TU0xfS0VZX1YxPSRTRVJWSUNFX1BBU1NXT1JEXzY0X0FQUFdSSVRFCiAgICAgIC0gJ19BUFBfU1lTVEVNX0VNQUlMX05BTUU9JHtfQVBQX1NZU1RFTV9FTUFJTF9OQU1FOi1BcHB3cml0ZX0nCiAgICAgIC0gJ19BUFBfU1lTVEVNX0VNQUlMX0FERFJFU1M9JHtfQVBQX1NZU1RFTV9FTUFJTF9BRERSRVNTOi10ZWFtQGFwcHdyaXRlLmlvfScKICAgICAgLSAnX0FQUF9EQl9IT1NUPSR7X0FQUF9EQl9IT1NUOi1hcHB3cml0ZS1tYXJpYWRifScKICAgICAgLSAnX0FQUF9EQl9QT1JUPSR7X0FQUF9EQl9QT1JUOi0zMzA2fScKICAgICAgLSAnX0FQUF9EQl9TQ0hFTUE9JHtfQVBQX0RCX1NDSEVNQTotYXBwd3JpdGV9JwogICAgICAtIF9BUFBfREJfVVNFUj0kU0VSVklDRV9VU0VSX01BUklBREIKICAgICAgLSBfQVBQX0RCX1BBU1M9JFNFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQgogICAgICAtICdfQVBQX1JFRElTX0hPU1Q9JHtfQVBQX1JFRElTX0hPU1Q6LWFwcHdyaXRlLXJlZGlzfScKICAgICAgLSAnX0FQUF9SRURJU19QT1JUPSR7X0FQUF9SRURJU19QT1JUOi02Mzc5fScKICAgICAgLSAnX0FQUF9SRURJU19VU0VSPSR7X0FQUF9SRURJU19VU0VSfScKICAgICAgLSAnX0FQUF9SRURJU19QQVNTPSR7X0FQUF9SRURJU19QQVNTfScKICAgICAgLSAnX0FQUF9TTVRQX0hPU1Q9JHtfQVBQX1NNVFBfSE9TVH0nCiAgICAgIC0gJ19BUFBfU01UUF9QT1JUPSR7X0FQUF9TTVRQX1BPUlR9JwogICAgICAtICdfQVBQX1NNVFBfU0VDVVJFPSR7X0FQUF9TTVRQX1NFQ1VSRX0nCiAgICAgIC0gJ19BUFBfU01UUF9VU0VSTkFNRT0ke19BUFBfU01UUF9VU0VSTkFNRX0nCiAgICAgIC0gJ19BUFBfU01UUF9QQVNTV09SRD0ke19BUFBfU01UUF9QQVNTV09SRH0nCiAgICAgIC0gJ19BUFBfTE9HR0lOR19DT05GSUc9JHtfQVBQX0xPR0dJTkdfQ09ORklHfScKICAgICAgLSAnX0FQUF9ET01BSU49JHtfQVBQX0RPTUFJTjotJFNFUlZJQ0VfRlFETl9BUFBXUklURX0nCiAgICAgIC0gJ19BUFBfT1BUSU9OU19GT1JDRV9IVFRQUz0ke19BUFBfT1BUSU9OU19GT1JDRV9IVFRQUzotZGlzYWJsZWR9JwogICAgICAtICdfQVBQX0RBVEFCQVNFX1NIQVJFRF9UQUJMRVM9JHtfQVBQX0RBVEFCQVNFX1NIQVJFRF9UQUJMRVN9JwogIGFwcHdyaXRlLXdvcmtlci1tZXNzYWdpbmc6CiAgICBpbWFnZTogJ2FwcHdyaXRlL2FwcHdyaXRlOjEuNy40JwogICAgZW50cnlwb2ludDogd29ya2VyLW1lc3NhZ2luZwogICAgY29udGFpbmVyX25hbWU6IGFwcHdyaXRlLXdvcmtlci1tZXNzYWdpbmcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2FwcHdyaXRlLXVwbG9hZHM6L3N0b3JhZ2UvdXBsb2FkczpydycKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBwd3JpdGUtcmVkaXMKICAgIGVudmlyb25tZW50OgogICAgICAtICdfQVBQX0VOVj0ke19BUFBfRU5WOi1wcm9kdWN0aW9ufScKICAgICAgLSAnX0FQUF9XT1JLRVJfUEVSX0NPUkU9JHtfQVBQX1dPUktFUl9QRVJfQ09SRTotNn0nCiAgICAgIC0gX0FQUF9PUEVOU1NMX0tFWV9WMT0kU0VSVklDRV9QQVNTV09SRF82NF9BUFBXUklURQogICAgICAtICdfQVBQX1JFRElTX0hPU1Q9JHtfQVBQX1JFRElTX0hPU1Q6LWFwcHdyaXRlLXJlZGlzfScKICAgICAgLSAnX0FQUF9SRURJU19QT1JUPSR7X0FQUF9SRURJU19QT1JUOi02Mzc5fScKICAgICAgLSAnX0FQUF9SRURJU19VU0VSPSR7X0FQUF9SRURJU19VU0VSfScKICAgICAgLSAnX0FQUF9SRURJU19QQVNTPSR7X0FQUF9SRURJU19QQVNTfScKICAgICAgLSAnX0FQUF9EQl9IT1NUPSR7X0FQUF9EQl9IT1NUOi1hcHB3cml0ZS1tYXJpYWRifScKICAgICAgLSAnX0FQUF9EQl9QT1JUPSR7X0FQUF9EQl9QT1JUOi0zMzA2fScKICAgICAgLSAnX0FQUF9EQl9TQ0hFTUE9JHtfQVBQX0RCX1NDSEVNQTotYXBwd3JpdGV9JwogICAgICAtIF9BUFBfREJfVVNFUj0kU0VSVklDRV9VU0VSX01BUklBREIKICAgICAgLSBfQVBQX0RCX1BBU1M9JFNFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQgogICAgICAtICdfQVBQX0xPR0dJTkdfQ09ORklHPSR7X0FQUF9MT0dHSU5HX0NPTkZJR30nCiAgICAgIC0gJ19BUFBfU01TX0ZST009JHtfQVBQX1NNU19GUk9NfScKICAgICAgLSAnX0FQUF9TTVNfUFJPVklERVI9JHtfQVBQX1NNU19QUk9WSURFUn0nCiAgICAgIC0gJ19BUFBfU01TX1BST0pFQ1RTX0RFTllfTElTVD0ke19BUFBfU01TX1BST0pFQ1RTX0RFTllfTElTVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9ERVZJQ0U9JHtfQVBQX1NUT1JBR0VfREVWSUNFOi1sb2NhbH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9TM19BQ0NFU1NfS0VZPSR7X0FQUF9TVE9SQUdFX1MzX0FDQ0VTU19LRVl9JwogICAgICAtICdfQVBQX1NUT1JBR0VfUzNfU0VDUkVUPSR7X0FQUF9TVE9SQUdFX1MzX1NFQ1JFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9TM19SRUdJT049JHtfQVBQX1NUT1JBR0VfUzNfUkVHSU9OOi11cy1lYXN0LTF9JwogICAgICAtICdfQVBQX1NUT1JBR0VfUzNfQlVDS0VUPSR7X0FQUF9TVE9SQUdFX1MzX0JVQ0tFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9TM19FTkRQT0lOVD0ke19BUFBfU1RPUkFHRV9TM19FTkRQT0lOVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9ET19TUEFDRVNfQUNDRVNTX0tFWT0ke19BUFBfU1RPUkFHRV9ET19TUEFDRVNfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9ET19TUEFDRVNfU0VDUkVUPSR7X0FQUF9TVE9SQUdFX0RPX1NQQUNFU19TRUNSRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX1JFR0lPTj0ke19BUFBfU1RPUkFHRV9ET19TUEFDRVNfUkVHSU9OOi11cy1lYXN0LTF9JwogICAgICAtICdfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX0JVQ0tFVD0ke19BUFBfU1RPUkFHRV9ET19TUEFDRVNfQlVDS0VUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9BQ0NFU1NfS0VZPSR7X0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9BQ0NFU1NfS0VZfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9TRUNSRVQ9JHtfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX1NFQ1JFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfUkVHSU9OPSR7X0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9SRUdJT046LXVzLXdlc3QtMDA0fScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9CVUNLRVQ9JHtfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX0JVQ0tFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9MSU5PREVfQUNDRVNTX0tFWT0ke19BUFBfU1RPUkFHRV9MSU5PREVfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9MSU5PREVfU0VDUkVUPSR7X0FQUF9TVE9SQUdFX0xJTk9ERV9TRUNSRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfTElOT0RFX1JFR0lPTj0ke19BUFBfU1RPUkFHRV9MSU5PREVfUkVHSU9OOi1ldS1jZW50cmFsLTF9JwogICAgICAtICdfQVBQX1NUT1JBR0VfTElOT0RFX0JVQ0tFVD0ke19BUFBfU1RPUkFHRV9MSU5PREVfQlVDS0VUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1dBU0FCSV9BQ0NFU1NfS0VZPSR7X0FQUF9TVE9SQUdFX1dBU0FCSV9BQ0NFU1NfS0VZfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1dBU0FCSV9TRUNSRVQ9JHtfQVBQX1NUT1JBR0VfV0FTQUJJX1NFQ1JFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9XQVNBQklfUkVHSU9OPSR7X0FQUF9TVE9SQUdFX1dBU0FCSV9SRUdJT046LWV1LWNlbnRyYWwtMX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9XQVNBQklfQlVDS0VUPSR7X0FQUF9TVE9SQUdFX1dBU0FCSV9CVUNLRVR9JwogICAgICAtICdfQVBQX0RBVEFCQVNFX1NIQVJFRF9UQUJMRVM9JHtfQVBQX0RBVEFCQVNFX1NIQVJFRF9UQUJMRVN9JwogIGFwcHdyaXRlLXdvcmtlci1taWdyYXRpb25zOgogICAgaW1hZ2U6ICdhcHB3cml0ZS9hcHB3cml0ZToxLjcuNCcKICAgIGVudHJ5cG9pbnQ6IHdvcmtlci1taWdyYXRpb25zCiAgICBjb250YWluZXJfbmFtZTogYXBwd3JpdGUtd29ya2VyLW1pZ3JhdGlvbnMKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2FwcHdyaXRlLWltcG9ydHM6L3N0b3JhZ2UvaW1wb3J0czpydycKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBwd3JpdGUtbWFyaWFkYgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ19BUFBfRU5WPSR7X0FQUF9FTlY6LXByb2R1Y3Rpb259JwogICAgICAtICdfQVBQX1dPUktFUl9QRVJfQ09SRT0ke19BUFBfV09SS0VSX1BFUl9DT1JFOi02fScKICAgICAgLSBfQVBQX09QRU5TU0xfS0VZX1YxPSRTRVJWSUNFX1BBU1NXT1JEXzY0X0FQUFdSSVRFCiAgICAgIC0gJ19BUFBfRE9NQUlOPSR7X0FQUF9ET01BSU46LSRTRVJWSUNFX0ZRRE5fQVBQV1JJVEV9JwogICAgICAtICdfQVBQX0RPTUFJTl9UQVJHRVRfQ05BTUU9JHtfQVBQX0RPTUFJTl9UQVJHRVRfQ05BTUV9JwogICAgICAtICdfQVBQX0RPTUFJTl9UQVJHRVRfQUFBQT0ke19BUFBfRE9NQUlOX1RBUkdFVF9BQUFBfScKICAgICAgLSAnX0FQUF9ET01BSU5fVEFSR0VUX0E9JHtfQVBQX0RPTUFJTl9UQVJHRVRfQX0nCiAgICAgIC0gJ19BUFBfRE9NQUlOX1RBUkdFVF9DQUE9JHtfQVBQX0RPTUFJTl9UQVJHRVRfQ0FBfScKICAgICAgLSAnX0FQUF9ETlM9JHtfQVBQX0ROU30nCiAgICAgIC0gJ19BUFBfRU1BSUxfU0VDVVJJVFk9JHtfQVBQX0VNQUlMX1NFQ1VSSVRZOi1jZXJ0c0BhcHB3cml0ZS5pb30nCiAgICAgIC0gJ19BUFBfUkVESVNfSE9TVD0ke19BUFBfUkVESVNfSE9TVDotYXBwd3JpdGUtcmVkaXN9JwogICAgICAtICdfQVBQX1JFRElTX1BPUlQ9JHtfQVBQX1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICAtICdfQVBQX1JFRElTX1VTRVI9JHtfQVBQX1JFRElTX1VTRVJ9JwogICAgICAtICdfQVBQX1JFRElTX1BBU1M9JHtfQVBQX1JFRElTX1BBU1N9JwogICAgICAtICdfQVBQX0RCX0hPU1Q9JHtfQVBQX0RCX0hPU1Q6LWFwcHdyaXRlLW1hcmlhZGJ9JwogICAgICAtICdfQVBQX0RCX1BPUlQ9JHtfQVBQX0RCX1BPUlQ6LTMzMDZ9JwogICAgICAtICdfQVBQX0RCX1NDSEVNQT0ke19BUFBfREJfU0NIRU1BOi1hcHB3cml0ZX0nCiAgICAgIC0gX0FQUF9EQl9VU0VSPSRTRVJWSUNFX1VTRVJfTUFSSUFEQgogICAgICAtIF9BUFBfREJfUEFTUz0kU0VSVklDRV9QQVNTV09SRF9NQVJJQURCCiAgICAgIC0gJ19BUFBfTE9HR0lOR19DT05GSUc9JHtfQVBQX0xPR0dJTkdfQ09ORklHfScKICAgICAgLSAnX0FQUF9NSUdSQVRJT05TX0ZJUkVCQVNFX0NMSUVOVF9JRD0ke19BUFBfTUlHUkFUSU9OU19GSVJFQkFTRV9DTElFTlRfSUR9JwogICAgICAtICdfQVBQX01JR1JBVElPTlNfRklSRUJBU0VfQ0xJRU5UX1NFQ1JFVD0ke19BUFBfTUlHUkFUSU9OU19GSVJFQkFTRV9DTElFTlRfU0VDUkVUfScKICAgICAgLSAnX0FQUF9EQVRBQkFTRV9TSEFSRURfVEFCTEVTPSR7X0FQUF9EQVRBQkFTRV9TSEFSRURfVEFCTEVTfScKICBhcHB3cml0ZS10YXNrLW1haW50ZW5hbmNlOgogICAgaW1hZ2U6ICdhcHB3cml0ZS9hcHB3cml0ZToxLjcuNCcKICAgIGVudHJ5cG9pbnQ6IG1haW50ZW5hbmNlCiAgICBjb250YWluZXJfbmFtZTogYXBwd3JpdGUtdGFzay1tYWludGVuYW5jZQogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcHB3cml0ZS1yZWRpcwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ19BUFBfRU5WPSR7X0FQUF9FTlY6LXByb2R1Y3Rpb259JwogICAgICAtICdfQVBQX1dPUktFUl9QRVJfQ09SRT0ke19BUFBfV09SS0VSX1BFUl9DT1JFOi02fScKICAgICAgLSAnX0FQUF9ET01BSU49JHtfQVBQX0RPTUFJTjotJFNFUlZJQ0VfRlFETl9BUFBXUklURX0nCiAgICAgIC0gJ19BUFBfRE9NQUlOX1RBUkdFVF9DTkFNRT0ke19BUFBfRE9NQUlOX1RBUkdFVF9DTkFNRX0nCiAgICAgIC0gJ19BUFBfRE9NQUlOX1RBUkdFVF9BQUFBPSR7X0FQUF9ET01BSU5fVEFSR0VUX0FBQUF9JwogICAgICAtICdfQVBQX0RPTUFJTl9UQVJHRVRfQT0ke19BUFBfRE9NQUlOX1RBUkdFVF9BfScKICAgICAgLSAnX0FQUF9ET01BSU5fVEFSR0VUX0NBQT0ke19BUFBfRE9NQUlOX1RBUkdFVF9DQUF9JwogICAgICAtICdfQVBQX0RPTUFJTl9GVU5DVElPTlM9JHtfQVBQX0RPTUFJTl9GVU5DVElPTlM6LWZ1bmN0aW9ucy4kU0VSVklDRV9GUUROX0FQUFdSSVRFfScKICAgICAgLSAnX0FQUF9ETlM9JHtfQVBQX0ROU30nCiAgICAgIC0gX0FQUF9PUEVOU1NMX0tFWV9WMT0kU0VSVklDRV9QQVNTV09SRF82NF9BUFBXUklURQogICAgICAtICdfQVBQX1JFRElTX0hPU1Q9JHtfQVBQX1JFRElTX0hPU1Q6LWFwcHdyaXRlLXJlZGlzfScKICAgICAgLSAnX0FQUF9SRURJU19QT1JUPSR7X0FQUF9SRURJU19QT1JUOi02Mzc5fScKICAgICAgLSAnX0FQUF9SRURJU19VU0VSPSR7X0FQUF9SRURJU19VU0VSfScKICAgICAgLSAnX0FQUF9SRURJU19QQVNTPSR7X0FQUF9SRURJU19QQVNTfScKICAgICAgLSAnX0FQUF9EQl9IT1NUPSR7X0FQUF9EQl9IT1NUOi1hcHB3cml0ZS1tYXJpYWRifScKICAgICAgLSAnX0FQUF9EQl9QT1JUPSR7X0FQUF9EQl9QT1JUOi0zMzA2fScKICAgICAgLSAnX0FQUF9EQl9TQ0hFTUE9JHtfQVBQX0RCX1NDSEVNQTotYXBwd3JpdGV9JwogICAgICAtIF9BUFBfREJfVVNFUj0kU0VSVklDRV9VU0VSX01BUklBREIKICAgICAgLSBfQVBQX0RCX1BBU1M9JFNFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQgogICAgICAtICdfQVBQX01BSU5URU5BTkNFX0lOVEVSVkFMPSR7X0FQUF9NQUlOVEVOQU5DRV9JTlRFUlZBTDotODY0MDB9JwogICAgICAtICdfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9FWEVDVVRJT049JHtfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9FWEVDVVRJT046LTEyMDk2MDB9JwogICAgICAtICdfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9DQUNIRT0ke19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0NBQ0hFOi0yNTkyMDAwfScKICAgICAgLSAnX0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fQUJVU0U9JHtfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9BQlVTRTotODY0MDB9JwogICAgICAtICdfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9BVURJVD0ke19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0FVRElUOi0xMjA5NjAwfScKICAgICAgLSAnX0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fQVVESVRfQ09OU09MRT0ke19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0FVRElUX0NPTlNPTEV9JwogICAgICAtICdfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9VU0FHRV9IT1VSTFk9JHtfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9VU0FHRV9IT1VSTFk6LTg2NDAwMDB9JwogICAgICAtICdfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9TQ0hFRFVMRVM9JHtfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9TQ0hFRFVMRVM6LTg2NDAwfScKICAgICAgLSAnX0FQUF9NQUlOVEVOQU5DRV9TVEFSVF9USU1FPSR7X0FQUF9NQUlOVEVOQU5DRV9TVEFSVF9USU1FfScKICAgICAgLSAnX0FQUF9EQVRBQkFTRV9TSEFSRURfVEFCTEVTPSR7X0FQUF9EQVRBQkFTRV9TSEFSRURfVEFCTEVTfScKICBhcHB3cml0ZS10YXNrLXN0YXRzLXJlc291cmNlczoKICAgIGltYWdlOiAnYXBwd3JpdGUvYXBwd3JpdGU6MS43LjQnCiAgICBjb250YWluZXJfbmFtZTogYXBwd3JpdGUtdGFzay1zdGF0cy1yZXNvdXJjZXMKICAgIGVudHJ5cG9pbnQ6IHN0YXRzLXJlc291cmNlcwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcHB3cml0ZS1yZWRpcwogICAgICAtIGFwcHdyaXRlLW1hcmlhZGIKICAgIGVudmlyb25tZW50OgogICAgICAtICdfQVBQX0VOVj0ke19BUFBfRU5WOi1wcm9kdWN0aW9ufScKICAgICAgLSAnX0FQUF9XT1JLRVJfUEVSX0NPUkU9JHtfQVBQX1dPUktFUl9QRVJfQ09SRTotNn0nCiAgICAgIC0gX0FQUF9PUEVOU1NMX0tFWV9WMT0kU0VSVklDRV9QQVNTV09SRF82NF9BUFBXUklURQogICAgICAtICdfQVBQX0RCX0hPU1Q9JHtfQVBQX0RCX0hPU1Q6LWFwcHdyaXRlLW1hcmlhZGJ9JwogICAgICAtICdfQVBQX0RCX1BPUlQ9JHtfQVBQX0RCX1BPUlQ6LTMzMDZ9JwogICAgICAtICdfQVBQX0RCX1NDSEVNQT0ke19BUFBfREJfU0NIRU1BOi1hcHB3cml0ZX0nCiAgICAgIC0gX0FQUF9EQl9VU0VSPSRTRVJWSUNFX1VTRVJfTUFSSUFEQgogICAgICAtIF9BUFBfREJfUEFTUz0kU0VSVklDRV9QQVNTV09SRF9NQVJJQURCCiAgICAgIC0gJ19BUFBfUkVESVNfSE9TVD0ke19BUFBfUkVESVNfSE9TVDotYXBwd3JpdGUtcmVkaXN9JwogICAgICAtICdfQVBQX1JFRElTX1BPUlQ9JHtfQVBQX1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICAtICdfQVBQX1JFRElTX1VTRVI9JHtfQVBQX1JFRElTX1VTRVJ9JwogICAgICAtICdfQVBQX1JFRElTX1BBU1M9JHtfQVBQX1JFRElTX1BBU1N9JwogICAgICAtICdfQVBQX1VTQUdFX1NUQVRTPSR7X0FQUF9VU0FHRV9TVEFUUzotZW5hYmxlZH0nCiAgICAgIC0gJ19BUFBfTE9HR0lOR19DT05GSUc9JHtfQVBQX0xPR0dJTkdfQ09ORklHfScKICAgICAgLSAnX0FQUF9EQVRBQkFTRV9TSEFSRURfVEFCTEVTPSR7X0FQUF9EQVRBQkFTRV9TSEFSRURfVEFCTEVTfScKICAgICAgLSAnX0FQUF9TVEFUU19SRVNPVVJDRVNfSU5URVJWQUw9JHtfQVBQX1NUQVRTX1JFU09VUkNFU19JTlRFUlZBTH0nCiAgYXBwd3JpdGUtd29ya2VyLXN0YXRzLXJlc291cmNlczoKICAgIGltYWdlOiAnYXBwd3JpdGUvYXBwd3JpdGU6MS43LjQnCiAgICBlbnRyeXBvaW50OiB3b3JrZXItc3RhdHMtcmVzb3VyY2VzCiAgICBjb250YWluZXJfbmFtZTogYXBwd3JpdGUtd29ya2VyLXN0YXRzLXJlc291cmNlcwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcHB3cml0ZS1yZWRpcwogICAgICAtIGFwcHdyaXRlLW1hcmlhZGIKICAgIGVudmlyb25tZW50OgogICAgICAtICdfQVBQX0VOVj0ke19BUFBfRU5WOi1wcm9kdWN0aW9ufScKICAgICAgLSAnX0FQUF9XT1JLRVJfUEVSX0NPUkU9JHtfQVBQX1dPUktFUl9QRVJfQ09SRTotNn0nCiAgICAgIC0gX0FQUF9PUEVOU1NMX0tFWV9WMT0kU0VSVklDRV9QQVNTV09SRF82NF9BUFBXUklURQogICAgICAtICdfQVBQX0RCX0hPU1Q9JHtfQVBQX0RCX0hPU1Q6LWFwcHdyaXRlLW1hcmlhZGJ9JwogICAgICAtICdfQVBQX0RCX1BPUlQ9JHtfQVBQX0RCX1BPUlQ6LTMzMDZ9JwogICAgICAtICdfQVBQX0RCX1NDSEVNQT0ke19BUFBfREJfU0NIRU1BOi1hcHB3cml0ZX0nCiAgICAgIC0gX0FQUF9EQl9VU0VSPSRTRVJWSUNFX1VTRVJfTUFSSUFEQgogICAgICAtIF9BUFBfREJfUEFTUz0kU0VSVklDRV9QQVNTV09SRF9NQVJJQURCCiAgICAgIC0gJ19BUFBfUkVESVNfSE9TVD0ke19BUFBfUkVESVNfSE9TVDotYXBwd3JpdGUtcmVkaXN9JwogICAgICAtICdfQVBQX1JFRElTX1BPUlQ9JHtfQVBQX1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICAtICdfQVBQX1JFRElTX1VTRVI9JHtfQVBQX1JFRElTX1VTRVJ9JwogICAgICAtICdfQVBQX1JFRElTX1BBU1M9JHtfQVBQX1JFRElTX1BBU1N9JwogICAgICAtICdfQVBQX1VTQUdFX1NUQVRTPSR7X0FQUF9VU0FHRV9TVEFUUzotZW5hYmxlZH0nCiAgICAgIC0gJ19BUFBfTE9HR0lOR19DT05GSUc9JHtfQVBQX0xPR0dJTkdfQ09ORklHfScKICAgICAgLSAnX0FQUF9TVEFUU19SRVNPVVJDRVNfSU5URVJWQUw9JHtfQVBQX1NUQVRTX1JFU09VUkNFU19JTlRFUlZBTH0nCiAgYXBwd3JpdGUtd29ya2VyLXN0YXRzLXVzYWdlOgogICAgaW1hZ2U6ICdhcHB3cml0ZS9hcHB3cml0ZToxLjcuNCcKICAgIGVudHJ5cG9pbnQ6IHdvcmtlci1zdGF0cy11c2FnZQogICAgY29udGFpbmVyX25hbWU6IGFwcHdyaXRlLXdvcmtlci1zdGF0cy11c2FnZQogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcHB3cml0ZS1yZWRpcwogICAgICAtIGFwcHdyaXRlLW1hcmlhZGIKICAgIGVudmlyb25tZW50OgogICAgICAtICdfQVBQX0VOVj0ke19BUFBfRU5WOi1wcm9kdWN0aW9ufScKICAgICAgLSAnX0FQUF9XT1JLRVJfUEVSX0NPUkU9JHtfQVBQX1dPUktFUl9QRVJfQ09SRTotNn0nCiAgICAgIC0gX0FQUF9PUEVOU1NMX0tFWV9WMT0kU0VSVklDRV9QQVNTV09SRF82NF9BUFBXUklURQogICAgICAtICdfQVBQX0RCX0hPU1Q9JHtfQVBQX0RCX0hPU1Q6LWFwcHdyaXRlLW1hcmlhZGJ9JwogICAgICAtICdfQVBQX0RCX1BPUlQ9JHtfQVBQX0RCX1BPUlQ6LTMzMDZ9JwogICAgICAtICdfQVBQX0RCX1NDSEVNQT0ke19BUFBfREJfU0NIRU1BOi1hcHB3cml0ZX0nCiAgICAgIC0gX0FQUF9EQl9VU0VSPSRTRVJWSUNFX1VTRVJfTUFSSUFEQgogICAgICAtIF9BUFBfREJfUEFTUz0kU0VSVklDRV9QQVNTV09SRF9NQVJJQURCCiAgICAgIC0gJ19BUFBfUkVESVNfSE9TVD0ke19BUFBfUkVESVNfSE9TVDotYXBwd3JpdGUtcmVkaXN9JwogICAgICAtICdfQVBQX1JFRElTX1BPUlQ9JHtfQVBQX1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICAtICdfQVBQX1JFRElTX1VTRVI9JHtfQVBQX1JFRElTX1VTRVJ9JwogICAgICAtICdfQVBQX1JFRElTX1BBU1M9JHtfQVBQX1JFRElTX1BBU1N9JwogICAgICAtICdfQVBQX1VTQUdFX1NUQVRTPSR7X0FQUF9VU0FHRV9TVEFUUzotZW5hYmxlZH0nCiAgICAgIC0gJ19BUFBfTE9HR0lOR19DT05GSUc9JHtfQVBQX0xPR0dJTkdfQ09ORklHfScKICAgICAgLSAnX0FQUF9VU0FHRV9BR0dSRUdBVElPTl9JTlRFUlZBTD0ke19BUFBfVVNBR0VfQUdHUkVHQVRJT05fSU5URVJWQUw6LTMwfScKICAgICAgLSAnX0FQUF9EQVRBQkFTRV9TSEFSRURfVEFCTEVTPSR7X0FQUF9EQVRBQkFTRV9TSEFSRURfVEFCTEVTfScKICBhcHB3cml0ZS10YXNrLXNjaGVkdWxlci1mdW5jdGlvbnM6CiAgICBpbWFnZTogJ2FwcHdyaXRlL2FwcHdyaXRlOjEuNy40JwogICAgZW50cnlwb2ludDogc2NoZWR1bGUtZnVuY3Rpb25zCiAgICBjb250YWluZXJfbmFtZTogYXBwd3JpdGUtdGFzay1zY2hlZHVsZXItZnVuY3Rpb25zCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwcHdyaXRlLW1hcmlhZGIKICAgICAgLSBhcHB3cml0ZS1yZWRpcwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ19BUFBfRU5WPSR7X0FQUF9FTlY6LXByb2R1Y3Rpb259JwogICAgICAtICdfQVBQX1dPUktFUl9QRVJfQ09SRT0ke19BUFBfV09SS0VSX1BFUl9DT1JFOi02fScKICAgICAgLSBfQVBQX09QRU5TU0xfS0VZX1YxPSRTRVJWSUNFX1BBU1NXT1JEXzY0X0FQUFdSSVRFCiAgICAgIC0gJ19BUFBfUkVESVNfSE9TVD0ke19BUFBfUkVESVNfSE9TVDotYXBwd3JpdGUtcmVkaXN9JwogICAgICAtICdfQVBQX1JFRElTX1BPUlQ9JHtfQVBQX1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICAtICdfQVBQX1JFRElTX1VTRVI9JHtfQVBQX1JFRElTX1VTRVJ9JwogICAgICAtICdfQVBQX1JFRElTX1BBU1M9JHtfQVBQX1JFRElTX1BBU1N9JwogICAgICAtICdfQVBQX0RCX0hPU1Q9JHtfQVBQX0RCX0hPU1Q6LWFwcHdyaXRlLW1hcmlhZGJ9JwogICAgICAtICdfQVBQX0RCX1BPUlQ9JHtfQVBQX0RCX1BPUlQ6LTMzMDZ9JwogICAgICAtICdfQVBQX0RCX1NDSEVNQT0ke19BUFBfREJfU0NIRU1BOi1hcHB3cml0ZX0nCiAgICAgIC0gX0FQUF9EQl9VU0VSPSRTRVJWSUNFX1VTRVJfTUFSSUFEQgogICAgICAtIF9BUFBfREJfUEFTUz0kU0VSVklDRV9QQVNTV09SRF9NQVJJQURCCiAgICAgIC0gJ19BUFBfREFUQUJBU0VfU0hBUkVEX1RBQkxFUz0ke19BUFBfREFUQUJBU0VfU0hBUkVEX1RBQkxFU30nCiAgYXBwd3JpdGUtdGFzay1zY2hlZHVsZXItZXhlY3V0aW9uczoKICAgIGltYWdlOiAnYXBwd3JpdGUvYXBwd3JpdGU6MS43LjQnCiAgICBlbnRyeXBvaW50OiBzY2hlZHVsZS1leGVjdXRpb25zCiAgICBjb250YWluZXJfbmFtZTogYXBwd3JpdGUtdGFzay1zY2hlZHVsZXItZXhlY3V0aW9ucwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcHB3cml0ZS1tYXJpYWRiCiAgICAgIC0gYXBwd3JpdGUtcmVkaXMKICAgIGVudmlyb25tZW50OgogICAgICAtICdfQVBQX0VOVj0ke19BUFBfRU5WOi1wcm9kdWN0aW9ufScKICAgICAgLSAnX0FQUF9XT1JLRVJfUEVSX0NPUkU9JHtfQVBQX1dPUktFUl9QRVJfQ09SRTotNn0nCiAgICAgIC0gX0FQUF9PUEVOU1NMX0tFWV9WMT0kU0VSVklDRV9QQVNTV09SRF82NF9BUFBXUklURQogICAgICAtICdfQVBQX1JFRElTX0hPU1Q9JHtfQVBQX1JFRElTX0hPU1Q6LWFwcHdyaXRlLXJlZGlzfScKICAgICAgLSAnX0FQUF9SRURJU19QT1JUPSR7X0FQUF9SRURJU19QT1JUOi02Mzc5fScKICAgICAgLSAnX0FQUF9SRURJU19VU0VSPSR7X0FQUF9SRURJU19VU0VSfScKICAgICAgLSAnX0FQUF9SRURJU19QQVNTPSR7X0FQUF9SRURJU19QQVNTfScKICAgICAgLSAnX0FQUF9EQl9IT1NUPSR7X0FQUF9EQl9IT1NUOi1hcHB3cml0ZS1tYXJpYWRifScKICAgICAgLSAnX0FQUF9EQl9QT1JUPSR7X0FQUF9EQl9QT1JUOi0zMzA2fScKICAgICAgLSAnX0FQUF9EQl9TQ0hFTUE9JHtfQVBQX0RCX1NDSEVNQTotYXBwd3JpdGV9JwogICAgICAtIF9BUFBfREJfVVNFUj0kU0VSVklDRV9VU0VSX01BUklBREIKICAgICAgLSBfQVBQX0RCX1BBU1M9JFNFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQgogICAgICAtICdfQVBQX0RBVEFCQVNFX1NIQVJFRF9UQUJMRVM9JHtfQVBQX0RBVEFCQVNFX1NIQVJFRF9UQUJMRVN9JwogIGFwcHdyaXRlLXRhc2stc2NoZWR1bGVyLW1lc3NhZ2VzOgogICAgaW1hZ2U6ICdhcHB3cml0ZS9hcHB3cml0ZToxLjcuNCcKICAgIGVudHJ5cG9pbnQ6IHNjaGVkdWxlLW1lc3NhZ2VzCiAgICBjb250YWluZXJfbmFtZTogYXBwd3JpdGUtdGFzay1zY2hlZHVsZXItbWVzc2FnZXMKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBwd3JpdGUtbWFyaWFkYgogICAgICAtIGFwcHdyaXRlLXJlZGlzCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnX0FQUF9FTlY9JHtfQVBQX0VOVjotcHJvZHVjdGlvbn0nCiAgICAgIC0gJ19BUFBfV09SS0VSX1BFUl9DT1JFPSR7X0FQUF9XT1JLRVJfUEVSX0NPUkU6LTZ9JwogICAgICAtIF9BUFBfT1BFTlNTTF9LRVlfVjE9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfQVBQV1JJVEUKICAgICAgLSAnX0FQUF9SRURJU19IT1NUPSR7X0FQUF9SRURJU19IT1NUOi1hcHB3cml0ZS1yZWRpc30nCiAgICAgIC0gJ19BUFBfUkVESVNfUE9SVD0ke19BUFBfUkVESVNfUE9SVDotNjM3OX0nCiAgICAgIC0gJ19BUFBfUkVESVNfVVNFUj0ke19BUFBfUkVESVNfVVNFUn0nCiAgICAgIC0gJ19BUFBfUkVESVNfUEFTUz0ke19BUFBfUkVESVNfUEFTU30nCiAgICAgIC0gJ19BUFBfREJfSE9TVD0ke19BUFBfREJfSE9TVDotYXBwd3JpdGUtbWFyaWFkYn0nCiAgICAgIC0gJ19BUFBfREJfUE9SVD0ke19BUFBfREJfUE9SVDotMzMwNn0nCiAgICAgIC0gJ19BUFBfREJfU0NIRU1BPSR7X0FQUF9EQl9TQ0hFTUE6LWFwcHdyaXRlfScKICAgICAgLSBfQVBQX0RCX1VTRVI9JFNFUlZJQ0VfVVNFUl9NQVJJQURCCiAgICAgIC0gX0FQUF9EQl9QQVNTPSRTRVJWSUNFX1BBU1NXT1JEX01BUklBREIKICAgICAgLSAnX0FQUF9EQVRBQkFTRV9TSEFSRURfVEFCTEVTPSR7X0FQUF9EQVRBQkFTRV9TSEFSRURfVEFCTEVTfScKICBhcHB3cml0ZS1hc3Npc3RhbnQ6CiAgICBpbWFnZTogJ2FwcHdyaXRlL2Fzc2lzdGFudDowLjguMycKICAgIGNvbnRhaW5lcl9uYW1lOiBhcHB3cml0ZS1hc3Npc3RhbnQKICAgIGVudmlyb25tZW50OgogICAgICAtICdfQVBQX0FTU0lTVEFOVF9PUEVOQUlfQVBJX0tFWT0ke19BUFBfQVNTSVNUQU5UX09QRU5BSV9BUElfS0VZfScKICBhcHB3cml0ZS1icm93c2VyOgogICAgaW1hZ2U6ICdhcHB3cml0ZS9icm93c2VyOjAuMi40JwogICAgY29udGFpbmVyX25hbWU6IGFwcHdyaXRlLWJyb3dzZXIKICAgIGhvc3RuYW1lOiBhcHB3cml0ZS1icm93c2VyCiAgb3BlbnJ1bnRpbWVzLWV4ZWN1dG9yOgogICAgY29udGFpbmVyX25hbWU6IG9wZW5ydW50aW1lcy1leGVjdXRvcgogICAgaG9zdG5hbWU6IGFwcHdyaXRlLWV4ZWN1dG9yCiAgICBzdG9wX3NpZ25hbDogU0lHSU5UCiAgICBpbWFnZTogJ29wZW5ydW50aW1lcy9leGVjdXRvcjowLjguNicKICAgIG5ldHdvcmtzOgogICAgICAtIHJ1bnRpbWVzCiAgICB2b2x1bWVzOgogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jaycKICAgICAgLSAnYXBwd3JpdGUtYnVpbGRzOi9zdG9yYWdlL2J1aWxkczpydycKICAgICAgLSAnYXBwd3JpdGUtZnVuY3Rpb25zOi9zdG9yYWdlL2Z1bmN0aW9uczpydycKICAgICAgLSAnYXBwd3JpdGUtc2l0ZXM6L3N0b3JhZ2Uvc2l0ZXM6cncnCiAgICAgIC0gJy90bXA6L3RtcDpydycKICAgIGVudmlyb25tZW50OgogICAgICAtIE9QUl9FWEVDVVRPUl9JTUFHRV9QVUxMPWRpc2FibGVkCiAgICAgIC0gJ09QUl9FWEVDVVRPUl9JTkFDVElWRV9UUkVTSE9MRD0ke19BUFBfQ09NUFVURV9JTkFDVElWRV9USFJFU0hPTER9JwogICAgICAtICdPUFJfRVhFQ1VUT1JfTUFJTlRFTkFOQ0VfSU5URVJWQUw9JHtfQVBQX0NPTVBVVEVfTUFJTlRFTkFOQ0VfSU5URVJWQUx9JwogICAgICAtICdPUFJfRVhFQ1VUT1JfTkVUV09SSz0ke19BUFBfQ09NUFVURV9SVU5USU1FU19ORVRXT1JLOi1ydW50aW1lc30nCiAgICAgIC0gJ09QUl9FWEVDVVRPUl9ET0NLRVJfSFVCX1VTRVJOQU1FPSR7X0FQUF9ET0NLRVJfSFVCX1VTRVJOQU1FfScKICAgICAgLSAnT1BSX0VYRUNVVE9SX0RPQ0tFUl9IVUJfUEFTU1dPUkQ9JHtfQVBQX0RPQ0tFUl9IVUJfUEFTU1dPUkR9JwogICAgICAtICdPUFJfRVhFQ1VUT1JfRU5WPSR7X0FQUF9FTlY6LXByb2R1Y3Rpb259JwogICAgICAtICdPUFJfRVhFQ1VUT1JfUlVOVElNRVM9JHtfQVBQX0ZVTkNUSU9OU19SVU5USU1FU30sJHtfQVBQX1NJVEVTX1JVTlRJTUVTfScKICAgICAgLSBPUFJfRVhFQ1VUT1JfU0VDUkVUPSRTRVJWSUNFX1BBU1NXT1JEXzY0X0FQUFdSSVRFCiAgICAgIC0gT1BSX0VYRUNVVE9SX1JVTlRJTUVfVkVSU0lPTlM9djUKICAgICAgLSAnT1BSX0VYRUNVVE9SX0xPR0dJTkdfQ09ORklHPSR7X0FQUF9MT0dHSU5HX0NPTkZJR30nCiAgICAgIC0gJ09QUl9FWEVDVVRPUl9TVE9SQUdFX0RFVklDRT0ke19BUFBfU1RPUkFHRV9ERVZJQ0U6LWxvY2FsfScKICAgICAgLSAnT1BSX0VYRUNVVE9SX1NUT1JBR0VfUzNfQUNDRVNTX0tFWT0ke19BUFBfU1RPUkFHRV9TM19BQ0NFU1NfS0VZfScKICAgICAgLSAnT1BSX0VYRUNVVE9SX1NUT1JBR0VfUzNfU0VDUkVUPSR7X0FQUF9TVE9SQUdFX1MzX1NFQ1JFVH0nCiAgICAgIC0gJ09QUl9FWEVDVVRPUl9TVE9SQUdFX1MzX1JFR0lPTj0ke19BUFBfU1RPUkFHRV9TM19SRUdJT059JwogICAgICAtICdPUFJfRVhFQ1VUT1JfU1RPUkFHRV9TM19CVUNLRVQ9JHtfQVBQX1NUT1JBR0VfUzNfQlVDS0VUfScKICAgICAgLSAnT1BSX0VYRUNVVE9SX1NUT1JBR0VfUzNfRU5EUE9JTlQ9JHtfQVBQX1NUT1JBR0VfUzNfRU5EUE9JTlR9JwogICAgICAtICdPUFJfRVhFQ1VUT1JfU1RPUkFHRV9ET19TUEFDRVNfQUNDRVNTX0tFWT0ke19BUFBfU1RPUkFHRV9ET19TUEFDRVNfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ09QUl9FWEVDVVRPUl9TVE9SQUdFX0RPX1NQQUNFU19TRUNSRVQ9JHtfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX1NFQ1JFVH0nCiAgICAgIC0gJ09QUl9FWEVDVVRPUl9TVE9SQUdFX0RPX1NQQUNFU19SRUdJT049JHtfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX1JFR0lPTn0nCiAgICAgIC0gJ09QUl9FWEVDVVRPUl9TVE9SQUdFX0RPX1NQQUNFU19CVUNLRVQ9JHtfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX0JVQ0tFVH0nCiAgICAgIC0gJ09QUl9FWEVDVVRPUl9TVE9SQUdFX0JBQ0tCTEFaRV9BQ0NFU1NfS0VZPSR7X0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9BQ0NFU1NfS0VZfScKICAgICAgLSAnT1BSX0VYRUNVVE9SX1NUT1JBR0VfQkFDS0JMQVpFX1NFQ1JFVD0ke19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfU0VDUkVUfScKICAgICAgLSAnT1BSX0VYRUNVVE9SX1NUT1JBR0VfQkFDS0JMQVpFX1JFR0lPTj0ke19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfUkVHSU9OfScKICAgICAgLSAnT1BSX0VYRUNVVE9SX1NUT1JBR0VfQkFDS0JMQVpFX0JVQ0tFVD0ke19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfQlVDS0VUfScKICAgICAgLSAnT1BSX0VYRUNVVE9SX1NUT1JBR0VfTElOT0RFX0FDQ0VTU19LRVk9JHtfQVBQX1NUT1JBR0VfTElOT0RFX0FDQ0VTU19LRVl9JwogICAgICAtICdPUFJfRVhFQ1VUT1JfU1RPUkFHRV9MSU5PREVfU0VDUkVUPSR7X0FQUF9TVE9SQUdFX0xJTk9ERV9TRUNSRVR9JwogICAgICAtICdPUFJfRVhFQ1VUT1JfU1RPUkFHRV9MSU5PREVfUkVHSU9OPSR7X0FQUF9TVE9SQUdFX0xJTk9ERV9SRUdJT059JwogICAgICAtICdPUFJfRVhFQ1VUT1JfU1RPUkFHRV9MSU5PREVfQlVDS0VUPSR7X0FQUF9TVE9SQUdFX0xJTk9ERV9CVUNLRVR9JwogICAgICAtICdPUFJfRVhFQ1VUT1JfU1RPUkFHRV9XQVNBQklfQUNDRVNTX0tFWT0ke19BUFBfU1RPUkFHRV9XQVNBQklfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ09QUl9FWEVDVVRPUl9TVE9SQUdFX1dBU0FCSV9TRUNSRVQ9JHtfQVBQX1NUT1JBR0VfV0FTQUJJX1NFQ1JFVH0nCiAgICAgIC0gJ09QUl9FWEVDVVRPUl9TVE9SQUdFX1dBU0FCSV9SRUdJT049JHtfQVBQX1NUT1JBR0VfV0FTQUJJX1JFR0lPTn0nCiAgICAgIC0gJ09QUl9FWEVDVVRPUl9TVE9SQUdFX1dBU0FCSV9CVUNLRVQ9JHtfQVBQX1NUT1JBR0VfV0FTQUJJX0JVQ0tFVH0nCiAgYXBwd3JpdGUtbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMC4xMScKICAgIGNvbnRhaW5lcl9uYW1lOiBhcHB3cml0ZS1tYXJpYWRiCiAgICB2b2x1bWVzOgogICAgICAtICdhcHB3cml0ZS1tYXJpYWRiOi92YXIvbGliL215c3FsOnJ3JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTVlTUUxfUk9PVF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NQVJJQURCUk9PVAogICAgICAtICdNWVNRTF9EQVRBQkFTRT0ke19BUFBfREJfU0NIRU1BOi1hcHB3cml0ZX0nCiAgICAgIC0gTVlTUUxfVVNFUj0kU0VSVklDRV9VU0VSX01BUklBREIKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NQVJJQURCCiAgICAgIC0gTUFSSUFEQl9BVVRPX1VQR1JBREU9MQogICAgY29tbWFuZDogJ215c3FsZCAtLWlubm9kYi1mbHVzaC1tZXRob2Q9ZnN5bmMnCiAgYXBwd3JpdGUtcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjcuMi40LWFscGluZScKICAgIGNvbnRhaW5lcl9uYW1lOiBhcHB3cml0ZS1yZWRpcwogICAgY29tbWFuZDogInJlZGlzLXNlcnZlciAtLW1heG1lbW9yeSAgICAgICAgICAgIDUxMm1iIC0tbWF4bWVtb3J5LXBvbGljeSAgICAgYWxsa2V5cy1scnUgLS1tYXhtZW1vcnktc2FtcGxlcyAgICA1XG4iCiAgICB2b2x1bWVzOgogICAgICAtICdhcHB3cml0ZS1yZWRpczovZGF0YTpydycKbmV0d29ya3M6CiAgcnVudGltZXM6CiAgICBuYW1lOiBydW50aW1lcwp2b2x1bWVzOgogIGFwcHdyaXRlLW1hcmlhZGI6IG51bGwKICBhcHB3cml0ZS1yZWRpczogbnVsbAogIGFwcHdyaXRlLWNhY2hlOiBudWxsCiAgYXBwd3JpdGUtdXBsb2FkczogbnVsbAogIGFwcHdyaXRlLWltcG9ydHM6IG51bGwKICBhcHB3cml0ZS1jZXJ0aWZpY2F0ZXM6IG51bGwKICBhcHB3cml0ZS1mdW5jdGlvbnM6IG51bGwKICBhcHB3cml0ZS1zaXRlczogbnVsbAogIGFwcHdyaXRlLWJ1aWxkczogbnVsbAogIGFwcHdyaXRlLWNvbmZpZzogbnVsbAo=", "tags": [ "backend", "backend-as-a-service", @@ -1307,7 +1307,7 @@ "getoutline": { "documentation": "https://docs.getoutline.com/s/hosting/doc/hosting-outline-nipGaCRBDu?utm_source=coolify.io", "slogan": "Your team\u2019s knowledge base", - "compose": "c2VydmljZXM6CiAgb3V0bGluZToKICAgIGltYWdlOiAnZG9ja2VyLmdldG91dGxpbmUuY29tL291dGxpbmV3aWtpL291dGxpbmU6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnc3RvcmFnZS1kYXRhOi92YXIvbGliL291dGxpbmUvZGF0YScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fT1VUTElORV8zMDAwCiAgICAgIC0gTk9ERV9FTlY9cHJvZHVjdGlvbgogICAgICAtICdTRUNSRVRfS0VZPSR7U0VSVklDRV9CQVNFNjRfT1VUTElORX0nCiAgICAgIC0gJ1VUSUxTX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfT1VUTElORX0nCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfNjRfUE9TVEdSRVN9QHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU19EQVRBQkFTRTotb3V0bGluZX0nCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovLzoke1NFUlZJQ0VfUEFTU1dPUkRfNjRfUkVESVN9QHJlZGlzOjYzNzknCiAgICAgIC0gJ1VSTD0ke1NFUlZJQ0VfRlFETl9PVVRMSU5FXzMwMDB9JwogICAgICAtICdQT1JUPSR7T1VUTElORV9QT1JUOi0zMDAwfScKICAgICAgLSAnRklMRV9TVE9SQUdFPSR7RklMRV9TVE9SQUdFOi1sb2NhbH0nCiAgICAgIC0gJ0ZJTEVfU1RPUkFHRV9MT0NBTF9ST09UX0RJUj0ke0ZJTEVfU1RPUkFHRV9MT0NBTF9ST09UX0RJUjotL3Zhci9saWIvb3V0bGluZS9kYXRhfScKICAgICAgLSAnRklMRV9TVE9SQUdFX1VQTE9BRF9NQVhfU0laRT0ke0ZJTEVfU1RPUkFHRV9VUExPQURfTUFYX1NJWkU6LTIwMDB9JwogICAgICAtICdGSUxFX1NUT1JBR0VfSU1QT1JUX01BWF9TSVpFPSR7RklMRV9TVE9SQUdFX0lNUE9SVF9NQVhfU0laRTotMTAwfScKICAgICAgLSAnRklMRV9TVE9SQUdFX1dPUktTUEFDRV9JTVBPUlRfTUFYX1NJWkU9JHtGSUxFX1NUT1JBR0VfV09SS1NQQUNFX0lNUE9SVF9NQVhfU0laRX0nCiAgICAgIC0gJ0FXU19BQ0NFU1NfS0VZX0lEPSR7QVdTX0FDQ0VTU19LRVlfSUR9JwogICAgICAtICdBV1NfU0VDUkVUX0FDQ0VTU19LRVk9JHtBV1NfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAtICdBV1NfUkVHSU9OPSR7QVdTX1JFR0lPTn0nCiAgICAgIC0gJ0FXU19TM19BQ0NFTEVSQVRFX1VSTD0ke0FXU19TM19BQ0NFTEVSQVRFX1VSTH0nCiAgICAgIC0gJ0FXU19TM19VUExPQURfQlVDS0VUX1VSTD0ke0FXU19TM19VUExPQURfQlVDS0VUX1VSTH0nCiAgICAgIC0gJ0FXU19TM19VUExPQURfQlVDS0VUX05BTUU9JHtBV1NfUzNfVVBMT0FEX0JVQ0tFVF9OQU1FfScKICAgICAgLSAnQVdTX1MzX0ZPUkNFX1BBVEhfU1RZTEU9JHtBV1NfUzNfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgICAgIC0gJ0FXU19TM19BQ0w9JHtBV1NfUzNfQUNMOi1wcml2YXRlfScKICAgICAgLSAnU0xBQ0tfQ0xJRU5UX0lEPSR7U0xBQ0tfQ0xJRU5UX0lEfScKICAgICAgLSAnU0xBQ0tfQ0xJRU5UX1NFQ1JFVD0ke1NMQUNLX0NMSUVOVF9TRUNSRVR9JwogICAgICAtICdHT09HTEVfQ0xJRU5UX0lEPSR7R09PR0xFX0NMSUVOVF9JRH0nCiAgICAgIC0gJ0dPT0dMRV9DTElFTlRfU0VDUkVUPSR7R09PR0xFX0NMSUVOVF9TRUNSRVR9JwogICAgICAtICdBWlVSRV9DTElFTlRfSUQ9JHtBWlVSRV9DTElFTlRfSUR9JwogICAgICAtICdBWlVSRV9DTElFTlRfU0VDUkVUPSR7QVpVUkVfQ0xJRU5UX1NFQ1JFVH0nCiAgICAgIC0gJ0FaVVJFX1JFU09VUkNFX0FQUF9JRD0ke0FaVVJFX1JFU09VUkNFX0FQUF9JRH0nCiAgICAgIC0gJ09JRENfQ0xJRU5UX0lEPSR7T0lEQ19DTElFTlRfSUR9JwogICAgICAtICdPSURDX0NMSUVOVF9TRUNSRVQ9JHtPSURDX0NMSUVOVF9TRUNSRVR9JwogICAgICAtICdPSURDX0FVVEhfVVJJPSR7T0lEQ19BVVRIX1VSSX0nCiAgICAgIC0gJ09JRENfVE9LRU5fVVJJPSR7T0lEQ19UT0tFTl9VUkl9JwogICAgICAtICdPSURDX1VTRVJJTkZPX1VSST0ke09JRENfVVNFUklORk9fVVJJfScKICAgICAgLSAnT0lEQ19MT0dPVVRfVVJJPSR7T0lEQ19MT0dPVVRfVVJJfScKICAgICAgLSAnT0lEQ19VU0VSTkFNRV9DTEFJTT0ke09JRENfVVNFUk5BTUVfQ0xBSU19JwogICAgICAtICdPSURDX0RJU1BMQVlfTkFNRT0ke09JRENfRElTUExBWV9OQU1FfScKICAgICAgLSAnT0lEQ19TQ09QRVM9JHtPSURDX1NDT1BFU30nCiAgICAgIC0gJ0dJVEhVQl9DTElFTlRfSUQ9JHtHSVRIVUJfQ0xJRU5UX0lEfScKICAgICAgLSAnR0lUSFVCX0NMSUVOVF9TRUNSRVQ9JHtHSVRIVUJfQ0xJRU5UX1NFQ1JFVH0nCiAgICAgIC0gJ0dJVEhVQl9BUFBfTkFNRT0ke0dJVEhVQl9BUFBfTkFNRX0nCiAgICAgIC0gJ0dJVEhVQl9BUFBfSUQ9JHtHSVRIVUJfQVBQX0lEfScKICAgICAgLSAnR0lUSFVCX0FQUF9QUklWQVRFX0tFWT0ke0dJVEhVQl9BUFBfUFJJVkFURV9LRVl9JwogICAgICAtICdESVNDT1JEX0NMSUVOVF9JRD0ke0RJU0NPUkRfQ0xJRU5UX0lEfScKICAgICAgLSAnRElTQ09SRF9DTElFTlRfU0VDUkVUPSR7RElTQ09SRF9DTElFTlRfU0VDUkVUfScKICAgICAgLSAnRElTQ09SRF9TRVJWRVJfSUQ9JHtESVNDT1JEX1NFUlZFUl9JRH0nCiAgICAgIC0gJ0RJU0NPUkRfU0VSVkVSX1JPTEVTPSR7RElTQ09SRF9TRVJWRVJfUk9MRVN9JwogICAgICAtICdQR1NTTE1PREU9JHtQR1NTTE1PREU6LWRpc2FibGV9JwogICAgICAtICdGT1JDRV9IVFRQUz0ke0ZPUkNFX0hUVFBTOi10cnVlfScKICAgICAgLSAnU01UUF9IT1NUPSR7U01UUF9IT1NUfScKICAgICAgLSAnU01UUF9QT1JUPSR7U01UUF9QT1JUfScKICAgICAgLSAnU01UUF9VU0VSTkFNRT0ke1NNVFBfVVNFUk5BTUV9JwogICAgICAtICdTTVRQX1BBU1NXT1JEPSR7U01UUF9QQVNTV09SRH0nCiAgICAgIC0gJ1NNVFBfRlJPTV9FTUFJTD0ke1NNVFBfRlJPTV9FTUFJTH0nCiAgICAgIC0gJ1NNVFBfUkVQTFlfRU1BSUw9JHtTTVRQX1JFUExZX0VNQUlMfScKICAgICAgLSAnU01UUF9UTFNfQ0lQSEVSUz0ke1NNVFBfVExTX0NJUEhFUlN9JwogICAgICAtICdTTVRQX1NFQ1VSRT0ke1NNVFBfU0VDVVJFfScKICAgICAgLSAnU01UUF9OQU1FPSR7U01UUF9OQU1FfScKICAgIGhlYWx0aGNoZWNrOgogICAgICBkaXNhYmxlOiB0cnVlCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOmFscGluZScKICAgIGVudmlyb25tZW50OgogICAgICAtICdSRURJU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfUkVESVN9JwogICAgY29tbWFuZDoKICAgICAgLSByZWRpcy1zZXJ2ZXIKICAgICAgLSAnLS1yZXF1aXJlcGFzcycKICAgICAgLSAnJHtTRVJWSUNFX1BBU1NXT1JEXzY0X1JFRElTfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtICctYScKICAgICAgICAtICcke1NFUlZJQ0VfUEFTU1dPUkRfNjRfUkVESVN9JwogICAgICAgIC0gUElORwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDMwcwogICAgICByZXRyaWVzOiAzCiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjEyLWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2RhdGFiYXNlLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RBVEFCQVNFOi1vdXRsaW5lfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBwZ19pc3JlYWR5CiAgICAgICAgLSAnLVUnCiAgICAgICAgLSAnJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAgIC0gJy1kJwogICAgICAgIC0gJyR7UE9TVEdSRVNfREFUQUJBU0U6LW91dGxpbmV9JwogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAzCg==", + "compose": "c2VydmljZXM6CiAgb3V0bGluZToKICAgIGltYWdlOiAnZG9ja2VyLmdldG91dGxpbmUuY29tL291dGxpbmV3aWtpL291dGxpbmU6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnc3RvcmFnZS1kYXRhOi92YXIvbGliL291dGxpbmUvZGF0YScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fT1VUTElORV8zMDAwCiAgICAgIC0gTk9ERV9FTlY9cHJvZHVjdGlvbgogICAgICAtICdTRUNSRVRfS0VZPSR7U0VSVklDRV9IRVhfMzJfT1VUTElORX0nCiAgICAgIC0gJ1VUSUxTX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfT1VUTElORX0nCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfNjRfUE9TVEdSRVN9QHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU19EQVRBQkFTRTotb3V0bGluZX0nCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovLzoke1NFUlZJQ0VfUEFTU1dPUkRfNjRfUkVESVN9QHJlZGlzOjYzNzknCiAgICAgIC0gJ1VSTD0ke1NFUlZJQ0VfRlFETl9PVVRMSU5FXzMwMDB9JwogICAgICAtICdQT1JUPSR7T1VUTElORV9QT1JUOi0zMDAwfScKICAgICAgLSAnRklMRV9TVE9SQUdFPSR7RklMRV9TVE9SQUdFOi1sb2NhbH0nCiAgICAgIC0gJ0ZJTEVfU1RPUkFHRV9MT0NBTF9ST09UX0RJUj0ke0ZJTEVfU1RPUkFHRV9MT0NBTF9ST09UX0RJUjotL3Zhci9saWIvb3V0bGluZS9kYXRhfScKICAgICAgLSAnRklMRV9TVE9SQUdFX1VQTE9BRF9NQVhfU0laRT0ke0ZJTEVfU1RPUkFHRV9VUExPQURfTUFYX1NJWkU6LTIwMDB9JwogICAgICAtICdGSUxFX1NUT1JBR0VfSU1QT1JUX01BWF9TSVpFPSR7RklMRV9TVE9SQUdFX0lNUE9SVF9NQVhfU0laRTotMTAwfScKICAgICAgLSAnRklMRV9TVE9SQUdFX1dPUktTUEFDRV9JTVBPUlRfTUFYX1NJWkU9JHtGSUxFX1NUT1JBR0VfV09SS1NQQUNFX0lNUE9SVF9NQVhfU0laRX0nCiAgICAgIC0gJ0FXU19BQ0NFU1NfS0VZX0lEPSR7QVdTX0FDQ0VTU19LRVlfSUR9JwogICAgICAtICdBV1NfU0VDUkVUX0FDQ0VTU19LRVk9JHtBV1NfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAtICdBV1NfUkVHSU9OPSR7QVdTX1JFR0lPTn0nCiAgICAgIC0gJ0FXU19TM19BQ0NFTEVSQVRFX1VSTD0ke0FXU19TM19BQ0NFTEVSQVRFX1VSTH0nCiAgICAgIC0gJ0FXU19TM19VUExPQURfQlVDS0VUX1VSTD0ke0FXU19TM19VUExPQURfQlVDS0VUX1VSTH0nCiAgICAgIC0gJ0FXU19TM19VUExPQURfQlVDS0VUX05BTUU9JHtBV1NfUzNfVVBMT0FEX0JVQ0tFVF9OQU1FfScKICAgICAgLSAnQVdTX1MzX0ZPUkNFX1BBVEhfU1RZTEU9JHtBV1NfUzNfRk9SQ0VfUEFUSF9TVFlMRTotdHJ1ZX0nCiAgICAgIC0gJ0FXU19TM19BQ0w9JHtBV1NfUzNfQUNMOi1wcml2YXRlfScKICAgICAgLSAnU0xBQ0tfQ0xJRU5UX0lEPSR7U0xBQ0tfQ0xJRU5UX0lEfScKICAgICAgLSAnU0xBQ0tfQ0xJRU5UX1NFQ1JFVD0ke1NMQUNLX0NMSUVOVF9TRUNSRVR9JwogICAgICAtICdHT09HTEVfQ0xJRU5UX0lEPSR7R09PR0xFX0NMSUVOVF9JRH0nCiAgICAgIC0gJ0dPT0dMRV9DTElFTlRfU0VDUkVUPSR7R09PR0xFX0NMSUVOVF9TRUNSRVR9JwogICAgICAtICdBWlVSRV9DTElFTlRfSUQ9JHtBWlVSRV9DTElFTlRfSUR9JwogICAgICAtICdBWlVSRV9DTElFTlRfU0VDUkVUPSR7QVpVUkVfQ0xJRU5UX1NFQ1JFVH0nCiAgICAgIC0gJ0FaVVJFX1JFU09VUkNFX0FQUF9JRD0ke0FaVVJFX1JFU09VUkNFX0FQUF9JRH0nCiAgICAgIC0gJ09JRENfQ0xJRU5UX0lEPSR7T0lEQ19DTElFTlRfSUR9JwogICAgICAtICdPSURDX0NMSUVOVF9TRUNSRVQ9JHtPSURDX0NMSUVOVF9TRUNSRVR9JwogICAgICAtICdPSURDX0FVVEhfVVJJPSR7T0lEQ19BVVRIX1VSSX0nCiAgICAgIC0gJ09JRENfVE9LRU5fVVJJPSR7T0lEQ19UT0tFTl9VUkl9JwogICAgICAtICdPSURDX1VTRVJJTkZPX1VSST0ke09JRENfVVNFUklORk9fVVJJfScKICAgICAgLSAnT0lEQ19MT0dPVVRfVVJJPSR7T0lEQ19MT0dPVVRfVVJJfScKICAgICAgLSAnT0lEQ19VU0VSTkFNRV9DTEFJTT0ke09JRENfVVNFUk5BTUVfQ0xBSU19JwogICAgICAtICdPSURDX0RJU1BMQVlfTkFNRT0ke09JRENfRElTUExBWV9OQU1FfScKICAgICAgLSAnT0lEQ19TQ09QRVM9JHtPSURDX1NDT1BFU30nCiAgICAgIC0gJ0dJVEhVQl9DTElFTlRfSUQ9JHtHSVRIVUJfQ0xJRU5UX0lEfScKICAgICAgLSAnR0lUSFVCX0NMSUVOVF9TRUNSRVQ9JHtHSVRIVUJfQ0xJRU5UX1NFQ1JFVH0nCiAgICAgIC0gJ0dJVEhVQl9BUFBfTkFNRT0ke0dJVEhVQl9BUFBfTkFNRX0nCiAgICAgIC0gJ0dJVEhVQl9BUFBfSUQ9JHtHSVRIVUJfQVBQX0lEfScKICAgICAgLSAnR0lUSFVCX0FQUF9QUklWQVRFX0tFWT0ke0dJVEhVQl9BUFBfUFJJVkFURV9LRVl9JwogICAgICAtICdESVNDT1JEX0NMSUVOVF9JRD0ke0RJU0NPUkRfQ0xJRU5UX0lEfScKICAgICAgLSAnRElTQ09SRF9DTElFTlRfU0VDUkVUPSR7RElTQ09SRF9DTElFTlRfU0VDUkVUfScKICAgICAgLSAnRElTQ09SRF9TRVJWRVJfSUQ9JHtESVNDT1JEX1NFUlZFUl9JRH0nCiAgICAgIC0gJ0RJU0NPUkRfU0VSVkVSX1JPTEVTPSR7RElTQ09SRF9TRVJWRVJfUk9MRVN9JwogICAgICAtICdQR1NTTE1PREU9JHtQR1NTTE1PREU6LWRpc2FibGV9JwogICAgICAtICdGT1JDRV9IVFRQUz0ke0ZPUkNFX0hUVFBTOi10cnVlfScKICAgICAgLSAnU01UUF9IT1NUPSR7U01UUF9IT1NUfScKICAgICAgLSAnU01UUF9QT1JUPSR7U01UUF9QT1JUfScKICAgICAgLSAnU01UUF9VU0VSTkFNRT0ke1NNVFBfVVNFUk5BTUV9JwogICAgICAtICdTTVRQX1BBU1NXT1JEPSR7U01UUF9QQVNTV09SRH0nCiAgICAgIC0gJ1NNVFBfRlJPTV9FTUFJTD0ke1NNVFBfRlJPTV9FTUFJTH0nCiAgICAgIC0gJ1NNVFBfUkVQTFlfRU1BSUw9JHtTTVRQX1JFUExZX0VNQUlMfScKICAgICAgLSAnU01UUF9UTFNfQ0lQSEVSUz0ke1NNVFBfVExTX0NJUEhFUlN9JwogICAgICAtICdTTVRQX1NFQ1VSRT0ke1NNVFBfU0VDVVJFfScKICAgICAgLSAnU01UUF9OQU1FPSR7U01UUF9OQU1FfScKICAgIGhlYWx0aGNoZWNrOgogICAgICBkaXNhYmxlOiB0cnVlCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOmFscGluZScKICAgIGVudmlyb25tZW50OgogICAgICAtICdSRURJU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfUkVESVN9JwogICAgY29tbWFuZDoKICAgICAgLSByZWRpcy1zZXJ2ZXIKICAgICAgLSAnLS1yZXF1aXJlcGFzcycKICAgICAgLSAnJHtTRVJWSUNFX1BBU1NXT1JEXzY0X1JFRElTfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtICctYScKICAgICAgICAtICcke1NFUlZJQ0VfUEFTU1dPUkRfNjRfUkVESVN9JwogICAgICAgIC0gUElORwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDMwcwogICAgICByZXRyaWVzOiAzCiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjEyLWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2RhdGFiYXNlLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RBVEFCQVNFOi1vdXRsaW5lfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBwZ19pc3JlYWR5CiAgICAgICAgLSAnLVUnCiAgICAgICAgLSAnJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAgIC0gJy1kJwogICAgICAgIC0gJyR7UE9TVEdSRVNfREFUQUJBU0U6LW91dGxpbmV9JwogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAzCg==", "tags": [ "knowledge base", "documentation" @@ -2443,6 +2443,27 @@ "minversion": "0.0.0", "port": "1883" }, + "n8n-with-postgres-and-worker": { + "documentation": "https://n8n.io?utm_source=coolify.io", + "slogan": "n8n is an extendable workflow automation tool with queue mode and workers.", + "compose": "c2VydmljZXM6CiAgbjhuOgogICAgaW1hZ2U6IGRvY2tlci5uOG4uaW8vbjhuaW8vbjhuCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fTjhOXzU2NzgKICAgICAgLSAnTjhOX0VESVRPUl9CQVNFX1VSTD0ke1NFUlZJQ0VfRlFETl9OOE59JwogICAgICAtICdXRUJIT09LX1VSTD0ke1NFUlZJQ0VfRlFETl9OOE59JwogICAgICAtICdOOE5fSE9TVD0ke1NFUlZJQ0VfRlFETl9OOE59JwogICAgICAtICdHRU5FUklDX1RJTUVaT05FPSR7R0VORVJJQ19USU1FWk9ORTotRXVyb3BlL0Jlcmxpbn0nCiAgICAgIC0gJ1RaPSR7VFo6LUV1cm9wZS9CZXJsaW59JwogICAgICAtIERCX1RZUEU9cG9zdGdyZXNkYgogICAgICAtICdEQl9QT1NUR1JFU0RCX0RBVEFCQVNFPSR7UE9TVEdSRVNfREI6LW44bn0nCiAgICAgIC0gREJfUE9TVEdSRVNEQl9IT1NUPXBvc3RncmVzcWwKICAgICAgLSBEQl9QT1NUR1JFU0RCX1BPUlQ9NTQzMgogICAgICAtIERCX1BPU1RHUkVTREJfVVNFUj0kU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIC0gREJfUE9TVEdSRVNEQl9TQ0hFTUE9cHVibGljCiAgICAgIC0gREJfUE9TVEdSRVNEQl9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtIEVYRUNVVElPTlNfTU9ERT1xdWV1ZQogICAgICAtIFFVRVVFX0JVTExfUkVESVNfSE9TVD1yZWRpcwogICAgICAtIFFVRVVFX0hFQUxUSF9DSEVDS19BQ1RJVkU9dHJ1ZQogICAgICAtICdOOE5fRU5DUllQVElPTl9LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX0VOQ1JZUFRJT059JwogICAgICAtIE44Tl9SVU5ORVJTX0VOQUJMRUQ9dHJ1ZQogICAgICAtIE9GRkxPQURfTUFOVUFMX0VYRUNVVElPTlNfVE9fV09SS0VSUz10cnVlCiAgICAgIC0gJ044Tl9CTE9DS19FTlZfQUNDRVNTX0lOX05PREU9JHtOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFOi10cnVlfScKICAgICAgLSAnTjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUz0ke044Tl9FTkZPUkNFX1NFVFRJTkdTX0ZJTEVfUEVSTUlTU0lPTlM6LXRydWV9JwogICAgdm9sdW1lczoKICAgICAgLSAnbjhuLWRhdGE6L2hvbWUvbm9kZS8ubjhuJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo1Njc4LycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIG44bi13b3JrZXI6CiAgICBpbWFnZTogZG9ja2VyLm44bi5pby9uOG5pby9uOG4KICAgIGNvbW1hbmQ6IHdvcmtlcgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0dFTkVSSUNfVElNRVpPTkU9JHtHRU5FUklDX1RJTUVaT05FOi1FdXJvcGUvQmVybGlufScKICAgICAgLSAnVFo9JHtUWjotRXVyb3BlL0Jlcmxpbn0nCiAgICAgIC0gREJfVFlQRT1wb3N0Z3Jlc2RiCiAgICAgIC0gJ0RCX1BPU1RHUkVTREJfREFUQUJBU0U9JHtQT1NUR1JFU19EQjotbjhufScKICAgICAgLSBEQl9QT1NUR1JFU0RCX0hPU1Q9cG9zdGdyZXNxbAogICAgICAtIERCX1BPU1RHUkVTREJfUE9SVD01NDMyCiAgICAgIC0gREJfUE9TVEdSRVNEQl9VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1NDSEVNQT1wdWJsaWMKICAgICAgLSBEQl9QT1NUR1JFU0RCX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gRVhFQ1VUSU9OU19NT0RFPXF1ZXVlCiAgICAgIC0gUVVFVUVfQlVMTF9SRURJU19IT1NUPXJlZGlzCiAgICAgIC0gUVVFVUVfSEVBTFRIX0NIRUNLX0FDVElWRT10cnVlCiAgICAgIC0gJ044Tl9FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfRU5DUllQVElPTn0nCiAgICAgIC0gTjhOX1JVTk5FUlNfRU5BQkxFRD10cnVlCiAgICAgIC0gJ044Tl9CTE9DS19FTlZfQUNDRVNTX0lOX05PREU9JHtOOE5fQkxPQ0tfRU5WX0FDQ0VTU19JTl9OT0RFOi10cnVlfScKICAgICAgLSAnTjhOX0VORk9SQ0VfU0VUVElOR1NfRklMRV9QRVJNSVNTSU9OUz0ke044Tl9FTkZPUkNFX1NFVFRJTkdTX0ZJTEVfUEVSTUlTU0lPTlM6LXRydWV9JwogICAgdm9sdW1lczoKICAgICAgLSAnbjhuLWRhdGE6L2hvbWUvbm9kZS8ubjhuJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo1Njc4L2hlYWx0aHonCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgIGRlcGVuZHNfb246CiAgICAgIG44bjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBwb3N0Z3Jlc3FsOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgcG9zdGdyZXNxbDoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYtYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXNxbC1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtIFBPU1RHUkVTX1VTRVI9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAtIFBPU1RHUkVTX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LW44bn0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjYtYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAncmVkaXMtZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCg==", + "tags": [ + "n8n", + "workflow", + "automation", + "open", + "source", + "low", + "code", + "queue", + "worker", + "scalable" + ], + "category": "automation", + "logo": "svgs/n8n.png", + "minversion": "0.0.0", + "port": "5678" + }, "n8n-with-postgresql": { "documentation": "https://n8n.io?utm_source=coolify.io", "slogan": "n8n is an extendable workflow automation tool.", diff --git a/tests/Feature/IpAllowlistTest.php b/tests/Feature/IpAllowlistTest.php index 3454a9c9d..959dc757d 100644 --- a/tests/Feature/IpAllowlistTest.php +++ b/tests/Feature/IpAllowlistTest.php @@ -8,7 +8,7 @@ test('IP allowlist with single IPs', function () { ]; foreach ($testCases as $case) { - $result = check_ip_against_allowlist($case['ip'], $case['allowlist']); + $result = checkIPAgainstAllowlist($case['ip'], $case['allowlist']); expect($result)->toBe($case['expected']); } }); @@ -24,7 +24,7 @@ test('IP allowlist with CIDR notation', function () { ]; foreach ($testCases as $case) { - $result = check_ip_against_allowlist($case['ip'], $case['allowlist']); + $result = checkIPAgainstAllowlist($case['ip'], $case['allowlist']); expect($result)->toBe($case['expected']); } }); @@ -40,16 +40,16 @@ test('IP allowlist with 0.0.0.0 allows all', function () { // Test 0.0.0.0 without subnet foreach ($testIps as $ip) { - $result = check_ip_against_allowlist($ip, ['0.0.0.0']); + $result = checkIPAgainstAllowlist($ip, ['0.0.0.0']); expect($result)->toBeTrue(); } // Test 0.0.0.0 with any subnet notation - should still allow all foreach ($testIps as $ip) { - expect(check_ip_against_allowlist($ip, ['0.0.0.0/0']))->toBeTrue(); - expect(check_ip_against_allowlist($ip, ['0.0.0.0/8']))->toBeTrue(); - expect(check_ip_against_allowlist($ip, ['0.0.0.0/24']))->toBeTrue(); - expect(check_ip_against_allowlist($ip, ['0.0.0.0/32']))->toBeTrue(); + expect(checkIPAgainstAllowlist($ip, ['0.0.0.0/0']))->toBeTrue(); + expect(checkIPAgainstAllowlist($ip, ['0.0.0.0/8']))->toBeTrue(); + expect(checkIPAgainstAllowlist($ip, ['0.0.0.0/24']))->toBeTrue(); + expect(checkIPAgainstAllowlist($ip, ['0.0.0.0/32']))->toBeTrue(); } }); @@ -66,44 +66,44 @@ test('IP allowlist with mixed entries', function () { ]; foreach ($testCases as $case) { - $result = check_ip_against_allowlist($case['ip'], $allowlist); + $result = checkIPAgainstAllowlist($case['ip'], $allowlist); expect($result)->toBe($case['expected']); } }); test('IP allowlist handles empty and invalid entries', function () { // Empty allowlist blocks all - expect(check_ip_against_allowlist('192.168.1.1', []))->toBeFalse(); - expect(check_ip_against_allowlist('192.168.1.1', ['']))->toBeFalse(); + expect(checkIPAgainstAllowlist('192.168.1.1', []))->toBeFalse(); + expect(checkIPAgainstAllowlist('192.168.1.1', ['']))->toBeFalse(); // Handles spaces - expect(check_ip_against_allowlist('192.168.1.100', [' 192.168.1.100 ']))->toBeTrue(); - expect(check_ip_against_allowlist('10.0.0.5', [' 10.0.0.0/8 ']))->toBeTrue(); + expect(checkIPAgainstAllowlist('192.168.1.100', [' 192.168.1.100 ']))->toBeTrue(); + expect(checkIPAgainstAllowlist('10.0.0.5', [' 10.0.0.0/8 ']))->toBeTrue(); // Invalid entries are skipped - expect(check_ip_against_allowlist('192.168.1.1', ['invalid.ip']))->toBeFalse(); - expect(check_ip_against_allowlist('192.168.1.1', ['192.168.1.0/33']))->toBeFalse(); // Invalid mask - expect(check_ip_against_allowlist('192.168.1.1', ['192.168.1.0/-1']))->toBeFalse(); // Invalid mask + expect(checkIPAgainstAllowlist('192.168.1.1', ['invalid.ip']))->toBeFalse(); + expect(checkIPAgainstAllowlist('192.168.1.1', ['192.168.1.0/33']))->toBeFalse(); // Invalid mask + expect(checkIPAgainstAllowlist('192.168.1.1', ['192.168.1.0/-1']))->toBeFalse(); // Invalid mask }); test('IP allowlist with various subnet sizes', function () { // /32 - single host - expect(check_ip_against_allowlist('192.168.1.1', ['192.168.1.1/32']))->toBeTrue(); - expect(check_ip_against_allowlist('192.168.1.2', ['192.168.1.1/32']))->toBeFalse(); + expect(checkIPAgainstAllowlist('192.168.1.1', ['192.168.1.1/32']))->toBeTrue(); + expect(checkIPAgainstAllowlist('192.168.1.2', ['192.168.1.1/32']))->toBeFalse(); // /31 - point-to-point link - expect(check_ip_against_allowlist('192.168.1.0', ['192.168.1.0/31']))->toBeTrue(); - expect(check_ip_against_allowlist('192.168.1.1', ['192.168.1.0/31']))->toBeTrue(); - expect(check_ip_against_allowlist('192.168.1.2', ['192.168.1.0/31']))->toBeFalse(); + expect(checkIPAgainstAllowlist('192.168.1.0', ['192.168.1.0/31']))->toBeTrue(); + expect(checkIPAgainstAllowlist('192.168.1.1', ['192.168.1.0/31']))->toBeTrue(); + expect(checkIPAgainstAllowlist('192.168.1.2', ['192.168.1.0/31']))->toBeFalse(); // /16 - class B - expect(check_ip_against_allowlist('172.16.0.1', ['172.16.0.0/16']))->toBeTrue(); - expect(check_ip_against_allowlist('172.16.255.255', ['172.16.0.0/16']))->toBeTrue(); - expect(check_ip_against_allowlist('172.17.0.1', ['172.16.0.0/16']))->toBeFalse(); + expect(checkIPAgainstAllowlist('172.16.0.1', ['172.16.0.0/16']))->toBeTrue(); + expect(checkIPAgainstAllowlist('172.16.255.255', ['172.16.0.0/16']))->toBeTrue(); + expect(checkIPAgainstAllowlist('172.17.0.1', ['172.16.0.0/16']))->toBeFalse(); // /0 - all addresses - expect(check_ip_against_allowlist('1.1.1.1', ['0.0.0.0/0']))->toBeTrue(); - expect(check_ip_against_allowlist('255.255.255.255', ['0.0.0.0/0']))->toBeTrue(); + expect(checkIPAgainstAllowlist('1.1.1.1', ['0.0.0.0/0']))->toBeTrue(); + expect(checkIPAgainstAllowlist('255.255.255.255', ['0.0.0.0/0']))->toBeTrue(); }); test('IP allowlist comma-separated string input', function () { @@ -111,10 +111,10 @@ test('IP allowlist comma-separated string input', function () { $allowlistString = '192.168.1.100,10.0.0.0/8,172.16.0.0/16'; $allowlist = explode(',', $allowlistString); - expect(check_ip_against_allowlist('192.168.1.100', $allowlist))->toBeTrue(); - expect(check_ip_against_allowlist('10.5.5.5', $allowlist))->toBeTrue(); - expect(check_ip_against_allowlist('172.16.10.10', $allowlist))->toBeTrue(); - expect(check_ip_against_allowlist('8.8.8.8', $allowlist))->toBeFalse(); + expect(checkIPAgainstAllowlist('192.168.1.100', $allowlist))->toBeTrue(); + expect(checkIPAgainstAllowlist('10.5.5.5', $allowlist))->toBeTrue(); + expect(checkIPAgainstAllowlist('172.16.10.10', $allowlist))->toBeTrue(); + expect(checkIPAgainstAllowlist('8.8.8.8', $allowlist))->toBeFalse(); }); test('ValidIpOrCidr validation rule', function () { diff --git a/tests/Unit/PrivateKeyStorageTest.php b/tests/Unit/PrivateKeyStorageTest.php new file mode 100644 index 000000000..00f39e3df --- /dev/null +++ b/tests/Unit/PrivateKeyStorageTest.php @@ -0,0 +1,316 @@ +<?php + +use App\Models\PrivateKey; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Storage; +use Tests\TestCase; + +class PrivateKeyStorageTest extends TestCase +{ + use RefreshDatabase; + + protected function setUp(): void + { + parent::setUp(); + + // Set up a test team for the tests + $this->actingAs(\App\Models\User::factory()->create()); + } + + protected function getValidPrivateKey(): string + { + return '-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk +hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA +AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV +uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== +-----END OPENSSH PRIVATE KEY-----'; + } + + /** @test */ + public function it_successfully_stores_private_key_in_filesystem() + { + Storage::fake('ssh-keys'); + + $privateKey = PrivateKey::createAndStore([ + 'name' => 'Test Key', + 'description' => 'Test Description', + 'private_key' => $this->getValidPrivateKey(), + 'team_id' => currentTeam()->id, + ]); + + $this->assertDatabaseHas('private_keys', [ + 'id' => $privateKey->id, + 'name' => 'Test Key', + ]); + + $filename = "ssh_key@{$privateKey->uuid}"; + Storage::disk('ssh-keys')->assertExists($filename); + + $storedContent = Storage::disk('ssh-keys')->get($filename); + $this->assertEquals($privateKey->private_key, $storedContent); + } + + /** @test */ + public function it_throws_exception_when_storage_fails() + { + Storage::fake('ssh-keys'); + + // Mock Storage::put to return false (simulating storage failure) + Storage::shouldReceive('disk') + ->with('ssh-keys') + ->andReturn( + \Mockery::mock() + ->shouldReceive('exists') + ->andReturn(true) + ->shouldReceive('put') + ->with(\Mockery::any(), 'test') + ->andReturn(true) + ->shouldReceive('delete') + ->with(\Mockery::any()) + ->andReturn(true) + ->shouldReceive('put') + ->with(\Mockery::pattern('/ssh_key@/'), \Mockery::any()) + ->andReturn(false) // Simulate storage failure + ->getMock() + ); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Failed to write SSH key to filesystem'); + + PrivateKey::createAndStore([ + 'name' => 'Test Key', + 'description' => 'Test Description', + 'private_key' => $this->getValidPrivateKey(), + 'team_id' => currentTeam()->id, + ]); + + // Assert that no database record was created due to transaction rollback + $this->assertDatabaseMissing('private_keys', [ + 'name' => 'Test Key', + ]); + } + + /** @test */ + public function it_throws_exception_when_storage_directory_is_not_writable() + { + Storage::fake('ssh-keys'); + + // Mock Storage disk to simulate directory not writable + Storage::shouldReceive('disk') + ->with('ssh-keys') + ->andReturn( + \Mockery::mock() + ->shouldReceive('exists') + ->with('') + ->andReturn(true) + ->shouldReceive('put') + ->with(\Mockery::pattern('/\.test_write_/'), 'test') + ->andReturn(false) // Simulate directory not writable + ->getMock() + ); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('SSH keys storage directory is not writable'); + + PrivateKey::createAndStore([ + 'name' => 'Test Key', + 'description' => 'Test Description', + 'private_key' => $this->getValidPrivateKey(), + 'team_id' => currentTeam()->id, + ]); + } + + /** @test */ + public function it_creates_storage_directory_if_not_exists() + { + Storage::fake('ssh-keys'); + + // Mock Storage disk to simulate directory not existing, then being created + Storage::shouldReceive('disk') + ->with('ssh-keys') + ->andReturn( + \Mockery::mock() + ->shouldReceive('exists') + ->with('') + ->andReturn(false) // Directory doesn't exist + ->shouldReceive('makeDirectory') + ->with('') + ->andReturn(true) // Successfully create directory + ->shouldReceive('put') + ->with(\Mockery::pattern('/\.test_write_/'), 'test') + ->andReturn(true) // Directory is writable after creation + ->shouldReceive('delete') + ->with(\Mockery::pattern('/\.test_write_/')) + ->andReturn(true) + ->shouldReceive('put') + ->with(\Mockery::pattern('/ssh_key@/'), \Mockery::any()) + ->andReturn(true) + ->shouldReceive('exists') + ->with(\Mockery::pattern('/ssh_key@/')) + ->andReturn(true) + ->shouldReceive('get') + ->with(\Mockery::pattern('/ssh_key@/')) + ->andReturn($this->getValidPrivateKey()) + ->getMock() + ); + + $privateKey = PrivateKey::createAndStore([ + 'name' => 'Test Key', + 'description' => 'Test Description', + 'private_key' => $this->getValidPrivateKey(), + 'team_id' => currentTeam()->id, + ]); + + $this->assertDatabaseHas('private_keys', [ + 'id' => $privateKey->id, + 'name' => 'Test Key', + ]); + } + + /** @test */ + public function it_throws_exception_when_file_content_verification_fails() + { + Storage::fake('ssh-keys'); + + // Mock Storage disk to simulate file being created but with wrong content + Storage::shouldReceive('disk') + ->with('ssh-keys') + ->andReturn( + \Mockery::mock() + ->shouldReceive('exists') + ->with('') + ->andReturn(true) + ->shouldReceive('put') + ->with(\Mockery::pattern('/\.test_write_/'), 'test') + ->andReturn(true) + ->shouldReceive('delete') + ->with(\Mockery::pattern('/\.test_write_/')) + ->andReturn(true) + ->shouldReceive('put') + ->with(\Mockery::pattern('/ssh_key@/'), \Mockery::any()) + ->andReturn(true) // File created successfully + ->shouldReceive('exists') + ->with(\Mockery::pattern('/ssh_key@/')) + ->andReturn(true) // File exists + ->shouldReceive('get') + ->with(\Mockery::pattern('/ssh_key@/')) + ->andReturn('corrupted content') // But content is wrong + ->shouldReceive('delete') + ->with(\Mockery::pattern('/ssh_key@/')) + ->andReturn(true) // Clean up bad file + ->getMock() + ); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('SSH key file content verification failed'); + + PrivateKey::createAndStore([ + 'name' => 'Test Key', + 'description' => 'Test Description', + 'private_key' => $this->getValidPrivateKey(), + 'team_id' => currentTeam()->id, + ]); + + // Assert that no database record was created due to transaction rollback + $this->assertDatabaseMissing('private_keys', [ + 'name' => 'Test Key', + ]); + } + + /** @test */ + public function it_successfully_deletes_private_key_from_filesystem() + { + Storage::fake('ssh-keys'); + + $privateKey = PrivateKey::createAndStore([ + 'name' => 'Test Key', + 'description' => 'Test Description', + 'private_key' => $this->getValidPrivateKey(), + 'team_id' => currentTeam()->id, + ]); + + $filename = "ssh_key@{$privateKey->uuid}"; + Storage::disk('ssh-keys')->assertExists($filename); + + $privateKey->delete(); + + Storage::disk('ssh-keys')->assertMissing($filename); + } + + /** @test */ + public function it_handles_database_transaction_rollback_on_storage_failure() + { + Storage::fake('ssh-keys'); + + // Count initial private keys + $initialCount = PrivateKey::count(); + + // Mock storage failure after database save + Storage::shouldReceive('disk') + ->with('ssh-keys') + ->andReturn( + \Mockery::mock() + ->shouldReceive('exists') + ->with('') + ->andReturn(true) + ->shouldReceive('put') + ->with(\Mockery::pattern('/\.test_write_/'), 'test') + ->andReturn(true) + ->shouldReceive('delete') + ->with(\Mockery::pattern('/\.test_write_/')) + ->andReturn(true) + ->shouldReceive('put') + ->with(\Mockery::pattern('/ssh_key@/'), \Mockery::any()) + ->andReturn(false) // Storage fails + ->getMock() + ); + + try { + PrivateKey::createAndStore([ + 'name' => 'Test Key', + 'description' => 'Test Description', + 'private_key' => $this->getValidPrivateKey(), + 'team_id' => currentTeam()->id, + ]); + } catch (\Exception $e) { + // Expected exception + } + + // Assert that database was rolled back + $this->assertEquals($initialCount, PrivateKey::count()); + $this->assertDatabaseMissing('private_keys', [ + 'name' => 'Test Key', + ]); + } + + /** @test */ + public function it_successfully_updates_private_key_with_transaction() + { + Storage::fake('ssh-keys'); + + $privateKey = PrivateKey::createAndStore([ + 'name' => 'Test Key', + 'description' => 'Test Description', + 'private_key' => $this->getValidPrivateKey(), + 'team_id' => currentTeam()->id, + ]); + + $newPrivateKey = str_replace('Test', 'Updated', $this->getValidPrivateKey()); + + $privateKey->updatePrivateKey([ + 'name' => 'Updated Key', + 'private_key' => $newPrivateKey, + ]); + + $this->assertDatabaseHas('private_keys', [ + 'id' => $privateKey->id, + 'name' => 'Updated Key', + ]); + + $filename = "ssh_key@{$privateKey->uuid}"; + $storedContent = Storage::disk('ssh-keys')->get($filename); + $this->assertEquals($newPrivateKey, $storedContent); + } +} diff --git a/tests/Unit/SshRetryMechanismTest.php b/tests/Unit/SshRetryMechanismTest.php new file mode 100644 index 000000000..23e1b867f --- /dev/null +++ b/tests/Unit/SshRetryMechanismTest.php @@ -0,0 +1,189 @@ +<?php + +namespace Tests\Unit; + +use App\Helpers\SshRetryHandler; +use App\Traits\SshRetryable; +use Tests\TestCase; + +class SshRetryMechanismTest extends TestCase +{ + public function test_ssh_retry_handler_exists() + { + $this->assertTrue(class_exists(\App\Helpers\SshRetryHandler::class)); + } + + public function test_ssh_retryable_trait_exists() + { + $this->assertTrue(trait_exists(\App\Traits\SshRetryable::class)); + } + + public function test_retry_on_ssh_connection_errors() + { + $handler = new class + { + use SshRetryable; + + // Make methods public for testing + public function test_is_retryable_ssh_error($error) + { + return $this->isRetryableSshError($error); + } + }; + + // Test various SSH error patterns + $sshErrors = [ + 'kex_exchange_identification: read: Connection reset by peer', + 'Connection refused', + 'Connection timed out', + 'ssh_exchange_identification: Connection closed by remote host', + 'Broken pipe', + 'No route to host', + 'Network is unreachable', + ]; + + foreach ($sshErrors as $error) { + $this->assertTrue( + $handler->test_is_retryable_ssh_error($error), + "Failed to identify as retryable: $error" + ); + } + } + + public function test_non_ssh_errors_are_not_retryable() + { + $handler = new class + { + use SshRetryable; + + // Make methods public for testing + public function test_is_retryable_ssh_error($error) + { + return $this->isRetryableSshError($error); + } + }; + + // Test non-SSH errors + $nonSshErrors = [ + 'Command not found', + 'Permission denied', + 'File not found', + 'Syntax error', + 'Invalid argument', + ]; + + foreach ($nonSshErrors as $error) { + $this->assertFalse( + $handler->test_is_retryable_ssh_error($error), + "Incorrectly identified as retryable: $error" + ); + } + } + + public function test_exponential_backoff_calculation() + { + $handler = new class + { + use SshRetryable; + + // Make method public for testing + public function test_calculate_retry_delay($attempt) + { + return $this->calculateRetryDelay($attempt); + } + }; + + // Test with default config values + config(['constants.ssh.retry_base_delay' => 2]); + config(['constants.ssh.retry_max_delay' => 30]); + config(['constants.ssh.retry_multiplier' => 2]); + + // Attempt 0: 2 seconds + $this->assertEquals(2, $handler->test_calculate_retry_delay(0)); + + // Attempt 1: 4 seconds + $this->assertEquals(4, $handler->test_calculate_retry_delay(1)); + + // Attempt 2: 8 seconds + $this->assertEquals(8, $handler->test_calculate_retry_delay(2)); + + // Attempt 3: 16 seconds + $this->assertEquals(16, $handler->test_calculate_retry_delay(3)); + + // Attempt 4: Should be capped at 30 seconds + $this->assertEquals(30, $handler->test_calculate_retry_delay(4)); + + // Attempt 5: Should still be capped at 30 seconds + $this->assertEquals(30, $handler->test_calculate_retry_delay(5)); + } + + public function test_retry_succeeds_after_failures() + { + $attemptCount = 0; + + config(['constants.ssh.max_retries' => 3]); + + // Simulate a function that fails twice then succeeds using the public static method + $result = SshRetryHandler::retry( + function () use (&$attemptCount) { + $attemptCount++; + if ($attemptCount < 3) { + throw new \RuntimeException('kex_exchange_identification: Connection reset by peer'); + } + + return 'success'; + }, + ['test' => 'retry_test'], + true + ); + + $this->assertEquals('success', $result); + $this->assertEquals(3, $attemptCount); + } + + public function test_retry_fails_after_max_attempts() + { + $attemptCount = 0; + + config(['constants.ssh.max_retries' => 3]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Connection reset by peer'); + + // Simulate a function that always fails using the public static method + SshRetryHandler::retry( + function () use (&$attemptCount) { + $attemptCount++; + throw new \RuntimeException('Connection reset by peer'); + }, + ['test' => 'retry_test'], + true + ); + } + + public function test_non_retryable_errors_fail_immediately() + { + $attemptCount = 0; + + config(['constants.ssh.max_retries' => 3]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Command not found'); + + try { + // Simulate a non-retryable error using the public static method + SshRetryHandler::retry( + function () use (&$attemptCount) { + $attemptCount++; + throw new \RuntimeException('Command not found'); + }, + ['test' => 'non_retryable_test'], + true + ); + } catch (\RuntimeException $e) { + // Should only attempt once since it's not retryable + $this->assertEquals(1, $attemptCount); + throw $e; + } + } +} diff --git a/versions.json b/versions.json index 4da699d67..2379f2cd7 100644 --- a/versions.json +++ b/versions.json @@ -1,19 +1,19 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.427" + "version": "4.0.0-beta.429" }, "nightly": { - "version": "4.0.0-beta.428" + "version": "4.0.0-beta.430" }, "helper": { - "version": "1.0.10" + "version": "1.0.11" }, "realtime": { "version": "1.0.10" }, "sentinel": { - "version": "0.0.15" + "version": "0.0.16" } } } \ No newline at end of file