Compare commits
5 Commits
aa680b25fd
...
ux-restruc
| Author | SHA1 | Date | |
|---|---|---|---|
| 85c44296ac | |||
| 3a851db08f | |||
| 5b977c1f41 | |||
| 8f603122e2 | |||
| 66f0ee9e50 |
@@ -63,3 +63,5 @@ AWS_BUCKET=
|
|||||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
|
|
||||||
|
HETZNER_KEY=
|
||||||
|
|||||||
78
.gitea/workflows/ci.yml
Normal file
78
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
name: Lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: git.bayliss.cloud/harry/gitea-ci-runner:php8.4
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install Composer dependencies
|
||||||
|
run: composer install --no-interaction --prefer-dist --optimize-autoloader
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Run frontend lint
|
||||||
|
run: bun run lint:check
|
||||||
|
|
||||||
|
- name: Check frontend formatting
|
||||||
|
run: bun run format:check
|
||||||
|
|
||||||
|
- name: Check PHP formatting
|
||||||
|
run: vendor/bin/pint --test
|
||||||
|
|
||||||
|
- name: Run static analysis
|
||||||
|
run: composer phpstan
|
||||||
|
|
||||||
|
tests:
|
||||||
|
name: Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: git.bayliss.cloud/harry/gitea-ci-runner:php8.4
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
env:
|
||||||
|
APP_ENV: testing
|
||||||
|
APP_KEY: base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
|
||||||
|
CACHE_STORE: array
|
||||||
|
DB_CONNECTION: sqlite
|
||||||
|
DB_DATABASE: database/testing.sqlite
|
||||||
|
MAIL_MAILER: array
|
||||||
|
QUEUE_CONNECTION: sync
|
||||||
|
SESSION_DRIVER: array
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Prepare SQLite database
|
||||||
|
run: touch database/testing.sqlite
|
||||||
|
|
||||||
|
- name: Install Composer dependencies
|
||||||
|
run: composer install --no-interaction --prefer-dist --optimize-autoloader
|
||||||
|
|
||||||
|
- name: Run test suite with coverage
|
||||||
|
run: composer coverage
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
resources/js/components/ui/*
|
|
||||||
resources/js/ziggy.js
|
|
||||||
resources/views/mail/*
|
|
||||||
18
.prettierrc
18
.prettierrc
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"semi": true,
|
|
||||||
"singleQuote": true,
|
|
||||||
"singleAttributePerLine": false,
|
|
||||||
"htmlWhitespaceSensitivity": "css",
|
|
||||||
"printWidth": 150,
|
|
||||||
"plugins": ["prettier-plugin-organize-imports", "prettier-plugin-tailwindcss"],
|
|
||||||
"tailwindFunctions": ["clsx", "cn"],
|
|
||||||
"tabWidth": 4,
|
|
||||||
"overrides": [
|
|
||||||
{
|
|
||||||
"files": "**/*.yml",
|
|
||||||
"options": {
|
|
||||||
"tabWidth": 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
76
AGENTS.md
76
AGENTS.md
@@ -6,6 +6,7 @@
|
|||||||
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.
|
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
|
## 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.
|
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.16
|
- php - 8.4.16
|
||||||
@@ -18,49 +19,59 @@ This application is a Laravel application and its main Laravel ecosystems packag
|
|||||||
- pestphp/pest (PEST) - v3
|
- pestphp/pest (PEST) - v3
|
||||||
- phpunit/phpunit (PHPUNIT) - v11
|
- phpunit/phpunit (PHPUNIT) - v11
|
||||||
|
|
||||||
|
|
||||||
## Conventions
|
## 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.
|
- 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()`.
|
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
||||||
- Check for existing components to reuse before writing a new one.
|
- Check for existing components to reuse before writing a new one.
|
||||||
|
|
||||||
## Verification Scripts
|
## 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.
|
- 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
|
## Application Structure & Architecture
|
||||||
|
|
||||||
- Stick to existing directory structure - don't create new base folders without approval.
|
- Stick to existing directory structure - don't create new base folders without approval.
|
||||||
- Do not change the application's dependencies without approval.
|
- Do not change the application's dependencies without approval.
|
||||||
|
|
||||||
## Frontend Bundling
|
## 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.
|
- 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
|
## Replies
|
||||||
|
|
||||||
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
||||||
|
|
||||||
## Documentation Files
|
## Documentation Files
|
||||||
- You must only create documentation files if explicitly requested by the user.
|
|
||||||
|
|
||||||
|
- You must only create documentation files if explicitly requested by the user.
|
||||||
|
|
||||||
=== boost rules ===
|
=== boost rules ===
|
||||||
|
|
||||||
## Laravel Boost
|
## Laravel Boost
|
||||||
|
|
||||||
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
|
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
|
||||||
|
|
||||||
## Artisan
|
## Artisan
|
||||||
|
|
||||||
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters.
|
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters.
|
||||||
|
|
||||||
## URLs
|
## 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.
|
- 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
|
## Tinker / Debugging
|
||||||
|
|
||||||
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
|
- 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.
|
- Use the `database-query` tool when you only need to read from the database.
|
||||||
|
|
||||||
## Reading Browser Logs With the `browser-logs` Tool
|
## Reading Browser Logs With the `browser-logs` Tool
|
||||||
|
|
||||||
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
|
- 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.
|
- Only recent browser logs will be useful - ignore old logs.
|
||||||
|
|
||||||
## Searching Documentation (Critically Important)
|
## 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.
|
- 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.
|
- 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.
|
- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches.
|
||||||
@@ -69,6 +80,7 @@ This application is a Laravel application and its main Laravel ecosystems packag
|
|||||||
- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
|
- 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
|
### Available Search Syntax
|
||||||
|
|
||||||
- You can and should pass multiple queries at once. The most relevant results will be returned first.
|
- 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'
|
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'
|
||||||
@@ -77,7 +89,6 @@ This application is a Laravel application and its main Laravel ecosystems packag
|
|||||||
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit"
|
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit"
|
||||||
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
|
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
|
||||||
|
|
||||||
|
|
||||||
=== php rules ===
|
=== php rules ===
|
||||||
|
|
||||||
## PHP
|
## PHP
|
||||||
@@ -85,11 +96,13 @@ This application is a Laravel application and its main Laravel ecosystems packag
|
|||||||
- Always use curly braces for control structures, even if it has one line.
|
- Always use curly braces for control structures, even if it has one line.
|
||||||
|
|
||||||
### Constructors
|
### Constructors
|
||||||
|
|
||||||
- Use PHP 8 constructor property promotion in `__construct()`.
|
- Use PHP 8 constructor property promotion in `__construct()`.
|
||||||
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
|
- <code-snippet>public function \_\_construct(public GitHub $github) { }</code-snippet>
|
||||||
- Do not allow empty `__construct()` methods with zero parameters.
|
- Do not allow empty `__construct()` methods with zero parameters.
|
||||||
|
|
||||||
### Type Declarations
|
### Type Declarations
|
||||||
|
|
||||||
- Always use explicit return type declarations for methods and functions.
|
- Always use explicit return type declarations for methods and functions.
|
||||||
- Use appropriate PHP type hints for method parameters.
|
- Use appropriate PHP type hints for method parameters.
|
||||||
|
|
||||||
@@ -101,14 +114,16 @@ protected function isAccessible(User $user, ?string $path = null): bool
|
|||||||
</code-snippet>
|
</code-snippet>
|
||||||
|
|
||||||
## Comments
|
## Comments
|
||||||
|
|
||||||
- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on.
|
- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on.
|
||||||
|
|
||||||
## PHPDoc Blocks
|
## PHPDoc Blocks
|
||||||
|
|
||||||
- Add useful array shape type definitions for arrays when appropriate.
|
- Add useful array shape type definitions for arrays when appropriate.
|
||||||
|
|
||||||
## Enums
|
## Enums
|
||||||
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
|
||||||
|
|
||||||
|
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||||
|
|
||||||
=== inertia-laravel/core rules ===
|
=== inertia-laravel/core rules ===
|
||||||
|
|
||||||
@@ -127,7 +142,6 @@ Route::get('/users', function () {
|
|||||||
});
|
});
|
||||||
</code-snippet>
|
</code-snippet>
|
||||||
|
|
||||||
|
|
||||||
=== inertia-laravel/v2 rules ===
|
=== inertia-laravel/v2 rules ===
|
||||||
|
|
||||||
## Inertia v2
|
## Inertia v2
|
||||||
@@ -135,6 +149,7 @@ Route::get('/users', function () {
|
|||||||
- Make use of all Inertia features from v1 & v2. Check the documentation before making any changes to ensure we are taking the correct approach.
|
- Make use of all Inertia features from v1 & v2. Check the documentation before making any changes to ensure we are taking the correct approach.
|
||||||
|
|
||||||
### Inertia v2 New Features
|
### Inertia v2 New Features
|
||||||
|
|
||||||
- Polling
|
- Polling
|
||||||
- Prefetching
|
- Prefetching
|
||||||
- Deferred props
|
- Deferred props
|
||||||
@@ -142,11 +157,12 @@ Route::get('/users', function () {
|
|||||||
- Lazy loading data on scroll
|
- Lazy loading data on scroll
|
||||||
|
|
||||||
### Deferred Props & Empty States
|
### Deferred Props & Empty States
|
||||||
|
|
||||||
- When using deferred props on the frontend, you should add a nice empty state with pulsing / animated skeleton.
|
- When using deferred props on the frontend, you should add a nice empty state with pulsing / animated skeleton.
|
||||||
|
|
||||||
### Inertia Form General Guidance
|
### Inertia Form General Guidance
|
||||||
- Build forms using the `useForm` helper. Use the code examples and `search-docs` tool with a query of `useForm helper` for guidance.
|
|
||||||
|
|
||||||
|
- Build forms using the `useForm` helper. Use the code examples and `search-docs` tool with a query of `useForm helper` for guidance.
|
||||||
|
|
||||||
=== laravel/core rules ===
|
=== laravel/core rules ===
|
||||||
|
|
||||||
@@ -157,6 +173,7 @@ Route::get('/users', function () {
|
|||||||
- 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.
|
- 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
|
### Database
|
||||||
|
|
||||||
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
|
- 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
|
- 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.
|
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
|
||||||
@@ -164,35 +181,43 @@ Route::get('/users', function () {
|
|||||||
- Use Laravel's query builder for very complex database operations.
|
- Use Laravel's query builder for very complex database operations.
|
||||||
|
|
||||||
### Model Creation
|
### 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`.
|
- 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
|
### 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.
|
- 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
|
### Controllers & Validation
|
||||||
|
|
||||||
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
|
- 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.
|
- Check sibling Form Requests to see if the application uses array or string based validation rules.
|
||||||
|
|
||||||
### Queues
|
### Queues
|
||||||
|
|
||||||
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
|
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
|
||||||
|
|
||||||
### Authentication & Authorization
|
### Authentication & Authorization
|
||||||
|
|
||||||
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
|
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
|
||||||
|
|
||||||
### URL Generation
|
### URL Generation
|
||||||
|
|
||||||
- When generating links to other pages, prefer named routes and the `route()` function.
|
- When generating links to other pages, prefer named routes and the `route()` function.
|
||||||
|
|
||||||
### Configuration
|
### 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')`.
|
- 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
|
### 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.
|
- 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()`.
|
- 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.
|
- 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
|
### 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`.
|
|
||||||
|
|
||||||
|
- 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/v12 rules ===
|
||||||
|
|
||||||
@@ -202,6 +227,7 @@ Route::get('/users', function () {
|
|||||||
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
|
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
|
||||||
|
|
||||||
### Laravel 12 Structure
|
### Laravel 12 Structure
|
||||||
|
|
||||||
- No middleware files in `app/Http/Middleware/`.
|
- No middleware files in `app/Http/Middleware/`.
|
||||||
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
|
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
|
||||||
- `bootstrap/providers.php` contains application specific service providers.
|
- `bootstrap/providers.php` contains application specific service providers.
|
||||||
@@ -209,12 +235,13 @@ Route::get('/users', function () {
|
|||||||
- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration.
|
- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration.
|
||||||
|
|
||||||
### Database
|
### 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.
|
- 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);`.
|
- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
|
||||||
|
|
||||||
### Models
|
### 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.
|
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
|
||||||
=== pint/core rules ===
|
=== pint/core rules ===
|
||||||
|
|
||||||
@@ -223,27 +250,29 @@ Route::get('/users', function () {
|
|||||||
- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
|
- 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.
|
- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues.
|
||||||
|
|
||||||
|
|
||||||
=== pest/core rules ===
|
=== pest/core rules ===
|
||||||
|
|
||||||
## Pest
|
## Pest
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
- If you need to verify a feature is working, write or update a Unit / Feature test.
|
- If you need to verify a feature is working, write or update a Unit / Feature test.
|
||||||
|
|
||||||
### Pest Tests
|
### Pest Tests
|
||||||
|
|
||||||
- All tests must be written using Pest. Use `php artisan make:test --pest <name>`.
|
- 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.
|
- 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 should test all of the happy paths, failure paths, and weird paths.
|
||||||
- Tests live in the `tests/Feature` and `tests/Unit` directories.
|
- Tests live in the `tests/Feature` and `tests/Unit` directories.
|
||||||
- Pest tests look and behave like this:
|
- Pest tests look and behave like this:
|
||||||
<code-snippet name="Basic Pest Test Example" lang="php">
|
<code-snippet name="Basic Pest Test Example" lang="php">
|
||||||
it('is true', function () {
|
it('is true', function () {
|
||||||
expect(true)->toBeTrue();
|
expect(true)->toBeTrue();
|
||||||
});
|
});
|
||||||
</code-snippet>
|
</code-snippet>
|
||||||
|
|
||||||
### Running Tests
|
### Running Tests
|
||||||
|
|
||||||
- Run the minimal number of tests using an appropriate filter before finalizing code edits.
|
- 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: `php artisan test`.
|
||||||
- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`.
|
- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`.
|
||||||
@@ -251,21 +280,25 @@ it('is true', function () {
|
|||||||
- 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.
|
- 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
|
### 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.:
|
- 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">
|
<code-snippet name="Pest Example Asserting postJson Response" lang="php">
|
||||||
it('returns all', function () {
|
it('returns all', function () {
|
||||||
$response = $this->postJson('/api/docs', []);
|
$response = $this->postJson('/api/docs', []);
|
||||||
|
|
||||||
$response->assertSuccessful();
|
$response->assertSuccessful();
|
||||||
});
|
|
||||||
</code-snippet>
|
});
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
### Mocking
|
### Mocking
|
||||||
|
|
||||||
- Mocking can be very helpful when appropriate.
|
- 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.
|
- 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.
|
- You can also create partial mocks using the same import or self method.
|
||||||
|
|
||||||
### Datasets
|
### 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.
|
- 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">
|
<code-snippet name="Pest Dataset Example" lang="php">
|
||||||
@@ -277,11 +310,14 @@ it('has emails', function (string $email) {
|
|||||||
]);
|
]);
|
||||||
</code-snippet>
|
</code-snippet>
|
||||||
|
|
||||||
|
|
||||||
=== tests rules ===
|
=== tests rules ===
|
||||||
|
|
||||||
## Test Enforcement
|
## 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.
|
- 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.
|
- 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>
|
</laravel-boost-guidelines>
|
||||||
|
|
||||||
|
# Keystone
|
||||||
|
|
||||||
|
- Keep CHANGELOG.md up to date.
|
||||||
|
|||||||
32
CHANGELOG.md
Normal file
32
CHANGELOG.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Reworked the dashboard to lead with recent applications and a latest-deployments activity feed with relative timestamps, replacing the organisation-picker layout.
|
||||||
|
- Rebuilt the environment view as a Network / Compute / Resources topology with dense spec cards, moving the commit-SHA deploy form and raw Caddyfile previews behind disclosures.
|
||||||
|
- Added a header organisation switcher, reordered the primary nav, and removed the onboarding nav item.
|
||||||
|
- Shared a fallback organisation when no organisation is in the route so the header navigation and switcher render on the dashboard, and gave the dashboard a header with a "New application" action and actionable empty states.
|
||||||
|
- Introduced a shared `StatusIndicator` (coloured dot + label) and standardised status rendering across the dashboard and environment views.
|
||||||
|
|
||||||
|
- Added a shadcn-vue `Select` component (radix-vue based) and replaced every native HTML `<select>` across the app with it.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Reworked the database seeder to generate a fully wired, mostly-running organisation: an ACTIVE server fleet with a control/build node, managed and GHCR registries, a Gitea source provider, and a ClipBin application with production and staging environments (web + postgres + valkey + caddy services, slices, endpoints, managed variables), plus build artifacts and an operations history covering completed, in-progress, and failed states.
|
||||||
|
- Expanded the managed registry plan with HTTPS registry requirements, image naming, credential handling, health checks, and build-node safeguards.
|
||||||
|
- Added managed registry build planning defaults, stable managed image references, and digest-based Compose rendering for registry-backed deployments.
|
||||||
|
- Hardened managed registry planning so config-only registry URLs are not treated as ready registry records and pushed artifact digests come from Docker push output.
|
||||||
|
- Preserved external registry override behavior when a managed registry is present.
|
||||||
|
- Added managed registry provisioning, readiness metadata, scoped encrypted registry credentials, build-enabled control node selection, secure Docker auth operation steps, and first-pass retention marking.
|
||||||
|
- Switched managed registry image paths to application and environment UUIDs.
|
||||||
|
- Hardened registry auth execution so operation secrets stay hidden, are cleared after use or cancellation, and Docker auth is installed for the root SSH execution context used by build and deploy steps.
|
||||||
|
- Added managed registry provision, smoke-check, and maintenance operations that generate registry:2, htpasswd, Caddy proxy, push/pull readiness, manifest deletion, and garbage-collection scripts without persisting raw registry passwords.
|
||||||
|
- Blocked managed registry build/deploy planning until represented smoke checks pass, and marked pruned registry artifacts after successful maintenance.
|
||||||
|
- Scoped managed registry maintenance operations to their selected artifact batch, added build/maintenance locking, scheduled daily pruning, and removed registry secret exposure through htpasswd and manifest-delete process arguments.
|
||||||
76
CLAUDE.md
76
CLAUDE.md
@@ -6,6 +6,7 @@
|
|||||||
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.
|
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
|
## 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.
|
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.16
|
- php - 8.4.16
|
||||||
@@ -18,49 +19,59 @@ This application is a Laravel application and its main Laravel ecosystems packag
|
|||||||
- pestphp/pest (PEST) - v3
|
- pestphp/pest (PEST) - v3
|
||||||
- phpunit/phpunit (PHPUNIT) - v11
|
- phpunit/phpunit (PHPUNIT) - v11
|
||||||
|
|
||||||
|
|
||||||
## Conventions
|
## 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.
|
- 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()`.
|
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
||||||
- Check for existing components to reuse before writing a new one.
|
- Check for existing components to reuse before writing a new one.
|
||||||
|
|
||||||
## Verification Scripts
|
## 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.
|
- 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
|
## Application Structure & Architecture
|
||||||
|
|
||||||
- Stick to existing directory structure - don't create new base folders without approval.
|
- Stick to existing directory structure - don't create new base folders without approval.
|
||||||
- Do not change the application's dependencies without approval.
|
- Do not change the application's dependencies without approval.
|
||||||
|
|
||||||
## Frontend Bundling
|
## 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.
|
- 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
|
## Replies
|
||||||
|
|
||||||
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
||||||
|
|
||||||
## Documentation Files
|
## Documentation Files
|
||||||
- You must only create documentation files if explicitly requested by the user.
|
|
||||||
|
|
||||||
|
- You must only create documentation files if explicitly requested by the user.
|
||||||
|
|
||||||
=== boost rules ===
|
=== boost rules ===
|
||||||
|
|
||||||
## Laravel Boost
|
## Laravel Boost
|
||||||
|
|
||||||
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
|
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
|
||||||
|
|
||||||
## Artisan
|
## Artisan
|
||||||
|
|
||||||
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters.
|
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters.
|
||||||
|
|
||||||
## URLs
|
## 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.
|
- 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
|
## Tinker / Debugging
|
||||||
|
|
||||||
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
|
- 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.
|
- Use the `database-query` tool when you only need to read from the database.
|
||||||
|
|
||||||
## Reading Browser Logs With the `browser-logs` Tool
|
## Reading Browser Logs With the `browser-logs` Tool
|
||||||
|
|
||||||
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
|
- 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.
|
- Only recent browser logs will be useful - ignore old logs.
|
||||||
|
|
||||||
## Searching Documentation (Critically Important)
|
## 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.
|
- 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.
|
- 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.
|
- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches.
|
||||||
@@ -69,6 +80,7 @@ This application is a Laravel application and its main Laravel ecosystems packag
|
|||||||
- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
|
- 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
|
### Available Search Syntax
|
||||||
|
|
||||||
- You can and should pass multiple queries at once. The most relevant results will be returned first.
|
- 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'
|
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'
|
||||||
@@ -77,7 +89,6 @@ This application is a Laravel application and its main Laravel ecosystems packag
|
|||||||
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit"
|
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit"
|
||||||
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
|
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
|
||||||
|
|
||||||
|
|
||||||
=== php rules ===
|
=== php rules ===
|
||||||
|
|
||||||
## PHP
|
## PHP
|
||||||
@@ -85,11 +96,13 @@ This application is a Laravel application and its main Laravel ecosystems packag
|
|||||||
- Always use curly braces for control structures, even if it has one line.
|
- Always use curly braces for control structures, even if it has one line.
|
||||||
|
|
||||||
### Constructors
|
### Constructors
|
||||||
|
|
||||||
- Use PHP 8 constructor property promotion in `__construct()`.
|
- Use PHP 8 constructor property promotion in `__construct()`.
|
||||||
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
|
- <code-snippet>public function \_\_construct(public GitHub $github) { }</code-snippet>
|
||||||
- Do not allow empty `__construct()` methods with zero parameters.
|
- Do not allow empty `__construct()` methods with zero parameters.
|
||||||
|
|
||||||
### Type Declarations
|
### Type Declarations
|
||||||
|
|
||||||
- Always use explicit return type declarations for methods and functions.
|
- Always use explicit return type declarations for methods and functions.
|
||||||
- Use appropriate PHP type hints for method parameters.
|
- Use appropriate PHP type hints for method parameters.
|
||||||
|
|
||||||
@@ -101,14 +114,16 @@ protected function isAccessible(User $user, ?string $path = null): bool
|
|||||||
</code-snippet>
|
</code-snippet>
|
||||||
|
|
||||||
## Comments
|
## Comments
|
||||||
|
|
||||||
- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on.
|
- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on.
|
||||||
|
|
||||||
## PHPDoc Blocks
|
## PHPDoc Blocks
|
||||||
|
|
||||||
- Add useful array shape type definitions for arrays when appropriate.
|
- Add useful array shape type definitions for arrays when appropriate.
|
||||||
|
|
||||||
## Enums
|
## Enums
|
||||||
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
|
||||||
|
|
||||||
|
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||||
|
|
||||||
=== inertia-laravel/core rules ===
|
=== inertia-laravel/core rules ===
|
||||||
|
|
||||||
@@ -127,7 +142,6 @@ Route::get('/users', function () {
|
|||||||
});
|
});
|
||||||
</code-snippet>
|
</code-snippet>
|
||||||
|
|
||||||
|
|
||||||
=== inertia-laravel/v2 rules ===
|
=== inertia-laravel/v2 rules ===
|
||||||
|
|
||||||
## Inertia v2
|
## Inertia v2
|
||||||
@@ -135,6 +149,7 @@ Route::get('/users', function () {
|
|||||||
- Make use of all Inertia features from v1 & v2. Check the documentation before making any changes to ensure we are taking the correct approach.
|
- Make use of all Inertia features from v1 & v2. Check the documentation before making any changes to ensure we are taking the correct approach.
|
||||||
|
|
||||||
### Inertia v2 New Features
|
### Inertia v2 New Features
|
||||||
|
|
||||||
- Polling
|
- Polling
|
||||||
- Prefetching
|
- Prefetching
|
||||||
- Deferred props
|
- Deferred props
|
||||||
@@ -142,11 +157,12 @@ Route::get('/users', function () {
|
|||||||
- Lazy loading data on scroll
|
- Lazy loading data on scroll
|
||||||
|
|
||||||
### Deferred Props & Empty States
|
### Deferred Props & Empty States
|
||||||
|
|
||||||
- When using deferred props on the frontend, you should add a nice empty state with pulsing / animated skeleton.
|
- When using deferred props on the frontend, you should add a nice empty state with pulsing / animated skeleton.
|
||||||
|
|
||||||
### Inertia Form General Guidance
|
### Inertia Form General Guidance
|
||||||
- Build forms using the `useForm` helper. Use the code examples and `search-docs` tool with a query of `useForm helper` for guidance.
|
|
||||||
|
|
||||||
|
- Build forms using the `useForm` helper. Use the code examples and `search-docs` tool with a query of `useForm helper` for guidance.
|
||||||
|
|
||||||
=== laravel/core rules ===
|
=== laravel/core rules ===
|
||||||
|
|
||||||
@@ -157,6 +173,7 @@ Route::get('/users', function () {
|
|||||||
- 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.
|
- 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
|
### Database
|
||||||
|
|
||||||
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
|
- 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
|
- 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.
|
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
|
||||||
@@ -164,35 +181,43 @@ Route::get('/users', function () {
|
|||||||
- Use Laravel's query builder for very complex database operations.
|
- Use Laravel's query builder for very complex database operations.
|
||||||
|
|
||||||
### Model Creation
|
### 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`.
|
- 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
|
### 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.
|
- 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
|
### Controllers & Validation
|
||||||
|
|
||||||
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
|
- 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.
|
- Check sibling Form Requests to see if the application uses array or string based validation rules.
|
||||||
|
|
||||||
### Queues
|
### Queues
|
||||||
|
|
||||||
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
|
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
|
||||||
|
|
||||||
### Authentication & Authorization
|
### Authentication & Authorization
|
||||||
|
|
||||||
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
|
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
|
||||||
|
|
||||||
### URL Generation
|
### URL Generation
|
||||||
|
|
||||||
- When generating links to other pages, prefer named routes and the `route()` function.
|
- When generating links to other pages, prefer named routes and the `route()` function.
|
||||||
|
|
||||||
### Configuration
|
### 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')`.
|
- 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
|
### 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.
|
- 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()`.
|
- 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.
|
- 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
|
### 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`.
|
|
||||||
|
|
||||||
|
- 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/v12 rules ===
|
||||||
|
|
||||||
@@ -202,6 +227,7 @@ Route::get('/users', function () {
|
|||||||
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
|
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
|
||||||
|
|
||||||
### Laravel 12 Structure
|
### Laravel 12 Structure
|
||||||
|
|
||||||
- No middleware files in `app/Http/Middleware/`.
|
- No middleware files in `app/Http/Middleware/`.
|
||||||
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
|
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
|
||||||
- `bootstrap/providers.php` contains application specific service providers.
|
- `bootstrap/providers.php` contains application specific service providers.
|
||||||
@@ -209,12 +235,13 @@ Route::get('/users', function () {
|
|||||||
- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration.
|
- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration.
|
||||||
|
|
||||||
### Database
|
### 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.
|
- 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);`.
|
- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
|
||||||
|
|
||||||
### Models
|
### 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.
|
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
|
||||||
=== pint/core rules ===
|
=== pint/core rules ===
|
||||||
|
|
||||||
@@ -223,27 +250,29 @@ Route::get('/users', function () {
|
|||||||
- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
|
- 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.
|
- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues.
|
||||||
|
|
||||||
|
|
||||||
=== pest/core rules ===
|
=== pest/core rules ===
|
||||||
|
|
||||||
## Pest
|
## Pest
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
- If you need to verify a feature is working, write or update a Unit / Feature test.
|
- If you need to verify a feature is working, write or update a Unit / Feature test.
|
||||||
|
|
||||||
### Pest Tests
|
### Pest Tests
|
||||||
|
|
||||||
- All tests must be written using Pest. Use `php artisan make:test --pest <name>`.
|
- 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.
|
- 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 should test all of the happy paths, failure paths, and weird paths.
|
||||||
- Tests live in the `tests/Feature` and `tests/Unit` directories.
|
- Tests live in the `tests/Feature` and `tests/Unit` directories.
|
||||||
- Pest tests look and behave like this:
|
- Pest tests look and behave like this:
|
||||||
<code-snippet name="Basic Pest Test Example" lang="php">
|
<code-snippet name="Basic Pest Test Example" lang="php">
|
||||||
it('is true', function () {
|
it('is true', function () {
|
||||||
expect(true)->toBeTrue();
|
expect(true)->toBeTrue();
|
||||||
});
|
});
|
||||||
</code-snippet>
|
</code-snippet>
|
||||||
|
|
||||||
### Running Tests
|
### Running Tests
|
||||||
|
|
||||||
- Run the minimal number of tests using an appropriate filter before finalizing code edits.
|
- 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: `php artisan test`.
|
||||||
- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`.
|
- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`.
|
||||||
@@ -251,21 +280,25 @@ it('is true', function () {
|
|||||||
- 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.
|
- 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
|
### 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.:
|
- 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">
|
<code-snippet name="Pest Example Asserting postJson Response" lang="php">
|
||||||
it('returns all', function () {
|
it('returns all', function () {
|
||||||
$response = $this->postJson('/api/docs', []);
|
$response = $this->postJson('/api/docs', []);
|
||||||
|
|
||||||
$response->assertSuccessful();
|
$response->assertSuccessful();
|
||||||
});
|
|
||||||
</code-snippet>
|
});
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
### Mocking
|
### Mocking
|
||||||
|
|
||||||
- Mocking can be very helpful when appropriate.
|
- 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.
|
- 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.
|
- You can also create partial mocks using the same import or self method.
|
||||||
|
|
||||||
### Datasets
|
### 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.
|
- 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">
|
<code-snippet name="Pest Dataset Example" lang="php">
|
||||||
@@ -277,11 +310,14 @@ it('has emails', function (string $email) {
|
|||||||
]);
|
]);
|
||||||
</code-snippet>
|
</code-snippet>
|
||||||
|
|
||||||
|
|
||||||
=== tests rules ===
|
=== tests rules ===
|
||||||
|
|
||||||
## Test Enforcement
|
## 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.
|
- 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.
|
- 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>
|
</laravel-boost-guidelines>
|
||||||
|
|
||||||
|
# Keystone
|
||||||
|
|
||||||
|
- Keep CHANGELOG.md up to date.
|
||||||
|
|||||||
@@ -4,11 +4,15 @@ namespace App\Actions\Environments;
|
|||||||
|
|
||||||
use App\Enums\BuildArtifactStatus;
|
use App\Enums\BuildArtifactStatus;
|
||||||
use App\Enums\BuildStrategy;
|
use App\Enums\BuildStrategy;
|
||||||
|
use App\Enums\RegistryType;
|
||||||
use App\Models\BuildArtifact;
|
use App\Models\BuildArtifact;
|
||||||
use App\Models\Operation;
|
use App\Models\Operation;
|
||||||
|
use App\Models\Registry;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use App\Models\Service;
|
use App\Models\Service;
|
||||||
use App\Services\Operations\RemoteCommandRunner;
|
use App\Services\Operations\RemoteCommandRunner;
|
||||||
|
use App\Services\Registries\RegistryDockerAuthScript;
|
||||||
|
use App\Services\Registries\RegistryResolver;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
||||||
class BuildApplicationArtifact
|
class BuildApplicationArtifact
|
||||||
@@ -61,6 +65,18 @@ class BuildApplicationArtifact
|
|||||||
|
|
||||||
private function buildServer(BuildArtifact $artifact): Server
|
private function buildServer(BuildArtifact $artifact): Server
|
||||||
{
|
{
|
||||||
|
$buildServerId = (int) ($artifact->metadata['build_server_id'] ?? 0);
|
||||||
|
|
||||||
|
if ($buildServerId > 0) {
|
||||||
|
$server = Server::find($buildServerId);
|
||||||
|
|
||||||
|
if ($server instanceof Server && $server->build_enabled) {
|
||||||
|
return $server;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new RuntimeException('Configured build server is missing or not build-enabled.');
|
||||||
|
}
|
||||||
|
|
||||||
if ($artifact->builtByService instanceof Service) {
|
if ($artifact->builtByService instanceof Service) {
|
||||||
$server = $artifact->builtByService->replicas->first()?->server ?: $artifact->builtByService->server;
|
$server = $artifact->builtByService->replicas->first()?->server ?: $artifact->builtByService->server;
|
||||||
|
|
||||||
@@ -70,7 +86,7 @@ class BuildApplicationArtifact
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (($artifact->metadata['build_strategy'] ?? null) === BuildStrategy::DEDICATED_BUILDER->value) {
|
if (($artifact->metadata['build_strategy'] ?? null) === BuildStrategy::DEDICATED_BUILDER->value) {
|
||||||
throw new RuntimeException('Dedicated builder strategy requires a builder service.');
|
throw new RuntimeException('Dedicated builder strategy requires a builder service or build-enabled server.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$services = $artifact->environment->services()
|
$services = $artifact->environment->services()
|
||||||
@@ -107,9 +123,13 @@ class BuildApplicationArtifact
|
|||||||
|
|
||||||
$operationDirectory = '/home/keystone/operations/build-'.$artifact->id.'-'.str()->random(8);
|
$operationDirectory = '/home/keystone/operations/build-'.$artifact->id.'-'.str()->random(8);
|
||||||
$imageReference = $artifact->registry_ref ?: $artifact->image_tag;
|
$imageReference = $artifact->registry_ref ?: $artifact->image_tag;
|
||||||
$pushCommand = $strategy === BuildStrategy::DEDICATED_BUILDER && $artifact->registry_ref
|
$publishCommands = $artifact->registry_ref && $strategy !== BuildStrategy::EXTERNAL_REGISTRY
|
||||||
? "\ndocker push ".escapeshellarg($imageReference)
|
? [
|
||||||
: '';
|
...$this->pushDigestCommands($imageReference),
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
'digest=$(docker image inspect --format '.escapeshellarg('{{if .RepoDigests}}{{index .RepoDigests 0}}{{else}}{{.Id}}{{end}}').' '.escapeshellarg($imageReference).')',
|
||||||
|
];
|
||||||
|
|
||||||
return implode("\n", [
|
return implode("\n", [
|
||||||
'set -euo pipefail',
|
'set -euo pipefail',
|
||||||
@@ -126,25 +146,91 @@ class BuildApplicationArtifact
|
|||||||
'git clone --depth 1 --branch '.escapeshellarg($artifact->environment->branch).' '.escapeshellarg($application->repository_url).' "$source_dir"',
|
'git clone --depth 1 --branch '.escapeshellarg($artifact->environment->branch).' '.escapeshellarg($application->repository_url).' "$source_dir"',
|
||||||
$this->writeFileCommand('$source_dir/Dockerfile.keystone', $this->dockerfile($artifact)),
|
$this->writeFileCommand('$source_dir/Dockerfile.keystone', $this->dockerfile($artifact)),
|
||||||
'cd "$source_dir"',
|
'cd "$source_dir"',
|
||||||
'docker build --file Dockerfile.keystone --tag '.escapeshellarg($imageReference).' .'.$pushCommand,
|
...$this->registryMaintenanceLockCommands($artifact),
|
||||||
'digest=$(docker image inspect --format '.escapeshellarg('{{if .RepoDigests}}{{index .RepoDigests 0}}{{else}}{{.Id}}{{end}}').' '.escapeshellarg($imageReference).')',
|
...$this->buildAuthCommands($artifact),
|
||||||
|
'docker build --file Dockerfile.keystone --tag '.escapeshellarg($imageReference).' .',
|
||||||
|
...$publishCommands,
|
||||||
'printf "image_digest=%s\n" "$digest"',
|
'printf "image_digest=%s\n" "$digest"',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function registryMaintenanceLockCommands(BuildArtifact $artifact): array
|
||||||
|
{
|
||||||
|
if (($artifact->metadata['registry_type'] ?? null) !== RegistryType::MANAGED->value) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'install -d -m 700 -o root -g root /home/keystone/registry',
|
||||||
|
'exec 9>/home/keystone/registry/maintenance.lock',
|
||||||
|
'flock 9',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function buildAuthCommands(BuildArtifact $artifact): array
|
||||||
|
{
|
||||||
|
if (($artifact->metadata['registry_type'] ?? null) !== RegistryType::MANAGED->value) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$registry = app(RegistryResolver::class)->buildRegistryFor($artifact->environment->application->organisation);
|
||||||
|
|
||||||
|
if (! $registry instanceof Registry || $registry->type !== RegistryType::MANAGED || ! $registry->credentials) {
|
||||||
|
throw new RuntimeException('Managed registry build credentials are not configured.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$auth = app(RegistryDockerAuthScript::class)->forBuild($registry, 'root');
|
||||||
|
$script = $auth['script'];
|
||||||
|
|
||||||
|
foreach ($auth['secrets'] as $key => $value) {
|
||||||
|
$script = str_replace("[!{$key}!]", $value, $script);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$script];
|
||||||
|
}
|
||||||
|
|
||||||
private function manifestDigestScript(BuildArtifact $artifact): string
|
private function manifestDigestScript(BuildArtifact $artifact): string
|
||||||
{
|
{
|
||||||
$imageReference = $artifact->registry_ref ?: $artifact->image_tag;
|
$imageReference = $artifact->registry_ref ?: $artifact->image_tag;
|
||||||
|
|
||||||
return implode("\n", [
|
return implode("\n", [
|
||||||
'set -euo pipefail',
|
'set -euo pipefail',
|
||||||
'manifest=$(docker manifest inspect '.escapeshellarg($imageReference).')',
|
...$this->manifestDigestCommands($imageReference),
|
||||||
'digest=$(printf "%s" "$manifest" | sed -n '.escapeshellarg('s/.*"digest": "\(sha256:[^"]*\)".*/\1/p').' | head -n 1)',
|
|
||||||
'test -n "$digest"',
|
|
||||||
'printf "image_digest=%s\n" "$digest"',
|
'printf "image_digest=%s\n" "$digest"',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function manifestDigestCommands(string $imageReference): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'inspect_output=$(docker buildx imagetools inspect '.escapeshellarg($imageReference).')',
|
||||||
|
'digest=$(printf "%s\n" "$inspect_output" | sed -n '.escapeshellarg('s/^Digest:[[:space:]]*\(sha256:[^[:space:]]*\).*/\1/p').' | head -n 1)',
|
||||||
|
'test -n "$digest"',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function pushDigestCommands(string $imageReference): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'push_output=$(docker push '.escapeshellarg($imageReference).')',
|
||||||
|
'printf "%s\n" "$push_output"',
|
||||||
|
'digest=$(printf "%s\n" "$push_output" | sed -n '.escapeshellarg('s/.*digest: \(sha256:[^[:space:]]*\).*/\1/p').' | tail -n 1)',
|
||||||
|
'test -n "$digest"',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
private function dockerfile(BuildArtifact $artifact): string
|
private function dockerfile(BuildArtifact $artifact): string
|
||||||
{
|
{
|
||||||
$service = $artifact->environment->services()
|
$service = $artifact->environment->services()
|
||||||
@@ -176,7 +262,7 @@ DOCKERFILE;
|
|||||||
private function digestFromOutput(string $output): string
|
private function digestFromOutput(string $output): string
|
||||||
{
|
{
|
||||||
if (preg_match('/image_digest=(?<digest>\S+)/', $output, $matches)) {
|
if (preg_match('/image_digest=(?<digest>\S+)/', $output, $matches)) {
|
||||||
return $this->digestFromOutput($matches['digest']);
|
$output = $matches['digest'];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (str_contains($output, '@')) {
|
if (str_contains($output, '@')) {
|
||||||
|
|||||||
@@ -4,13 +4,21 @@ namespace App\Actions\Environments;
|
|||||||
|
|
||||||
use App\Enums\BuildArtifactStatus;
|
use App\Enums\BuildArtifactStatus;
|
||||||
use App\Enums\BuildStrategy;
|
use App\Enums\BuildStrategy;
|
||||||
|
use App\Enums\RegistryType;
|
||||||
use App\Enums\ServiceCategory;
|
use App\Enums\ServiceCategory;
|
||||||
use App\Models\BuildArtifact;
|
use App\Models\BuildArtifact;
|
||||||
use App\Models\Environment;
|
use App\Models\Environment;
|
||||||
|
use App\Services\Registries\ImageReference;
|
||||||
|
use App\Services\Registries\RegistryResolver;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
||||||
class PlanBuildArtifact
|
class PlanBuildArtifact
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly RegistryResolver $registryResolver,
|
||||||
|
private readonly ImageReference $imageReference,
|
||||||
|
) {}
|
||||||
|
|
||||||
public function execute(Environment $environment, string $commitSha): BuildArtifact
|
public function execute(Environment $environment, string $commitSha): BuildArtifact
|
||||||
{
|
{
|
||||||
$environment->loadMissing(['application.organisation.registries', 'services.replicas']);
|
$environment->loadMissing(['application.organisation.registries', 'services.replicas']);
|
||||||
@@ -26,36 +34,48 @@ class PlanBuildArtifact
|
|||||||
}
|
}
|
||||||
|
|
||||||
$targetServerCount = $this->targetServerCount($environment);
|
$targetServerCount = $this->targetServerCount($environment);
|
||||||
$registry = $environment->application->organisation->registries()->first();
|
$registry = $this->registryResolver->buildRegistryFor($environment->application->organisation);
|
||||||
|
$registryType = $this->registryType($registry);
|
||||||
|
|
||||||
if ($targetServerCount > 1 && ! $registry) {
|
if ($targetServerCount > 1 && ! $registry) {
|
||||||
throw new RuntimeException('A registry is required before building artifacts for multi-server deployments.');
|
$blocker = $this->registryResolver->managedRegistryBlockerFor($environment->application->organisation);
|
||||||
|
|
||||||
|
throw new RuntimeException($blocker ?: 'A registry is required before building artifacts for multi-server deployments.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$builder = $environment->application->organisation->services()
|
$builder = $environment->application->organisation->services()
|
||||||
->where('category', ServiceCategory::BUILDER)
|
->where('category', ServiceCategory::BUILDER)
|
||||||
->first();
|
->first();
|
||||||
|
$buildServerId = null;
|
||||||
|
|
||||||
|
if ($registryType === RegistryType::MANAGED) {
|
||||||
|
$buildServerId = (int) $registry->control_server_id;
|
||||||
|
|
||||||
|
if ($buildServerId <= 0) {
|
||||||
|
throw new RuntimeException('A control/build server is required for managed registry builds.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$strategy = match (true) {
|
$strategy = match (true) {
|
||||||
|
$registryType === RegistryType::MANAGED => BuildStrategy::DEDICATED_BUILDER,
|
||||||
$registry !== null => BuildStrategy::EXTERNAL_REGISTRY,
|
$registry !== null => BuildStrategy::EXTERNAL_REGISTRY,
|
||||||
$builder !== null => BuildStrategy::DEDICATED_BUILDER,
|
$builder !== null => BuildStrategy::DEDICATED_BUILDER,
|
||||||
default => BuildStrategy::TARGET_SERVER,
|
default => BuildStrategy::TARGET_SERVER,
|
||||||
};
|
};
|
||||||
|
|
||||||
$imageTag = str($environment->application->name)
|
$imageTag = $this->imageReference->tagFor($environment, $commitSha, $registry);
|
||||||
->slug()
|
|
||||||
->append(':'.substr($commitSha, 0, 12))
|
|
||||||
->value();
|
|
||||||
|
|
||||||
return $environment->buildArtifacts()->create([
|
return $environment->buildArtifacts()->create([
|
||||||
'commit_sha' => $commitSha,
|
'commit_sha' => $commitSha,
|
||||||
'image_tag' => $imageTag,
|
'image_tag' => $imageTag,
|
||||||
'registry_ref' => $registry ? rtrim((string) $registry->url, '/').'/'.$imageTag : null,
|
'registry_ref' => $registry ? $this->imageReference->registryReference($registry, $imageTag) : null,
|
||||||
'built_by_service_id' => $builder?->id,
|
'built_by_service_id' => $builder?->id,
|
||||||
'status' => BuildArtifactStatus::PENDING,
|
'status' => BuildArtifactStatus::PENDING,
|
||||||
'metadata' => [
|
'metadata' => [
|
||||||
'build_strategy' => $strategy->value,
|
'build_strategy' => $strategy->value,
|
||||||
|
'registry_type' => $registryType?->value,
|
||||||
'target_server_count' => $targetServerCount,
|
'target_server_count' => $targetServerCount,
|
||||||
|
'build_server_id' => $buildServerId,
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -73,4 +93,17 @@ class PlanBuildArtifact
|
|||||||
|
|
||||||
return $environment->services->sum('desired_replicas') > 1 ? 2 : 1;
|
return $environment->services->sum('desired_replicas') > 1 ? 2 : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function registryType(mixed $registry): ?RegistryType
|
||||||
|
{
|
||||||
|
if (! $registry) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($registry->type instanceof RegistryType) {
|
||||||
|
return $registry->type;
|
||||||
|
}
|
||||||
|
|
||||||
|
return RegistryType::tryFrom((string) $registry->type);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,14 @@ use App\Enums\DeployPolicy;
|
|||||||
use App\Enums\EnvironmentAttachmentRole;
|
use App\Enums\EnvironmentAttachmentRole;
|
||||||
use App\Enums\SchedulerMode;
|
use App\Enums\SchedulerMode;
|
||||||
use App\Models\Environment;
|
use App\Models\Environment;
|
||||||
|
use App\Services\Registries\RegistryResolver;
|
||||||
|
|
||||||
class PlanEnvironmentDeployment
|
class PlanEnvironmentDeployment
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly RegistryResolver $registryResolver,
|
||||||
|
) {}
|
||||||
|
|
||||||
public function execute(Environment $environment): EnvironmentDeploymentPlan
|
public function execute(Environment $environment): EnvironmentDeploymentPlan
|
||||||
{
|
{
|
||||||
$environment->loadMissing([
|
$environment->loadMissing([
|
||||||
@@ -40,9 +45,9 @@ class PlanEnvironmentDeployment
|
|||||||
return new EnvironmentDeploymentPlan(
|
return new EnvironmentDeploymentPlan(
|
||||||
services: $deployableServices->all(),
|
services: $deployableServices->all(),
|
||||||
dependencies: $dependencies->all(),
|
dependencies: $dependencies->all(),
|
||||||
requiresRegistry: $targetServerCount > 1 && $environment->application->organisation->registries()->doesntExist(),
|
requiresRegistry: $targetServerCount > 1 && ! $this->registryResolver->buildRegistryFor($environment->application->organisation),
|
||||||
warnings: $this->warnings($environment),
|
warnings: $this->warnings($environment),
|
||||||
blockers: $this->blockers($environment),
|
blockers: $this->blockers($environment, $targetServerCount),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,18 +79,28 @@ class PlanEnvironmentDeployment
|
|||||||
/**
|
/**
|
||||||
* @return array<int, string>
|
* @return array<int, string>
|
||||||
*/
|
*/
|
||||||
private function blockers(Environment $environment): array
|
private function blockers(Environment $environment, int $targetServerCount): array
|
||||||
{
|
{
|
||||||
|
$blockers = [];
|
||||||
|
|
||||||
|
if ($targetServerCount > 1 && ! $this->registryResolver->buildRegistryFor($environment->application->organisation)) {
|
||||||
|
$blocker = $this->registryResolver->managedRegistryBlockerFor($environment->application->organisation);
|
||||||
|
|
||||||
|
if ($blocker !== null) {
|
||||||
|
$blockers[] = $blocker;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (! $environment->scheduler_enabled || $environment->scheduler_mode !== SchedulerMode::SINGLE) {
|
if (! $environment->scheduler_enabled || $environment->scheduler_mode !== SchedulerMode::SINGLE) {
|
||||||
return [];
|
return $blockers;
|
||||||
}
|
}
|
||||||
|
|
||||||
$target = $environment->services->firstWhere('id', $environment->scheduler_target_service_id);
|
$target = $environment->services->firstWhere('id', $environment->scheduler_target_service_id);
|
||||||
|
|
||||||
if ($target && $target->desired_replicas > 1 && in_array('scheduler', $target->process_roles ?? [], true)) {
|
if ($target && $target->desired_replicas > 1 && in_array('scheduler', $target->process_roles ?? [], true)) {
|
||||||
return ['Scheduler mode single requires the scheduler target service to run exactly one replica.'];
|
$blockers[] = 'Scheduler mode single requires the scheduler target service to run exactly one replica.';
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
return $blockers;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Registries;
|
||||||
|
|
||||||
|
use App\Enums\BuildArtifactStatus;
|
||||||
|
use App\Enums\OperationKind;
|
||||||
|
use App\Enums\OperationStatus;
|
||||||
|
use App\Models\BuildArtifact;
|
||||||
|
use App\Models\Operation;
|
||||||
|
use App\Models\Registry;
|
||||||
|
use App\Models\Server;
|
||||||
|
use App\Services\Registries\ManagedRegistryOperationScripts;
|
||||||
|
use App\Services\Registries\ManagedRegistryRetention;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class CreateManagedRegistryMaintenanceOperation
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ManagedRegistryRetention $retention,
|
||||||
|
private readonly ManagedRegistryOperationScripts $scripts,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function execute(Registry $registry): Operation
|
||||||
|
{
|
||||||
|
$server = $registry->controlServer;
|
||||||
|
|
||||||
|
if (! $server instanceof Server) {
|
||||||
|
throw new RuntimeException('A control/build server is required to prune the managed registry.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$activeBuilds = $registry->organisation->applications()
|
||||||
|
->whereHas('environments.buildArtifacts', fn ($query) => $query
|
||||||
|
->where('status', BuildArtifactStatus::BUILDING)
|
||||||
|
->where('registry_ref', 'like', rtrim((string) $registry->url, '/').'/%'))
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($activeBuilds) {
|
||||||
|
throw new RuntimeException('Managed registry pruning cannot run while builds are active.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->retention->markPrunable($registry);
|
||||||
|
$artifacts = $this->prunableArtifacts($registry);
|
||||||
|
$maintenance = $this->scripts->maintenance($registry, $artifacts);
|
||||||
|
|
||||||
|
$operation = $server->operations()->create([
|
||||||
|
'kind' => OperationKind::REGISTRY_MAINTENANCE,
|
||||||
|
'status' => OperationStatus::PENDING,
|
||||||
|
'metadata' => [
|
||||||
|
'registry_id' => $registry->id,
|
||||||
|
'artifact_ids' => $artifacts->pluck('id')->values()->all(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$operation->steps()->create([
|
||||||
|
'name' => 'Delete prunable manifests and run registry GC',
|
||||||
|
'order' => 1,
|
||||||
|
'status' => OperationStatus::PENDING,
|
||||||
|
'script' => $maintenance['script'],
|
||||||
|
'secrets' => $maintenance['secrets'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $operation->refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return \Illuminate\Support\Collection<int, BuildArtifact>
|
||||||
|
*/
|
||||||
|
private function prunableArtifacts(Registry $registry): \Illuminate\Support\Collection
|
||||||
|
{
|
||||||
|
return $registry->organisation->applications()
|
||||||
|
->with(['environments.buildArtifacts' => fn ($query) => $query
|
||||||
|
->where('status', BuildArtifactStatus::PRUNABLE)
|
||||||
|
->where('registry_ref', 'like', rtrim((string) $registry->url, '/').'/%')])
|
||||||
|
->get()
|
||||||
|
->flatMap(fn ($application) => $application->environments)
|
||||||
|
->flatMap(fn ($environment) => $environment->buildArtifacts)
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Registries;
|
||||||
|
|
||||||
|
use App\Enums\OperationKind;
|
||||||
|
use App\Enums\OperationStatus;
|
||||||
|
use App\Models\Operation;
|
||||||
|
use App\Models\Registry;
|
||||||
|
use App\Models\Server;
|
||||||
|
use App\Services\Registries\ManagedRegistryOperationScripts;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class CreateManagedRegistryProvisionOperation
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ManagedRegistryOperationScripts $scripts,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function execute(Registry $registry): Operation
|
||||||
|
{
|
||||||
|
$server = $registry->controlServer;
|
||||||
|
|
||||||
|
if (! $server instanceof Server) {
|
||||||
|
throw new RuntimeException('A control/build server is required to provision the managed registry.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$provision = $this->scripts->provision($registry);
|
||||||
|
|
||||||
|
$operation = $server->operations()->create([
|
||||||
|
'kind' => OperationKind::REGISTRY_PROVISION,
|
||||||
|
'status' => OperationStatus::PENDING,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$operation->steps()->create([
|
||||||
|
'name' => 'Install managed Docker registry',
|
||||||
|
'order' => 1,
|
||||||
|
'status' => OperationStatus::PENDING,
|
||||||
|
'script' => $provision['script'],
|
||||||
|
'secrets' => $provision['secrets'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $operation->refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Registries;
|
||||||
|
|
||||||
|
use App\Enums\OperationKind;
|
||||||
|
use App\Enums\OperationStatus;
|
||||||
|
use App\Models\Operation;
|
||||||
|
use App\Models\Registry;
|
||||||
|
use App\Models\Server;
|
||||||
|
use App\Services\Registries\ManagedRegistryOperationScripts;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class CreateManagedRegistrySmokeCheckOperation
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ManagedRegistryOperationScripts $scripts,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param iterable<int, Server> $runtimeServers
|
||||||
|
*/
|
||||||
|
public function execute(Registry $registry, ?Server $buildServer = null, iterable $runtimeServers = []): Operation
|
||||||
|
{
|
||||||
|
$controlServer = $registry->controlServer;
|
||||||
|
|
||||||
|
if (! $controlServer instanceof Server) {
|
||||||
|
throw new RuntimeException('A control/build server is required to check the managed registry.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$buildServer ??= $controlServer;
|
||||||
|
$smokeRef = rtrim((string) $registry->url, '/').'/keystone/smoke/server-'.$buildServer->id.':latest';
|
||||||
|
$checks = [
|
||||||
|
'control_https' => 'pending',
|
||||||
|
'build_push' => 'pending',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($runtimeServers as $server) {
|
||||||
|
$checks['runtime_pull_server_'.$server->id] = 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
$registry->forceFill([
|
||||||
|
'readiness_checks' => $checks,
|
||||||
|
'health_status' => 'pending',
|
||||||
|
'ready_at' => null,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$operation = $controlServer->operations()->create([
|
||||||
|
'kind' => OperationKind::REGISTRY_HEALTH_CHECK,
|
||||||
|
'status' => OperationStatus::PENDING,
|
||||||
|
'metadata' => [
|
||||||
|
'registry_id' => $registry->id,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$build = $this->scripts->smokeCheck($registry, $buildServer, 'build', $smokeRef);
|
||||||
|
$operation->steps()->create([
|
||||||
|
'name' => 'Check registry HTTPS and build push',
|
||||||
|
'order' => 1,
|
||||||
|
'status' => OperationStatus::PENDING,
|
||||||
|
'script' => $build['script'],
|
||||||
|
'secrets' => $build['secrets'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$order = 2;
|
||||||
|
foreach ($runtimeServers as $server) {
|
||||||
|
$runtime = $this->scripts->smokeCheck($registry, $server, 'runtime', $smokeRef);
|
||||||
|
$child = $server->operations()->create([
|
||||||
|
'kind' => OperationKind::REGISTRY_HEALTH_CHECK,
|
||||||
|
'parent_id' => $operation->id,
|
||||||
|
'status' => OperationStatus::PENDING,
|
||||||
|
'metadata' => [
|
||||||
|
'registry_id' => $registry->id,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$child->steps()->create([
|
||||||
|
'name' => 'Check runtime registry pull on '.$server->name,
|
||||||
|
'order' => $order++,
|
||||||
|
'status' => OperationStatus::PENDING,
|
||||||
|
'script' => $runtime['script'],
|
||||||
|
'secrets' => $runtime['secrets'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $operation->refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
42
app/Actions/Registries/CreateRegistryAuthOperation.php
Normal file
42
app/Actions/Registries/CreateRegistryAuthOperation.php
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Registries;
|
||||||
|
|
||||||
|
use App\Enums\OperationKind;
|
||||||
|
use App\Enums\OperationStatus;
|
||||||
|
use App\Models\Operation;
|
||||||
|
use App\Models\Registry;
|
||||||
|
use App\Models\Server;
|
||||||
|
use App\Services\Registries\RegistryDockerAuthScript;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
class CreateRegistryAuthOperation
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly RegistryDockerAuthScript $registryDockerAuthScript,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function execute(Registry $registry, Server $server, string $scope): Operation
|
||||||
|
{
|
||||||
|
$auth = match ($scope) {
|
||||||
|
'build' => $this->registryDockerAuthScript->forBuild($registry, 'root'),
|
||||||
|
'runtime' => $this->registryDockerAuthScript->forRuntime($registry, 'root'),
|
||||||
|
default => throw new InvalidArgumentException('Registry auth scope must be build or runtime.'),
|
||||||
|
};
|
||||||
|
|
||||||
|
$operation = $server->operations()->create([
|
||||||
|
'kind' => OperationKind::CREDENTIAL_ROTATION,
|
||||||
|
'status' => OperationStatus::PENDING,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$operation->steps()->create([
|
||||||
|
'name' => 'Configure '.$scope.' registry auth',
|
||||||
|
'order' => 1,
|
||||||
|
'status' => OperationStatus::PENDING,
|
||||||
|
'script' => $auth['script'],
|
||||||
|
'secrets' => $auth['secrets'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $operation->refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/Console/Commands/CheckManagedRegistry.php
Normal file
49
app/Console/Commands/CheckManagedRegistry.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Actions\Registries\CreateManagedRegistrySmokeCheckOperation;
|
||||||
|
use App\Enums\RegistryType;
|
||||||
|
use App\Models\Registry;
|
||||||
|
use App\Models\Server;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class CheckManagedRegistry extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'keystone:managed-registry:check
|
||||||
|
{registry : Managed registry id}
|
||||||
|
{--build-server= : Build server id, defaults to the registry control server}
|
||||||
|
{--runtime-server=* : Runtime server id to pull the smoke image}
|
||||||
|
{--dispatch : Dispatch the first operation step immediately}';
|
||||||
|
|
||||||
|
protected $description = 'Create managed registry HTTPS/auth/push/pull smoke-check operations.';
|
||||||
|
|
||||||
|
public function handle(CreateManagedRegistrySmokeCheckOperation $operations): int
|
||||||
|
{
|
||||||
|
$registry = Registry::query()
|
||||||
|
->where('type', RegistryType::MANAGED->value)
|
||||||
|
->findOrFail((int) $this->argument('registry'));
|
||||||
|
|
||||||
|
$buildServer = $this->option('build-server')
|
||||||
|
? Server::query()
|
||||||
|
->where('organisation_id', $registry->organisation_id)
|
||||||
|
->findOrFail((int) $this->option('build-server'))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$runtimeServers = collect($this->option('runtime-server'))
|
||||||
|
->map(fn (string $serverId): Server => Server::query()
|
||||||
|
->where('organisation_id', $registry->organisation_id)
|
||||||
|
->findOrFail((int) $serverId));
|
||||||
|
|
||||||
|
$operation = $operations->execute($registry, $buildServer, $runtimeServers);
|
||||||
|
|
||||||
|
$this->info("Created registry smoke-check operation {$operation->id}.");
|
||||||
|
|
||||||
|
if ($this->option('dispatch')) {
|
||||||
|
$operation->steps()->orderBy('order')->first()?->dispatchJob();
|
||||||
|
$this->info('Dispatched registry smoke-check operation.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
87
app/Console/Commands/ProvisionManagedRegistry.php
Normal file
87
app/Console/Commands/ProvisionManagedRegistry.php
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Actions\Registries\CreateManagedRegistryProvisionOperation;
|
||||||
|
use App\Models\Organisation;
|
||||||
|
use App\Models\Server;
|
||||||
|
use App\Services\Registries\ManagedRegistryHealth;
|
||||||
|
use App\Services\Registries\ManagedRegistryProvisioner;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class ProvisionManagedRegistry extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'keystone:managed-registry:provision
|
||||||
|
{organisation : Organisation id or slug}
|
||||||
|
{--url= : HTTPS registry hostname}
|
||||||
|
{--control-server= : Control/build server id}
|
||||||
|
{--storage-path= : Registry storage path}
|
||||||
|
{--retention= : Successful artifacts to retain per environment}
|
||||||
|
{--create-operation : Create the remote registry install/proxy operation}
|
||||||
|
{--dispatch : Dispatch the first operation step immediately}
|
||||||
|
{--mark-healthy : Mark the persisted registry ready after configuration validation}';
|
||||||
|
|
||||||
|
protected $description = 'Persist and optionally install a first-party managed Docker registry.';
|
||||||
|
|
||||||
|
public function handle(ManagedRegistryProvisioner $provisioner, ManagedRegistryHealth $health, CreateManagedRegistryProvisionOperation $operations): int
|
||||||
|
{
|
||||||
|
$organisationKey = (string) $this->argument('organisation');
|
||||||
|
$organisation = Organisation::query()
|
||||||
|
->where('id', $organisationKey)
|
||||||
|
->orWhere('slug', $organisationKey)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$url = (string) ($this->option('url') ?: config('keystone.managed_registry.url'));
|
||||||
|
|
||||||
|
if ($url === '') {
|
||||||
|
$this->error('Provide --url or KEYSTONE_MANAGED_REGISTRY_URL.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$controlServer = $this->option('control-server')
|
||||||
|
? Server::query()
|
||||||
|
->where('organisation_id', $organisation->id)
|
||||||
|
->findOrFail((int) $this->option('control-server'))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$registry = $provisioner->provision(
|
||||||
|
organisation: $organisation,
|
||||||
|
url: $url,
|
||||||
|
controlServer: $controlServer,
|
||||||
|
storagePath: $this->option('storage-path') ? (string) $this->option('storage-path') : null,
|
||||||
|
retention: $this->option('retention') ? (int) $this->option('retention') : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
$blocker = $health->readinessBlocker($registry);
|
||||||
|
|
||||||
|
if ($this->option('mark-healthy') && $blocker !== null && $blocker !== 'Managed registry has not passed readiness checks.') {
|
||||||
|
$this->error($blocker);
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->option('mark-healthy')) {
|
||||||
|
$registry->markHealthy('Marked ready by provisioning command.');
|
||||||
|
$blocker = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Managed registry {$registry->url} persisted for {$organisation->name}.");
|
||||||
|
|
||||||
|
if ($blocker !== null) {
|
||||||
|
$this->warn($blocker);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->option('create-operation')) {
|
||||||
|
$operation = $operations->execute($registry);
|
||||||
|
$this->info("Created registry provision operation {$operation->id}.");
|
||||||
|
|
||||||
|
if ($this->option('dispatch')) {
|
||||||
|
$operation->steps()->orderBy('order')->first()?->dispatchJob();
|
||||||
|
$this->info('Dispatched registry provision operation.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/Console/Commands/PruneManagedRegistry.php
Normal file
40
app/Console/Commands/PruneManagedRegistry.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Actions\Registries\CreateManagedRegistryMaintenanceOperation;
|
||||||
|
use App\Enums\RegistryType;
|
||||||
|
use App\Models\Registry;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class PruneManagedRegistry extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'keystone:managed-registry:prune
|
||||||
|
{registry? : Managed registry id}
|
||||||
|
{--dispatch : Dispatch the first operation step immediately}';
|
||||||
|
|
||||||
|
protected $description = 'Create managed registry manifest deletion and garbage-collection operations.';
|
||||||
|
|
||||||
|
public function handle(CreateManagedRegistryMaintenanceOperation $operations): int
|
||||||
|
{
|
||||||
|
$registries = Registry::query()
|
||||||
|
->where('type', RegistryType::MANAGED->value)
|
||||||
|
->when($this->argument('registry'), fn ($query) => $query->whereKey((int) $this->argument('registry')))
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$count = 0;
|
||||||
|
|
||||||
|
foreach ($registries as $registry) {
|
||||||
|
$operation = $operations->execute($registry);
|
||||||
|
$count++;
|
||||||
|
|
||||||
|
if ($this->option('dispatch')) {
|
||||||
|
$operation->steps()->orderBy('order')->first()?->dispatchJob();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Created {$count} managed registry maintenance operation(s).");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,4 +12,6 @@ enum BuildArtifactStatus: string
|
|||||||
case BUILDING = 'building';
|
case BUILDING = 'building';
|
||||||
case AVAILABLE = 'available';
|
case AVAILABLE = 'available';
|
||||||
case FAILED = 'failed';
|
case FAILED = 'failed';
|
||||||
|
case PRUNABLE = 'prunable';
|
||||||
|
case PRUNED = 'pruned';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,4 +17,7 @@ enum OperationKind: string
|
|||||||
case GATEWAY_CUTOVER = 'gateway_cutover';
|
case GATEWAY_CUTOVER = 'gateway_cutover';
|
||||||
case CONFIG_CHANGE = 'config_change';
|
case CONFIG_CHANGE = 'config_change';
|
||||||
case CREDENTIAL_ROTATION = 'credential_rotation';
|
case CREDENTIAL_ROTATION = 'credential_rotation';
|
||||||
|
case REGISTRY_PROVISION = 'registry_provision';
|
||||||
|
case REGISTRY_HEALTH_CHECK = 'registry_health_check';
|
||||||
|
case REGISTRY_MAINTENANCE = 'registry_maintenance';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ enum RegistryType: string
|
|||||||
{
|
{
|
||||||
use Arrayable;
|
use Arrayable;
|
||||||
|
|
||||||
|
case MANAGED = 'managed';
|
||||||
case GENERIC = 'generic';
|
case GENERIC = 'generic';
|
||||||
case GITEA = 'gitea';
|
case GITEA = 'gitea';
|
||||||
case GHCR = 'ghcr';
|
case GHCR = 'ghcr';
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Actions\Applications\VerifyRepositoryAccess;
|
|||||||
use App\Enums\RepositoryType;
|
use App\Enums\RepositoryType;
|
||||||
use App\Enums\ServerStatus;
|
use App\Enums\ServerStatus;
|
||||||
use App\Http\Requests\StoreApplicationRequest;
|
use App\Http\Requests\StoreApplicationRequest;
|
||||||
|
use App\Http\Requests\UpdateApplicationRequest;
|
||||||
use App\Models\Application;
|
use App\Models\Application;
|
||||||
use App\Models\Organisation;
|
use App\Models\Organisation;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
@@ -27,9 +28,12 @@ class ApplicationController extends Controller
|
|||||||
|
|
||||||
public function create(Request $request): Response
|
public function create(Request $request): Response
|
||||||
{
|
{
|
||||||
Organisation::findOrFail($request->route('organisation'));
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
|
||||||
return inertia('applications/Create');
|
return inertia('applications/Create', [
|
||||||
|
'sourceProviders' => $organisation->sourceProviders()->get(),
|
||||||
|
'repositoryTypes' => RepositoryType::toArray(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store(StoreApplicationRequest $request): RedirectResponse
|
public function store(StoreApplicationRequest $request): RedirectResponse
|
||||||
@@ -38,8 +42,9 @@ class ApplicationController extends Controller
|
|||||||
|
|
||||||
$application = $organisation->applications()->create([
|
$application = $organisation->applications()->create([
|
||||||
'name' => $request->string('name')->toString(),
|
'name' => $request->string('name')->toString(),
|
||||||
|
'source_provider_id' => $this->sourceProviderIdFor($organisation, $request->integer('source_provider_id') ?: null),
|
||||||
'repository_url' => $request->string('repository_url')->toString(),
|
'repository_url' => $request->string('repository_url')->toString(),
|
||||||
'repository_type' => RepositoryType::GIT,
|
'repository_type' => $request->enum('repository_type', RepositoryType::class),
|
||||||
'default_branch' => $request->string('default_branch')->toString(),
|
'default_branch' => $request->string('default_branch')->toString(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -56,14 +61,24 @@ class ApplicationController extends Controller
|
|||||||
$id = $request->route('application');
|
$id = $request->route('application');
|
||||||
$organisation = Organisation::findOrFail($request->route('organisation'));
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
$application = Application::with([
|
$application = Application::with([
|
||||||
|
'environments.buildArtifacts' => fn ($query) => $query->latest()->limit(5),
|
||||||
|
'environments.operations' => fn ($query) => $query->latest()->limit(1),
|
||||||
|
'environments.services.replicas',
|
||||||
|
'environments.services.endpoints',
|
||||||
'environments.services.slices',
|
'environments.services.slices',
|
||||||
'environments.attachments.service',
|
'environments.attachments.service',
|
||||||
'environments.variables',
|
'environments.variables',
|
||||||
'organisation',
|
'organisation',
|
||||||
|
'sourceProvider',
|
||||||
])->whereBelongsTo($organisation)->findOrFail($id);
|
])->whereBelongsTo($organisation)->findOrFail($id);
|
||||||
|
|
||||||
return inertia('applications/Show', [
|
return inertia('applications/Show', [
|
||||||
'application' => $application,
|
'application' => $application,
|
||||||
|
'deploymentRequirements' => [
|
||||||
|
'registryRequired' => $organisation->servers()->count() > 1 && $organisation->registries()->doesntExist(),
|
||||||
|
'registryCount' => $organisation->registries()->count(),
|
||||||
|
'serverCount' => $organisation->servers()->count(),
|
||||||
|
],
|
||||||
'servers' => inertia()->optional(function () use ($application) {
|
'servers' => inertia()->optional(function () use ($application) {
|
||||||
return $application
|
return $application
|
||||||
->organisation
|
->organisation
|
||||||
@@ -75,6 +90,51 @@ class ApplicationController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function edit(Request $request): Response
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
|
||||||
|
return inertia('applications/Edit', [
|
||||||
|
'application' => $application,
|
||||||
|
'repositoryTypes' => RepositoryType::toArray(),
|
||||||
|
'sourceProviders' => $organisation->sourceProviders()->get(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdateApplicationRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
|
||||||
|
$application->update([
|
||||||
|
'name' => $request->string('name')->toString(),
|
||||||
|
'source_provider_id' => $this->sourceProviderIdFor($organisation, $request->integer('source_provider_id') ?: null),
|
||||||
|
'repository_type' => $request->enum('repository_type', RepositoryType::class),
|
||||||
|
'repository_url' => $request->string('repository_url')->toString(),
|
||||||
|
'default_branch' => $request->string('default_branch')->toString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('applications.show', [
|
||||||
|
'organisation' => $organisation->id,
|
||||||
|
'application' => $application->id,
|
||||||
|
])
|
||||||
|
->with('success', 'Application updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
|
||||||
|
$application->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('applications.index', ['organisation' => $organisation->id])
|
||||||
|
->with('success', 'Application deleted.');
|
||||||
|
}
|
||||||
|
|
||||||
public function verifyRepository(Request $request): RedirectResponse
|
public function verifyRepository(Request $request): RedirectResponse
|
||||||
{
|
{
|
||||||
$organisation = Organisation::findOrFail($request->route('organisation'));
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
@@ -86,4 +146,23 @@ class ApplicationController extends Controller
|
|||||||
|
|
||||||
return back()->with('success', 'Repository access verified.');
|
return back()->with('success', 'Repository access verified.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function rotateDeployKey(Request $request, GenerateDeployKey $generateDeployKey): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
|
||||||
|
$generateDeployKey->execute($application);
|
||||||
|
|
||||||
|
return back()->with('success', 'Deploy key rotated. Install the new public key before verifying access.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sourceProviderIdFor(Organisation $organisation, ?int $sourceProviderId): ?int
|
||||||
|
{
|
||||||
|
if ($sourceProviderId === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $organisation->sourceProviders()->findOrFail($sourceProviderId)->id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,8 @@
|
|||||||
namespace App\Http\Controllers\Auth;
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Auth\Events\Registered;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Illuminate\Validation\Rules;
|
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
|
||||||
@@ -23,31 +18,8 @@ class RegisteredUserController extends Controller
|
|||||||
return Inertia::render('auth/Register');
|
return Inertia::render('auth/Register');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle an incoming registration request.
|
|
||||||
*
|
|
||||||
* @throws \Illuminate\Validation\ValidationException
|
|
||||||
*/
|
|
||||||
public function store(Request $request): RedirectResponse
|
public function store(Request $request): RedirectResponse
|
||||||
{
|
{
|
||||||
abort(403, 'Registration is disabled.');
|
abort(403, 'Registration is disabled.');
|
||||||
|
|
||||||
$request->validate([
|
|
||||||
'name' => 'required|string|max:255',
|
|
||||||
'email' => 'required|string|lowercase|email|max:255|unique:'.User::class,
|
|
||||||
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$user = User::create([
|
|
||||||
'name' => $request->name,
|
|
||||||
'email' => $request->email,
|
|
||||||
'password' => Hash::make($request->password),
|
|
||||||
]);
|
|
||||||
|
|
||||||
event(new Registered($user));
|
|
||||||
|
|
||||||
Auth::login($user);
|
|
||||||
|
|
||||||
return to_route('dashboard');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
44
app/Http/Controllers/BuildArtifactController.php
Normal file
44
app/Http/Controllers/BuildArtifactController.php
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\BuildArtifact;
|
||||||
|
use App\Models\Organisation;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class BuildArtifactController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
$environment = $application->environments()->findOrFail($request->route('environment'));
|
||||||
|
|
||||||
|
return inertia('build-artifacts/Index', [
|
||||||
|
'application' => $application,
|
||||||
|
'environment' => $environment,
|
||||||
|
'artifacts' => $environment->buildArtifacts()
|
||||||
|
->with(['builtByOperation', 'builtByService'])
|
||||||
|
->latest()
|
||||||
|
->paginate(30),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request): Response
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
$environment = $application->environments()->findOrFail($request->route('environment'));
|
||||||
|
/** @var BuildArtifact $artifact */
|
||||||
|
$artifact = $environment->buildArtifacts()
|
||||||
|
->with(['builtByOperation.steps', 'builtByService'])
|
||||||
|
->findOrFail($request->route('artifact'));
|
||||||
|
|
||||||
|
return inertia('build-artifacts/Show', [
|
||||||
|
'application' => $application,
|
||||||
|
'environment' => $environment,
|
||||||
|
'artifact' => $artifact,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ use App\Actions\Environments\AttachManagedService;
|
|||||||
use App\Enums\EnvironmentAttachmentRole;
|
use App\Enums\EnvironmentAttachmentRole;
|
||||||
use App\Enums\ServiceType;
|
use App\Enums\ServiceType;
|
||||||
use App\Http\Requests\StoreEnvironmentAttachmentRequest;
|
use App\Http\Requests\StoreEnvironmentAttachmentRequest;
|
||||||
|
use App\Http\Requests\UpdateEnvironmentAttachmentRequest;
|
||||||
use App\Models\Organisation;
|
use App\Models\Organisation;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -27,6 +28,12 @@ class EnvironmentAttachmentController extends Controller
|
|||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->get(['id', 'name', 'type', 'category']),
|
->get(['id', 'name', 'type', 'category']),
|
||||||
'roles' => array_values(EnvironmentAttachmentRole::toArray()),
|
'roles' => array_values(EnvironmentAttachmentRole::toArray()),
|
||||||
|
'compatibility' => [
|
||||||
|
EnvironmentAttachmentRole::DATABASE->value => [ServiceType::POSTGRES->value],
|
||||||
|
EnvironmentAttachmentRole::CACHE->value => [ServiceType::VALKEY->value],
|
||||||
|
EnvironmentAttachmentRole::QUEUE->value => [ServiceType::VALKEY->value],
|
||||||
|
EnvironmentAttachmentRole::GATEWAY->value => [ServiceType::CADDY->value],
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +44,7 @@ class EnvironmentAttachmentController extends Controller
|
|||||||
$environment = $application->environments()->findOrFail($request->route('environment'));
|
$environment = $application->environments()->findOrFail($request->route('environment'));
|
||||||
$service = $organisation->services()->findOrFail($request->integer('service_id'));
|
$service = $organisation->services()->findOrFail($request->integer('service_id'));
|
||||||
|
|
||||||
app(AttachManagedService::class)->execute(
|
$attachment = app(AttachManagedService::class)->execute(
|
||||||
environment: $environment,
|
environment: $environment,
|
||||||
service: $service,
|
service: $service,
|
||||||
role: $request->enum('role', EnvironmentAttachmentRole::class),
|
role: $request->enum('role', EnvironmentAttachmentRole::class),
|
||||||
@@ -46,6 +53,17 @@ class EnvironmentAttachmentController extends Controller
|
|||||||
isPrimary: $request->boolean('is_primary', true),
|
isPrimary: $request->boolean('is_primary', true),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if ($request->enum('role', EnvironmentAttachmentRole::class) === EnvironmentAttachmentRole::GATEWAY && $attachment->serviceSlice) {
|
||||||
|
$attachment->serviceSlice->update([
|
||||||
|
'config' => [
|
||||||
|
...($attachment->serviceSlice->config ?? []),
|
||||||
|
'domain' => $request->filled('domain') ? $request->string('domain')->toString() : null,
|
||||||
|
'path_prefix' => $request->filled('path_prefix') ? $request->string('path_prefix')->toString() : '/',
|
||||||
|
'tls_enabled' => $request->boolean('tls_enabled', true),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
return redirect()
|
return redirect()
|
||||||
->route('environments.show', [
|
->route('environments.show', [
|
||||||
'organisation' => $organisation->id,
|
'organisation' => $organisation->id,
|
||||||
@@ -54,4 +72,73 @@ class EnvironmentAttachmentController extends Controller
|
|||||||
])
|
])
|
||||||
->with('success', 'Managed service attached.');
|
->with('success', 'Managed service attached.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function edit(Request $request): Response
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
$environment = $application->environments()->findOrFail($request->route('environment'));
|
||||||
|
$attachment = $environment->attachments()
|
||||||
|
->with(['service', 'serviceSlice'])
|
||||||
|
->findOrFail($request->route('attachment'));
|
||||||
|
|
||||||
|
return inertia('environment-attachments/Edit', [
|
||||||
|
'application' => $application,
|
||||||
|
'environment' => $environment,
|
||||||
|
'attachment' => $attachment,
|
||||||
|
'roles' => array_values(EnvironmentAttachmentRole::toArray()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdateEnvironmentAttachmentRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
$environment = $application->environments()->findOrFail($request->route('environment'));
|
||||||
|
$attachment = $environment->attachments()->findOrFail($request->route('attachment'));
|
||||||
|
|
||||||
|
$attachment->update([
|
||||||
|
'role' => $request->enum('role', EnvironmentAttachmentRole::class),
|
||||||
|
'env_prefix' => $request->filled('env_prefix') ? $request->string('env_prefix')->toString() : null,
|
||||||
|
'is_primary' => $request->boolean('is_primary'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($attachment->serviceSlice && $request->enum('role', EnvironmentAttachmentRole::class) === EnvironmentAttachmentRole::GATEWAY) {
|
||||||
|
$attachment->serviceSlice->update([
|
||||||
|
'config' => [
|
||||||
|
...($attachment->serviceSlice->config ?? []),
|
||||||
|
'domain' => $request->filled('domain') ? $request->string('domain')->toString() : null,
|
||||||
|
'path_prefix' => $request->filled('path_prefix') ? $request->string('path_prefix')->toString() : '/',
|
||||||
|
'tls_enabled' => $request->boolean('tls_enabled', true),
|
||||||
|
'certificate_status' => $request->filled('certificate_status') ? $request->string('certificate_status')->toString() : null,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('environments.show', [
|
||||||
|
'organisation' => $organisation->id,
|
||||||
|
'application' => $application->id,
|
||||||
|
'environment' => $environment->id,
|
||||||
|
])
|
||||||
|
->with('success', 'Attachment updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
$environment = $application->environments()->findOrFail($request->route('environment'));
|
||||||
|
$attachment = $environment->attachments()->findOrFail($request->route('attachment'));
|
||||||
|
|
||||||
|
$attachment->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('environments.show', [
|
||||||
|
'organisation' => $organisation->id,
|
||||||
|
'application' => $application->id,
|
||||||
|
'environment' => $environment->id,
|
||||||
|
])
|
||||||
|
->with('success', 'Attachment detached.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,54 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Actions\Applications\CreateLaravelEnvironment;
|
||||||
|
use App\Enums\BuildStrategy;
|
||||||
|
use App\Enums\EnvironmentAttachmentRole;
|
||||||
|
use App\Enums\SchedulerMode;
|
||||||
|
use App\Enums\ServiceType;
|
||||||
|
use App\Http\Requests\StoreEnvironmentRequest;
|
||||||
|
use App\Http\Requests\UpdateEnvironmentRequest;
|
||||||
|
use App\Models\Environment;
|
||||||
use App\Models\Organisation;
|
use App\Models\Organisation;
|
||||||
|
use App\Support\CaddyRouteRenderer;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
|
||||||
class EnvironmentController extends Controller
|
class EnvironmentController extends Controller
|
||||||
{
|
{
|
||||||
|
public function create(Request $request): Response
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
|
||||||
|
return inertia('environments/Create', [
|
||||||
|
'application' => $application,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StoreEnvironmentRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
|
||||||
|
$environment = app(CreateLaravelEnvironment::class)->execute(
|
||||||
|
application: $application,
|
||||||
|
name: $request->string('name')->toString(),
|
||||||
|
branch: $request->string('branch')->toString(),
|
||||||
|
phpVersion: $request->string('php_version')->toString(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('environments.show', [
|
||||||
|
'organisation' => $organisation->id,
|
||||||
|
'application' => $application->id,
|
||||||
|
'environment' => $environment->id,
|
||||||
|
])
|
||||||
|
->with('success', 'Environment created.');
|
||||||
|
}
|
||||||
|
|
||||||
public function show(Request $request): Response
|
public function show(Request $request): Response
|
||||||
{
|
{
|
||||||
$organisation = Organisation::findOrFail($request->route('organisation'));
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
@@ -15,18 +57,151 @@ class EnvironmentController extends Controller
|
|||||||
$environment = $application->environments()
|
$environment = $application->environments()
|
||||||
->with([
|
->with([
|
||||||
'services.replicas',
|
'services.replicas',
|
||||||
|
'services.endpoints',
|
||||||
'services.slices',
|
'services.slices',
|
||||||
'services.operations.steps',
|
'services.operations.steps',
|
||||||
'attachments.service',
|
'attachments.service',
|
||||||
'attachments.serviceSlice',
|
'attachments.serviceSlice',
|
||||||
'variables',
|
'variables',
|
||||||
|
'buildArtifacts.builtByService',
|
||||||
'operations.steps',
|
'operations.steps',
|
||||||
|
'operations.children.target',
|
||||||
])
|
])
|
||||||
->findOrFail($request->route('environment'));
|
->findOrFail($request->route('environment'));
|
||||||
|
|
||||||
|
$serverCount = $this->serverIdsFor($environment)->count();
|
||||||
|
|
||||||
return inertia('environments/Show', [
|
return inertia('environments/Show', [
|
||||||
'application' => $application,
|
'application' => $application,
|
||||||
'environment' => $environment,
|
'environment' => $environment,
|
||||||
|
'deploymentRequirements' => [
|
||||||
|
'registryRequired' => $organisation->registries()->doesntExist() && $serverCount > 1,
|
||||||
|
'registryCount' => $organisation->registries()->count(),
|
||||||
|
'serverCount' => $serverCount,
|
||||||
|
],
|
||||||
|
'gatewayRoutePreviews' => $this->gatewayRoutePreviews($environment),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function edit(Request $request): Response
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
$environment = $application->environments()
|
||||||
|
->with('services')
|
||||||
|
->findOrFail($request->route('environment'));
|
||||||
|
|
||||||
|
return inertia('environments/Edit', [
|
||||||
|
'application' => $application,
|
||||||
|
'environment' => $environment,
|
||||||
|
'schedulerModes' => array_values(SchedulerMode::toArray()),
|
||||||
|
'buildStrategies' => array_values(BuildStrategy::toArray()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdateEnvironmentRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
$environment = $application->environments()
|
||||||
|
->with('services')
|
||||||
|
->findOrFail($request->route('environment'));
|
||||||
|
|
||||||
|
$schedulerTargetServiceId = $request->integer('scheduler_target_service_id') ?: null;
|
||||||
|
|
||||||
|
if ($schedulerTargetServiceId !== null) {
|
||||||
|
abort_unless($environment->services()->whereKey($schedulerTargetServiceId)->exists(), 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$environment->update([
|
||||||
|
'name' => $request->string('name')->toString(),
|
||||||
|
'branch' => $request->string('branch')->toString(),
|
||||||
|
'status' => $request->string('status')->toString(),
|
||||||
|
'scheduler_enabled' => $request->boolean('scheduler_enabled'),
|
||||||
|
'scheduler_target_service_id' => $schedulerTargetServiceId,
|
||||||
|
'scheduler_mode' => $request->enum('scheduler_mode', SchedulerMode::class),
|
||||||
|
'build_config' => [
|
||||||
|
...($environment->build_config ?? []),
|
||||||
|
'build_strategy' => $request->enum('build_strategy', BuildStrategy::class)?->value,
|
||||||
|
'php_version' => $request->string('php_version')->toString(),
|
||||||
|
'document_root' => $request->string('document_root')->toString(),
|
||||||
|
'health_path' => $request->string('health_path')->toString(),
|
||||||
|
'js_package_manager' => $request->string('js_package_manager')->toString(),
|
||||||
|
'js_build_command' => $request->filled('js_build_command')
|
||||||
|
? $request->string('js_build_command')->toString()
|
||||||
|
: null,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('environments.show', [
|
||||||
|
'organisation' => $organisation->id,
|
||||||
|
'application' => $application->id,
|
||||||
|
'environment' => $environment->id,
|
||||||
|
])
|
||||||
|
->with('success', 'Environment updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
$environment = $application->environments()->findOrFail($request->route('environment'));
|
||||||
|
|
||||||
|
$environment->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('applications.show', [
|
||||||
|
'organisation' => $organisation->id,
|
||||||
|
'application' => $application->id,
|
||||||
|
])
|
||||||
|
->with('success', 'Environment deleted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return \Illuminate\Support\Collection<int, int>
|
||||||
|
*/
|
||||||
|
private function serverIdsFor(Environment $environment): Collection
|
||||||
|
{
|
||||||
|
return $environment->services
|
||||||
|
->flatMap(fn ($service) => [
|
||||||
|
$service->server_id,
|
||||||
|
...$service->replicas->pluck('server_id')->all(),
|
||||||
|
])
|
||||||
|
->filter()
|
||||||
|
->unique()
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, array{attachment_id: int, caddyfile: string}>
|
||||||
|
*/
|
||||||
|
private function gatewayRoutePreviews(Environment $environment): Collection
|
||||||
|
{
|
||||||
|
$upstreams = $this->previewGatewayUpstreams($environment);
|
||||||
|
$renderer = app(CaddyRouteRenderer::class);
|
||||||
|
|
||||||
|
return $environment->attachments
|
||||||
|
->filter(fn ($attachment): bool => $attachment->role === EnvironmentAttachmentRole::GATEWAY)
|
||||||
|
->map(fn ($attachment): array => [
|
||||||
|
'attachment_id' => $attachment->id,
|
||||||
|
'caddyfile' => $renderer->render($attachment, $upstreams),
|
||||||
|
])
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function previewGatewayUpstreams(Environment $environment): array
|
||||||
|
{
|
||||||
|
return $environment->services
|
||||||
|
->filter(fn ($service): bool => $service->type === ServiceType::LARAVEL && in_array('web', $service->process_roles ?? [], true))
|
||||||
|
->flatMap(fn ($service) => $service->replicas->map(
|
||||||
|
fn ($replica): string => ($replica->internal_host ?: $replica->container_name).':'.($replica->internal_port ?: 80)
|
||||||
|
))
|
||||||
|
->values()
|
||||||
|
->whenEmpty(fn ($upstreams) => $upstreams->push('web:80'))
|
||||||
|
->all();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,22 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Requests\StoreEnvironmentDeploymentRequest;
|
||||||
use App\Jobs\Environments\DeployEnvironment;
|
use App\Jobs\Environments\DeployEnvironment;
|
||||||
use App\Models\Application;
|
use App\Models\Application;
|
||||||
use App\Models\Environment;
|
use App\Models\Environment;
|
||||||
use App\Models\Organisation;
|
use App\Models\Organisation;
|
||||||
|
use App\Services\Registries\RegistryResolver;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
class EnvironmentDeploymentController extends Controller
|
class EnvironmentDeploymentController extends Controller
|
||||||
{
|
{
|
||||||
public function store(Organisation $organisation, Application $application, Environment $environment): RedirectResponse
|
public function __construct(
|
||||||
|
private readonly RegistryResolver $registryResolver,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function store(StoreEnvironmentDeploymentRequest $request, Organisation $organisation, Application $application, Environment $environment): RedirectResponse
|
||||||
{
|
{
|
||||||
abort_unless(
|
abort_unless(
|
||||||
(int) $application->organisation_id === (int) $organisation->id
|
(int) $application->organisation_id === (int) $organisation->id
|
||||||
@@ -18,7 +25,16 @@ class EnvironmentDeploymentController extends Controller
|
|||||||
404,
|
404,
|
||||||
);
|
);
|
||||||
|
|
||||||
dispatch(new DeployEnvironment($environment));
|
$environment->loadMissing('services.replicas');
|
||||||
|
|
||||||
|
if (! $this->registryResolver->buildRegistryFor($organisation) && $this->serverIdsFor($environment)->count() > 1) {
|
||||||
|
return back()->with('error', 'Configure a registry before deploying this environment to multiple servers.');
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(new DeployEnvironment(
|
||||||
|
environment: $environment,
|
||||||
|
targetCommit: $request->validated('target_commit') ?: null,
|
||||||
|
));
|
||||||
|
|
||||||
return redirect()->route('environments.show', [
|
return redirect()->route('environments.show', [
|
||||||
'organisation' => $organisation->id,
|
'organisation' => $organisation->id,
|
||||||
@@ -26,4 +42,19 @@ class EnvironmentDeploymentController extends Controller
|
|||||||
'environment' => $environment->id,
|
'environment' => $environment->id,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return \Illuminate\Support\Collection<int, int>
|
||||||
|
*/
|
||||||
|
private function serverIdsFor(Environment $environment): Collection
|
||||||
|
{
|
||||||
|
return $environment->services
|
||||||
|
->flatMap(fn ($service) => [
|
||||||
|
$service->server_id,
|
||||||
|
...$service->replicas->pluck('server_id')->all(),
|
||||||
|
])
|
||||||
|
->filter()
|
||||||
|
->unique()
|
||||||
|
->values();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
24
app/Http/Controllers/EnvironmentIndexController.php
Normal file
24
app/Http/Controllers/EnvironmentIndexController.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Organisation;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class EnvironmentIndexController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(Organisation $organisation): Response
|
||||||
|
{
|
||||||
|
$applications = $organisation->applications()
|
||||||
|
->with([
|
||||||
|
'environments' => fn ($query) => $query
|
||||||
|
->withCount(['services', 'attachments', 'variables', 'buildArtifacts'])
|
||||||
|
->latest(),
|
||||||
|
])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return inertia('environments/Index', [
|
||||||
|
'applications' => $applications,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,9 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Enums\EnvironmentVariableSource;
|
use App\Enums\EnvironmentVariableSource;
|
||||||
|
use App\Http\Requests\ImportEnvironmentVariablesRequest;
|
||||||
use App\Http\Requests\StoreEnvironmentVariableRequest;
|
use App\Http\Requests\StoreEnvironmentVariableRequest;
|
||||||
|
use App\Http\Requests\UpdateEnvironmentVariableRequest;
|
||||||
use App\Models\Organisation;
|
use App\Models\Organisation;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -11,6 +13,22 @@ use Inertia\Response;
|
|||||||
|
|
||||||
class EnvironmentVariableController extends Controller
|
class EnvironmentVariableController extends Controller
|
||||||
{
|
{
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
$environment = $application->environments()->findOrFail($request->route('environment'));
|
||||||
|
|
||||||
|
return inertia('environment-variables/Index', [
|
||||||
|
'application' => $application,
|
||||||
|
'environment' => $environment,
|
||||||
|
'variables' => $environment->variables()
|
||||||
|
->with('serviceSlice')
|
||||||
|
->orderBy('key')
|
||||||
|
->get(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function create(Request $request): Response
|
public function create(Request $request): Response
|
||||||
{
|
{
|
||||||
$organisation = Organisation::findOrFail($request->route('organisation'));
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
@@ -35,11 +53,136 @@ class EnvironmentVariableController extends Controller
|
|||||||
'value' => $request->string('value')->toString(),
|
'value' => $request->string('value')->toString(),
|
||||||
'source' => EnvironmentVariableSource::USER,
|
'source' => EnvironmentVariableSource::USER,
|
||||||
'service_slice_id' => null,
|
'service_slice_id' => null,
|
||||||
'overridable' => true,
|
'overridable' => $request->boolean('overridable', true),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return redirect()
|
return redirect()
|
||||||
->route('applications.show', ['organisation' => $organisation->id, 'application' => $application->id])
|
->route('environments.show', [
|
||||||
|
'organisation' => $organisation->id,
|
||||||
|
'application' => $application->id,
|
||||||
|
'environment' => $environment->id,
|
||||||
|
])
|
||||||
->with('success', 'Environment variable saved.');
|
->with('success', 'Environment variable saved.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function import(ImportEnvironmentVariablesRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
$environment = $application->environments()->findOrFail($request->route('environment'));
|
||||||
|
$count = 0;
|
||||||
|
|
||||||
|
foreach ($this->parseDotEnv($request->string('contents')->toString()) as $key => $value) {
|
||||||
|
$environment->variables()->updateOrCreate([
|
||||||
|
'key' => $key,
|
||||||
|
], [
|
||||||
|
'value' => $value,
|
||||||
|
'source' => EnvironmentVariableSource::USER,
|
||||||
|
'service_slice_id' => null,
|
||||||
|
'overridable' => $request->boolean('overridable', true),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('environment-variables.index', [
|
||||||
|
'organisation' => $organisation->id,
|
||||||
|
'application' => $application->id,
|
||||||
|
'environment' => $environment->id,
|
||||||
|
])
|
||||||
|
->with('success', "{$count} environment variables imported.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit(Request $request): Response
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
$environment = $application->environments()->findOrFail($request->route('environment'));
|
||||||
|
$variable = $environment->variables()->with('serviceSlice')->findOrFail($request->route('variable'));
|
||||||
|
|
||||||
|
return inertia('environment-variables/Edit', [
|
||||||
|
'application' => $application,
|
||||||
|
'environment' => $environment,
|
||||||
|
'variable' => $variable,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdateEnvironmentVariableRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
$environment = $application->environments()->findOrFail($request->route('environment'));
|
||||||
|
$variable = $environment->variables()->findOrFail($request->route('variable'));
|
||||||
|
|
||||||
|
$variable->update([
|
||||||
|
'key' => $request->string('key')->toString(),
|
||||||
|
'value' => $request->string('value')->toString(),
|
||||||
|
'overridable' => $request->boolean('overridable'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('environment-variables.index', [
|
||||||
|
'organisation' => $organisation->id,
|
||||||
|
'application' => $application->id,
|
||||||
|
'environment' => $environment->id,
|
||||||
|
])
|
||||||
|
->with('success', 'Environment variable updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
$environment = $application->environments()->findOrFail($request->route('environment'));
|
||||||
|
$variable = $environment->variables()->findOrFail($request->route('variable'));
|
||||||
|
|
||||||
|
$variable->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('environment-variables.index', [
|
||||||
|
'organisation' => $organisation->id,
|
||||||
|
'application' => $application->id,
|
||||||
|
'environment' => $environment->id,
|
||||||
|
])
|
||||||
|
->with('success', 'Environment variable deleted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function parseDotEnv(string $contents): array
|
||||||
|
{
|
||||||
|
return collect(preg_split('/\R/', $contents) ?: [])
|
||||||
|
->map(fn (string $line): string => trim($line))
|
||||||
|
->reject(fn (string $line): bool => $line === '' || str_starts_with($line, '#'))
|
||||||
|
->mapWithKeys(function (string $line): array {
|
||||||
|
if (str_starts_with($line, 'export ')) {
|
||||||
|
$line = trim(substr($line, 7));
|
||||||
|
}
|
||||||
|
|
||||||
|
[$key, $value] = array_pad(explode('=', $line, 2), 2, '');
|
||||||
|
$key = trim($key);
|
||||||
|
|
||||||
|
if (! preg_match('/^[A-Z][A-Z0-9_]*$/', $key)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$key => $this->unquoteDotEnvValue(trim($value))];
|
||||||
|
})
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function unquoteDotEnvValue(string $value): string
|
||||||
|
{
|
||||||
|
if (str_starts_with($value, '"') && str_ends_with($value, '"')) {
|
||||||
|
return stripcslashes(substr($value, 1, -1));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($value, "'") && str_ends_with($value, "'")) {
|
||||||
|
return substr($value, 1, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
162
app/Http/Controllers/GatewayRouteController.php
Normal file
162
app/Http/Controllers/GatewayRouteController.php
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Actions\Environments\AttachManagedService;
|
||||||
|
use App\Enums\EnvironmentAttachmentRole;
|
||||||
|
use App\Enums\ServiceType;
|
||||||
|
use App\Http\Requests\StoreGatewayRouteRequest;
|
||||||
|
use App\Http\Requests\UpdateGatewayRouteRequest;
|
||||||
|
use App\Models\EnvironmentAttachment;
|
||||||
|
use App\Models\Organisation;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class GatewayRouteController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
$environment = $application->environments()
|
||||||
|
->with(['attachments.service', 'attachments.serviceSlice'])
|
||||||
|
->findOrFail($request->route('environment'));
|
||||||
|
|
||||||
|
return inertia('gateway-routes/Index', [
|
||||||
|
'application' => $application,
|
||||||
|
'environment' => $environment,
|
||||||
|
'routes' => $environment->attachments
|
||||||
|
->filter(fn (EnvironmentAttachment $attachment): bool => $attachment->role === EnvironmentAttachmentRole::GATEWAY)
|
||||||
|
->values(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(Request $request): Response
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
$environment = $application->environments()->findOrFail($request->route('environment'));
|
||||||
|
|
||||||
|
return inertia('gateway-routes/Create', [
|
||||||
|
'application' => $application,
|
||||||
|
'environment' => $environment,
|
||||||
|
'services' => $organisation->services()
|
||||||
|
->where('type', ServiceType::CADDY->value)
|
||||||
|
->orderBy('name')
|
||||||
|
->get(['id', 'name', 'type', 'category']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StoreGatewayRouteRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
$environment = $application->environments()->findOrFail($request->route('environment'));
|
||||||
|
$service = $organisation->services()
|
||||||
|
->where('type', ServiceType::CADDY->value)
|
||||||
|
->findOrFail($request->integer('service_id'));
|
||||||
|
|
||||||
|
$attachment = app(AttachManagedService::class)->execute(
|
||||||
|
environment: $environment,
|
||||||
|
service: $service,
|
||||||
|
role: EnvironmentAttachmentRole::GATEWAY,
|
||||||
|
name: $request->string('name')->toString(),
|
||||||
|
isPrimary: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
$attachment->serviceSlice?->update([
|
||||||
|
'config' => [
|
||||||
|
...($attachment->serviceSlice->config ?? []),
|
||||||
|
...$this->routeConfig($request),
|
||||||
|
'certificate_status' => $request->boolean('tls_enabled', true) ? 'pending' : 'disabled',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('gateway.routes.index', [
|
||||||
|
'organisation' => $organisation->id,
|
||||||
|
'application' => $application->id,
|
||||||
|
'environment' => $environment->id,
|
||||||
|
])
|
||||||
|
->with('success', 'Gateway route created.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit(Request $request): Response
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
$environment = $application->environments()->findOrFail($request->route('environment'));
|
||||||
|
$route = $environment->attachments()
|
||||||
|
->with(['service', 'serviceSlice'])
|
||||||
|
->where('role', EnvironmentAttachmentRole::GATEWAY->value)
|
||||||
|
->findOrFail($request->route('route'));
|
||||||
|
|
||||||
|
return inertia('gateway-routes/Edit', [
|
||||||
|
'application' => $application,
|
||||||
|
'environment' => $environment,
|
||||||
|
'routeAttachment' => $route,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdateGatewayRouteRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
$environment = $application->environments()->findOrFail($request->route('environment'));
|
||||||
|
$route = $environment->attachments()
|
||||||
|
->with('serviceSlice')
|
||||||
|
->where('role', EnvironmentAttachmentRole::GATEWAY->value)
|
||||||
|
->findOrFail($request->route('route'));
|
||||||
|
|
||||||
|
$route->serviceSlice?->update([
|
||||||
|
'config' => [
|
||||||
|
...($route->serviceSlice->config ?? []),
|
||||||
|
...$this->routeConfig($request),
|
||||||
|
'certificate_status' => $request->filled('certificate_status')
|
||||||
|
? $request->string('certificate_status')->toString()
|
||||||
|
: ($request->boolean('tls_enabled', true) ? 'pending' : 'disabled'),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('gateway.routes.index', [
|
||||||
|
'organisation' => $organisation->id,
|
||||||
|
'application' => $application->id,
|
||||||
|
'environment' => $environment->id,
|
||||||
|
])
|
||||||
|
->with('success', 'Gateway route updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
$environment = $application->environments()->findOrFail($request->route('environment'));
|
||||||
|
$route = $environment->attachments()
|
||||||
|
->where('role', EnvironmentAttachmentRole::GATEWAY->value)
|
||||||
|
->findOrFail($request->route('route'));
|
||||||
|
|
||||||
|
$route->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('gateway.routes.index', [
|
||||||
|
'organisation' => $organisation->id,
|
||||||
|
'application' => $application->id,
|
||||||
|
'environment' => $environment->id,
|
||||||
|
])
|
||||||
|
->with('success', 'Gateway route removed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{domain: string, path_prefix: string, tls_enabled: bool}
|
||||||
|
*/
|
||||||
|
private function routeConfig(StoreGatewayRouteRequest|UpdateGatewayRouteRequest $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'domain' => $request->string('domain')->toString(),
|
||||||
|
'path_prefix' => $request->string('path_prefix')->toString(),
|
||||||
|
'tls_enabled' => $request->boolean('tls_enabled', true),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,10 @@ class OnboardingController extends Controller
|
|||||||
{
|
{
|
||||||
$organisation->loadCount(['providers', 'sourceProviders', 'registries', 'servers', 'applications']);
|
$organisation->loadCount(['providers', 'sourceProviders', 'registries', 'servers', 'applications']);
|
||||||
|
|
||||||
|
$applicationNeedingDeployKey = $organisation->applications()
|
||||||
|
->whereNull('deploy_key_installed_at')
|
||||||
|
->first();
|
||||||
|
|
||||||
$steps = [
|
$steps = [
|
||||||
[
|
[
|
||||||
'key' => 'organisation',
|
'key' => 'organisation',
|
||||||
@@ -48,6 +52,17 @@ class OnboardingController extends Controller
|
|||||||
'complete' => $organisation->applications_count > 0,
|
'complete' => $organisation->applications_count > 0,
|
||||||
'href' => route('applications.create', ['organisation' => $organisation->id]),
|
'href' => route('applications.create', ['organisation' => $organisation->id]),
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'key' => 'deploy-key',
|
||||||
|
'label' => 'Deploy key',
|
||||||
|
'complete' => $organisation->applications_count === 0 || $applicationNeedingDeployKey === null,
|
||||||
|
'href' => $applicationNeedingDeployKey
|
||||||
|
? route('applications.show', [
|
||||||
|
'organisation' => $organisation->id,
|
||||||
|
'application' => $applicationNeedingDeployKey->id,
|
||||||
|
])
|
||||||
|
: route('applications.index', ['organisation' => $organisation->id]),
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
$next = collect($steps)->firstWhere('complete', false) ?? $steps[array_key_last($steps)];
|
$next = collect($steps)->firstWhere('complete', false) ?? $steps[array_key_last($steps)];
|
||||||
|
|||||||
181
app/Http/Controllers/OperationController.php
Normal file
181
app/Http/Controllers/OperationController.php
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Enums\OperationKind;
|
||||||
|
use App\Enums\OperationStatus;
|
||||||
|
use App\Models\Application;
|
||||||
|
use App\Models\Environment;
|
||||||
|
use App\Models\Operation;
|
||||||
|
use App\Models\Organisation;
|
||||||
|
use App\Models\Server;
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Models\ServiceReplica;
|
||||||
|
use App\Models\ServiceSlice;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Response;
|
||||||
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
|
||||||
|
class OperationController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request, Organisation $organisation): Response
|
||||||
|
{
|
||||||
|
$applicationIds = $organisation->applications()->pluck('id');
|
||||||
|
$environmentIds = Environment::query()
|
||||||
|
->whereIn('application_id', $applicationIds)
|
||||||
|
->pluck('id');
|
||||||
|
$serverIds = $organisation->servers()->pluck('id');
|
||||||
|
$serviceIds = $organisation->services()->pluck('id');
|
||||||
|
$replicaIds = ServiceReplica::query()
|
||||||
|
->whereIn('service_id', $serviceIds)
|
||||||
|
->pluck('id');
|
||||||
|
$sliceIds = ServiceSlice::query()
|
||||||
|
->whereIn('service_id', $serviceIds)
|
||||||
|
->pluck('id');
|
||||||
|
|
||||||
|
$operations = Operation::query()
|
||||||
|
->with(['target', 'parent', 'children.target'])
|
||||||
|
->withCount('steps', 'children')
|
||||||
|
->where(function (Builder $query) use ($applicationIds, $environmentIds, $serverIds, $serviceIds, $replicaIds, $sliceIds): void {
|
||||||
|
$query
|
||||||
|
->where(function (Builder $query) use ($applicationIds): void {
|
||||||
|
$query->where('target_type', (new Application)->getMorphClass())
|
||||||
|
->whereIn('target_id', $applicationIds);
|
||||||
|
})
|
||||||
|
->orWhere(function (Builder $query) use ($environmentIds): void {
|
||||||
|
$query->where('target_type', (new Environment)->getMorphClass())
|
||||||
|
->whereIn('target_id', $environmentIds);
|
||||||
|
})
|
||||||
|
->orWhere(function (Builder $query) use ($serverIds): void {
|
||||||
|
$query->where('target_type', (new Server)->getMorphClass())
|
||||||
|
->whereIn('target_id', $serverIds);
|
||||||
|
})
|
||||||
|
->orWhere(function (Builder $query) use ($serviceIds): void {
|
||||||
|
$query->where('target_type', (new Service)->getMorphClass())
|
||||||
|
->whereIn('target_id', $serviceIds);
|
||||||
|
})
|
||||||
|
->orWhere(function (Builder $query) use ($replicaIds): void {
|
||||||
|
$query->where('target_type', (new ServiceReplica)->getMorphClass())
|
||||||
|
->whereIn('target_id', $replicaIds);
|
||||||
|
})
|
||||||
|
->orWhere(function (Builder $query) use ($sliceIds): void {
|
||||||
|
$query->where('target_type', (new ServiceSlice)->getMorphClass())
|
||||||
|
->whereIn('target_id', $sliceIds);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->when($request->filled('kind'), fn (Builder $query) => $query->where('kind', $request->string('kind')->toString()))
|
||||||
|
->when($request->filled('status'), fn (Builder $query) => $query->where('status', $request->string('status')->toString()))
|
||||||
|
->latest()
|
||||||
|
->paginate(30)
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return inertia('operations/Index', [
|
||||||
|
'operations' => $operations,
|
||||||
|
'filters' => $request->only(['kind', 'status']),
|
||||||
|
'operationKinds' => OperationKind::toArray(),
|
||||||
|
'operationStatuses' => OperationStatus::toArray(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Organisation $organisation, Operation $operation): Response
|
||||||
|
{
|
||||||
|
abort_unless($this->operationBelongsToOrganisation($operation, $organisation), 404);
|
||||||
|
|
||||||
|
$operation->load([
|
||||||
|
'target',
|
||||||
|
'parent.target',
|
||||||
|
'children.target',
|
||||||
|
'children.steps',
|
||||||
|
'steps',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return inertia('operations/Show', [
|
||||||
|
'operation' => $operation,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function retry(Organisation $organisation, Operation $operation): RedirectResponse
|
||||||
|
{
|
||||||
|
abort_unless($this->operationBelongsToOrganisation($operation, $organisation), 404);
|
||||||
|
|
||||||
|
$operation->update([
|
||||||
|
'status' => OperationStatus::PENDING,
|
||||||
|
'started_at' => null,
|
||||||
|
'finished_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('operations.show', [
|
||||||
|
'organisation' => $organisation->id,
|
||||||
|
'operation' => $operation->id,
|
||||||
|
])
|
||||||
|
->with('success', 'Operation queued to run again.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancel(Organisation $organisation, Operation $operation): RedirectResponse
|
||||||
|
{
|
||||||
|
abort_unless($this->operationBelongsToOrganisation($operation, $organisation), 404);
|
||||||
|
|
||||||
|
$operation->update([
|
||||||
|
'status' => OperationStatus::CANCELLED,
|
||||||
|
'finished_at' => now(),
|
||||||
|
]);
|
||||||
|
$this->clearOperationSecrets($operation);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('operations.show', [
|
||||||
|
'organisation' => $organisation->id,
|
||||||
|
'operation' => $operation->id,
|
||||||
|
])
|
||||||
|
->with('success', 'Operation cancelled.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function downloadLogs(Organisation $organisation, Operation $operation): StreamedResponse
|
||||||
|
{
|
||||||
|
abort_unless($this->operationBelongsToOrganisation($operation, $organisation), 404);
|
||||||
|
|
||||||
|
$operation->load('steps');
|
||||||
|
|
||||||
|
return response()->streamDownload(function () use ($operation): void {
|
||||||
|
foreach ($operation->steps as $step) {
|
||||||
|
echo "# {$step->name}\n\n";
|
||||||
|
|
||||||
|
if ($step->logs) {
|
||||||
|
echo "## Logs\n{$step->logs}\n\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($step->error_logs) {
|
||||||
|
echo "## Error Logs\n{$step->error_logs}\n\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, "operation-{$operation->hash}.log", [
|
||||||
|
'Content-Type' => 'text/plain',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function operationBelongsToOrganisation(Operation $operation, Organisation $organisation): bool
|
||||||
|
{
|
||||||
|
$target = $operation->target;
|
||||||
|
|
||||||
|
return match (true) {
|
||||||
|
$target instanceof Application => $target->organisation_id === $organisation->id,
|
||||||
|
$target instanceof Environment => $target->application()->where('organisation_id', $organisation->id)->exists(),
|
||||||
|
$target instanceof Server => $target->organisation_id === $organisation->id,
|
||||||
|
$target instanceof Service => $target->organisation_id === $organisation->id,
|
||||||
|
$target instanceof ServiceReplica => $target->service()->where('organisation_id', $organisation->id)->exists(),
|
||||||
|
$target instanceof ServiceSlice => $target->service()->where('organisation_id', $organisation->id)->exists(),
|
||||||
|
default => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function clearOperationSecrets(Operation $operation): void
|
||||||
|
{
|
||||||
|
$operation->steps()->update(['secrets' => null]);
|
||||||
|
|
||||||
|
$operation->children()->get()->each(function (Operation $child): void {
|
||||||
|
$this->clearOperationSecrets($child);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,11 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Enums\ServiceStatus;
|
||||||
|
use App\Models\Operation;
|
||||||
use App\Models\Organisation;
|
use App\Models\Organisation;
|
||||||
use App\Models\Provider;
|
use App\Models\Provider;
|
||||||
|
use App\Models\Service;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
|
|
||||||
@@ -15,7 +18,23 @@ class OrganisationController extends Controller
|
|||||||
'providers' => Inertia::lazy(fn () => Provider::whereOrganisationId($request->route('organisation'))->get()),
|
'providers' => Inertia::lazy(fn () => Provider::whereOrganisationId($request->route('organisation'))->get()),
|
||||||
'registries' => Inertia::lazy(fn () => Organisation::findOrFail($request->route('organisation'))->registries()->get()),
|
'registries' => Inertia::lazy(fn () => Organisation::findOrFail($request->route('organisation'))->registries()->get()),
|
||||||
'sourceProviders' => Inertia::lazy(fn () => Organisation::findOrFail($request->route('organisation'))->sourceProviders()->get()),
|
'sourceProviders' => Inertia::lazy(fn () => Organisation::findOrFail($request->route('organisation'))->sourceProviders()->get()),
|
||||||
'organisation' => Organisation::withCount('servers', 'applications', 'members')->findOrFail($request->route('organisation')),
|
'organisation' => Organisation::with('members')
|
||||||
|
->withCount('servers', 'applications', 'members', 'providers', 'sourceProviders', 'registries')
|
||||||
|
->findOrFail($request->route('organisation')),
|
||||||
|
'health' => [
|
||||||
|
'unhealthy_services' => Service::query()
|
||||||
|
->where('organisation_id', $request->route('organisation'))
|
||||||
|
->whereNot('status', ServiceStatus::RUNNING)
|
||||||
|
->count(),
|
||||||
|
'failed_operations' => Operation::query()
|
||||||
|
->whereHasMorph('target', [Service::class], fn ($query) => $query->where('organisation_id', $request->route('organisation')))
|
||||||
|
->where('status', 'failed')
|
||||||
|
->count(),
|
||||||
|
'locked_variables' => Organisation::findOrFail($request->route('organisation'))
|
||||||
|
->applications()
|
||||||
|
->whereHas('environments.variables', fn ($query) => $query->where('overridable', false))
|
||||||
|
->count(),
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
120
app/Http/Controllers/OrganisationMemberController.php
Normal file
120
app/Http/Controllers/OrganisationMemberController.php
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Enums\OrganisationRole;
|
||||||
|
use App\Http\Requests\StoreOrganisationMemberRequest;
|
||||||
|
use App\Http\Requests\UpdateOrganisationInvitationRequest;
|
||||||
|
use App\Http\Requests\UpdateOrganisationMemberRequest;
|
||||||
|
use App\Models\Organisation;
|
||||||
|
use App\Models\OrganisationInvitation;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class OrganisationMemberController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Organisation $organisation): Response
|
||||||
|
{
|
||||||
|
return inertia('organisation-members/Index', [
|
||||||
|
'organisation' => $organisation->load(['members', 'invitations.invitedBy']),
|
||||||
|
'roles' => array_values(OrganisationRole::toArray()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StoreOrganisationMemberRequest $request, Organisation $organisation): RedirectResponse
|
||||||
|
{
|
||||||
|
$email = Str::lower($request->string('email')->toString());
|
||||||
|
$user = User::query()
|
||||||
|
->where('email', $email)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($user === null) {
|
||||||
|
abort_if(
|
||||||
|
$organisation->invitations()->where('email', $email)->whereNull('accepted_at')->exists(),
|
||||||
|
422,
|
||||||
|
'This email already has a pending invitation.'
|
||||||
|
);
|
||||||
|
|
||||||
|
$organisation->invitations()->create([
|
||||||
|
'email' => $email,
|
||||||
|
'role' => $request->enum('role', OrganisationRole::class),
|
||||||
|
'token' => Str::random(40),
|
||||||
|
'invited_by_user_id' => $request->user()?->id,
|
||||||
|
'expires_at' => now()->addDays(14),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('organisation-members.index', ['organisation' => $organisation->id])
|
||||||
|
->with('success', 'Invitation created.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$organisation->members()->syncWithoutDetaching([
|
||||||
|
$user->id => ['role' => $request->enum('role', OrganisationRole::class)],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$organisation->invitations()
|
||||||
|
->where('email', $email)
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('organisation-members.index', ['organisation' => $organisation->id])
|
||||||
|
->with('success', 'Member added.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdateOrganisationMemberRequest $request, Organisation $organisation, User $member): RedirectResponse
|
||||||
|
{
|
||||||
|
abort_unless($organisation->members()->whereKey($member->id)->exists(), 404);
|
||||||
|
|
||||||
|
$organisation->members()->updateExistingPivot($member->id, [
|
||||||
|
'role' => $request->enum('role', OrganisationRole::class),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('organisation-members.index', ['organisation' => $organisation->id])
|
||||||
|
->with('success', 'Member role updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateInvitation(
|
||||||
|
UpdateOrganisationInvitationRequest $request,
|
||||||
|
Organisation $organisation,
|
||||||
|
OrganisationInvitation $invitation
|
||||||
|
): RedirectResponse {
|
||||||
|
abort_unless($invitation->organisation_id === $organisation->id, 404);
|
||||||
|
|
||||||
|
$invitation->update([
|
||||||
|
'role' => $request->enum('role', OrganisationRole::class),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('organisation-members.index', ['organisation' => $organisation->id])
|
||||||
|
->with('success', 'Invitation role updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request, Organisation $organisation, User $member): RedirectResponse
|
||||||
|
{
|
||||||
|
abort_if($organisation->owner_id === $member->id, 422, 'The organisation owner cannot be removed.');
|
||||||
|
|
||||||
|
$organisation->members()->detach($member->id);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('organisation-members.index', ['organisation' => $organisation->id])
|
||||||
|
->with('success', 'Member removed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroyInvitation(
|
||||||
|
Request $request,
|
||||||
|
Organisation $organisation,
|
||||||
|
OrganisationInvitation $invitation
|
||||||
|
): RedirectResponse {
|
||||||
|
abort_unless($invitation->organisation_id === $organisation->id, 404);
|
||||||
|
|
||||||
|
$invitation->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('organisation-members.index', ['organisation' => $organisation->id])
|
||||||
|
->with('success', 'Invitation cancelled.');
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/Http/Controllers/ProviderController.php
Normal file
49
app/Http/Controllers/ProviderController.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Enums\ProviderType;
|
||||||
|
use App\Http\Requests\StoreProviderRequest;
|
||||||
|
use App\Models\Organisation;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class ProviderController extends Controller
|
||||||
|
{
|
||||||
|
public function create(Request $request): Response
|
||||||
|
{
|
||||||
|
Organisation::findOrFail($request->route('organisation'));
|
||||||
|
|
||||||
|
return inertia('providers/Create', [
|
||||||
|
'providerTypes' => array_values(ProviderType::toArray()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StoreProviderRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
|
||||||
|
$organisation->providers()->create([
|
||||||
|
'name' => $request->string('name')->toString(),
|
||||||
|
'type' => $request->enum('type', ProviderType::class),
|
||||||
|
'token' => $request->string('token')->toString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('organisations.show', ['organisation' => $organisation->id])
|
||||||
|
->with('success', 'Server provider created.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$provider = $organisation->providers()->findOrFail($request->route('provider'));
|
||||||
|
|
||||||
|
$provider->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('organisations.show', ['organisation' => $organisation->id])
|
||||||
|
->with('success', 'Server provider deleted.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,19 +4,33 @@ namespace App\Http\Controllers;
|
|||||||
|
|
||||||
use App\Enums\RegistryType;
|
use App\Enums\RegistryType;
|
||||||
use App\Http\Requests\StoreRegistryRequest;
|
use App\Http\Requests\StoreRegistryRequest;
|
||||||
|
use App\Http\Requests\UpdateRegistryRequest;
|
||||||
|
use App\Models\BuildArtifact;
|
||||||
use App\Models\Organisation;
|
use App\Models\Organisation;
|
||||||
|
use App\Models\Registry;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
|
||||||
class RegistryController extends Controller
|
class RegistryController extends Controller
|
||||||
{
|
{
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
|
||||||
|
return inertia('registries/Index', [
|
||||||
|
'registries' => $organisation->registries()
|
||||||
|
->latest()
|
||||||
|
->get(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function create(Request $request): Response
|
public function create(Request $request): Response
|
||||||
{
|
{
|
||||||
Organisation::findOrFail($request->route('organisation'));
|
Organisation::findOrFail($request->route('organisation'));
|
||||||
|
|
||||||
return inertia('registries/Create', [
|
return inertia('registries/Create', [
|
||||||
'registryTypes' => array_values(RegistryType::toArray()),
|
'registryTypes' => $this->userConfigurableRegistryTypes(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,4 +52,90 @@ class RegistryController extends Controller
|
|||||||
->route('organisations.show', ['organisation' => $organisation->id])
|
->route('organisations.show', ['organisation' => $organisation->id])
|
||||||
->with('success', 'Registry created.');
|
->with('success', 'Registry created.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function show(Request $request): Response
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
/** @var Registry $registry */
|
||||||
|
$registry = $organisation->registries()->findOrFail($request->route('registry'));
|
||||||
|
$registryUrl = rtrim((string) $registry->url, '/');
|
||||||
|
$artifacts = BuildArtifact::query()
|
||||||
|
->with(['environment.application', 'builtByService'])
|
||||||
|
->whereHas('environment.application', fn ($query) => $query->where('organisation_id', $organisation->id))
|
||||||
|
->when($registryUrl !== '', fn ($query) => $query->where('registry_ref', 'like', $registryUrl.'%'));
|
||||||
|
|
||||||
|
return inertia('registries/Show', [
|
||||||
|
'registry' => $registry,
|
||||||
|
'artifactCount' => (clone $artifacts)->count(),
|
||||||
|
'environmentCount' => (clone $artifacts)->distinct('environment_id')->count('environment_id'),
|
||||||
|
'artifacts' => $artifacts
|
||||||
|
->latest()
|
||||||
|
->paginate(20),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit(Request $request): Response
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$registry = $organisation->registries()->findOrFail($request->route('registry'));
|
||||||
|
|
||||||
|
return inertia('registries/Edit', [
|
||||||
|
'registry' => $registry,
|
||||||
|
'registryTypes' => $this->userConfigurableRegistryTypes(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdateRegistryRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
/** @var Registry $registry */
|
||||||
|
$registry = $organisation->registries()->findOrFail($request->route('registry'));
|
||||||
|
|
||||||
|
abort_if($registry->type === RegistryType::MANAGED, 403);
|
||||||
|
|
||||||
|
$credentials = $registry->credentials ?? [];
|
||||||
|
$username = $request->string('username')->toString();
|
||||||
|
|
||||||
|
if ($request->filled('password')) {
|
||||||
|
$credentials['password'] = $request->string('password')->toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
$credentials['username'] = $username;
|
||||||
|
|
||||||
|
$registry->update([
|
||||||
|
'name' => $request->string('name')->toString(),
|
||||||
|
'type' => $request->enum('type', RegistryType::class),
|
||||||
|
'url' => rtrim($request->string('url')->toString(), '/'),
|
||||||
|
'credentials' => $credentials,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('organisations.show', ['organisation' => $organisation->id])
|
||||||
|
->with('success', 'Registry updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$registry = $organisation->registries()->findOrFail($request->route('registry'));
|
||||||
|
|
||||||
|
abort_if($registry->type === RegistryType::MANAGED, 403);
|
||||||
|
|
||||||
|
$registry->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('organisations.show', ['organisation' => $organisation->id])
|
||||||
|
->with('success', 'Registry deleted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function userConfigurableRegistryTypes(): array
|
||||||
|
{
|
||||||
|
return collect(RegistryType::toArray())
|
||||||
|
->reject(fn (string $type) => $type === RegistryType::MANAGED->value)
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,26 +3,33 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Actions\GenerateRandomSlug;
|
use App\Actions\GenerateRandomSlug;
|
||||||
|
use App\Enums\OperationKind;
|
||||||
|
use App\Enums\OperationStatus;
|
||||||
use App\Enums\ServerStatus;
|
use App\Enums\ServerStatus;
|
||||||
use App\Jobs\Servers\WaitForServerToConnect;
|
use App\Jobs\Servers\WaitForServerToConnect;
|
||||||
use App\Models\Organisation;
|
use App\Models\Organisation;
|
||||||
use App\Models\Provider;
|
use App\Models\Provider;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
class ServerController extends Controller
|
class ServerController extends Controller
|
||||||
{
|
{
|
||||||
public function index(Request $request)
|
public function index(Request $request): Response
|
||||||
{
|
{
|
||||||
$organisation = Organisation::findOrFail($request->route('organisation'));
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
|
||||||
return inertia('servers/Index', [
|
return inertia('servers/Index', [
|
||||||
'servers' => $organisation->servers()->paginate(30),
|
'servers' => $organisation->servers()->paginate(30),
|
||||||
|
'networks' => $organisation->networks()
|
||||||
|
->with(['servers' => fn ($query) => $query->select('id', 'network_id', 'name', 'private_ip', 'status')])
|
||||||
|
->get(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create(Request $request)
|
public function create(Request $request): Response
|
||||||
{
|
{
|
||||||
$organisation = Organisation::findOrFail($request->route('organisation'));
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
|
||||||
@@ -55,7 +62,7 @@ class ServerController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store(Request $request)
|
public function store(Request $request): RedirectResponse
|
||||||
{
|
{
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'provider' => ['required', 'exists:providers,id'],
|
'provider' => ['required', 'exists:providers,id'],
|
||||||
@@ -135,13 +142,63 @@ class ServerController extends Controller
|
|||||||
return redirect()->route('servers.show', ['organisation' => $organisation->id, 'server' => $server->id]);
|
return redirect()->route('servers.show', ['organisation' => $organisation->id, 'server' => $server->id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function show(Request $request)
|
public function show(Request $request): Response
|
||||||
{
|
{
|
||||||
$organisation = Organisation::findOrFail($request->route('organisation'));
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
$server = $organisation->servers()->findOrFail($request->route('server'));
|
$server = $organisation->servers()->findOrFail($request->route('server'));
|
||||||
|
|
||||||
return inertia('servers/Show', [
|
return inertia('servers/Show', [
|
||||||
'server' => $server->load('services.slices', 'serviceOperations.steps', 'serviceOperations.target'),
|
'server' => $server->load(
|
||||||
|
'firewallRules',
|
||||||
|
'network',
|
||||||
|
'operations.steps',
|
||||||
|
'operations.children.target',
|
||||||
|
'services.slices',
|
||||||
|
'services.endpoints',
|
||||||
|
'serviceOperations.steps',
|
||||||
|
'serviceOperations.children.target',
|
||||||
|
'serviceOperations.target',
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$server = $organisation->servers()->findOrFail($request->route('server'));
|
||||||
|
|
||||||
|
$server->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('servers.index', ['organisation' => $organisation->id])
|
||||||
|
->with('success', 'Server deleted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function heal(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$server = $organisation->servers()->findOrFail($request->route('server'));
|
||||||
|
|
||||||
|
$operation = $server->operations()->create([
|
||||||
|
'kind' => OperationKind::SERVER_PROVISION,
|
||||||
|
'status' => OperationStatus::PENDING,
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
'Check server shell' => 'true',
|
||||||
|
'Check Docker' => 'docker --version && docker compose version',
|
||||||
|
'Check Keystone directories' => 'test -d /home/keystone && test -d /home/keystone/services',
|
||||||
|
] as $order => $script) {
|
||||||
|
$operation->steps()->create([
|
||||||
|
'name' => $order,
|
||||||
|
'order' => $operation->steps()->count() + 1,
|
||||||
|
'status' => OperationStatus::PENDING,
|
||||||
|
'script' => $script,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('servers.show', ['organisation' => $organisation->id, 'server' => $server->id])
|
||||||
|
->with('success', 'Server heal operation queued.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
41
app/Http/Controllers/ServerFirewallRuleController.php
Normal file
41
app/Http/Controllers/ServerFirewallRuleController.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Actions\FirewallRules\UninstallFirewallRule;
|
||||||
|
use App\Http\Requests\StoreServerFirewallRuleRequest;
|
||||||
|
use App\Models\FirewallRule;
|
||||||
|
use App\Models\Organisation;
|
||||||
|
use App\Models\Server;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class ServerFirewallRuleController extends Controller
|
||||||
|
{
|
||||||
|
public function store(StoreServerFirewallRuleRequest $request, Organisation $organisation, Server $server): RedirectResponse
|
||||||
|
{
|
||||||
|
abort_unless((int) $server->organisation_id === (int) $organisation->id, 404);
|
||||||
|
|
||||||
|
$server->firewallRules()->create($request->validated());
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('servers.show', [$organisation, $server])
|
||||||
|
->with('success', 'Firewall rule queued for installation.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request, Organisation $organisation, Server $server, FirewallRule $firewallRule, UninstallFirewallRule $uninstallFirewallRule): RedirectResponse
|
||||||
|
{
|
||||||
|
abort_unless(
|
||||||
|
(int) $server->organisation_id === (int) $organisation->id
|
||||||
|
&& (int) $firewallRule->server_id === (int) $server->id,
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
|
||||||
|
$uninstallFirewallRule->execute($firewallRule);
|
||||||
|
$firewallRule->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('servers.show', [$organisation, $server])
|
||||||
|
->with('success', 'Firewall rule removed.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,10 +3,12 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Actions\Services\CreateService;
|
use App\Actions\Services\CreateService;
|
||||||
|
use App\Enums\DeployPolicy;
|
||||||
use App\Enums\ServiceCategory;
|
use App\Enums\ServiceCategory;
|
||||||
use App\Enums\ServiceType;
|
use App\Enums\ServiceType;
|
||||||
use App\Http\Requests\StoreServiceRequest;
|
use App\Http\Requests\StoreServiceRequest;
|
||||||
use App\Http\Requests\UpdateServiceRequest;
|
use App\Http\Requests\UpdateServiceRequest;
|
||||||
|
use App\Models\Organisation;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -49,7 +51,7 @@ class ServiceController extends Controller
|
|||||||
{
|
{
|
||||||
$server = Server::findOrFail($request->route('server'));
|
$server = Server::findOrFail($request->route('server'));
|
||||||
$service = $server->services()
|
$service = $server->services()
|
||||||
->with(['replicas', 'slices', 'operations.steps', 'environment.application'])
|
->with(['replicas', 'slices', 'endpoints', 'operations.steps', 'operations.children.target', 'environment.application'])
|
||||||
->findOrFail($request->route('service'));
|
->findOrFail($request->route('service'));
|
||||||
|
|
||||||
return inertia('services/Show', [
|
return inertia('services/Show', [
|
||||||
@@ -58,6 +60,23 @@ class ServiceController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function showForEnvironment(Request $request): Response
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||||
|
$environment = $application->environments()->findOrFail($request->route('environment'));
|
||||||
|
$service = $environment->services()
|
||||||
|
->with(['server', 'replicas', 'slices', 'endpoints', 'operations.steps', 'operations.children.target', 'environment.application'])
|
||||||
|
->findOrFail($request->route('service'));
|
||||||
|
|
||||||
|
return inertia('services/Show', [
|
||||||
|
'server' => $service->server,
|
||||||
|
'service' => $service,
|
||||||
|
'environment' => $environment,
|
||||||
|
'application' => $application,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function edit(Request $request): Response
|
public function edit(Request $request): Response
|
||||||
{
|
{
|
||||||
$server = Server::findOrFail($request->route('server'));
|
$server = Server::findOrFail($request->route('server'));
|
||||||
@@ -66,6 +85,7 @@ class ServiceController extends Controller
|
|||||||
return inertia('services/Edit', [
|
return inertia('services/Edit', [
|
||||||
'server' => $server,
|
'server' => $server,
|
||||||
'service' => $service,
|
'service' => $service,
|
||||||
|
'deployPolicies' => array_values(DeployPolicy::toArray()),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +94,31 @@ class ServiceController extends Controller
|
|||||||
$server = Server::findOrFail($request->route('server'));
|
$server = Server::findOrFail($request->route('server'));
|
||||||
$service = $server->services()->findOrFail($request->route('service'));
|
$service = $server->services()->findOrFail($request->route('service'));
|
||||||
|
|
||||||
$service->update($request->validated());
|
$validated = $request->validated();
|
||||||
|
|
||||||
|
$service->update([
|
||||||
|
'name' => $validated['name'],
|
||||||
|
'desired_replicas' => $validated['desired_replicas'],
|
||||||
|
'default_cpu_limit' => $validated['default_cpu_limit'] ?? null,
|
||||||
|
'default_memory_limit_mb' => $validated['default_memory_limit_mb'] ?? null,
|
||||||
|
'deploy_policy' => $request->enum('deploy_policy', DeployPolicy::class) ?? $service->deploy_policy,
|
||||||
|
'version_track' => $validated['version_track'] ?? $service->version_track,
|
||||||
|
'available_image_digest' => $validated['available_image_digest'] ?? null,
|
||||||
|
'process_roles' => collect(explode(',', $validated['process_roles'] ?? ''))
|
||||||
|
->map(fn (string $role): string => trim($role))
|
||||||
|
->filter()
|
||||||
|
->values()
|
||||||
|
->all(),
|
||||||
|
'config' => [
|
||||||
|
...($service->config ?? []),
|
||||||
|
'migration_mode' => $validated['migration_mode'] ?? null,
|
||||||
|
'migration_timing' => $validated['migration_timing'] ?? null,
|
||||||
|
'migration_command' => $validated['migration_command'] ?? null,
|
||||||
|
'health_path' => $validated['health_path'] ?? null,
|
||||||
|
'backup_enabled' => $request->boolean('backup_enabled'),
|
||||||
|
'backup_command' => $validated['backup_command'] ?? null,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
return redirect()
|
return redirect()
|
||||||
->route('services.show', [
|
->route('services.show', [
|
||||||
@@ -84,4 +128,19 @@ class ServiceController extends Controller
|
|||||||
])
|
])
|
||||||
->with('success', 'Service updated.');
|
->with('success', 'Service updated.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$server = Server::findOrFail($request->route('server'));
|
||||||
|
$service = $server->services()->findOrFail($request->route('service'));
|
||||||
|
|
||||||
|
$service->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('servers.show', [
|
||||||
|
'organisation' => $server->organisation_id,
|
||||||
|
'server' => $server->id,
|
||||||
|
])
|
||||||
|
->with('success', 'Service deleted.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
102
app/Http/Controllers/ServiceReplicaController.php
Normal file
102
app/Http/Controllers/ServiceReplicaController.php
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Enums\OperationKind;
|
||||||
|
use App\Enums\OperationStatus;
|
||||||
|
use App\Models\Organisation;
|
||||||
|
use App\Models\ServiceReplica;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class ServiceReplicaController extends Controller
|
||||||
|
{
|
||||||
|
public function show(Request $request): Response
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$server = $organisation->servers()->findOrFail($request->route('server'));
|
||||||
|
$service = $server->services()->with('environment.application')->findOrFail($request->route('service'));
|
||||||
|
$replica = $service->replicas()
|
||||||
|
->with(['server', 'operation.steps', 'operations.steps', 'operations.children.target'])
|
||||||
|
->findOrFail($request->route('replica'));
|
||||||
|
|
||||||
|
return inertia('service-replicas/Show', [
|
||||||
|
'server' => $server,
|
||||||
|
'service' => $service,
|
||||||
|
'replica' => $replica,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restart(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$server = $organisation->servers()->findOrFail($request->route('server'));
|
||||||
|
$service = $server->services()->findOrFail($request->route('service'));
|
||||||
|
$replica = $service->replicas()->findOrFail($request->route('replica'));
|
||||||
|
|
||||||
|
$this->queueLifecycleOperation($replica, 'Restart replica', "docker restart {$replica->container_name}");
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('service-replicas.show', [
|
||||||
|
'organisation' => $organisation->id,
|
||||||
|
'server' => $server->id,
|
||||||
|
'service' => $service->id,
|
||||||
|
'replica' => $replica->id,
|
||||||
|
])
|
||||||
|
->with('success', 'Replica restart queued.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function start(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$server = $organisation->servers()->findOrFail($request->route('server'));
|
||||||
|
$service = $server->services()->findOrFail($request->route('service'));
|
||||||
|
$replica = $service->replicas()->findOrFail($request->route('replica'));
|
||||||
|
|
||||||
|
$this->queueLifecycleOperation($replica, 'Start replica', "docker start {$replica->container_name}");
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('service-replicas.show', [
|
||||||
|
'organisation' => $organisation->id,
|
||||||
|
'server' => $server->id,
|
||||||
|
'service' => $service->id,
|
||||||
|
'replica' => $replica->id,
|
||||||
|
])
|
||||||
|
->with('success', 'Replica start queued.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stop(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$server = $organisation->servers()->findOrFail($request->route('server'));
|
||||||
|
$service = $server->services()->findOrFail($request->route('service'));
|
||||||
|
$replica = $service->replicas()->findOrFail($request->route('replica'));
|
||||||
|
|
||||||
|
$this->queueLifecycleOperation($replica, 'Stop replica', "docker stop {$replica->container_name}");
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('service-replicas.show', [
|
||||||
|
'organisation' => $organisation->id,
|
||||||
|
'server' => $server->id,
|
||||||
|
'service' => $service->id,
|
||||||
|
'replica' => $replica->id,
|
||||||
|
])
|
||||||
|
->with('success', 'Replica stop queued.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function queueLifecycleOperation(ServiceReplica $replica, string $name, string $script): void
|
||||||
|
{
|
||||||
|
$operation = $replica->operations()->create([
|
||||||
|
'kind' => OperationKind::REPLICA_DEPLOY,
|
||||||
|
'status' => OperationStatus::PENDING,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$operation->steps()->create([
|
||||||
|
'name' => $name,
|
||||||
|
'order' => 1,
|
||||||
|
'status' => OperationStatus::PENDING,
|
||||||
|
'script' => $script,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
104
app/Http/Controllers/ServiceSliceController.php
Normal file
104
app/Http/Controllers/ServiceSliceController.php
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Requests\StoreServiceSliceRequest;
|
||||||
|
use App\Models\Organisation;
|
||||||
|
use App\Models\ServiceSlice;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class ServiceSliceController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$server = $organisation->servers()->findOrFail($request->route('server'));
|
||||||
|
$service = $server->services()
|
||||||
|
->with('environment.application')
|
||||||
|
->findOrFail($request->route('service'));
|
||||||
|
|
||||||
|
return inertia('service-slices/Index', [
|
||||||
|
'server' => $server,
|
||||||
|
'service' => $service,
|
||||||
|
'slices' => $service->slices()
|
||||||
|
->with(['environment.application', 'attachments', 'operations.steps', 'operations.children.target'])
|
||||||
|
->latest()
|
||||||
|
->get(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request): Response
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$server = $organisation->servers()->findOrFail($request->route('server'));
|
||||||
|
$service = $server->services()->with('environment.application')->findOrFail($request->route('service'));
|
||||||
|
$slice = $service->slices()
|
||||||
|
->with(['environment.application', 'attachments.environment.application', 'operations.steps', 'operations.children.target'])
|
||||||
|
->findOrFail($request->route('slice'));
|
||||||
|
|
||||||
|
return inertia('service-slices/Show', [
|
||||||
|
'server' => $server,
|
||||||
|
'service' => $service,
|
||||||
|
'slice' => $slice,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(Request $request): Response
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$server = $organisation->servers()->findOrFail($request->route('server'));
|
||||||
|
$service = $server->services()->findOrFail($request->route('service'));
|
||||||
|
|
||||||
|
return inertia('service-slices/Create', [
|
||||||
|
'server' => $server,
|
||||||
|
'service' => $service,
|
||||||
|
'environments' => $organisation->applications()
|
||||||
|
->with('environments')
|
||||||
|
->get()
|
||||||
|
->pluck('environments')
|
||||||
|
->flatten()
|
||||||
|
->values(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StoreServiceSliceRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$server = $organisation->servers()->findOrFail($request->route('server'));
|
||||||
|
$service = $server->services()->findOrFail($request->route('service'));
|
||||||
|
$environmentId = $request->integer('environment_id') ?: null;
|
||||||
|
|
||||||
|
if ($environmentId !== null) {
|
||||||
|
$belongsToOrganisation = $organisation->applications()
|
||||||
|
->whereHas('environments', fn ($query) => $query->whereKey($environmentId))
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
abort_unless($belongsToOrganisation, 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = $request->filled('config')
|
||||||
|
? json_decode($request->string('config')->toString(), true, flags: JSON_THROW_ON_ERROR)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
/** @var ServiceSlice $slice */
|
||||||
|
$slice = $service->slices()->create([
|
||||||
|
'environment_id' => $environmentId,
|
||||||
|
'name' => $request->string('name')->toString(),
|
||||||
|
'type' => $request->string('type')->toString(),
|
||||||
|
'status' => $request->string('status')->toString(),
|
||||||
|
'config' => $config,
|
||||||
|
'credentials' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('service-slices.show', [
|
||||||
|
'organisation' => $organisation->id,
|
||||||
|
'server' => $server->id,
|
||||||
|
'service' => $service->id,
|
||||||
|
'slice' => $slice->id,
|
||||||
|
])
|
||||||
|
->with('success', 'Slice created.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Actions\Services\CreateStatefulServiceUpdateOperation;
|
use App\Actions\Services\CreateStatefulServiceUpdateOperation;
|
||||||
|
use App\Actions\Services\ResolveServiceImageDigest;
|
||||||
use App\Enums\ServiceType;
|
use App\Enums\ServiceType;
|
||||||
use App\Http\Requests\StoreServiceUpdateRequest;
|
use App\Http\Requests\StoreServiceUpdateRequest;
|
||||||
use App\Models\Organisation;
|
use App\Models\Organisation;
|
||||||
@@ -33,6 +34,7 @@ class ServiceUpdateController extends Controller
|
|||||||
CreateStatefulServiceUpdateOperation $createStatefulServiceUpdateOperation,
|
CreateStatefulServiceUpdateOperation $createStatefulServiceUpdateOperation,
|
||||||
): RedirectResponse {
|
): RedirectResponse {
|
||||||
abort_unless((int) $server->organisation_id === (int) $organisation->id && (int) $service->server_id === (int) $server->id, 404);
|
abort_unless((int) $server->organisation_id === (int) $organisation->id && (int) $service->server_id === (int) $server->id, 404);
|
||||||
|
abort_unless($request->string('confirmation')->toString() === $service->name, 422);
|
||||||
|
|
||||||
$createStatefulServiceUpdateOperation->execute(
|
$createStatefulServiceUpdateOperation->execute(
|
||||||
service: $service,
|
service: $service,
|
||||||
@@ -45,4 +47,26 @@ class ServiceUpdateController extends Controller
|
|||||||
'server' => $server->id,
|
'server' => $server->id,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function resolve(
|
||||||
|
Organisation $organisation,
|
||||||
|
Server $server,
|
||||||
|
Service $service,
|
||||||
|
ResolveServiceImageDigest $resolveServiceImageDigest,
|
||||||
|
): RedirectResponse {
|
||||||
|
abort_unless((int) $server->organisation_id === (int) $organisation->id && (int) $service->server_id === (int) $server->id, 404);
|
||||||
|
abort_unless(in_array($service->type, [ServiceType::POSTGRES, ServiceType::VALKEY], true), 404);
|
||||||
|
|
||||||
|
$service->update([
|
||||||
|
'available_image_digest' => $resolveServiceImageDigest->execute($service),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('service-updates.create', [
|
||||||
|
'organisation' => $organisation->id,
|
||||||
|
'server' => $server->id,
|
||||||
|
'service' => $service->id,
|
||||||
|
])
|
||||||
|
->with('success', 'Latest image digest resolved.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,26 @@ namespace App\Http\Controllers;
|
|||||||
|
|
||||||
use App\Enums\SourceProviderType;
|
use App\Enums\SourceProviderType;
|
||||||
use App\Http\Requests\StoreSourceProviderRequest;
|
use App\Http\Requests\StoreSourceProviderRequest;
|
||||||
|
use App\Http\Requests\UpdateSourceProviderRequest;
|
||||||
use App\Models\Organisation;
|
use App\Models\Organisation;
|
||||||
|
use App\Models\SourceProvider;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
|
||||||
class SourceProviderController extends Controller
|
class SourceProviderController extends Controller
|
||||||
{
|
{
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
|
||||||
|
return inertia('source-providers/Index', [
|
||||||
|
'sourceProviders' => $organisation->sourceProviders()
|
||||||
|
->latest()
|
||||||
|
->get(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function create(Request $request): Response
|
public function create(Request $request): Response
|
||||||
{
|
{
|
||||||
Organisation::findOrFail($request->route('organisation'));
|
Organisation::findOrFail($request->route('organisation'));
|
||||||
@@ -35,4 +48,44 @@ class SourceProviderController extends Controller
|
|||||||
->route('organisations.show', ['organisation' => $organisation->id])
|
->route('organisations.show', ['organisation' => $organisation->id])
|
||||||
->with('success', 'Source provider created.');
|
->with('success', 'Source provider created.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function edit(Request $request): Response
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$sourceProvider = $organisation->sourceProviders()->findOrFail($request->route('source_provider'));
|
||||||
|
|
||||||
|
return inertia('source-providers/Edit', [
|
||||||
|
'sourceProvider' => $sourceProvider,
|
||||||
|
'sourceProviderTypes' => array_values(SourceProviderType::toArray()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdateSourceProviderRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
/** @var SourceProvider $sourceProvider */
|
||||||
|
$sourceProvider = $organisation->sourceProviders()->findOrFail($request->route('source_provider'));
|
||||||
|
|
||||||
|
$sourceProvider->update([
|
||||||
|
'name' => $request->string('name')->toString(),
|
||||||
|
'type' => $request->enum('type', SourceProviderType::class),
|
||||||
|
'url' => $request->filled('url') ? rtrim($request->string('url')->toString(), '/') : null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('organisations.show', ['organisation' => $organisation->id])
|
||||||
|
->with('success', 'Source provider updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||||
|
$sourceProvider = $organisation->sourceProviders()->findOrFail($request->route('source_provider'));
|
||||||
|
|
||||||
|
$sourceProvider->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('organisations.show', ['organisation' => $organisation->id])
|
||||||
|
->with('success', 'Source provider deleted.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ class CreateNetworkRequest extends Request implements HasBody
|
|||||||
[
|
[
|
||||||
'type' => 'cloud',
|
'type' => 'cloud',
|
||||||
'ip_range' => '10.0.1.0/24',
|
'ip_range' => '10.0.1.0/24',
|
||||||
'network_zone' => $this->networkZone
|
'network_zone' => $this->networkZone,
|
||||||
]
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,9 +30,7 @@ class HandleInertiaRequests extends Middleware
|
|||||||
return [
|
return [
|
||||||
...parent::share($request),
|
...parent::share($request),
|
||||||
'name' => config('app.name'),
|
'name' => config('app.name'),
|
||||||
'organisation' => $request->route('organisation')
|
'organisation' => $this->resolveOrganisation($request),
|
||||||
? Organisation::with('applications')->findOrFail($this->routeKey($request->route('organisation')))
|
|
||||||
: null,
|
|
||||||
'application' => $request->route('application')
|
'application' => $request->route('application')
|
||||||
? Application::with('environments')->findOrFail($this->routeKey($request->route('application')))
|
? Application::with('environments')->findOrFail($this->routeKey($request->route('application')))
|
||||||
: null,
|
: null,
|
||||||
@@ -51,6 +49,20 @@ class HandleInertiaRequests extends Middleware
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function resolveOrganisation(Request $request): ?Organisation
|
||||||
|
{
|
||||||
|
$query = Organisation::with('applications.environments')
|
||||||
|
->withCount(['providers', 'sourceProviders', 'registries', 'servers', 'applications']);
|
||||||
|
|
||||||
|
if ($request->route('organisation')) {
|
||||||
|
return $query->findOrFail($this->routeKey($request->route('organisation')));
|
||||||
|
}
|
||||||
|
|
||||||
|
$organisationId = $request->user()?->organisations()->value('organisations.id');
|
||||||
|
|
||||||
|
return $organisationId ? $query->find($organisationId) : null;
|
||||||
|
}
|
||||||
|
|
||||||
private function routeKey(mixed $routeValue): mixed
|
private function routeKey(mixed $routeValue): mixed
|
||||||
{
|
{
|
||||||
return $routeValue instanceof Model ? $routeValue->getKey() : $routeValue;
|
return $routeValue instanceof Model ? $routeValue->getKey() : $routeValue;
|
||||||
|
|||||||
29
app/Http/Requests/ImportEnvironmentVariablesRequest.php
Normal file
29
app/Http/Requests/ImportEnvironmentVariablesRequest.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class ImportEnvironmentVariablesRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'contents' => ['required', 'string', 'max:20000'],
|
||||||
|
'overridable' => ['sometimes', 'boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Http\Requests;
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Enums\RepositoryType;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class StoreApplicationRequest extends FormRequest
|
class StoreApplicationRequest extends FormRequest
|
||||||
{
|
{
|
||||||
@@ -23,6 +25,8 @@ class StoreApplicationRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'source_provider_id' => ['nullable', 'integer', 'exists:source_providers,id'],
|
||||||
|
'repository_type' => ['required', Rule::enum(RepositoryType::class)],
|
||||||
'repository_url' => ['required', 'string', 'max:255', 'regex:/^(git@[^:]+:.+|ssh:\/\/.+)$/i'],
|
'repository_url' => ['required', 'string', 'max:255', 'regex:/^(git@[^:]+:.+|ssh:\/\/.+)$/i'],
|
||||||
'default_branch' => ['required', 'string', 'max:255', 'regex:/^[A-Za-z0-9._\/-]+$/'],
|
'default_branch' => ['required', 'string', 'max:255', 'regex:/^[A-Za-z0-9._\/-]+$/'],
|
||||||
'environment_name' => ['required', 'string', 'max:255'],
|
'environment_name' => ['required', 'string', 'max:255'],
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ class StoreEnvironmentAttachmentRequest extends FormRequest
|
|||||||
'name' => ['nullable', 'string', 'max:255'],
|
'name' => ['nullable', 'string', 'max:255'],
|
||||||
'env_prefix' => ['nullable', 'string', 'max:32', 'regex:/^[A-Z][A-Z0-9_]*$/'],
|
'env_prefix' => ['nullable', 'string', 'max:32', 'regex:/^[A-Z][A-Z0-9_]*$/'],
|
||||||
'is_primary' => ['boolean'],
|
'is_primary' => ['boolean'],
|
||||||
|
'domain' => ['nullable', 'string', 'max:255'],
|
||||||
|
'path_prefix' => ['nullable', 'string', 'max:255'],
|
||||||
|
'tls_enabled' => ['boolean'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
28
app/Http/Requests/StoreEnvironmentDeploymentRequest.php
Normal file
28
app/Http/Requests/StoreEnvironmentDeploymentRequest.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StoreEnvironmentDeploymentRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'target_commit' => ['nullable', 'string', 'size:40', 'regex:/^[a-fA-F0-9]{40}$/'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/Http/Requests/StoreEnvironmentRequest.php
Normal file
30
app/Http/Requests/StoreEnvironmentRequest.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StoreEnvironmentRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'branch' => ['required', 'string', 'max:255', 'regex:/^[A-Za-z0-9._\/-]+$/'],
|
||||||
|
'php_version' => ['required', 'string', 'max:20'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ class StoreEnvironmentVariableRequest extends FormRequest
|
|||||||
return [
|
return [
|
||||||
'key' => ['required', 'string', 'max:255', 'regex:/^[A-Z][A-Z0-9_]*$/'],
|
'key' => ['required', 'string', 'max:255', 'regex:/^[A-Z][A-Z0-9_]*$/'],
|
||||||
'value' => ['nullable', 'string'],
|
'value' => ['nullable', 'string'],
|
||||||
|
'overridable' => ['boolean'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
32
app/Http/Requests/StoreGatewayRouteRequest.php
Normal file
32
app/Http/Requests/StoreGatewayRouteRequest.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StoreGatewayRouteRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'service_id' => ['required', 'integer', 'exists:services,id'],
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'domain' => ['required', 'string', 'max:255'],
|
||||||
|
'path_prefix' => ['required', 'string', 'max:255', 'regex:/^\//'],
|
||||||
|
'tls_enabled' => ['boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/Http/Requests/StoreOrganisationMemberRequest.php
Normal file
31
app/Http/Requests/StoreOrganisationMemberRequest.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Enums\OrganisationRole;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class StoreOrganisationMemberRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email' => ['required', 'email', 'max:255'],
|
||||||
|
'role' => ['required', Rule::enum(OrganisationRole::class)],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/Http/Requests/StoreProviderRequest.php
Normal file
32
app/Http/Requests/StoreProviderRequest.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Enums\ProviderType;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class StoreProviderRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'type' => ['required', Rule::enum(ProviderType::class)],
|
||||||
|
'token' => ['required', 'string', 'max:2000'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,8 +25,8 @@ class StoreRegistryRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
'type' => ['required', Rule::enum(RegistryType::class)],
|
'type' => ['required', Rule::enum(RegistryType::class), Rule::notIn([RegistryType::MANAGED->value])],
|
||||||
'url' => ['required', 'string', 'max:255'],
|
'url' => ['required', 'string', 'max:255', 'not_regex:#^https?://#i'],
|
||||||
'username' => ['nullable', 'string', 'max:255'],
|
'username' => ['nullable', 'string', 'max:255'],
|
||||||
'password' => ['nullable', 'string', 'max:255'],
|
'password' => ['nullable', 'string', 'max:255'],
|
||||||
];
|
];
|
||||||
|
|||||||
32
app/Http/Requests/StoreServerFirewallRuleRequest.php
Normal file
32
app/Http/Requests/StoreServerFirewallRuleRequest.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Enums\FirewallRuleType;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class StoreServerFirewallRuleRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => ['required', Rule::enum(FirewallRuleType::class)],
|
||||||
|
'ports' => ['required', 'string', 'max:50', 'regex:/^[A-Za-z0-9:\/,-]+$/'],
|
||||||
|
'from' => ['nullable', 'string', 'max:255', 'regex:/^[A-Fa-f0-9:.\/-]+$/'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/Http/Requests/StoreServiceSliceRequest.php
Normal file
32
app/Http/Requests/StoreServiceSliceRequest.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StoreServiceSliceRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'type' => ['required', 'string', 'max:255'],
|
||||||
|
'environment_id' => ['nullable', 'integer'],
|
||||||
|
'status' => ['required', 'string', 'max:255'],
|
||||||
|
'config' => ['nullable', 'string'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ class StoreServiceUpdateRequest extends FormRequest
|
|||||||
return [
|
return [
|
||||||
'image_digest' => ['required', 'string', 'starts_with:sha256:'],
|
'image_digest' => ['required', 'string', 'starts_with:sha256:'],
|
||||||
'backup_requested' => ['sometimes', 'boolean'],
|
'backup_requested' => ['sometimes', 'boolean'],
|
||||||
|
'confirmation' => ['required', 'string'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
34
app/Http/Requests/UpdateApplicationRequest.php
Normal file
34
app/Http/Requests/UpdateApplicationRequest.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Enums\RepositoryType;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpdateApplicationRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'source_provider_id' => ['nullable', 'integer', 'exists:source_providers,id'],
|
||||||
|
'repository_type' => ['required', Rule::enum(RepositoryType::class)],
|
||||||
|
'repository_url' => ['required', 'string', 'max:255', 'regex:/^(git@[^:]+:.+|ssh:\/\/.+)$/i'],
|
||||||
|
'default_branch' => ['required', 'string', 'max:255', 'regex:/^[A-Za-z0-9._\/-]+$/'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
36
app/Http/Requests/UpdateEnvironmentAttachmentRequest.php
Normal file
36
app/Http/Requests/UpdateEnvironmentAttachmentRequest.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Enums\EnvironmentAttachmentRole;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpdateEnvironmentAttachmentRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'role' => ['required', Rule::enum(EnvironmentAttachmentRole::class)],
|
||||||
|
'env_prefix' => ['nullable', 'string', 'max:255', 'regex:/^[A-Z][A-Z0-9_]*$/'],
|
||||||
|
'is_primary' => ['boolean'],
|
||||||
|
'domain' => ['nullable', 'string', 'max:255'],
|
||||||
|
'path_prefix' => ['nullable', 'string', 'max:255'],
|
||||||
|
'tls_enabled' => ['boolean'],
|
||||||
|
'certificate_status' => ['nullable', 'string', 'max:255'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
42
app/Http/Requests/UpdateEnvironmentRequest.php
Normal file
42
app/Http/Requests/UpdateEnvironmentRequest.php
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Enums\BuildStrategy;
|
||||||
|
use App\Enums\SchedulerMode;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpdateEnvironmentRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'branch' => ['required', 'string', 'max:255', 'regex:/^[A-Za-z0-9._\/-]+$/'],
|
||||||
|
'status' => ['required', 'string', 'max:255'],
|
||||||
|
'scheduler_enabled' => ['boolean'],
|
||||||
|
'scheduler_target_service_id' => ['nullable', 'integer'],
|
||||||
|
'scheduler_mode' => ['required', Rule::enum(SchedulerMode::class)],
|
||||||
|
'build_strategy' => ['required', Rule::enum(BuildStrategy::class)],
|
||||||
|
'php_version' => ['nullable', 'string', 'max:20'],
|
||||||
|
'document_root' => ['nullable', 'string', 'max:255'],
|
||||||
|
'health_path' => ['nullable', 'string', 'max:255'],
|
||||||
|
'js_package_manager' => ['nullable', 'string', 'max:50'],
|
||||||
|
'js_build_command' => ['nullable', 'string', 'max:255'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/Http/Requests/UpdateEnvironmentVariableRequest.php
Normal file
30
app/Http/Requests/UpdateEnvironmentVariableRequest.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class UpdateEnvironmentVariableRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'key' => ['required', 'string', 'max:255', 'regex:/^[A-Z][A-Z0-9_]*$/'],
|
||||||
|
'value' => ['nullable', 'string'],
|
||||||
|
'overridable' => ['boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/Http/Requests/UpdateGatewayRouteRequest.php
Normal file
31
app/Http/Requests/UpdateGatewayRouteRequest.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class UpdateGatewayRouteRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'domain' => ['required', 'string', 'max:255'],
|
||||||
|
'path_prefix' => ['required', 'string', 'max:255', 'regex:/^\//'],
|
||||||
|
'tls_enabled' => ['boolean'],
|
||||||
|
'certificate_status' => ['nullable', 'string', 'max:255'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/Http/Requests/UpdateOrganisationInvitationRequest.php
Normal file
30
app/Http/Requests/UpdateOrganisationInvitationRequest.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Enums\OrganisationRole;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpdateOrganisationInvitationRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'role' => ['required', Rule::enum(OrganisationRole::class)],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/Http/Requests/UpdateOrganisationMemberRequest.php
Normal file
30
app/Http/Requests/UpdateOrganisationMemberRequest.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Enums\OrganisationRole;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpdateOrganisationMemberRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'role' => ['required', Rule::enum(OrganisationRole::class)],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
34
app/Http/Requests/UpdateRegistryRequest.php
Normal file
34
app/Http/Requests/UpdateRegistryRequest.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Enums\RegistryType;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpdateRegistryRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'type' => ['required', Rule::enum(RegistryType::class), Rule::notIn([RegistryType::MANAGED->value])],
|
||||||
|
'url' => ['required', 'string', 'max:255', 'not_regex:#^https?://#i'],
|
||||||
|
'username' => ['nullable', 'string', 'max:255'],
|
||||||
|
'password' => ['nullable', 'string', 'max:255'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Http\Requests;
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Enums\DeployPolicy;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class UpdateServiceRequest extends FormRequest
|
class UpdateServiceRequest extends FormRequest
|
||||||
{
|
{
|
||||||
@@ -26,6 +28,16 @@ class UpdateServiceRequest extends FormRequest
|
|||||||
'desired_replicas' => ['required', 'integer', 'min:0', 'max:25'],
|
'desired_replicas' => ['required', 'integer', 'min:0', 'max:25'],
|
||||||
'default_cpu_limit' => ['nullable', 'numeric', 'min:0.125', 'max:64'],
|
'default_cpu_limit' => ['nullable', 'numeric', 'min:0.125', 'max:64'],
|
||||||
'default_memory_limit_mb' => ['nullable', 'integer', 'min:64', 'max:1048576'],
|
'default_memory_limit_mb' => ['nullable', 'integer', 'min:64', 'max:1048576'],
|
||||||
|
'deploy_policy' => ['nullable', Rule::enum(DeployPolicy::class)],
|
||||||
|
'version_track' => ['nullable', 'string', 'max:255'],
|
||||||
|
'available_image_digest' => ['nullable', 'string', 'max:255'],
|
||||||
|
'process_roles' => ['nullable', 'string', 'max:255'],
|
||||||
|
'migration_mode' => ['nullable', 'string', 'max:255'],
|
||||||
|
'migration_timing' => ['nullable', 'string', 'max:255'],
|
||||||
|
'migration_command' => ['nullable', 'string', 'max:255'],
|
||||||
|
'health_path' => ['nullable', 'string', 'max:255'],
|
||||||
|
'backup_enabled' => ['sometimes', 'boolean'],
|
||||||
|
'backup_command' => ['nullable', 'string', 'max:1000'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
32
app/Http/Requests/UpdateSourceProviderRequest.php
Normal file
32
app/Http/Requests/UpdateSourceProviderRequest.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Enums\SourceProviderType;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpdateSourceProviderRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'type' => ['required', Rule::enum(SourceProviderType::class)],
|
||||||
|
'url' => ['nullable', 'string', 'max:255'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,9 +15,14 @@ use App\Enums\ServiceEndpointScope;
|
|||||||
use App\Models\Environment;
|
use App\Models\Environment;
|
||||||
use App\Models\EnvironmentAttachment;
|
use App\Models\EnvironmentAttachment;
|
||||||
use App\Models\Operation;
|
use App\Models\Operation;
|
||||||
|
use App\Models\Registry;
|
||||||
|
use App\Models\Server;
|
||||||
use App\Models\Service;
|
use App\Models\Service;
|
||||||
use App\Models\ServiceReplica;
|
use App\Models\ServiceReplica;
|
||||||
use App\Services\Compose\ComposeRenderer;
|
use App\Services\Compose\ComposeRenderer;
|
||||||
|
use App\Services\Registries\RegistryDockerAuthScript;
|
||||||
|
use App\Services\Registries\RegistryResolver;
|
||||||
|
use App\Support\CaddyRouteRenderer;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Queue\Queueable;
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
@@ -29,6 +34,7 @@ class DeployEnvironment implements ShouldQueue
|
|||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public Environment $environment,
|
public Environment $environment,
|
||||||
|
public ?string $targetCommit = null,
|
||||||
) {
|
) {
|
||||||
//
|
//
|
||||||
}
|
}
|
||||||
@@ -51,7 +57,7 @@ class DeployEnvironment implements ShouldQueue
|
|||||||
'started_at' => now(),
|
'started_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$commitSha = app(ResolveEnvironmentCommit::class)->execute($this->environment);
|
$commitSha = $this->targetCommit ?? app(ResolveEnvironmentCommit::class)->execute($this->environment);
|
||||||
$services = $this->servicesNeedingDeployment($plan->services, $commitSha);
|
$services = $this->servicesNeedingDeployment($plan->services, $commitSha);
|
||||||
|
|
||||||
if ($services === []) {
|
if ($services === []) {
|
||||||
@@ -65,6 +71,7 @@ class DeployEnvironment implements ShouldQueue
|
|||||||
|
|
||||||
$artifact = app(PlanBuildArtifact::class)->execute($this->environment, $commitSha);
|
$artifact = app(PlanBuildArtifact::class)->execute($this->environment, $commitSha);
|
||||||
$artifact = app(BuildApplicationArtifact::class)->execute($artifact, $operation);
|
$artifact = app(BuildApplicationArtifact::class)->execute($artifact, $operation);
|
||||||
|
$registry = app(RegistryResolver::class)->buildRegistryFor($this->environment->application->organisation);
|
||||||
|
|
||||||
foreach ($services as $service) {
|
foreach ($services as $service) {
|
||||||
$service->update([
|
$service->update([
|
||||||
@@ -78,8 +85,8 @@ class DeployEnvironment implements ShouldQueue
|
|||||||
'status' => OperationStatus::PENDING,
|
'status' => OperationStatus::PENDING,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->createServiceDeploySteps($child, $service, $commitSha, $artifact->image_digest);
|
$this->createServiceDeploySteps($child, $service, $commitSha, $artifact->image_digest, $artifact->registry_ref);
|
||||||
$this->createReplicaDeployOperations($child, $service, $artifact->registry_ref);
|
$this->createReplicaDeployOperations($child, $service, $artifact->registry_ref, $registry);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->createGatewayOperations($operation);
|
$this->createGatewayOperations($operation);
|
||||||
@@ -98,19 +105,20 @@ class DeployEnvironment implements ShouldQueue
|
|||||||
->all();
|
->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function createServiceDeploySteps(Operation $operation, Service $service, string $commitSha, string $imageDigest): void
|
private function createServiceDeploySteps(Operation $operation, Service $service, string $commitSha, string $imageDigest, ?string $imageReference = null): void
|
||||||
{
|
{
|
||||||
foreach ($this->serviceDeployScripts($service, $commitSha, $imageDigest) as $index => $step) {
|
foreach ($this->serviceDeployScripts($service, $commitSha, $imageDigest, $imageReference) as $index => $step) {
|
||||||
$operation->steps()->create([
|
$operation->steps()->create([
|
||||||
'name' => $step['name'],
|
'name' => $step['name'],
|
||||||
'order' => $index + 1,
|
'order' => $index + 1,
|
||||||
'status' => OperationStatus::PENDING,
|
'status' => OperationStatus::PENDING,
|
||||||
'script' => $step['script'],
|
'script' => $step['script'],
|
||||||
|
'secrets' => $step['secrets'] ?? null,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function createReplicaDeployOperations(Operation $parent, Service $service, ?string $imageReference = null): void
|
private function createReplicaDeployOperations(Operation $parent, Service $service, ?string $imageReference = null, ?Registry $registry = null): void
|
||||||
{
|
{
|
||||||
$replicas = $this->ensureServiceReplicas($service);
|
$replicas = $this->ensureServiceReplicas($service);
|
||||||
|
|
||||||
@@ -131,12 +139,13 @@ class DeployEnvironment implements ShouldQueue
|
|||||||
'health_status' => 'unknown',
|
'health_status' => 'unknown',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
foreach ($this->replicaDeployScripts($service, $replica, $imageReference) as $index => $step) {
|
foreach ($this->replicaDeployScripts($service, $replica, $imageReference, $registry, $serviceReplica) as $index => $step) {
|
||||||
$operation->steps()->create([
|
$operation->steps()->create([
|
||||||
'name' => $step['name'],
|
'name' => $step['name'],
|
||||||
'order' => $index + 1,
|
'order' => $index + 1,
|
||||||
'status' => OperationStatus::PENDING,
|
'status' => OperationStatus::PENDING,
|
||||||
'script' => $step['script'],
|
'script' => $step['script'],
|
||||||
|
'secrets' => $step['secrets'] ?? null,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -242,7 +251,7 @@ class DeployEnvironment implements ShouldQueue
|
|||||||
/**
|
/**
|
||||||
* @return array<int, array{name: string, script: string}>
|
* @return array<int, array{name: string, script: string}>
|
||||||
*/
|
*/
|
||||||
private function serviceDeployScripts(Service $service, string $commitSha, string $imageDigest): array
|
private function serviceDeployScripts(Service $service, string $commitSha, string $imageDigest, ?string $imageReference = null): array
|
||||||
{
|
{
|
||||||
$servicePath = $this->servicePath($service);
|
$servicePath = $this->servicePath($service);
|
||||||
$composePath = "{$servicePath}/compose.yml";
|
$composePath = "{$servicePath}/compose.yml";
|
||||||
@@ -262,7 +271,7 @@ class DeployEnvironment implements ShouldQueue
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
'name' => 'Render Compose files',
|
'name' => 'Render Compose files',
|
||||||
'script' => $this->composeUploadScript($service),
|
'script' => $this->composeUploadScript($service, $this->fullImageReference($imageReference, $imageDigest)),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -309,17 +318,32 @@ class DeployEnvironment implements ShouldQueue
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, array{name: string, script: string}>
|
* @return array<int, array{name: string, script: string, secrets?: array<string, string>}>
|
||||||
*/
|
*/
|
||||||
private function replicaDeployScripts(Service $service, int $replica, ?string $imageReference = null): array
|
private function replicaDeployScripts(Service $service, int $replica, ?string $imageReference = null, ?Registry $registry = null, ?ServiceReplica $serviceReplica = null): array
|
||||||
{
|
{
|
||||||
$composePath = $this->servicePath($service).'/compose.yml';
|
$composePath = $this->servicePath($service).'/compose.yml';
|
||||||
$project = "keystone_service_{$service->id}_replica_{$replica}";
|
$project = "keystone_service_{$service->id}_replica_{$replica}";
|
||||||
$serviceKey = $this->serviceKey($service);
|
$serviceKey = $this->serviceKey($service);
|
||||||
|
$targetServer = $serviceReplica?->server ?: $service->server;
|
||||||
|
|
||||||
$steps = [];
|
$steps = [
|
||||||
|
[
|
||||||
|
'name' => "Render replica {$replica} Compose files",
|
||||||
|
'script' => $this->composeUploadScript($service, $this->fullImageReference($imageReference, $service->available_image_digest)),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
if ($imageReference && $service->available_image_digest) {
|
if ($imageReference && $service->available_image_digest) {
|
||||||
|
if ($registry instanceof Registry && $registry->credentials) {
|
||||||
|
$auth = app(RegistryDockerAuthScript::class)->forRuntime($registry, $this->dockerAuthUser($targetServer));
|
||||||
|
$steps[] = [
|
||||||
|
'name' => "Configure registry auth for replica {$replica}",
|
||||||
|
'script' => $auth['script'],
|
||||||
|
'secrets' => $auth['secrets'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
$steps[] = [
|
$steps[] = [
|
||||||
'name' => "Pull image for replica {$replica}",
|
'name' => "Pull image for replica {$replica}",
|
||||||
'script' => 'docker pull '.escapeshellarg($imageReference.'@'.$service->available_image_digest),
|
'script' => 'docker pull '.escapeshellarg($imageReference.'@'.$service->available_image_digest),
|
||||||
@@ -352,16 +376,21 @@ class DeployEnvironment implements ShouldQueue
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function composeUploadScript(Service $service): string
|
private function dockerAuthUser(?Server $server): string
|
||||||
|
{
|
||||||
|
return 'root';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function composeUploadScript(Service $service, ?string $fullImageReference = null): string
|
||||||
{
|
{
|
||||||
$servicePath = $this->servicePath($service);
|
$servicePath = $this->servicePath($service);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$renderer = app(ComposeRenderer::class);
|
$renderer = app(ComposeRenderer::class);
|
||||||
$compose = $renderer->render($service);
|
$compose = $renderer->render($this->serviceForCompose($service, $fullImageReference));
|
||||||
$env = $renderer->renderEnvironmentFile($service);
|
$env = $renderer->renderEnvironmentFile($service);
|
||||||
} catch (InvalidArgumentException) {
|
} catch (InvalidArgumentException) {
|
||||||
$compose = "services:\n {$this->serviceKey($service)}:\n image: \"{$service->available_image_digest}\"\n";
|
$compose = "services:\n {$this->serviceKey($service)}:\n image: \"".($fullImageReference ?: $service->available_image_digest)."\"\n";
|
||||||
$env = '';
|
$env = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,21 +401,52 @@ class DeployEnvironment implements ShouldQueue
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function fullImageReference(?string $imageReference, ?string $imageDigest): ?string
|
||||||
|
{
|
||||||
|
if (! $imageReference || ! $imageDigest) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $imageReference.'@'.$imageDigest;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function serviceForCompose(Service $service, ?string $fullImageReference): Service
|
||||||
|
{
|
||||||
|
if (! $fullImageReference) {
|
||||||
|
return $service;
|
||||||
|
}
|
||||||
|
|
||||||
|
$clone = clone $service;
|
||||||
|
$clone->available_image_digest = $fullImageReference;
|
||||||
|
|
||||||
|
return $clone;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, array{name: string, script: string}>
|
* @return array<int, array{name: string, script: string}>
|
||||||
*/
|
*/
|
||||||
private function gatewayCutoverSteps(EnvironmentAttachment $attachment): array
|
private function gatewayCutoverSteps(EnvironmentAttachment $attachment): array
|
||||||
{
|
{
|
||||||
$containerName = $attachment->service->replicas()->first()?->container_name;
|
$containerName = $attachment->service->replicas()->first()?->container_name;
|
||||||
|
$config = $attachment->serviceSlice?->config ?? [];
|
||||||
|
$domain = $config['domain'] ?? null;
|
||||||
|
$tlsEnabled = $config['tls_enabled'] ?? true;
|
||||||
$reloadCommand = $containerName
|
$reloadCommand = $containerName
|
||||||
? 'docker exec '.escapeshellarg($containerName).' caddy reload --config /etc/caddy/Caddyfile'
|
? 'docker exec '.escapeshellarg($containerName).' caddy reload --config /etc/caddy/Caddyfile'
|
||||||
: "docker compose -f /home/keystone/services/{$attachment->service_id}/compose.yml exec -T {$this->serviceKey($attachment->service)} caddy reload --config /etc/caddy/Caddyfile";
|
: "docker compose -f /home/keystone/services/{$attachment->service_id}/compose.yml exec -T {$this->serviceKey($attachment->service)} caddy reload --config /etc/caddy/Caddyfile";
|
||||||
|
$certificateCheck = $tlsEnabled && $domain
|
||||||
|
? 'curl --fail --silent --show-error --head https://'.escapeshellarg($domain).' >/dev/null'
|
||||||
|
: 'true # TLS disabled or no domain configured for this route';
|
||||||
|
|
||||||
return [
|
return [
|
||||||
[
|
[
|
||||||
'name' => 'Validate Caddy route configuration',
|
'name' => 'Validate Caddy route configuration',
|
||||||
'script' => 'test -s /home/keystone/gateway/Caddyfile',
|
'script' => 'test -s /home/keystone/gateway/Caddyfile',
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'name' => 'Check TLS certificate status',
|
||||||
|
'script' => $certificateCheck,
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'name' => 'Reload Caddy',
|
'name' => 'Reload Caddy',
|
||||||
'script' => $reloadCommand,
|
'script' => $reloadCommand,
|
||||||
@@ -406,15 +466,13 @@ class DeployEnvironment implements ShouldQueue
|
|||||||
|
|
||||||
private function configureCaddyRouteScript(EnvironmentAttachment $attachment): string
|
private function configureCaddyRouteScript(EnvironmentAttachment $attachment): string
|
||||||
{
|
{
|
||||||
$route = $attachment->serviceSlice?->name ?? $this->environment->name;
|
|
||||||
$upstreams = $this->gatewayUpstreams($attachment);
|
$upstreams = $this->gatewayUpstreams($attachment);
|
||||||
|
$caddyfile = app(CaddyRouteRenderer::class)->render($attachment, $upstreams);
|
||||||
|
|
||||||
return implode("\n", [
|
return implode("\n", [
|
||||||
'mkdir -p /home/keystone/gateway/Caddyfile.d',
|
'mkdir -p /home/keystone/gateway/Caddyfile.d',
|
||||||
"cat > /home/keystone/gateway/Caddyfile.d/{$attachment->id}.caddy <<'KEYSTONE_CADDY_ROUTE'",
|
"cat > /home/keystone/gateway/Caddyfile.d/{$attachment->id}.caddy <<'KEYSTONE_CADDY_ROUTE'",
|
||||||
"{$route} {",
|
$caddyfile,
|
||||||
' reverse_proxy '.implode(' ', $upstreams),
|
|
||||||
'}',
|
|
||||||
'KEYSTONE_CADDY_ROUTE',
|
'KEYSTONE_CADDY_ROUTE',
|
||||||
'cat /home/keystone/gateway/Caddyfile.d/*.caddy > /home/keystone/gateway/Caddyfile',
|
'cat /home/keystone/gateway/Caddyfile.d/*.caddy > /home/keystone/gateway/Caddyfile',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -2,11 +2,15 @@
|
|||||||
|
|
||||||
namespace App\Jobs\Services;
|
namespace App\Jobs\Services;
|
||||||
|
|
||||||
|
use App\Enums\BuildArtifactStatus;
|
||||||
use App\Enums\OperationStatus;
|
use App\Enums\OperationStatus;
|
||||||
|
use App\Enums\RegistryType;
|
||||||
use App\Enums\ServiceStatus;
|
use App\Enums\ServiceStatus;
|
||||||
|
use App\Models\BuildArtifact;
|
||||||
use App\Models\Environment;
|
use App\Models\Environment;
|
||||||
use App\Models\Operation;
|
use App\Models\Operation;
|
||||||
use App\Models\OperationStep;
|
use App\Models\OperationStep;
|
||||||
|
use App\Models\Registry;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use App\Models\Service;
|
use App\Models\Service;
|
||||||
use App\Models\ServiceReplica;
|
use App\Models\ServiceReplica;
|
||||||
@@ -110,6 +114,9 @@ class RunStep implements ShouldQueue
|
|||||||
if ($operation->is($this->step->operation)) {
|
if ($operation->is($this->step->operation)) {
|
||||||
$this->markTargetCompleted();
|
$this->markTargetCompleted();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->markRegistryHealthOperationCompleted($operation);
|
||||||
|
$this->markRegistryMaintenanceOperationCompleted($operation);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function dispatchNextChildOperation(Operation $operation): bool
|
private function dispatchNextChildOperation(Operation $operation): bool
|
||||||
@@ -166,6 +173,7 @@ class RunStep implements ShouldQueue
|
|||||||
$target instanceof ServiceReplica => $target->server,
|
$target instanceof ServiceReplica => $target->server,
|
||||||
$target instanceof Service => $target->replicas()->with('server')->first()?->server ?: $target->server,
|
$target instanceof Service => $target->replicas()->with('server')->first()?->server ?: $target->server,
|
||||||
$target instanceof ServiceSlice => $target->service->replicas()->with('server')->first()?->server ?: $target->service->server,
|
$target instanceof ServiceSlice => $target->service->replicas()->with('server')->first()?->server ?: $target->service->server,
|
||||||
|
$target instanceof Server => $target,
|
||||||
$target instanceof Environment => $target->services()->with(['server', 'replicas.server'])->get()
|
$target instanceof Environment => $target->services()->with(['server', 'replicas.server'])->get()
|
||||||
->flatMap(fn (Service $service) => $service->replicas->pluck('server')->filter())
|
->flatMap(fn (Service $service) => $service->replicas->pluck('server')->filter())
|
||||||
->first() ?: $target->services()->with('server')->get()->pluck('server')->filter()->first(),
|
->first() ?: $target->services()->with('server')->get()->pluck('server')->filter()->first(),
|
||||||
@@ -190,10 +198,12 @@ class RunStep implements ShouldQueue
|
|||||||
'status' => OperationStatus::FAILED,
|
'status' => OperationStatus::FAILED,
|
||||||
'finished_at' => now(),
|
'finished_at' => now(),
|
||||||
'error_logs' => $this->step->error_logs."\n".trim($message),
|
'error_logs' => $this->step->error_logs."\n".trim($message),
|
||||||
|
'secrets' => null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->step->operation->steps()->where('order', '>', $this->step->order)->update([
|
$this->step->operation->steps()->where('order', '>', $this->step->order)->update([
|
||||||
'status' => OperationStatus::CANCELLED,
|
'status' => OperationStatus::CANCELLED,
|
||||||
|
'secrets' => null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->step->operation->update([
|
$this->step->operation->update([
|
||||||
@@ -203,13 +213,114 @@ class RunStep implements ShouldQueue
|
|||||||
|
|
||||||
$this->cancelDescendants($this->step->operation);
|
$this->cancelDescendants($this->step->operation);
|
||||||
$this->cancelPendingSiblingsAndAncestors($this->step->operation);
|
$this->cancelPendingSiblingsAndAncestors($this->step->operation);
|
||||||
|
$this->markRegistryHealthOperationFailed($this->step->operation, trim($message));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function markRegistryHealthOperationCompleted(Operation $operation): void
|
||||||
|
{
|
||||||
|
if ($operation->kind !== \App\Enums\OperationKind::REGISTRY_HEALTH_CHECK) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($operation->parent_id !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$registry = $this->managedRegistryForOperation($operation);
|
||||||
|
|
||||||
|
if (! $registry instanceof Registry) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$checks = collect($registry->readiness_checks ?? [])
|
||||||
|
->map(fn (): string => 'passed')
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$registry->forceFill([
|
||||||
|
'readiness_checks' => $checks,
|
||||||
|
])->save();
|
||||||
|
$registry->markHealthy('Managed registry smoke checks passed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function markRegistryHealthOperationFailed(Operation $operation, string $message): void
|
||||||
|
{
|
||||||
|
if ($operation->kind !== \App\Enums\OperationKind::REGISTRY_HEALTH_CHECK) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$registry = $this->managedRegistryForOperation($operation);
|
||||||
|
|
||||||
|
$registry?->markUnhealthy('Managed registry smoke check failed: '.$message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function markRegistryMaintenanceOperationCompleted(Operation $operation): void
|
||||||
|
{
|
||||||
|
if ($operation->kind !== \App\Enums\OperationKind::REGISTRY_MAINTENANCE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$registry = $this->managedRegistryForOperation($operation);
|
||||||
|
|
||||||
|
if (! $registry instanceof Registry) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$artifactIds = collect($operation->metadata['artifact_ids'] ?? [])
|
||||||
|
->filter(fn ($id): bool => is_numeric($id))
|
||||||
|
->map(fn ($id): int => (int) $id)
|
||||||
|
->values();
|
||||||
|
|
||||||
|
BuildArtifact::query()
|
||||||
|
->whereIn('id', $artifactIds)
|
||||||
|
->where('status', BuildArtifactStatus::PRUNABLE)
|
||||||
|
->where('registry_ref', 'like', rtrim((string) $registry->url, '/').'/%')
|
||||||
|
->each(function ($artifact): void {
|
||||||
|
$artifact->update([
|
||||||
|
'status' => BuildArtifactStatus::PRUNED,
|
||||||
|
'metadata' => [
|
||||||
|
...($artifact->metadata ?? []),
|
||||||
|
'pruned_at' => now()->toIso8601String(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function managedRegistryForOperation(Operation $operation): ?Registry
|
||||||
|
{
|
||||||
|
$registryId = $operation->metadata['registry_id'] ?? $operation->parent?->metadata['registry_id'] ?? null;
|
||||||
|
|
||||||
|
if ($registryId) {
|
||||||
|
$registry = Registry::query()
|
||||||
|
->where('type', RegistryType::MANAGED->value)
|
||||||
|
->find($registryId);
|
||||||
|
|
||||||
|
if ($registry instanceof Registry) {
|
||||||
|
return $registry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$server = $operation->target;
|
||||||
|
|
||||||
|
if (! $server instanceof Server) {
|
||||||
|
$server = $operation->parent?->target;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $server instanceof Server) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Registry::query()
|
||||||
|
->where('type', RegistryType::MANAGED->value)
|
||||||
|
->where('control_server_id', $server->id)
|
||||||
|
->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function cancelDescendants(Operation $operation): void
|
private function cancelDescendants(Operation $operation): void
|
||||||
{
|
{
|
||||||
$operation->children()->with('children')->get()->each(function (Operation $child): void {
|
$operation->children()->with('children')->get()->each(function (Operation $child): void {
|
||||||
$child->steps()->where('status', OperationStatus::PENDING)->update([
|
$child->steps()->whereIn('status', [OperationStatus::PENDING, OperationStatus::IN_PROGRESS])->update([
|
||||||
'status' => OperationStatus::CANCELLED,
|
'status' => OperationStatus::CANCELLED,
|
||||||
|
'secrets' => null,
|
||||||
]);
|
]);
|
||||||
$child->update([
|
$child->update([
|
||||||
'status' => OperationStatus::CANCELLED,
|
'status' => OperationStatus::CANCELLED,
|
||||||
@@ -232,8 +343,9 @@ class RunStep implements ShouldQueue
|
|||||||
->whereIn('status', [OperationStatus::PENDING, OperationStatus::IN_PROGRESS])
|
->whereIn('status', [OperationStatus::PENDING, OperationStatus::IN_PROGRESS])
|
||||||
->get()
|
->get()
|
||||||
->each(function (Operation $sibling): void {
|
->each(function (Operation $sibling): void {
|
||||||
$sibling->steps()->where('status', OperationStatus::PENDING)->update([
|
$sibling->steps()->whereIn('status', [OperationStatus::PENDING, OperationStatus::IN_PROGRESS])->update([
|
||||||
'status' => OperationStatus::CANCELLED,
|
'status' => OperationStatus::CANCELLED,
|
||||||
|
'secrets' => null,
|
||||||
]);
|
]);
|
||||||
$sibling->update([
|
$sibling->update([
|
||||||
'status' => OperationStatus::CANCELLED,
|
'status' => OperationStatus::CANCELLED,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class Application extends Model
|
class Application extends Model
|
||||||
{
|
{
|
||||||
@@ -15,6 +16,13 @@ class Application extends Model
|
|||||||
|
|
||||||
protected $guarded = [];
|
protected $guarded = [];
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::creating(function (Application $application): void {
|
||||||
|
$application->uuid ??= (string) Str::uuid();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@@ -29,6 +37,11 @@ class Application extends Model
|
|||||||
return $this->belongsTo(Organisation::class);
|
return $this->belongsTo(Organisation::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function sourceProvider(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(SourceProvider::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function environments(): HasMany
|
public function environments(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Environment::class);
|
return $this->hasMany(Environment::class);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class Environment extends Model
|
class Environment extends Model
|
||||||
{
|
{
|
||||||
@@ -15,6 +16,13 @@ class Environment extends Model
|
|||||||
|
|
||||||
protected $guarded = [];
|
protected $guarded = [];
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::creating(function (Environment $environment): void {
|
||||||
|
$environment->uuid ??= (string) Str::uuid();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -42,9 +42,9 @@ class FirewallRule extends Model
|
|||||||
$command .= ' delete';
|
$command .= ' delete';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->type === 'allow') {
|
if ($this->type === FirewallRuleType::ALLOW) {
|
||||||
$command .= ' allow';
|
$command .= ' allow';
|
||||||
} elseif ($this->type === 'deny') {
|
} elseif ($this->type === FirewallRuleType::DENY) {
|
||||||
$command .= ' deny';
|
$command .= ' deny';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class Operation extends Model
|
|||||||
return [
|
return [
|
||||||
'kind' => OperationKind::class,
|
'kind' => OperationKind::class,
|
||||||
'status' => OperationStatus::class,
|
'status' => OperationStatus::class,
|
||||||
|
'metadata' => 'array',
|
||||||
'started_at' => 'datetime',
|
'started_at' => 'datetime',
|
||||||
'finished_at' => 'datetime',
|
'finished_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ class OperationStep extends Model
|
|||||||
{
|
{
|
||||||
protected $guarded = [];
|
protected $guarded = [];
|
||||||
|
|
||||||
|
protected $hidden = [
|
||||||
|
'secrets',
|
||||||
|
];
|
||||||
|
|
||||||
protected $appends = [
|
protected $appends = [
|
||||||
'logs_excerpt',
|
'logs_excerpt',
|
||||||
'error_logs_excerpt',
|
'error_logs_excerpt',
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ class Organisation extends Model
|
|||||||
->withTimestamps();
|
->withTimestamps();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function invitations(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(OrganisationInvitation::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function servers(): HasMany
|
public function servers(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Server::class);
|
return $this->hasMany(Server::class);
|
||||||
|
|||||||
35
app/Models/OrganisationInvitation.php
Normal file
35
app/Models/OrganisationInvitation.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Enums\OrganisationRole;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class OrganisationInvitation extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<\Database\Factories\OrganisationInvitationFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $guarded = [];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'role' => OrganisationRole::class,
|
||||||
|
'accepted_at' => 'datetime',
|
||||||
|
'expires_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function organisation(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Organisation::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function invitedBy(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'invited_by_user_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ namespace App\Models;
|
|||||||
use App\Enums\RegistryType;
|
use App\Enums\RegistryType;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
class Registry extends Model
|
class Registry extends Model
|
||||||
{
|
{
|
||||||
@@ -17,6 +18,9 @@ class Registry extends Model
|
|||||||
return [
|
return [
|
||||||
'type' => RegistryType::class,
|
'type' => RegistryType::class,
|
||||||
'credentials' => 'encrypted:array',
|
'credentials' => 'encrypted:array',
|
||||||
|
'readiness_checks' => 'array',
|
||||||
|
'health_checked_at' => 'datetime',
|
||||||
|
'ready_at' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,4 +28,34 @@ class Registry extends Model
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(Organisation::class);
|
return $this->belongsTo(Organisation::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function controlServer(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Server::class, 'control_server_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markHealthy(?string $message = null): void
|
||||||
|
{
|
||||||
|
$this->forceFill([
|
||||||
|
'health_status' => 'healthy',
|
||||||
|
'health_message' => $message,
|
||||||
|
'health_checked_at' => Carbon::now(),
|
||||||
|
'ready_at' => $this->ready_at ?? Carbon::now(),
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markUnhealthy(string $message): void
|
||||||
|
{
|
||||||
|
$this->forceFill([
|
||||||
|
'health_status' => 'unhealthy',
|
||||||
|
'health_message' => $message,
|
||||||
|
'health_checked_at' => Carbon::now(),
|
||||||
|
'ready_at' => null,
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isReady(): bool
|
||||||
|
{
|
||||||
|
return $this->type !== RegistryType::MANAGED || ($this->ready_at !== null && $this->health_status === 'healthy');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||||
use Spatie\Ssh\Ssh;
|
use Spatie\Ssh\Ssh;
|
||||||
|
|
||||||
class Server extends Model
|
class Server extends Model
|
||||||
@@ -21,6 +22,8 @@ class Server extends Model
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'status' => ServerStatus::class,
|
'status' => ServerStatus::class,
|
||||||
|
'is_control_node' => 'boolean',
|
||||||
|
'build_enabled' => 'boolean',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +34,7 @@ class Server extends Model
|
|||||||
|
|
||||||
public function network(): BelongsTo
|
public function network(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Network::class, 'network');
|
return $this->belongsTo(Network::class, 'network_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function organisation(): BelongsTo
|
public function organisation(): BelongsTo
|
||||||
@@ -69,6 +72,11 @@ class Server extends Model
|
|||||||
)->where('target_type', (new Service)->getMorphClass());
|
)->where('target_type', (new Service)->getMorphClass());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function operations(): MorphMany
|
||||||
|
{
|
||||||
|
return $this->morphMany(Operation::class, 'target');
|
||||||
|
}
|
||||||
|
|
||||||
public function sshClient(string $user = 'root'): Ssh
|
public function sshClient(string $user = 'root'): Ssh
|
||||||
{
|
{
|
||||||
return Ssh::create($user, $this->ipv4)
|
return Ssh::create($user, $this->ipv4)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Models;
|
|||||||
use App\Enums\SourceProviderType;
|
use App\Enums\SourceProviderType;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
class SourceProvider extends Model
|
class SourceProvider extends Model
|
||||||
{
|
{
|
||||||
@@ -22,4 +23,9 @@ class SourceProvider extends Model
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(Organisation::class);
|
return $this->belongsTo(Organisation::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function applications(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Application::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
54
app/Services/Registries/ImageReference.php
Normal file
54
app/Services/Registries/ImageReference.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Registries;
|
||||||
|
|
||||||
|
use App\Enums\RegistryType;
|
||||||
|
use App\Models\Environment;
|
||||||
|
use App\Models\Registry;
|
||||||
|
|
||||||
|
class ImageReference
|
||||||
|
{
|
||||||
|
public function tagFor(Environment $environment, string $commitSha, ?Registry $registry = null): string
|
||||||
|
{
|
||||||
|
$tag = substr($commitSha, 0, 12);
|
||||||
|
|
||||||
|
if ($this->registryType($registry) === RegistryType::MANAGED) {
|
||||||
|
$namespace = trim((string) config('keystone.managed_registry.namespace', 'keystone'), '/');
|
||||||
|
$applicationId = $environment->application->uuid ?: 'app-'.$environment->application_id;
|
||||||
|
$environmentId = $environment->uuid ?: 'env-'.$environment->id;
|
||||||
|
$path = $namespace.'/'.$applicationId.'/'.$environmentId;
|
||||||
|
|
||||||
|
return $path.':'.$tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
return str($environment->application->name)
|
||||||
|
->slug()
|
||||||
|
->append(':'.$tag)
|
||||||
|
->value();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function registryReference(Registry $registry, string $imageTag): string
|
||||||
|
{
|
||||||
|
return rtrim($this->registryHost((string) $registry->url), '/').'/'.ltrim($imageTag, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function registryHost(string $url): string
|
||||||
|
{
|
||||||
|
$host = preg_replace('#^https?://#', '', trim($url));
|
||||||
|
|
||||||
|
return $host === null ? trim($url) : $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function registryType(?Registry $registry): ?RegistryType
|
||||||
|
{
|
||||||
|
if (! $registry instanceof Registry) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($registry->type instanceof RegistryType) {
|
||||||
|
return $registry->type;
|
||||||
|
}
|
||||||
|
|
||||||
|
return RegistryType::tryFrom((string) $registry->type);
|
||||||
|
}
|
||||||
|
}
|
||||||
128
app/Services/Registries/ManagedRegistryHealth.php
Normal file
128
app/Services/Registries/ManagedRegistryHealth.php
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Registries;
|
||||||
|
|
||||||
|
use App\Enums\RegistryType;
|
||||||
|
use App\Models\Registry;
|
||||||
|
use App\Models\Server;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
|
class ManagedRegistryHealth
|
||||||
|
{
|
||||||
|
public function check(Registry $registry): bool
|
||||||
|
{
|
||||||
|
if ($registry->type !== RegistryType::MANAGED) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = $this->configurationBlocker($registry);
|
||||||
|
|
||||||
|
if ($message !== null) {
|
||||||
|
$registry->markUnhealthy($message);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = Http::timeout(5)->get('https://'.$registry->url.'/v2/');
|
||||||
|
} catch (\Throwable $exception) {
|
||||||
|
$registry->markUnhealthy('Registry URL is not reachable over HTTPS: '.$exception->getMessage());
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($response->status(), [200, 401], true)) {
|
||||||
|
$registry->markUnhealthy('Registry HTTPS check returned HTTP '.$response->status().'.');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$checks = $registry->readiness_checks ?? [];
|
||||||
|
$checks['control_https'] = 'passed';
|
||||||
|
|
||||||
|
$registry->forceFill([
|
||||||
|
'readiness_checks' => $checks,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
if ($this->readinessChecksPassed($registry->refresh())) {
|
||||||
|
$registry->markHealthy('Registry HTTPS endpoint and smoke checks passed.');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$registry->markUnhealthy('Registry HTTPS endpoint is reachable, but smoke checks have not all passed.');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function readinessBlocker(Registry $registry): ?string
|
||||||
|
{
|
||||||
|
if ($registry->type !== RegistryType::MANAGED) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = $this->configurationBlocker($registry);
|
||||||
|
|
||||||
|
if ($message !== null) {
|
||||||
|
return $message;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($registry->ready_at === null || $registry->health_status !== 'healthy') {
|
||||||
|
return 'Managed registry has not passed readiness checks.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->readinessChecksPassed($registry)) {
|
||||||
|
return 'Managed registry smoke checks have not all passed.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function configurationBlocker(Registry $registry): ?string
|
||||||
|
{
|
||||||
|
if (! $registry->url) {
|
||||||
|
return 'Managed registry URL is not configured.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! str_contains((string) $registry->url, '.')) {
|
||||||
|
return 'Managed registry must use a resolvable HTTPS hostname.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$credentials = $registry->credentials ?? [];
|
||||||
|
|
||||||
|
foreach (['build_username', 'build_password', 'runtime_username', 'runtime_password'] as $key) {
|
||||||
|
if (blank($credentials[$key] ?? null)) {
|
||||||
|
return 'Managed registry credentials are incomplete.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$controlServer = $registry->controlServer;
|
||||||
|
|
||||||
|
if (! $controlServer instanceof Server) {
|
||||||
|
return 'A control/build server is required for managed registry builds.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $controlServer->build_enabled) {
|
||||||
|
return 'The managed registry control server is not build-enabled.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function readinessChecksPassed(Registry $registry): bool
|
||||||
|
{
|
||||||
|
$checks = $registry->readiness_checks ?? [];
|
||||||
|
|
||||||
|
if ($checks === []) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['control_https', 'build_push'] as $requiredCheck) {
|
||||||
|
if (! array_key_exists($requiredCheck, $checks)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return collect($checks)->every(fn (mixed $status): bool => $status === 'passed');
|
||||||
|
}
|
||||||
|
}
|
||||||
189
app/Services/Registries/ManagedRegistryOperationScripts.php
Normal file
189
app/Services/Registries/ManagedRegistryOperationScripts.php
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Registries;
|
||||||
|
|
||||||
|
use App\Models\BuildArtifact;
|
||||||
|
use App\Models\Registry;
|
||||||
|
use App\Models\Server;
|
||||||
|
|
||||||
|
class ManagedRegistryOperationScripts
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array{script: string, secrets: array<string, string>}
|
||||||
|
*/
|
||||||
|
public function provision(Registry $registry): array
|
||||||
|
{
|
||||||
|
$credentials = $registry->credentials ?? [];
|
||||||
|
$host = $this->host($registry);
|
||||||
|
$storagePath = $registry->storage_path ?: (string) config('keystone.managed_registry.storage_path');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'script' => implode("\n", [
|
||||||
|
'set -euo pipefail',
|
||||||
|
'storage_path='.escapeshellarg($storagePath),
|
||||||
|
'registry_host='.escapeshellarg($host),
|
||||||
|
'install -d -m 700 -o root -g root /home/keystone/registry/auth /home/keystone/registry/config',
|
||||||
|
'install -d -m 755 -o root -g root "$storage_path"',
|
||||||
|
'tmp_htpasswd=$(mktemp)',
|
||||||
|
'cleanup() { rm -f "$tmp_htpasswd"; unset build_password runtime_password; }',
|
||||||
|
'trap cleanup EXIT',
|
||||||
|
'build_password=$(printf %s '.escapeshellarg('[!build_password_base64!]').' | base64 -d)',
|
||||||
|
'runtime_password=$(printf %s '.escapeshellarg('[!runtime_password_base64!]').' | base64 -d)',
|
||||||
|
'printf %s "$build_password" | docker run -i --rm --entrypoint htpasswd httpd:2.4-alpine -Bni '.escapeshellarg((string) ($credentials['build_username'] ?? 'keystone-build')).' > "$tmp_htpasswd"',
|
||||||
|
'printf %s "$runtime_password" | docker run -i --rm --entrypoint htpasswd httpd:2.4-alpine -Bni '.escapeshellarg((string) ($credentials['runtime_username'] ?? 'keystone-runtime')).' >> "$tmp_htpasswd"',
|
||||||
|
'install -m 600 -o root -g root "$tmp_htpasswd" /home/keystone/registry/auth/htpasswd',
|
||||||
|
'cat > /home/keystone/registry/config/config.yml <<\'KEYSTONE_REGISTRY_CONFIG\'',
|
||||||
|
'version: 0.1',
|
||||||
|
'log:',
|
||||||
|
' fields:',
|
||||||
|
' service: registry',
|
||||||
|
'storage:',
|
||||||
|
' filesystem:',
|
||||||
|
' rootdirectory: /var/lib/registry',
|
||||||
|
' delete:',
|
||||||
|
' enabled: true',
|
||||||
|
'http:',
|
||||||
|
' addr: :5000',
|
||||||
|
'auth:',
|
||||||
|
' htpasswd:',
|
||||||
|
' realm: keystone-managed-registry',
|
||||||
|
' path: /auth/htpasswd',
|
||||||
|
'KEYSTONE_REGISTRY_CONFIG',
|
||||||
|
'docker rm -f keystone-managed-registry >/dev/null 2>&1 || true',
|
||||||
|
'docker run -d --name keystone-managed-registry --restart unless-stopped -p 127.0.0.1:5000:5000 -e REGISTRY_STORAGE_DELETE_ENABLED=true -v "$storage_path:/var/lib/registry" -v /home/keystone/registry/auth:/auth:ro -v /home/keystone/registry/config/config.yml:/etc/docker/registry/config.yml:ro registry:2',
|
||||||
|
'install -d -m 755 /home/keystone/gateway/Caddyfile.d',
|
||||||
|
'cat > /home/keystone/gateway/Caddyfile.d/managed-registry.caddy <<KEYSTONE_CADDY_REGISTRY',
|
||||||
|
'$registry_host {',
|
||||||
|
' reverse_proxy 127.0.0.1:5000',
|
||||||
|
'}',
|
||||||
|
'KEYSTONE_CADDY_REGISTRY',
|
||||||
|
'if test -d /home/keystone/gateway/Caddyfile.d; then cat /home/keystone/gateway/Caddyfile.d/*.caddy > /home/keystone/gateway/Caddyfile; fi',
|
||||||
|
'if docker ps --format \'{{.Names}}\' | grep -qx gateway-1; then docker exec gateway-1 caddy reload --config /etc/caddy/Caddyfile; fi',
|
||||||
|
'if docker ps --format \'{{.Names}}\' | grep -qx caddy; then docker exec caddy caddy reload --config /etc/caddy/Caddyfile; fi',
|
||||||
|
'if docker ps --format \'{{.Names}}\' | grep -Eqx \'(gateway-1|caddy)\'; then curl --fail --silent --show-error --location --head https://"$registry_host"/v2/ || test "$?" = "22"; else echo "Registry proxy reload skipped because no Caddy container is running."; fi',
|
||||||
|
]),
|
||||||
|
'secrets' => [
|
||||||
|
'build_password_base64' => base64_encode((string) ($credentials['build_password'] ?? '')),
|
||||||
|
'runtime_password_base64' => base64_encode((string) ($credentials['runtime_password'] ?? '')),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{script: string, secrets: array<string, string>}
|
||||||
|
*/
|
||||||
|
public function smokeCheck(Registry $registry, Server $server, string $scope, ?string $imageReference = null): array
|
||||||
|
{
|
||||||
|
$credentials = $registry->credentials ?? [];
|
||||||
|
$username = (string) ($credentials[$scope.'_username'] ?? '');
|
||||||
|
$password = (string) ($credentials[$scope.'_password'] ?? '');
|
||||||
|
$host = $this->host($registry);
|
||||||
|
$repository = $imageReference ?: $host.'/keystone/smoke/server-'.$server->id.':latest';
|
||||||
|
|
||||||
|
$commands = [
|
||||||
|
'set -euo pipefail',
|
||||||
|
'registry_host='.escapeshellarg($host),
|
||||||
|
'image_ref='.escapeshellarg($repository),
|
||||||
|
'username='.escapeshellarg($username),
|
||||||
|
'password=$(printf %s '.escapeshellarg('[!registry_password_base64!]').' | base64 -d)',
|
||||||
|
'curl --fail --silent --show-error --user "$username:$password" https://"$registry_host"/v2/ >/dev/null',
|
||||||
|
'printf %s '.escapeshellarg('[!registry_password_base64!]').' | base64 -d | docker login "$registry_host" --username '.escapeshellarg($username).' --password-stdin >/dev/null',
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($scope === 'build') {
|
||||||
|
$commands = [
|
||||||
|
...$commands,
|
||||||
|
'docker pull busybox:latest >/dev/null',
|
||||||
|
'docker tag busybox:latest "$image_ref"',
|
||||||
|
'docker push "$image_ref" >/dev/null',
|
||||||
|
'docker buildx imagetools inspect "$image_ref" >/dev/null',
|
||||||
|
'printf "smoke_ref=%s\n" "$image_ref"',
|
||||||
|
'unset password',
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
$commands = [
|
||||||
|
...$commands,
|
||||||
|
'docker pull "$image_ref" >/dev/null',
|
||||||
|
'unset password',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'script' => implode("\n", $commands),
|
||||||
|
'secrets' => [
|
||||||
|
'registry_password_base64' => base64_encode($password),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param iterable<int, BuildArtifact> $artifacts
|
||||||
|
* @return array{script: string, secrets: array<string, string>}
|
||||||
|
*/
|
||||||
|
public function maintenance(Registry $registry, iterable $artifacts): array
|
||||||
|
{
|
||||||
|
$credentials = $registry->credentials ?? [];
|
||||||
|
$host = $this->host($registry);
|
||||||
|
$deletions = [];
|
||||||
|
|
||||||
|
foreach ($artifacts as $artifact) {
|
||||||
|
$repository = $this->repositoryPath((string) $artifact->registry_ref);
|
||||||
|
|
||||||
|
if ($repository === '' || blank($artifact->image_digest)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$deletions[] = 'delete_manifest '.escapeshellarg($repository).' '.escapeshellarg((string) $artifact->image_digest).' || delete_failures=1';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'script' => implode("\n", [
|
||||||
|
'set -euo pipefail',
|
||||||
|
'registry_host='.escapeshellarg($host),
|
||||||
|
'lock_file=/home/keystone/registry/maintenance.lock',
|
||||||
|
'exec 9>"$lock_file"',
|
||||||
|
'flock -n 9',
|
||||||
|
'username='.escapeshellarg((string) ($credentials['build_username'] ?? 'keystone-build')),
|
||||||
|
'password=$(printf %s '.escapeshellarg('[!registry_password_base64!]').' | base64 -d)',
|
||||||
|
'curl_config=$(mktemp)',
|
||||||
|
'registry_was_stopped=0',
|
||||||
|
'cleanup() { rm -f "$curl_config"; unset password auth_header; if test "$registry_was_stopped" = "1"; then docker start keystone-managed-registry >/dev/null 2>&1 || true; fi; }',
|
||||||
|
'trap cleanup EXIT',
|
||||||
|
'auth_header=$(printf "%s:%s" "$username" "$password" | base64 | tr -d "\n")',
|
||||||
|
'printf "header = \"Authorization: Basic %s\"\n" "$auth_header" > "$curl_config"',
|
||||||
|
'chmod 600 "$curl_config"',
|
||||||
|
'delete_failures=0',
|
||||||
|
'delete_manifest() {',
|
||||||
|
' repository="$1"',
|
||||||
|
' digest="$2"',
|
||||||
|
' status=$(curl --silent --show-error --output /tmp/keystone-registry-delete-response --write-out "%{http_code}" --request DELETE --config "$curl_config" --header "Accept: application/vnd.docker.distribution.manifest.v2+json" "https://$registry_host/v2/$repository/manifests/$digest" || true)',
|
||||||
|
' case "$status" in 2*|404) printf "deleted_manifest=%s@%s status=%s\n" "$repository" "$digest" "$status" ;; *) cat /tmp/keystone-registry-delete-response >&2; printf "delete_failed=%s@%s status=%s\n" "$repository" "$digest" "$status" >&2; return 1 ;; esac',
|
||||||
|
'}',
|
||||||
|
...$deletions,
|
||||||
|
'test "$delete_failures" = "0"',
|
||||||
|
'docker stop keystone-managed-registry',
|
||||||
|
'registry_was_stopped=1',
|
||||||
|
'docker run --rm -v /home/keystone/registry/config/config.yml:/etc/docker/registry/config.yml:ro -v '.escapeshellarg(($registry->storage_path ?: (string) config('keystone.managed_registry.storage_path')).':/var/lib/registry').' registry:2 garbage-collect --delete-untagged /etc/docker/registry/config.yml',
|
||||||
|
'docker start keystone-managed-registry',
|
||||||
|
'registry_was_stopped=0',
|
||||||
|
'unset password',
|
||||||
|
]),
|
||||||
|
'secrets' => [
|
||||||
|
'registry_password_base64' => base64_encode((string) ($credentials['build_password'] ?? '')),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function host(Registry $registry): string
|
||||||
|
{
|
||||||
|
return rtrim((string) preg_replace('#^https?://#', '', (string) $registry->url), '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function repositoryPath(string $reference): string
|
||||||
|
{
|
||||||
|
$withoutHost = str($reference)->after('/')->value();
|
||||||
|
$withoutTag = preg_replace('/:[^:\/]+$/', '', $withoutHost);
|
||||||
|
|
||||||
|
return trim((string) $withoutTag, '/');
|
||||||
|
}
|
||||||
|
}
|
||||||
64
app/Services/Registries/ManagedRegistryProvisioner.php
Normal file
64
app/Services/Registries/ManagedRegistryProvisioner.php
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Registries;
|
||||||
|
|
||||||
|
use App\Enums\RegistryType;
|
||||||
|
use App\Models\Organisation;
|
||||||
|
use App\Models\Registry;
|
||||||
|
use App\Models\Server;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class ManagedRegistryProvisioner
|
||||||
|
{
|
||||||
|
public function provision(Organisation $organisation, string $url, ?Server $controlServer = null, ?string $storagePath = null, ?int $retention = null): Registry
|
||||||
|
{
|
||||||
|
$registry = $organisation->registries()->firstOrNew([
|
||||||
|
'type' => RegistryType::MANAGED->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$registry->fill([
|
||||||
|
'name' => 'Managed Registry',
|
||||||
|
'url' => $this->registryHost($url),
|
||||||
|
'storage_path' => $storagePath ?: (string) config('keystone.managed_registry.storage_path'),
|
||||||
|
'retention_successful_artifacts' => $retention ?: (int) config('keystone.managed_registry.retention.successful_artifacts_per_environment', 3),
|
||||||
|
'control_server_id' => $controlServer?->id,
|
||||||
|
'credentials' => $this->credentials($registry->credentials ?? []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($registry->health_status === null) {
|
||||||
|
$registry->health_status = 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
$registry->save();
|
||||||
|
|
||||||
|
if ($controlServer instanceof Server) {
|
||||||
|
$controlServer->forceFill([
|
||||||
|
'is_control_node' => true,
|
||||||
|
'build_enabled' => true,
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $registry->refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $existing
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function credentials(array $existing): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'build_username' => (string) ($existing['build_username'] ?? 'keystone-build'),
|
||||||
|
'build_password' => (string) ($existing['build_password'] ?? Str::password(40)),
|
||||||
|
'runtime_username' => (string) ($existing['runtime_username'] ?? 'keystone-runtime'),
|
||||||
|
'runtime_password' => (string) ($existing['runtime_password'] ?? Str::password(40)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function registryHost(string $url): string
|
||||||
|
{
|
||||||
|
$host = preg_replace('#^https?://#', '', trim($url));
|
||||||
|
|
||||||
|
return rtrim($host === null ? trim($url) : $host, '/');
|
||||||
|
}
|
||||||
|
}
|
||||||
80
app/Services/Registries/ManagedRegistryRetention.php
Normal file
80
app/Services/Registries/ManagedRegistryRetention.php
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Registries;
|
||||||
|
|
||||||
|
use App\Enums\BuildArtifactStatus;
|
||||||
|
use App\Enums\RegistryType;
|
||||||
|
use App\Models\BuildArtifact;
|
||||||
|
use App\Models\Environment;
|
||||||
|
use App\Models\Registry;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class ManagedRegistryRetention
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return Collection<int, BuildArtifact>
|
||||||
|
*/
|
||||||
|
public function markPrunable(Registry $registry): Collection
|
||||||
|
{
|
||||||
|
if ($registry->type !== RegistryType::MANAGED) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
$keep = max(1, (int) $registry->retention_successful_artifacts);
|
||||||
|
$updated = collect();
|
||||||
|
|
||||||
|
Environment::query()
|
||||||
|
->whereHas('buildArtifacts', fn ($query) => $query
|
||||||
|
->where('status', BuildArtifactStatus::AVAILABLE)
|
||||||
|
->where('registry_ref', 'like', rtrim((string) $registry->url, '/').'/%'))
|
||||||
|
->with(['services', 'buildArtifacts' => fn ($query) => $query
|
||||||
|
->where('status', BuildArtifactStatus::AVAILABLE)
|
||||||
|
->where('registry_ref', 'like', rtrim((string) $registry->url, '/').'/%')
|
||||||
|
->latest()])
|
||||||
|
->each(function (Environment $environment) use ($keep, $updated): void {
|
||||||
|
$activeDigests = $environment->services
|
||||||
|
->flatMap(fn ($service): array => [
|
||||||
|
$service->available_image_digest,
|
||||||
|
$service->current_image_digest,
|
||||||
|
])
|
||||||
|
->filter()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$environment->buildArtifacts
|
||||||
|
->skip($keep)
|
||||||
|
->filter(fn (BuildArtifact $artifact): bool => ! in_array($artifact->image_digest, $activeDigests, true))
|
||||||
|
->each(function (BuildArtifact $artifact) use ($updated): void {
|
||||||
|
$metadata = $artifact->metadata ?? [];
|
||||||
|
$artifact->update([
|
||||||
|
'status' => BuildArtifactStatus::PRUNABLE,
|
||||||
|
'metadata' => [
|
||||||
|
...$metadata,
|
||||||
|
'prunable_at' => now()->toIso8601String(),
|
||||||
|
'prune_command' => $this->deleteManifestCommand($artifact),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$updated->push($artifact->refresh());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return $updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteManifestCommand(BuildArtifact $artifact): string
|
||||||
|
{
|
||||||
|
$reference = (string) ($artifact->registry_ref ?? '');
|
||||||
|
$digest = (string) $artifact->image_digest;
|
||||||
|
|
||||||
|
return 'curl --fail --silent --show-error --request DELETE '.escapeshellarg('https://'.$this->manifestPath($reference, $digest));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function manifestPath(string $reference, string $digest): string
|
||||||
|
{
|
||||||
|
$hostAndPath = preg_replace('/:[^:\/]+$/', '', $reference);
|
||||||
|
$path = str($hostAndPath)->after('/')->value();
|
||||||
|
|
||||||
|
return $path === ''
|
||||||
|
? 'v2/'
|
||||||
|
: str($reference)->before('/')->value().'/v2/'.$path.'/manifests/'.$digest;
|
||||||
|
}
|
||||||
|
}
|
||||||
50
app/Services/Registries/RegistryDockerAuthScript.php
Normal file
50
app/Services/Registries/RegistryDockerAuthScript.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Registries;
|
||||||
|
|
||||||
|
use App\Models\Registry;
|
||||||
|
|
||||||
|
class RegistryDockerAuthScript
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array{script: string, secrets: array<string, string>}
|
||||||
|
*/
|
||||||
|
public function forBuild(Registry $registry, string $user = 'keystone'): array
|
||||||
|
{
|
||||||
|
return $this->forCredential($registry, 'build', $user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{script: string, secrets: array<string, string>}
|
||||||
|
*/
|
||||||
|
public function forRuntime(Registry $registry, string $user = 'keystone'): array
|
||||||
|
{
|
||||||
|
return $this->forCredential($registry, 'runtime', $user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{script: string, secrets: array<string, string>}
|
||||||
|
*/
|
||||||
|
private function forCredential(Registry $registry, string $scope, string $user): array
|
||||||
|
{
|
||||||
|
$credentials = $registry->credentials ?? [];
|
||||||
|
$username = (string) ($credentials[$scope.'_username'] ?? $credentials['username'] ?? '');
|
||||||
|
$password = (string) ($credentials[$scope.'_password'] ?? $credentials['password'] ?? '');
|
||||||
|
$home = $user === 'root' ? '/root' : '/home/'.$user;
|
||||||
|
$registryHost = rtrim((string) preg_replace('#^https?://#', '', (string) $registry->url), '/');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'script' => implode("\n", [
|
||||||
|
'set -euo pipefail',
|
||||||
|
'install -d -m 700 -o '.escapeshellarg($user).' -g '.escapeshellarg($user).' '.escapeshellarg($home.'/.docker'),
|
||||||
|
'export DOCKER_CONFIG='.escapeshellarg($home.'/.docker'),
|
||||||
|
'printf %s '.escapeshellarg('[!registry_password_base64!]').' | base64 -d | docker login '.escapeshellarg($registryHost).' --username '.escapeshellarg($username).' --password-stdin >/dev/null',
|
||||||
|
'chown '.escapeshellarg($user.':'.$user).' '.escapeshellarg($home.'/.docker/config.json'),
|
||||||
|
'chmod 600 '.escapeshellarg($home.'/.docker/config.json'),
|
||||||
|
]),
|
||||||
|
'secrets' => [
|
||||||
|
'registry_password_base64' => base64_encode($password),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
47
app/Services/Registries/RegistryResolver.php
Normal file
47
app/Services/Registries/RegistryResolver.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Registries;
|
||||||
|
|
||||||
|
use App\Enums\RegistryType;
|
||||||
|
use App\Models\Organisation;
|
||||||
|
use App\Models\Registry;
|
||||||
|
|
||||||
|
class RegistryResolver
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ManagedRegistryHealth $managedRegistryHealth,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function buildRegistryFor(Organisation $organisation): ?Registry
|
||||||
|
{
|
||||||
|
$externalRegistry = $organisation->registries()
|
||||||
|
->where('type', '!=', RegistryType::MANAGED->value)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($externalRegistry instanceof Registry) {
|
||||||
|
return $externalRegistry;
|
||||||
|
}
|
||||||
|
|
||||||
|
$managedRegistry = $organisation->registries()
|
||||||
|
->where('type', RegistryType::MANAGED->value)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($managedRegistry instanceof Registry) {
|
||||||
|
return $this->managedRegistryHealth->readinessBlocker($managedRegistry) === null ? $managedRegistry : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function managedRegistryBlockerFor(Organisation $organisation): ?string
|
||||||
|
{
|
||||||
|
$managedRegistry = $organisation->registries()
|
||||||
|
->where('type', RegistryType::MANAGED->value)
|
||||||
|
->with('controlServer')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return $managedRegistry instanceof Registry
|
||||||
|
? $this->managedRegistryHealth->readinessBlocker($managedRegistry)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
38
app/Support/CaddyRouteRenderer.php
Normal file
38
app/Support/CaddyRouteRenderer.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use App\Models\EnvironmentAttachment;
|
||||||
|
|
||||||
|
class CaddyRouteRenderer
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $upstreams
|
||||||
|
*/
|
||||||
|
public function render(EnvironmentAttachment $attachment, array $upstreams): string
|
||||||
|
{
|
||||||
|
$config = $attachment->serviceSlice?->config ?? [];
|
||||||
|
$domain = $config['domain'] ?? $attachment->serviceSlice?->name ?? $attachment->environment->name;
|
||||||
|
$pathPrefix = $config['path_prefix'] ?? '/';
|
||||||
|
$siteAddress = ($config['tls_enabled'] ?? true) ? $domain : "http://{$domain}";
|
||||||
|
$upstreamTargets = $upstreams === [] ? ['web:80'] : $upstreams;
|
||||||
|
|
||||||
|
if ($pathPrefix === '/') {
|
||||||
|
return implode("\n", [
|
||||||
|
"{$siteAddress} {",
|
||||||
|
' reverse_proxy '.implode(' ', $upstreamTargets),
|
||||||
|
'}',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedPath = rtrim($pathPrefix, '/');
|
||||||
|
|
||||||
|
return implode("\n", [
|
||||||
|
"{$siteAddress} {",
|
||||||
|
" handle_path {$normalizedPath}* {",
|
||||||
|
' reverse_proxy '.implode(' ', $upstreamTargets),
|
||||||
|
' }',
|
||||||
|
'}',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,7 +27,8 @@ class Ip
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($maskBits > 0) {
|
if ($maskBits > 0) {
|
||||||
$maskValue = chr(pow(2, $maskBits) - 1);
|
$maskValue = (1 << $maskBits) - 1;
|
||||||
|
$maskValue <<= (8 - $maskBits);
|
||||||
$subnetByte = ord($subnet[$maskBytes]);
|
$subnetByte = ord($subnet[$maskBytes]);
|
||||||
$ipByte = ord($ip[$maskBytes]);
|
$ipByte = ord($ip[$maskBytes]);
|
||||||
|
|
||||||
|
|||||||
@@ -20,11 +20,13 @@
|
|||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
"fakerphp/faker": "^1.23",
|
||||||
|
"larastan/larastan": "^3.0",
|
||||||
"laravel/boost": "^1.1",
|
"laravel/boost": "^1.1",
|
||||||
"laravel/pail": "^1.2.2",
|
"laravel/pail": "^1.2.2",
|
||||||
"laravel/pint": "^1.18",
|
"laravel/pint": "^1.18",
|
||||||
"laravel/sail": "^1.41",
|
"laravel/sail": "^1.41",
|
||||||
"mockery/mockery": "^1.6",
|
"mockery/mockery": "^1.6",
|
||||||
|
"mrpunyapal/peststan": "^0.2.5",
|
||||||
"nunomaduro/collision": "^8.6",
|
"nunomaduro/collision": "^8.6",
|
||||||
"pestphp/pest": "^3.7",
|
"pestphp/pest": "^3.7",
|
||||||
"pestphp/pest-plugin-laravel": "^3.1"
|
"pestphp/pest-plugin-laravel": "^3.1"
|
||||||
@@ -65,6 +67,14 @@
|
|||||||
"npm run build:ssr",
|
"npm run build:ssr",
|
||||||
"Composer\\Config::disableProcessTimeout",
|
"Composer\\Config::disableProcessTimeout",
|
||||||
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"php artisan inertia:start-ssr\" --names=server,queue,logs,ssr"
|
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"php artisan inertia:start-ssr\" --names=server,queue,logs,ssr"
|
||||||
|
],
|
||||||
|
"phpstan": "vendor/bin/phpstan analyse --memory-limit=1G",
|
||||||
|
"coverage": [
|
||||||
|
"XDEBUG_MODE=coverage vendor/bin/pest --coverage --min=95"
|
||||||
|
],
|
||||||
|
"quality": [
|
||||||
|
"composer phpstan",
|
||||||
|
"composer coverage"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"extra": {
|
"extra": {
|
||||||
|
|||||||
254
composer.lock
generated
254
composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "69f6de114270a8beb46d9283a2acd24d",
|
"content-hash": "f73763833c370943f03916f4eaa3ce26",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "brick/math",
|
"name": "brick/math",
|
||||||
@@ -6540,6 +6540,47 @@
|
|||||||
},
|
},
|
||||||
"time": "2020-07-09T08:09:16+00:00"
|
"time": "2020-07-09T08:09:16+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "iamcal/sql-parser",
|
||||||
|
"version": "v0.6",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/iamcal/SQLParser.git",
|
||||||
|
"reference": "947083e2dca211a6f12fb1beb67a01e387de9b62"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/iamcal/SQLParser/zipball/947083e2dca211a6f12fb1beb67a01e387de9b62",
|
||||||
|
"reference": "947083e2dca211a6f12fb1beb67a01e387de9b62",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"php-coveralls/php-coveralls": "^1.0",
|
||||||
|
"phpunit/phpunit": "^5|^6|^7|^8|^9"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"iamcal\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Cal Henderson",
|
||||||
|
"email": "cal@iamcal.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "MySQL schema parser",
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/iamcal/SQLParser/issues",
|
||||||
|
"source": "https://github.com/iamcal/SQLParser/tree/v0.6"
|
||||||
|
},
|
||||||
|
"time": "2025-03-17T16:59:46+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "jean85/pretty-package-versions",
|
"name": "jean85/pretty-package-versions",
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
@@ -6600,6 +6641,99 @@
|
|||||||
},
|
},
|
||||||
"time": "2025-03-19T14:43:43+00:00"
|
"time": "2025-03-19T14:43:43+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "larastan/larastan",
|
||||||
|
"version": "v3.3.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/larastan/larastan.git",
|
||||||
|
"reference": "b032de3918a8bab9ee7f1bb71609842243fd89d9"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/larastan/larastan/zipball/b032de3918a8bab9ee7f1bb71609842243fd89d9",
|
||||||
|
"reference": "b032de3918a8bab9ee7f1bb71609842243fd89d9",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-json": "*",
|
||||||
|
"iamcal/sql-parser": "^0.6.0",
|
||||||
|
"illuminate/console": "^11.42.2 || ^12.0",
|
||||||
|
"illuminate/container": "^11.42.2 || ^12.0",
|
||||||
|
"illuminate/contracts": "^11.42.2 || ^12.0",
|
||||||
|
"illuminate/database": "^11.42.2 || ^12.0",
|
||||||
|
"illuminate/http": "^11.42.2 || ^12.0",
|
||||||
|
"illuminate/pipeline": "^11.42.2 || ^12.0",
|
||||||
|
"illuminate/support": "^11.42.2 || ^12.0",
|
||||||
|
"php": "^8.2",
|
||||||
|
"phpstan/phpstan": "^2.1.8"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"doctrine/coding-standard": "^12.0",
|
||||||
|
"laravel/framework": "^11.42.2 || ^12.0",
|
||||||
|
"mockery/mockery": "^1.6",
|
||||||
|
"nikic/php-parser": "^5.3",
|
||||||
|
"orchestra/canvas": "^v9.1.3 || ^10.0",
|
||||||
|
"orchestra/testbench-core": "^9.5.2 || ^10.0",
|
||||||
|
"phpstan/phpstan-deprecation-rules": "^2.0.0",
|
||||||
|
"phpunit/phpunit": "^10.5.35 || ^11.3.6"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"orchestra/testbench": "Using Larastan for analysing a package needs Testbench"
|
||||||
|
},
|
||||||
|
"type": "phpstan-extension",
|
||||||
|
"extra": {
|
||||||
|
"phpstan": {
|
||||||
|
"includes": [
|
||||||
|
"extension.neon"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "3.0-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Larastan\\Larastan\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Can Vural",
|
||||||
|
"email": "can9119@gmail.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Nuno Maduro",
|
||||||
|
"email": "enunomaduro@gmail.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Larastan - Discover bugs in your code without running it. A phpstan/phpstan wrapper for Laravel",
|
||||||
|
"keywords": [
|
||||||
|
"PHPStan",
|
||||||
|
"code analyse",
|
||||||
|
"code analysis",
|
||||||
|
"larastan",
|
||||||
|
"laravel",
|
||||||
|
"package",
|
||||||
|
"php",
|
||||||
|
"static analysis"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/larastan/larastan/issues",
|
||||||
|
"source": "https://github.com/larastan/larastan/tree/v3.3.0"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://github.com/canvural",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2025-04-03T19:11:55+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "laravel/boost",
|
"name": "laravel/boost",
|
||||||
"version": "v1.1.5",
|
"version": "v1.1.5",
|
||||||
@@ -7080,6 +7214,69 @@
|
|||||||
},
|
},
|
||||||
"time": "2024-05-16T03:13:13+00:00"
|
"time": "2024-05-16T03:13:13+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "mrpunyapal/peststan",
|
||||||
|
"version": "0.2.10",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/MrPunyapal/PestStan.git",
|
||||||
|
"reference": "750859a911050915cb6e3efbfde30e900ad717bf"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/MrPunyapal/PestStan/zipball/750859a911050915cb6e3efbfde30e900ad717bf",
|
||||||
|
"reference": "750859a911050915cb6e3efbfde30e900ad717bf",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^8.2",
|
||||||
|
"phpstan/phpstan": "^2.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"laravel/pint": "^1.18",
|
||||||
|
"mrpunyapal/rector-pest": "^0.2.0",
|
||||||
|
"nunomaduro/pao": "^0.1.4",
|
||||||
|
"pestphp/pest": "^3.0 || ^4.0 || ^5.0",
|
||||||
|
"phpstan/extension-installer": "^1.4",
|
||||||
|
"phpstan/phpstan-strict-rules": "^2.0",
|
||||||
|
"rector/rector": "^2.0"
|
||||||
|
},
|
||||||
|
"type": "phpstan-extension",
|
||||||
|
"extra": {
|
||||||
|
"phpstan": {
|
||||||
|
"includes": [
|
||||||
|
"extension.neon"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"PestStan\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"description": "PHPStan extension for Pest PHP testing framework",
|
||||||
|
"keywords": [
|
||||||
|
"PHPStan",
|
||||||
|
"pest",
|
||||||
|
"static-analysis",
|
||||||
|
"testing"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/MrPunyapal/PestStan/issues",
|
||||||
|
"source": "https://github.com/MrPunyapal/PestStan/tree/0.2.10"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://github.com/mrpunyapal",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-05-10T16:55:11+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "myclabs/deep-copy",
|
"name": "myclabs/deep-copy",
|
||||||
"version": "1.13.0",
|
"version": "1.13.0",
|
||||||
@@ -7976,6 +8173,59 @@
|
|||||||
},
|
},
|
||||||
"time": "2025-02-19T13:28:12+00:00"
|
"time": "2025-02-19T13:28:12+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "phpstan/phpstan",
|
||||||
|
"version": "2.1.54",
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/8be50c3992107dc837b17da4d140fbbdf9a5c5bd",
|
||||||
|
"reference": "8be50c3992107dc837b17da4d140fbbdf9a5c5bd",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^7.4|^8.0"
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"phpstan/phpstan-shim": "*"
|
||||||
|
},
|
||||||
|
"bin": [
|
||||||
|
"phpstan",
|
||||||
|
"phpstan.phar"
|
||||||
|
],
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"bootstrap.php"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"description": "PHPStan - PHP Static Analysis Tool",
|
||||||
|
"keywords": [
|
||||||
|
"dev",
|
||||||
|
"static analysis"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"docs": "https://phpstan.org/user-guide/getting-started",
|
||||||
|
"forum": "https://github.com/phpstan/phpstan/discussions",
|
||||||
|
"issues": "https://github.com/phpstan/phpstan/issues",
|
||||||
|
"security": "https://github.com/phpstan/phpstan/security/policy",
|
||||||
|
"source": "https://github.com/phpstan/phpstan-src"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://github.com/ondrejmirtes",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/phpstan",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-04-29T13:31:09+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "phpunit/php-code-coverage",
|
"name": "phpunit/php-code-coverage",
|
||||||
"version": "11.0.9",
|
"version": "11.0.9",
|
||||||
@@ -9569,5 +9819,5 @@
|
|||||||
"php": "^8.2"
|
"php": "^8.2"
|
||||||
},
|
},
|
||||||
"platform-dev": {},
|
"platform-dev": {},
|
||||||
"plugin-api-version": "2.9.0"
|
"plugin-api-version": "2.6.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,15 @@ use App\Enums\ServiceCategory;
|
|||||||
use App\Enums\ServiceType;
|
use App\Enums\ServiceType;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
'managed_registry' => [
|
||||||
|
'url' => env('KEYSTONE_MANAGED_REGISTRY_URL'),
|
||||||
|
'namespace' => env('KEYSTONE_MANAGED_REGISTRY_NAMESPACE', 'keystone'),
|
||||||
|
'storage_path' => env('KEYSTONE_MANAGED_REGISTRY_STORAGE_PATH', '/home/keystone/registry/data'),
|
||||||
|
'retention' => [
|
||||||
|
'successful_artifacts_per_environment' => (int) env('KEYSTONE_MANAGED_REGISTRY_RETAIN_SUCCESSFUL_ARTIFACTS', 3),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
'drivers' => [
|
'drivers' => [
|
||||||
'postgres' => [
|
'postgres' => [
|
||||||
'18' => Postgres18Driver::class,
|
'18' => Postgres18Driver::class,
|
||||||
|
|||||||
32
database/factories/OrganisationInvitationFactory.php
Normal file
32
database/factories/OrganisationInvitationFactory.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Enums\OrganisationRole;
|
||||||
|
use App\Models\Organisation;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\OrganisationInvitation>
|
||||||
|
*/
|
||||||
|
class OrganisationInvitationFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'organisation_id' => Organisation::factory(),
|
||||||
|
'invited_by_user_id' => User::factory(),
|
||||||
|
'email' => $this->faker->unique()->safeEmail(),
|
||||||
|
'role' => OrganisationRole::MEMBER,
|
||||||
|
'token' => Str::random(40),
|
||||||
|
'expires_at' => now()->addDays(14),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,8 @@ class ServerFactory extends Factory
|
|||||||
'os' => 'ubuntu',
|
'os' => 'ubuntu',
|
||||||
'plan' => '26',
|
'plan' => '26',
|
||||||
'user' => 'keystone',
|
'user' => 'keystone',
|
||||||
|
'is_control_node' => false,
|
||||||
|
'build_enabled' => false,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?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('applications', function (Blueprint $table) {
|
||||||
|
$table->foreignId('source_provider_id')
|
||||||
|
->nullable()
|
||||||
|
->after('organisation_id')
|
||||||
|
->constrained('source_providers')
|
||||||
|
->nullOnDelete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('applications', function (Blueprint $table) {
|
||||||
|
$table->dropConstrainedForeignId('source_provider_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Organisation;
|
||||||
|
use App\Models\User;
|
||||||
|
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::create('organisation_invitations', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignIdFor(Organisation::class)->constrained()->cascadeOnDelete();
|
||||||
|
$table->foreignIdFor(User::class, 'invited_by_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||||
|
$table->string('email');
|
||||||
|
$table->string('role');
|
||||||
|
$table->string('token')->unique();
|
||||||
|
$table->timestamp('accepted_at')->nullable();
|
||||||
|
$table->timestamp('expires_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['organisation_id', 'email']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('organisation_invitations');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('registries', function (Blueprint $table) {
|
||||||
|
$table->string('storage_path')->nullable()->after('url');
|
||||||
|
$table->unsignedInteger('retention_successful_artifacts')->default(3)->after('storage_path');
|
||||||
|
$table->string('health_status')->default('pending')->after('retention_successful_artifacts');
|
||||||
|
$table->text('health_message')->nullable()->after('health_status');
|
||||||
|
$table->json('readiness_checks')->nullable()->after('health_message');
|
||||||
|
$table->timestamp('health_checked_at')->nullable()->after('readiness_checks');
|
||||||
|
$table->timestamp('ready_at')->nullable()->after('health_checked_at');
|
||||||
|
$table->foreignId('control_server_id')->nullable()->after('ready_at')->constrained('servers')->nullOnDelete();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('servers', function (Blueprint $table) {
|
||||||
|
$table->boolean('is_control_node')->default(false)->after('user');
|
||||||
|
$table->boolean('build_enabled')->default(false)->after('is_control_node');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('operations', function (Blueprint $table) {
|
||||||
|
$table->json('metadata')->nullable()->after('status');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('applications', function (Blueprint $table) {
|
||||||
|
$table->uuid('uuid')->nullable()->after('id');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('environments', function (Blueprint $table) {
|
||||||
|
$table->uuid('uuid')->nullable()->after('id');
|
||||||
|
});
|
||||||
|
|
||||||
|
DB::table('applications')->whereNull('uuid')->orderBy('id')->each(function (object $application): void {
|
||||||
|
DB::table('applications')->where('id', $application->id)->update(['uuid' => (string) Str::uuid()]);
|
||||||
|
});
|
||||||
|
|
||||||
|
DB::table('environments')->whereNull('uuid')->orderBy('id')->each(function (object $environment): void {
|
||||||
|
DB::table('environments')->where('id', $environment->id)->update(['uuid' => (string) Str::uuid()]);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('applications', function (Blueprint $table) {
|
||||||
|
$table->unique('uuid');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('environments', function (Blueprint $table) {
|
||||||
|
$table->unique('uuid');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('environments', function (Blueprint $table) {
|
||||||
|
$table->dropUnique(['uuid']);
|
||||||
|
$table->dropColumn('uuid');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('applications', function (Blueprint $table) {
|
||||||
|
$table->dropUnique(['uuid']);
|
||||||
|
$table->dropColumn('uuid');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('servers', function (Blueprint $table) {
|
||||||
|
$table->dropColumn(['is_control_node', 'build_enabled']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('operations', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('metadata');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('registries', function (Blueprint $table) {
|
||||||
|
$table->dropConstrainedForeignId('control_server_id');
|
||||||
|
$table->dropColumn([
|
||||||
|
'storage_path',
|
||||||
|
'retention_successful_artifacts',
|
||||||
|
'health_status',
|
||||||
|
'health_message',
|
||||||
|
'readiness_checks',
|
||||||
|
'health_checked_at',
|
||||||
|
'ready_at',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -4,11 +4,8 @@ namespace Database\Seeders;
|
|||||||
|
|
||||||
use App\Enums\OrganisationRole;
|
use App\Enums\OrganisationRole;
|
||||||
use App\Enums\ProviderType;
|
use App\Enums\ProviderType;
|
||||||
use App\Enums\RepositoryType;
|
|
||||||
use App\Models\Organisation;
|
use App\Models\Organisation;
|
||||||
use App\Models\Server;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
@@ -36,37 +33,11 @@ class DatabaseSeeder extends Seeder
|
|||||||
$provider = $organisation->providers()->create([
|
$provider = $organisation->providers()->create([
|
||||||
'name' => 'Hetzner',
|
'name' => 'Hetzner',
|
||||||
'type' => ProviderType::HETZNER,
|
'type' => ProviderType::HETZNER,
|
||||||
'token' => env('HETZNER_KEY'),
|
'token' => env('HETZNER_KEY') ?: 'local-placeholder-token',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (! app()->isProduction()) {
|
if (! app()->isProduction()) {
|
||||||
$network = $organisation->networks()->create([
|
app(SimulatedEnvironmentSeeder::class)->seed($organisation, $provider);
|
||||||
'name' => 'keystone',
|
|
||||||
'external_id' => 'net-12345',
|
|
||||||
'provider_id' => $provider->id,
|
|
||||||
'ip_range' => fake()->ipv4() . '/24',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$servers = Server::factory(40)
|
|
||||||
->forNetwork($network->id)
|
|
||||||
->forOrganisation($organisation->id)
|
|
||||||
->forProvider($provider->id)
|
|
||||||
->create();
|
|
||||||
|
|
||||||
$organisation->servers()->saveMany($servers);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$application = $organisation->applications()->create([
|
|
||||||
'name' => 'ClipBin',
|
|
||||||
'repository_url' => 'git@github.com:hjbdev/clipbin.git',
|
|
||||||
'repository_type' => RepositoryType::GIT,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$application->environments()->create([
|
|
||||||
'name' => 'Dev',
|
|
||||||
'branch' => 'main',
|
|
||||||
'url' => 'https://dev.clipbin.hjb.dev',
|
|
||||||
'status' => 'active',
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
441
database/seeders/SimulatedEnvironmentSeeder.php
Normal file
441
database/seeders/SimulatedEnvironmentSeeder.php
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Actions\Applications\CreateLaravelEnvironment;
|
||||||
|
use App\Actions\Environments\AttachManagedService;
|
||||||
|
use App\Actions\Services\RegisterServiceEndpoint;
|
||||||
|
use App\Enums\BuildArtifactStatus;
|
||||||
|
use App\Enums\DeployPolicy;
|
||||||
|
use App\Enums\EnvironmentAttachmentRole;
|
||||||
|
use App\Enums\EnvironmentVariableSource;
|
||||||
|
use App\Enums\OperationKind;
|
||||||
|
use App\Enums\OperationStatus;
|
||||||
|
use App\Enums\RegistryType;
|
||||||
|
use App\Enums\RepositoryType;
|
||||||
|
use App\Enums\ServerStatus;
|
||||||
|
use App\Enums\ServiceCategory;
|
||||||
|
use App\Enums\ServiceStatus;
|
||||||
|
use App\Enums\ServiceType;
|
||||||
|
use App\Enums\SourceProviderType;
|
||||||
|
use App\Models\Application;
|
||||||
|
use App\Models\Environment;
|
||||||
|
use App\Models\Network;
|
||||||
|
use App\Models\Organisation;
|
||||||
|
use App\Models\Provider;
|
||||||
|
use App\Models\Registry;
|
||||||
|
use App\Models\Server;
|
||||||
|
use App\Models\Service;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class SimulatedEnvironmentSeeder extends Seeder
|
||||||
|
{
|
||||||
|
private Registry $managedRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a fully wired, mostly-running organisation: an ACTIVE server fleet,
|
||||||
|
* registries, a source provider, and one rich application with production +
|
||||||
|
* staging environments (web + postgres + valkey + caddy each), plus a
|
||||||
|
* believable operations history. Reuses the real domain actions so the graph
|
||||||
|
* is internally consistent, without dispatching any deployment jobs.
|
||||||
|
*/
|
||||||
|
public function seed(Organisation $organisation, Provider $provider): void
|
||||||
|
{
|
||||||
|
$network = $organisation->networks()->create([
|
||||||
|
'name' => 'keystone',
|
||||||
|
'external_id' => 'net-12345',
|
||||||
|
'provider_id' => $provider->id,
|
||||||
|
'ip_range' => '10.0.0.0/24',
|
||||||
|
]);
|
||||||
|
|
||||||
|
[$control, $workers] = $this->seedFleet($organisation, $provider, $network);
|
||||||
|
|
||||||
|
$this->seedRegistries($organisation, $control);
|
||||||
|
$sourceProvider = $this->seedSourceProvider($organisation);
|
||||||
|
$application = $this->seedApplication($organisation, $sourceProvider);
|
||||||
|
|
||||||
|
$production = $this->seedEnvironment($application, $control, $workers, 'production', 'main');
|
||||||
|
$staging = $this->seedEnvironment($application, $control, $workers, 'staging', 'develop');
|
||||||
|
|
||||||
|
$this->seedVariety($production, $staging);
|
||||||
|
$this->seedOperationsHistory($control, $production, $staging);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: Server, 1: Collection<int, Server>}
|
||||||
|
*/
|
||||||
|
private function seedFleet(Organisation $organisation, Provider $provider, Network $network): array
|
||||||
|
{
|
||||||
|
$factory = fn (): \Database\Factories\ServerFactory => Server::factory()
|
||||||
|
->forNetwork($network->id)
|
||||||
|
->forOrganisation($organisation->id)
|
||||||
|
->forProvider($provider->id);
|
||||||
|
|
||||||
|
$control = $factory()->create([
|
||||||
|
'name' => 'keystone-control-1',
|
||||||
|
'status' => ServerStatus::ACTIVE,
|
||||||
|
'provider_status' => 'running',
|
||||||
|
'private_ip' => '10.0.0.10',
|
||||||
|
'is_control_node' => true,
|
||||||
|
'build_enabled' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$workers = collect(range(1, 3))->map(fn (int $index): Server => $factory()->create([
|
||||||
|
'name' => "keystone-worker-{$index}",
|
||||||
|
'status' => ServerStatus::ACTIVE,
|
||||||
|
'provider_status' => 'running',
|
||||||
|
'private_ip' => '10.0.0.'.(20 + $index),
|
||||||
|
]));
|
||||||
|
|
||||||
|
return [$control, $workers];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedRegistries(Organisation $organisation, Server $control): void
|
||||||
|
{
|
||||||
|
$this->managedRegistry = $organisation->registries()->create([
|
||||||
|
'name' => 'Keystone Managed',
|
||||||
|
'type' => RegistryType::MANAGED,
|
||||||
|
'url' => 'registry.keystone.internal:5000',
|
||||||
|
'storage_path' => '/home/keystone/registry/data',
|
||||||
|
'control_server_id' => $control->id,
|
||||||
|
'readiness_checks' => [
|
||||||
|
'storage_writable' => true,
|
||||||
|
'http_reachable' => true,
|
||||||
|
'auth_configured' => true,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->managedRegistry->markHealthy('Registry online and serving manifests');
|
||||||
|
|
||||||
|
$organisation->registries()->create([
|
||||||
|
'name' => 'GitHub Container Registry',
|
||||||
|
'type' => RegistryType::GHCR,
|
||||||
|
'url' => 'ghcr.io',
|
||||||
|
'credentials' => [
|
||||||
|
'username' => 'keystone-bot',
|
||||||
|
'token' => Str::password(40),
|
||||||
|
],
|
||||||
|
'health_status' => 'healthy',
|
||||||
|
'health_message' => 'Authenticated successfully',
|
||||||
|
'health_checked_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedSourceProvider(Organisation $organisation): \App\Models\SourceProvider
|
||||||
|
{
|
||||||
|
return $organisation->sourceProviders()->create([
|
||||||
|
'name' => 'Gitea',
|
||||||
|
'type' => SourceProviderType::GITEA,
|
||||||
|
'url' => 'https://git.keystone.internal',
|
||||||
|
'config' => [
|
||||||
|
'api_url' => 'https://git.keystone.internal/api/v1',
|
||||||
|
'organisation' => 'stratbucket',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedApplication(Organisation $organisation, \App\Models\SourceProvider $sourceProvider): Application
|
||||||
|
{
|
||||||
|
return $organisation->applications()->create([
|
||||||
|
'name' => 'ClipBin',
|
||||||
|
'source_provider_id' => $sourceProvider->id,
|
||||||
|
'repository_url' => 'git@git.keystone.internal:stratbucket/clipbin.git',
|
||||||
|
'repository_type' => RepositoryType::GIT,
|
||||||
|
'default_branch' => 'main',
|
||||||
|
'deploy_key_installed_at' => now()->subWeeks(2),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedEnvironment(
|
||||||
|
Application $application,
|
||||||
|
Server $control,
|
||||||
|
Collection $workers,
|
||||||
|
string $name,
|
||||||
|
string $branch,
|
||||||
|
): Environment {
|
||||||
|
$environment = app(CreateLaravelEnvironment::class)->execute($application, $name, $branch);
|
||||||
|
|
||||||
|
$web = $environment->services()->where('type', ServiceType::LARAVEL)->firstOrFail();
|
||||||
|
$web->forceFill(['server_id' => $workers->first()->id])->save();
|
||||||
|
$this->createReplica($web, $workers->first(), 80);
|
||||||
|
|
||||||
|
$postgres = $this->createDependencyService(
|
||||||
|
$environment, $workers->get(1), 'postgres',
|
||||||
|
ServiceCategory::DATABASE, ServiceType::POSTGRES, '18', 5432, DeployPolicy::DEPENDENCY_ONLY,
|
||||||
|
);
|
||||||
|
$valkey = $this->createDependencyService(
|
||||||
|
$environment, $workers->get(2), 'valkey',
|
||||||
|
ServiceCategory::CACHE, ServiceType::VALKEY, '8', 6379, DeployPolicy::DEPENDENCY_ONLY,
|
||||||
|
);
|
||||||
|
$caddy = $this->createDependencyService(
|
||||||
|
$environment, $control, 'gateway',
|
||||||
|
ServiceCategory::GATEWAY, ServiceType::CADDY, '2', 80, DeployPolicy::MANUAL_OR_ON_ROUTE_CHANGE,
|
||||||
|
);
|
||||||
|
|
||||||
|
app(AttachManagedService::class)->execute($environment, $postgres, EnvironmentAttachmentRole::DATABASE, isPrimary: true);
|
||||||
|
app(AttachManagedService::class)->execute($environment, $valkey, EnvironmentAttachmentRole::CACHE);
|
||||||
|
app(AttachManagedService::class)->execute($environment, $caddy, EnvironmentAttachmentRole::GATEWAY);
|
||||||
|
|
||||||
|
foreach ([$web, $postgres, $valkey, $caddy] as $service) {
|
||||||
|
foreach ($service->replicas()->get() as $replica) {
|
||||||
|
app(RegisterServiceEndpoint::class)->execute($replica);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->seedUserVariables($environment);
|
||||||
|
$this->advanceToRunning($environment);
|
||||||
|
|
||||||
|
return $environment->refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createDependencyService(
|
||||||
|
Environment $environment,
|
||||||
|
Server $server,
|
||||||
|
string $name,
|
||||||
|
ServiceCategory $category,
|
||||||
|
ServiceType $type,
|
||||||
|
string $version,
|
||||||
|
int $port,
|
||||||
|
DeployPolicy $deployPolicy,
|
||||||
|
): Service {
|
||||||
|
$service = $environment->services()->create([
|
||||||
|
'organisation_id' => $environment->application->organisation_id,
|
||||||
|
'server_id' => $server->id,
|
||||||
|
'name' => $name,
|
||||||
|
'category' => $category,
|
||||||
|
'type' => $type,
|
||||||
|
'version' => $version,
|
||||||
|
'version_track' => $version,
|
||||||
|
'driver_name' => "{$type->value}.{$version}",
|
||||||
|
'status' => ServiceStatus::NOT_INSTALLED,
|
||||||
|
'deploy_policy' => $deployPolicy,
|
||||||
|
'process_roles' => [],
|
||||||
|
'desired_replicas' => 1,
|
||||||
|
'config' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (method_exists($service->driver(), 'defaultCredentials')) {
|
||||||
|
$service->credentials = $service->driver()->defaultCredentials();
|
||||||
|
$service->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->createReplica($service, $server, $port);
|
||||||
|
|
||||||
|
return $service;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createReplica(Service $service, Server $server, int $port): void
|
||||||
|
{
|
||||||
|
$service->replicas()->create([
|
||||||
|
'server_id' => $server->id,
|
||||||
|
'container_name' => "keystone-service-{$service->id}-1",
|
||||||
|
'internal_host' => "keystone-service-{$service->id}",
|
||||||
|
'internal_port' => $port,
|
||||||
|
'status' => 'pending',
|
||||||
|
'health_status' => 'unknown',
|
||||||
|
'config' => [],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedUserVariables(Environment $environment): void
|
||||||
|
{
|
||||||
|
$values = [
|
||||||
|
'APP_NAME' => 'ClipBin',
|
||||||
|
'APP_ENV' => $environment->name,
|
||||||
|
'LOG_CHANNEL' => 'stderr',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($values as $key => $value) {
|
||||||
|
$environment->variables()->updateOrCreate(['key' => $key], [
|
||||||
|
'value' => $value,
|
||||||
|
'source' => EnvironmentVariableSource::USER,
|
||||||
|
'overridable' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function advanceToRunning(Environment $environment): void
|
||||||
|
{
|
||||||
|
$environment->forceFill(['status' => 'active'])->save();
|
||||||
|
|
||||||
|
foreach ($environment->services()->with('replicas', 'slices')->get() as $service) {
|
||||||
|
$digest = $this->digest();
|
||||||
|
|
||||||
|
$service->forceFill([
|
||||||
|
'status' => ServiceStatus::RUNNING,
|
||||||
|
'current_image_digest' => $digest,
|
||||||
|
'available_image_digest' => $digest,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$service->replicas->each->forceFill([
|
||||||
|
'status' => 'running',
|
||||||
|
'health_status' => 'healthy',
|
||||||
|
'image_digest' => $digest,
|
||||||
|
])->each->save();
|
||||||
|
|
||||||
|
$service->slices->each->forceFill(['status' => 'active'])->each->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->completeSliceProvisionOperations($environment);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function completeSliceProvisionOperations(Environment $environment): void
|
||||||
|
{
|
||||||
|
foreach ($environment->services()->with('slices.operations.steps')->get() as $service) {
|
||||||
|
foreach ($service->slices as $slice) {
|
||||||
|
foreach ($slice->operations as $operation) {
|
||||||
|
$operation->forceFill([
|
||||||
|
'status' => OperationStatus::COMPLETED,
|
||||||
|
'started_at' => now()->subMinutes(8),
|
||||||
|
'finished_at' => now()->subMinutes(7),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$operation->steps->each->forceFill([
|
||||||
|
'status' => OperationStatus::COMPLETED,
|
||||||
|
'logs' => 'Slice provisioned successfully.',
|
||||||
|
'started_at' => now()->subMinutes(8),
|
||||||
|
'finished_at' => now()->subMinutes(7),
|
||||||
|
])->each->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedVariety(Environment $production, Environment $staging): void
|
||||||
|
{
|
||||||
|
$stagingValkey = $staging->services()->where('type', ServiceType::VALKEY)->firstOrFail();
|
||||||
|
$stagingValkey->replicas()->update(['health_status' => 'unhealthy']);
|
||||||
|
|
||||||
|
$production->buildArtifacts()->create([
|
||||||
|
'commit_sha' => $this->sha(),
|
||||||
|
'image_tag' => 'clipbin:production-'.Str::random(7),
|
||||||
|
'image_digest' => $this->digest(),
|
||||||
|
'registry_ref' => $this->managedRegistry->url.'/keystone/clipbin',
|
||||||
|
'built_by_service_id' => $production->services()->where('type', ServiceType::LARAVEL)->value('id'),
|
||||||
|
'status' => BuildArtifactStatus::AVAILABLE,
|
||||||
|
'metadata' => ['branch' => 'main', 'build_seconds' => 142],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$staging->buildArtifacts()->create([
|
||||||
|
'commit_sha' => $this->sha(),
|
||||||
|
'image_tag' => 'clipbin:staging-'.Str::random(7),
|
||||||
|
'registry_ref' => $this->managedRegistry->url.'/keystone/clipbin',
|
||||||
|
'built_by_service_id' => $staging->services()->where('type', ServiceType::LARAVEL)->value('id'),
|
||||||
|
'status' => BuildArtifactStatus::BUILDING,
|
||||||
|
'metadata' => ['branch' => 'develop'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedOperationsHistory(Server $control, Environment $production, Environment $staging): void
|
||||||
|
{
|
||||||
|
$registryOp = $control->operations()->create([
|
||||||
|
'kind' => OperationKind::REGISTRY_PROVISION,
|
||||||
|
'status' => OperationStatus::COMPLETED,
|
||||||
|
'started_at' => now()->subDays(5),
|
||||||
|
'finished_at' => now()->subDays(5)->addMinutes(4),
|
||||||
|
]);
|
||||||
|
$registryOp->steps()->create([
|
||||||
|
'name' => 'Provision managed registry',
|
||||||
|
'order' => 1,
|
||||||
|
'status' => OperationStatus::COMPLETED,
|
||||||
|
'script' => 'docker run -d --name keystone-registry registry:2',
|
||||||
|
'logs' => 'Registry container started and reachable.',
|
||||||
|
'started_at' => now()->subDays(5),
|
||||||
|
'finished_at' => now()->subDays(5)->addMinutes(4),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$serverOp = $control->operations()->create([
|
||||||
|
'kind' => OperationKind::SERVER_PROVISION,
|
||||||
|
'status' => OperationStatus::COMPLETED,
|
||||||
|
'started_at' => now()->subDays(6),
|
||||||
|
'finished_at' => now()->subDays(6)->addMinutes(9),
|
||||||
|
]);
|
||||||
|
$serverOp->steps()->create([
|
||||||
|
'name' => 'Install container runtime',
|
||||||
|
'order' => 1,
|
||||||
|
'status' => OperationStatus::COMPLETED,
|
||||||
|
'script' => 'apt-get install -y docker-ce',
|
||||||
|
'logs' => 'Docker installed, daemon active.',
|
||||||
|
'started_at' => now()->subDays(6),
|
||||||
|
'finished_at' => now()->subDays(6)->addMinutes(9),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$deployOp = $production->operations()->create([
|
||||||
|
'kind' => OperationKind::ENVIRONMENT_DEPLOY,
|
||||||
|
'status' => OperationStatus::COMPLETED,
|
||||||
|
'started_at' => now()->subHours(3),
|
||||||
|
'finished_at' => now()->subHours(3)->addMinutes(6),
|
||||||
|
]);
|
||||||
|
$deployOp->steps()->create([
|
||||||
|
'name' => 'Build application image',
|
||||||
|
'order' => 1,
|
||||||
|
'status' => OperationStatus::COMPLETED,
|
||||||
|
'script' => 'keystone build --env production',
|
||||||
|
'logs' => 'Image built and pushed to managed registry.',
|
||||||
|
'started_at' => now()->subHours(3),
|
||||||
|
'finished_at' => now()->subHours(3)->addMinutes(4),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$web = $production->services()->where('type', ServiceType::LARAVEL)->firstOrFail();
|
||||||
|
$serviceOp = $web->operations()->create([
|
||||||
|
'parent_id' => $deployOp->id,
|
||||||
|
'kind' => OperationKind::SERVICE_DEPLOY,
|
||||||
|
'status' => OperationStatus::COMPLETED,
|
||||||
|
'started_at' => now()->subHours(3)->addMinutes(4),
|
||||||
|
'finished_at' => now()->subHours(3)->addMinutes(6),
|
||||||
|
]);
|
||||||
|
$serviceOp->steps()->create([
|
||||||
|
'name' => 'Roll out web replica',
|
||||||
|
'order' => 1,
|
||||||
|
'status' => OperationStatus::COMPLETED,
|
||||||
|
'script' => 'keystone service:deploy web',
|
||||||
|
'logs' => 'Replica healthy, traffic switched.',
|
||||||
|
'started_at' => now()->subHours(3)->addMinutes(4),
|
||||||
|
'finished_at' => now()->subHours(3)->addMinutes(6),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$inProgress = $staging->operations()->create([
|
||||||
|
'kind' => OperationKind::ENVIRONMENT_DEPLOY,
|
||||||
|
'status' => OperationStatus::IN_PROGRESS,
|
||||||
|
'started_at' => now()->subMinutes(2),
|
||||||
|
]);
|
||||||
|
$inProgress->steps()->create([
|
||||||
|
'name' => 'Build application image',
|
||||||
|
'order' => 1,
|
||||||
|
'status' => OperationStatus::IN_PROGRESS,
|
||||||
|
'script' => 'keystone build --env staging',
|
||||||
|
'logs' => 'Compiling assets...',
|
||||||
|
'started_at' => now()->subMinutes(2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$stagingValkey = $staging->services()->where('type', ServiceType::VALKEY)->firstOrFail();
|
||||||
|
$failedOp = $stagingValkey->operations()->create([
|
||||||
|
'kind' => OperationKind::SERVICE_DEPLOY,
|
||||||
|
'status' => OperationStatus::FAILED,
|
||||||
|
'started_at' => now()->subHours(1),
|
||||||
|
'finished_at' => now()->subHours(1)->addMinutes(2),
|
||||||
|
]);
|
||||||
|
$failedOp->steps()->create([
|
||||||
|
'name' => 'Start valkey replica',
|
||||||
|
'order' => 1,
|
||||||
|
'status' => OperationStatus::FAILED,
|
||||||
|
'script' => 'keystone service:deploy valkey',
|
||||||
|
'logs' => 'Pulling image valkey/valkey:8...',
|
||||||
|
'error_logs' => 'Health check failed: connection refused on 6379 after 30s.',
|
||||||
|
'started_at' => now()->subHours(1),
|
||||||
|
'finished_at' => now()->subHours(1)->addMinutes(2),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function digest(): string
|
||||||
|
{
|
||||||
|
return 'sha256:'.hash('sha256', Str::uuid()->toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sha(): string
|
||||||
|
{
|
||||||
|
return substr(hash('sha1', Str::uuid()->toString()), 0, 40);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,7 +30,7 @@ class TestEnvironmentSeeder extends Seeder
|
|||||||
$organisation->providers()->create([
|
$organisation->providers()->create([
|
||||||
'name' => 'Hetzner',
|
'name' => 'Hetzner',
|
||||||
'type' => ProviderType::HETZNER,
|
'type' => ProviderType::HETZNER,
|
||||||
'token' => env('HETZNER_KEY'),
|
'token' => env('HETZNER_KEY') ?: 'local-placeholder-token',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user