Implement Keystone environment deployments
This commit is contained in:
287
AGENTS.md
Normal file
287
AGENTS.md
Normal file
@@ -0,0 +1,287 @@
|
||||
<laravel-boost-guidelines>
|
||||
=== foundation rules ===
|
||||
|
||||
# Laravel Boost Guidelines
|
||||
|
||||
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
|
||||
|
||||
## Foundational Context
|
||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||
|
||||
- php - 8.4.16
|
||||
- inertiajs/inertia-laravel (INERTIA) - v2
|
||||
- laravel/framework (LARAVEL) - v12
|
||||
- laravel/prompts (PROMPTS) - v0
|
||||
- tightenco/ziggy (ZIGGY) - v2
|
||||
- laravel/pint (PINT) - v1
|
||||
- laravel/sail (SAIL) - v1
|
||||
- pestphp/pest (PEST) - v3
|
||||
- phpunit/phpunit (PHPUNIT) - v11
|
||||
|
||||
|
||||
## Conventions
|
||||
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming.
|
||||
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
||||
- Check for existing components to reuse before writing a new one.
|
||||
|
||||
## Verification Scripts
|
||||
- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
|
||||
|
||||
## Application Structure & Architecture
|
||||
- Stick to existing directory structure - don't create new base folders without approval.
|
||||
- Do not change the application's dependencies without approval.
|
||||
|
||||
## Frontend Bundling
|
||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
|
||||
|
||||
## Replies
|
||||
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
||||
|
||||
## Documentation Files
|
||||
- You must only create documentation files if explicitly requested by the user.
|
||||
|
||||
|
||||
=== boost rules ===
|
||||
|
||||
## Laravel Boost
|
||||
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
|
||||
|
||||
## Artisan
|
||||
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters.
|
||||
|
||||
## URLs
|
||||
- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port.
|
||||
|
||||
## Tinker / Debugging
|
||||
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
|
||||
- Use the `database-query` tool when you only need to read from the database.
|
||||
|
||||
## Reading Browser Logs With the `browser-logs` Tool
|
||||
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
|
||||
- Only recent browser logs will be useful - ignore old logs.
|
||||
|
||||
## Searching Documentation (Critically Important)
|
||||
- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
|
||||
- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
|
||||
- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches.
|
||||
- Search the documentation before making code changes to ensure we are taking the correct approach.
|
||||
- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
|
||||
- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
|
||||
|
||||
### Available Search Syntax
|
||||
- You can and should pass multiple queries at once. The most relevant results will be returned first.
|
||||
|
||||
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'
|
||||
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit"
|
||||
3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order
|
||||
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit"
|
||||
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
|
||||
|
||||
|
||||
=== php rules ===
|
||||
|
||||
## PHP
|
||||
|
||||
- Always use curly braces for control structures, even if it has one line.
|
||||
|
||||
### Constructors
|
||||
- Use PHP 8 constructor property promotion in `__construct()`.
|
||||
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
|
||||
- Do not allow empty `__construct()` methods with zero parameters.
|
||||
|
||||
### Type Declarations
|
||||
- Always use explicit return type declarations for methods and functions.
|
||||
- Use appropriate PHP type hints for method parameters.
|
||||
|
||||
<code-snippet name="Explicit Return Types and Method Params" lang="php">
|
||||
protected function isAccessible(User $user, ?string $path = null): bool
|
||||
{
|
||||
...
|
||||
}
|
||||
</code-snippet>
|
||||
|
||||
## Comments
|
||||
- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on.
|
||||
|
||||
## PHPDoc Blocks
|
||||
- Add useful array shape type definitions for arrays when appropriate.
|
||||
|
||||
## Enums
|
||||
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||
|
||||
|
||||
=== inertia-laravel/core rules ===
|
||||
|
||||
## Inertia Core
|
||||
|
||||
- Inertia.js components should be placed in the `resources/js/Pages` directory unless specified differently in the JS bundler (vite.config.js).
|
||||
- Use `Inertia::render()` for server-side routing instead of traditional Blade views.
|
||||
- Use `search-docs` for accurate guidance on all things Inertia.
|
||||
|
||||
<code-snippet lang="php" name="Inertia::render Example">
|
||||
// routes/web.php example
|
||||
Route::get('/users', function () {
|
||||
return Inertia::render('Users/Index', [
|
||||
'users' => User::all()
|
||||
]);
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== inertia-laravel/v2 rules ===
|
||||
|
||||
## Inertia v2
|
||||
|
||||
- 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
|
||||
- Polling
|
||||
- Prefetching
|
||||
- Deferred props
|
||||
- Infinite scrolling using merging props and `WhenVisible`
|
||||
- Lazy loading data on scroll
|
||||
|
||||
### Deferred Props & Empty States
|
||||
- When using deferred props on the frontend, you should add a nice empty state with pulsing / animated skeleton.
|
||||
|
||||
### 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.
|
||||
|
||||
|
||||
=== laravel/core rules ===
|
||||
|
||||
## Do Things the Laravel Way
|
||||
|
||||
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
||||
- If you're creating a generic PHP class, use `artisan make:class`.
|
||||
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
||||
|
||||
### Database
|
||||
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
|
||||
- Use Eloquent models and relationships before suggesting raw database queries
|
||||
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
|
||||
- Generate code that prevents N+1 query problems by using eager loading.
|
||||
- Use Laravel's query builder for very complex database operations.
|
||||
|
||||
### Model Creation
|
||||
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
|
||||
|
||||
### APIs & Eloquent Resources
|
||||
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
||||
|
||||
### Controllers & Validation
|
||||
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
|
||||
- Check sibling Form Requests to see if the application uses array or string based validation rules.
|
||||
|
||||
### Queues
|
||||
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
|
||||
|
||||
### Authentication & Authorization
|
||||
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
|
||||
|
||||
### URL Generation
|
||||
- When generating links to other pages, prefer named routes and the `route()` function.
|
||||
|
||||
### Configuration
|
||||
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
|
||||
|
||||
### Testing
|
||||
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
||||
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
||||
- When creating tests, make use of `php artisan make:test [options] <name>` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||
|
||||
### Vite Error
|
||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
|
||||
|
||||
|
||||
=== laravel/v12 rules ===
|
||||
|
||||
## Laravel 12
|
||||
|
||||
- Use the `search-docs` tool to get version specific documentation.
|
||||
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
|
||||
|
||||
### Laravel 12 Structure
|
||||
- No middleware files in `app/Http/Middleware/`.
|
||||
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
|
||||
- `bootstrap/providers.php` contains application specific service providers.
|
||||
- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration.
|
||||
- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration.
|
||||
|
||||
### Database
|
||||
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
|
||||
- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
|
||||
|
||||
### Models
|
||||
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
|
||||
|
||||
|
||||
=== pint/core rules ===
|
||||
|
||||
## Laravel Pint Code Formatter
|
||||
|
||||
- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
|
||||
- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues.
|
||||
|
||||
|
||||
=== pest/core rules ===
|
||||
|
||||
## Pest
|
||||
|
||||
### Testing
|
||||
- If you need to verify a feature is working, write or update a Unit / Feature test.
|
||||
|
||||
### Pest Tests
|
||||
- All tests must be written using Pest. Use `php artisan make:test --pest <name>`.
|
||||
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application.
|
||||
- Tests should test all of the happy paths, failure paths, and weird paths.
|
||||
- Tests live in the `tests/Feature` and `tests/Unit` directories.
|
||||
- Pest tests look and behave like this:
|
||||
<code-snippet name="Basic Pest Test Example" lang="php">
|
||||
it('is true', function () {
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
### Running Tests
|
||||
- Run the minimal number of tests using an appropriate filter before finalizing code edits.
|
||||
- To run all tests: `php artisan test`.
|
||||
- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`.
|
||||
- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file).
|
||||
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
|
||||
|
||||
### Pest Assertions
|
||||
- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.:
|
||||
<code-snippet name="Pest Example Asserting postJson Response" lang="php">
|
||||
it('returns all', function () {
|
||||
$response = $this->postJson('/api/docs', []);
|
||||
|
||||
$response->assertSuccessful();
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
### Mocking
|
||||
- Mocking can be very helpful when appropriate.
|
||||
- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do.
|
||||
- You can also create partial mocks using the same import or self method.
|
||||
|
||||
### Datasets
|
||||
- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules.
|
||||
|
||||
<code-snippet name="Pest Dataset Example" lang="php">
|
||||
it('has emails', function (string $email) {
|
||||
expect($email)->not->toBeEmpty();
|
||||
})->with([
|
||||
'james' => 'james@laravel.com',
|
||||
'taylor' => 'taylor@laravel.com',
|
||||
]);
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== tests rules ===
|
||||
|
||||
## Test Enforcement
|
||||
|
||||
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter.
|
||||
</laravel-boost-guidelines>
|
||||
287
CLAUDE.md
Normal file
287
CLAUDE.md
Normal file
@@ -0,0 +1,287 @@
|
||||
<laravel-boost-guidelines>
|
||||
=== foundation rules ===
|
||||
|
||||
# Laravel Boost Guidelines
|
||||
|
||||
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
|
||||
|
||||
## Foundational Context
|
||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||
|
||||
- php - 8.4.16
|
||||
- inertiajs/inertia-laravel (INERTIA) - v2
|
||||
- laravel/framework (LARAVEL) - v12
|
||||
- laravel/prompts (PROMPTS) - v0
|
||||
- tightenco/ziggy (ZIGGY) - v2
|
||||
- laravel/pint (PINT) - v1
|
||||
- laravel/sail (SAIL) - v1
|
||||
- pestphp/pest (PEST) - v3
|
||||
- phpunit/phpunit (PHPUNIT) - v11
|
||||
|
||||
|
||||
## Conventions
|
||||
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming.
|
||||
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
||||
- Check for existing components to reuse before writing a new one.
|
||||
|
||||
## Verification Scripts
|
||||
- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
|
||||
|
||||
## Application Structure & Architecture
|
||||
- Stick to existing directory structure - don't create new base folders without approval.
|
||||
- Do not change the application's dependencies without approval.
|
||||
|
||||
## Frontend Bundling
|
||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
|
||||
|
||||
## Replies
|
||||
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
||||
|
||||
## Documentation Files
|
||||
- You must only create documentation files if explicitly requested by the user.
|
||||
|
||||
|
||||
=== boost rules ===
|
||||
|
||||
## Laravel Boost
|
||||
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
|
||||
|
||||
## Artisan
|
||||
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters.
|
||||
|
||||
## URLs
|
||||
- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port.
|
||||
|
||||
## Tinker / Debugging
|
||||
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
|
||||
- Use the `database-query` tool when you only need to read from the database.
|
||||
|
||||
## Reading Browser Logs With the `browser-logs` Tool
|
||||
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
|
||||
- Only recent browser logs will be useful - ignore old logs.
|
||||
|
||||
## Searching Documentation (Critically Important)
|
||||
- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
|
||||
- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
|
||||
- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches.
|
||||
- Search the documentation before making code changes to ensure we are taking the correct approach.
|
||||
- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
|
||||
- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
|
||||
|
||||
### Available Search Syntax
|
||||
- You can and should pass multiple queries at once. The most relevant results will be returned first.
|
||||
|
||||
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'
|
||||
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit"
|
||||
3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order
|
||||
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit"
|
||||
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
|
||||
|
||||
|
||||
=== php rules ===
|
||||
|
||||
## PHP
|
||||
|
||||
- Always use curly braces for control structures, even if it has one line.
|
||||
|
||||
### Constructors
|
||||
- Use PHP 8 constructor property promotion in `__construct()`.
|
||||
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
|
||||
- Do not allow empty `__construct()` methods with zero parameters.
|
||||
|
||||
### Type Declarations
|
||||
- Always use explicit return type declarations for methods and functions.
|
||||
- Use appropriate PHP type hints for method parameters.
|
||||
|
||||
<code-snippet name="Explicit Return Types and Method Params" lang="php">
|
||||
protected function isAccessible(User $user, ?string $path = null): bool
|
||||
{
|
||||
...
|
||||
}
|
||||
</code-snippet>
|
||||
|
||||
## Comments
|
||||
- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on.
|
||||
|
||||
## PHPDoc Blocks
|
||||
- Add useful array shape type definitions for arrays when appropriate.
|
||||
|
||||
## Enums
|
||||
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||
|
||||
|
||||
=== inertia-laravel/core rules ===
|
||||
|
||||
## Inertia Core
|
||||
|
||||
- Inertia.js components should be placed in the `resources/js/Pages` directory unless specified differently in the JS bundler (vite.config.js).
|
||||
- Use `Inertia::render()` for server-side routing instead of traditional Blade views.
|
||||
- Use `search-docs` for accurate guidance on all things Inertia.
|
||||
|
||||
<code-snippet lang="php" name="Inertia::render Example">
|
||||
// routes/web.php example
|
||||
Route::get('/users', function () {
|
||||
return Inertia::render('Users/Index', [
|
||||
'users' => User::all()
|
||||
]);
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== inertia-laravel/v2 rules ===
|
||||
|
||||
## Inertia v2
|
||||
|
||||
- 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
|
||||
- Polling
|
||||
- Prefetching
|
||||
- Deferred props
|
||||
- Infinite scrolling using merging props and `WhenVisible`
|
||||
- Lazy loading data on scroll
|
||||
|
||||
### Deferred Props & Empty States
|
||||
- When using deferred props on the frontend, you should add a nice empty state with pulsing / animated skeleton.
|
||||
|
||||
### 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.
|
||||
|
||||
|
||||
=== laravel/core rules ===
|
||||
|
||||
## Do Things the Laravel Way
|
||||
|
||||
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
||||
- If you're creating a generic PHP class, use `artisan make:class`.
|
||||
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
||||
|
||||
### Database
|
||||
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
|
||||
- Use Eloquent models and relationships before suggesting raw database queries
|
||||
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
|
||||
- Generate code that prevents N+1 query problems by using eager loading.
|
||||
- Use Laravel's query builder for very complex database operations.
|
||||
|
||||
### Model Creation
|
||||
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
|
||||
|
||||
### APIs & Eloquent Resources
|
||||
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
||||
|
||||
### Controllers & Validation
|
||||
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
|
||||
- Check sibling Form Requests to see if the application uses array or string based validation rules.
|
||||
|
||||
### Queues
|
||||
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
|
||||
|
||||
### Authentication & Authorization
|
||||
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
|
||||
|
||||
### URL Generation
|
||||
- When generating links to other pages, prefer named routes and the `route()` function.
|
||||
|
||||
### Configuration
|
||||
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
|
||||
|
||||
### Testing
|
||||
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
||||
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
||||
- When creating tests, make use of `php artisan make:test [options] <name>` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||
|
||||
### Vite Error
|
||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
|
||||
|
||||
|
||||
=== laravel/v12 rules ===
|
||||
|
||||
## Laravel 12
|
||||
|
||||
- Use the `search-docs` tool to get version specific documentation.
|
||||
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
|
||||
|
||||
### Laravel 12 Structure
|
||||
- No middleware files in `app/Http/Middleware/`.
|
||||
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
|
||||
- `bootstrap/providers.php` contains application specific service providers.
|
||||
- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration.
|
||||
- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration.
|
||||
|
||||
### Database
|
||||
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
|
||||
- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
|
||||
|
||||
### Models
|
||||
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
|
||||
|
||||
|
||||
=== pint/core rules ===
|
||||
|
||||
## Laravel Pint Code Formatter
|
||||
|
||||
- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
|
||||
- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues.
|
||||
|
||||
|
||||
=== pest/core rules ===
|
||||
|
||||
## Pest
|
||||
|
||||
### Testing
|
||||
- If you need to verify a feature is working, write or update a Unit / Feature test.
|
||||
|
||||
### Pest Tests
|
||||
- All tests must be written using Pest. Use `php artisan make:test --pest <name>`.
|
||||
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application.
|
||||
- Tests should test all of the happy paths, failure paths, and weird paths.
|
||||
- Tests live in the `tests/Feature` and `tests/Unit` directories.
|
||||
- Pest tests look and behave like this:
|
||||
<code-snippet name="Basic Pest Test Example" lang="php">
|
||||
it('is true', function () {
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
### Running Tests
|
||||
- Run the minimal number of tests using an appropriate filter before finalizing code edits.
|
||||
- To run all tests: `php artisan test`.
|
||||
- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`.
|
||||
- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file).
|
||||
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
|
||||
|
||||
### Pest Assertions
|
||||
- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.:
|
||||
<code-snippet name="Pest Example Asserting postJson Response" lang="php">
|
||||
it('returns all', function () {
|
||||
$response = $this->postJson('/api/docs', []);
|
||||
|
||||
$response->assertSuccessful();
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
### Mocking
|
||||
- Mocking can be very helpful when appropriate.
|
||||
- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do.
|
||||
- You can also create partial mocks using the same import or self method.
|
||||
|
||||
### Datasets
|
||||
- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules.
|
||||
|
||||
<code-snippet name="Pest Dataset Example" lang="php">
|
||||
it('has emails', function (string $email) {
|
||||
expect($email)->not->toBeEmpty();
|
||||
})->with([
|
||||
'james' => 'james@laravel.com',
|
||||
'taylor' => 'taylor@laravel.com',
|
||||
]);
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== tests rules ===
|
||||
|
||||
## Test Enforcement
|
||||
|
||||
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter.
|
||||
</laravel-boost-guidelines>
|
||||
@@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Applications;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Instance;
|
||||
use App\Models\Server;
|
||||
|
||||
class CreateInstance
|
||||
{
|
||||
public function execute(
|
||||
Application $application,
|
||||
Server $server,
|
||||
string $branch = 'main',
|
||||
array $config = []
|
||||
): Instance {
|
||||
return $application->instances()->create([
|
||||
'server_id' => $server->id,
|
||||
'branch' => $branch,
|
||||
'status' => 'pending',
|
||||
'config' => $config,
|
||||
]);
|
||||
}
|
||||
}
|
||||
65
app/Actions/Applications/CreateLaravelEnvironment.php
Normal file
65
app/Actions/Applications/CreateLaravelEnvironment.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Applications;
|
||||
|
||||
use App\Enums\DeployPolicy;
|
||||
use App\Enums\SchedulerMode;
|
||||
use App\Enums\ServiceCategory;
|
||||
use App\Enums\ServiceStatus;
|
||||
use App\Enums\ServiceType;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
|
||||
class CreateLaravelEnvironment
|
||||
{
|
||||
public function execute(
|
||||
Application $application,
|
||||
string $name,
|
||||
?string $branch = null,
|
||||
string $phpVersion = '8.4',
|
||||
): Environment {
|
||||
$environment = $application->environments()->create([
|
||||
'name' => $name,
|
||||
'branch' => $branch ?? $application->default_branch,
|
||||
'status' => 'pending',
|
||||
'scheduler_enabled' => true,
|
||||
'scheduler_mode' => SchedulerMode::SINGLE,
|
||||
'build_config' => [
|
||||
'php_version' => $phpVersion,
|
||||
'document_root' => 'public',
|
||||
'health_path' => '/up',
|
||||
'js_build_command' => null,
|
||||
'js_package_manager' => 'bun',
|
||||
],
|
||||
]);
|
||||
|
||||
$web = $environment->services()->create([
|
||||
'organisation_id' => $application->organisation_id,
|
||||
'name' => 'web',
|
||||
'category' => ServiceCategory::APPLICATION,
|
||||
'type' => ServiceType::LARAVEL,
|
||||
'version' => "php-{$phpVersion}",
|
||||
'version_track' => "php-{$phpVersion}",
|
||||
'driver_name' => "laravel.php-{$phpVersion}",
|
||||
'status' => ServiceStatus::NOT_INSTALLED,
|
||||
'desired_replicas' => 1,
|
||||
'deploy_policy' => DeployPolicy::WITH_ENVIRONMENT,
|
||||
'process_roles' => ['web', 'scheduler'],
|
||||
'config' => [
|
||||
'migration_mode' => 'auto',
|
||||
'migration_timing' => 'pre_switch',
|
||||
'migration_command' => 'php artisan migrate --force',
|
||||
'document_root' => 'public',
|
||||
'health_path' => '/up',
|
||||
'js_build_command' => null,
|
||||
'js_package_manager' => 'bun',
|
||||
],
|
||||
]);
|
||||
|
||||
$environment->forceFill([
|
||||
'scheduler_target_service_id' => $web->id,
|
||||
])->save();
|
||||
|
||||
return $environment->refresh();
|
||||
}
|
||||
}
|
||||
78
app/Actions/Applications/GenerateDeployKey.php
Normal file
78
app/Actions/Applications/GenerateDeployKey.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Applications;
|
||||
|
||||
use App\Models\Application;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class GenerateDeployKey
|
||||
{
|
||||
/**
|
||||
* @param array{public: string, private: string, fingerprint?: string}|null $keyPair
|
||||
*/
|
||||
public function execute(Application $application, ?array $keyPair = null): Application
|
||||
{
|
||||
$keyPair ??= $this->generateWithSshKeygen($application);
|
||||
|
||||
$application->forceFill([
|
||||
'deploy_key_public' => $keyPair['public'],
|
||||
'deploy_key_private' => $keyPair['private'],
|
||||
'deploy_key_fingerprint' => $keyPair['fingerprint'] ?? $this->fingerprint($keyPair['public']),
|
||||
'deploy_key_installed_at' => null,
|
||||
])->save();
|
||||
|
||||
return $application->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{public: string, private: string, fingerprint: string}
|
||||
*/
|
||||
private function generateWithSshKeygen(Application $application): array
|
||||
{
|
||||
$directory = storage_path('app/private/deploy-keys/'.str()->uuid()->toString());
|
||||
$privateKeyPath = $directory.'/id_ed25519';
|
||||
|
||||
File::ensureDirectoryExists($directory, 0700);
|
||||
|
||||
try {
|
||||
$result = Process::run([
|
||||
'ssh-keygen',
|
||||
'-t',
|
||||
'ed25519',
|
||||
'-C',
|
||||
"keystone-application-{$application->id}",
|
||||
'-N',
|
||||
'',
|
||||
'-f',
|
||||
$privateKeyPath,
|
||||
]);
|
||||
|
||||
if ($result->failed()) {
|
||||
throw new RuntimeException('Unable to generate deploy key: '.$result->errorOutput());
|
||||
}
|
||||
|
||||
return [
|
||||
'public' => trim(File::get($privateKeyPath.'.pub')),
|
||||
'private' => trim(File::get($privateKeyPath)),
|
||||
'fingerprint' => $this->fingerprint(trim(File::get($privateKeyPath.'.pub'))),
|
||||
];
|
||||
} finally {
|
||||
rescue(fn () => File::deleteDirectory($directory), report: false);
|
||||
}
|
||||
}
|
||||
|
||||
private function fingerprint(string $publicKey): string
|
||||
{
|
||||
try {
|
||||
$parts = explode(' ', trim($publicKey));
|
||||
$keyMaterial = $parts[1] ?? $publicKey;
|
||||
|
||||
return 'SHA256:'.rtrim(strtr(base64_encode(hash('sha256', base64_decode($keyMaterial, true) ?: $publicKey, true)), '+/', '-_'), '=');
|
||||
} catch (Throwable) {
|
||||
return 'SHA256:'.hash('sha256', $publicKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
51
app/Actions/Applications/VerifyRepositoryAccess.php
Normal file
51
app/Actions/Applications/VerifyRepositoryAccess.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Applications;
|
||||
|
||||
use App\Models\Application;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use RuntimeException;
|
||||
|
||||
class VerifyRepositoryAccess
|
||||
{
|
||||
public function execute(Application $application): bool
|
||||
{
|
||||
if (! $application->deploy_key_private) {
|
||||
throw new RuntimeException('Application does not have a deploy key.');
|
||||
}
|
||||
|
||||
$directory = storage_path('app/private/operations/repository-access-'.$application->id.'-'.str()->random(8));
|
||||
$keyPath = $directory.'/deploy_key';
|
||||
|
||||
File::ensureDirectoryExists($directory, 0700);
|
||||
File::put($keyPath, $application->deploy_key_private);
|
||||
File::chmod($keyPath, 0600);
|
||||
|
||||
try {
|
||||
$result = Process::path($directory)
|
||||
->env([
|
||||
'GIT_SSH_COMMAND' => 'ssh -i '.$keyPath.' -o IdentitiesOnly=yes -o StrictHostKeyChecking=no',
|
||||
])
|
||||
->run([
|
||||
'git',
|
||||
'ls-remote',
|
||||
'--heads',
|
||||
$application->repository_url,
|
||||
$application->default_branch,
|
||||
]);
|
||||
|
||||
if ($result->successful()) {
|
||||
$application->forceFill([
|
||||
'deploy_key_installed_at' => now(),
|
||||
])->save();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} finally {
|
||||
rescue(fn () => File::deleteDirectory($directory), report: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
152
app/Actions/Environments/AttachManagedService.php
Normal file
152
app/Actions/Environments/AttachManagedService.php
Normal file
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Environments;
|
||||
|
||||
use App\Drivers\Concerns\SupportsSlices;
|
||||
use App\Enums\EnvironmentAttachmentRole;
|
||||
use App\Enums\EnvironmentVariableSource;
|
||||
use App\Enums\OperationKind;
|
||||
use App\Enums\OperationStatus;
|
||||
use App\Enums\ServiceType;
|
||||
use App\Models\Environment;
|
||||
use App\Models\EnvironmentAttachment;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceSlice;
|
||||
use Illuminate\Support\Str;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class AttachManagedService
|
||||
{
|
||||
public function execute(
|
||||
Environment $environment,
|
||||
Service $service,
|
||||
EnvironmentAttachmentRole $role,
|
||||
?string $name = null,
|
||||
?string $envPrefix = null,
|
||||
bool $isPrimary = true,
|
||||
): EnvironmentAttachment {
|
||||
$slice = $this->createDefaultSlice($environment, $service, $role, $name);
|
||||
|
||||
$attachment = $environment->attachments()->create([
|
||||
'service_id' => $service->id,
|
||||
'service_slice_id' => $slice?->id,
|
||||
'role' => $role,
|
||||
'env_prefix' => $envPrefix,
|
||||
'is_primary' => $isPrimary,
|
||||
]);
|
||||
|
||||
$this->syncManagedVariables($environment, $service, $slice, $envPrefix, $role);
|
||||
$this->createSliceProvisionOperation($service, $slice);
|
||||
|
||||
return $attachment;
|
||||
}
|
||||
|
||||
private function createDefaultSlice(
|
||||
Environment $environment,
|
||||
Service $service,
|
||||
EnvironmentAttachmentRole $role,
|
||||
?string $name,
|
||||
): ?ServiceSlice {
|
||||
return match ($service->type) {
|
||||
ServiceType::POSTGRES => $service->slices()->firstOrCreate([
|
||||
'environment_id' => $environment->id,
|
||||
'type' => 'database_user',
|
||||
'name' => $name ?? $this->sliceName($environment),
|
||||
], [
|
||||
'status' => 'pending',
|
||||
'config' => [],
|
||||
'credentials' => [
|
||||
'database' => $name ?? $this->sliceName($environment),
|
||||
'username' => $name ?? $this->sliceName($environment),
|
||||
'password' => Str::password(32),
|
||||
],
|
||||
]),
|
||||
ServiceType::VALKEY => $service->slices()->firstOrCreate([
|
||||
'environment_id' => $environment->id,
|
||||
'type' => 'logical_database',
|
||||
'name' => $name ?? $this->sliceName($environment),
|
||||
], [
|
||||
'status' => 'pending',
|
||||
'config' => [
|
||||
'database' => $this->nextValkeyDatabase($service),
|
||||
],
|
||||
]),
|
||||
ServiceType::CADDY => $service->slices()->firstOrCreate([
|
||||
'environment_id' => $environment->id,
|
||||
'type' => 'route',
|
||||
'name' => $name ?? $environment->name,
|
||||
], [
|
||||
'status' => 'pending',
|
||||
'config' => [],
|
||||
]),
|
||||
default => $role === EnvironmentAttachmentRole::CUSTOM ? null : throw new InvalidArgumentException("Service [{$service->type->value}] does not support managed attachments."),
|
||||
};
|
||||
}
|
||||
|
||||
private function syncManagedVariables(Environment $environment, Service $service, ?ServiceSlice $slice, ?string $envPrefix, EnvironmentAttachmentRole $role): void
|
||||
{
|
||||
if (! $slice) {
|
||||
return;
|
||||
}
|
||||
|
||||
$driver = $service->driver();
|
||||
|
||||
if (! $driver instanceof SupportsSlices) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($driver->environmentExportsForSlice($slice, $role) as $key => $value) {
|
||||
$environment->variables()->updateOrCreate([
|
||||
'key' => $this->variableKey($key, $envPrefix),
|
||||
], [
|
||||
'value' => $value,
|
||||
'source' => EnvironmentVariableSource::MANAGED_ATTACHMENT,
|
||||
'service_slice_id' => $slice->id,
|
||||
'overridable' => false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function createSliceProvisionOperation(Service $service, ?ServiceSlice $slice): void
|
||||
{
|
||||
if (! $slice || ! $slice->wasRecentlyCreated) {
|
||||
return;
|
||||
}
|
||||
|
||||
$driver = $service->driver();
|
||||
|
||||
if (! $driver instanceof SupportsSlices) {
|
||||
return;
|
||||
}
|
||||
|
||||
$operation = $slice->operations()->create([
|
||||
'kind' => OperationKind::SLICE_PROVISION,
|
||||
'status' => OperationStatus::PENDING,
|
||||
]);
|
||||
|
||||
$operation->steps()->create([
|
||||
'name' => 'Provision '.$service->type->value.' slice',
|
||||
'order' => 1,
|
||||
'status' => OperationStatus::PENDING,
|
||||
'script' => $driver->provisionSliceScript($slice),
|
||||
]);
|
||||
}
|
||||
|
||||
private function nextValkeyDatabase(Service $service): int
|
||||
{
|
||||
return ((int) $service->slices()
|
||||
->where('type', 'logical_database')
|
||||
->get()
|
||||
->max(fn (ServiceSlice $slice): int => (int) ($slice->config['database'] ?? 0))) + 1;
|
||||
}
|
||||
|
||||
private function sliceName(Environment $environment): string
|
||||
{
|
||||
return str($environment->application->name.' '.$environment->name)->slug('_')->value();
|
||||
}
|
||||
|
||||
private function variableKey(string $key, ?string $envPrefix): string
|
||||
{
|
||||
return $envPrefix ? $envPrefix.'_'.$key : $key;
|
||||
}
|
||||
}
|
||||
192
app/Actions/Environments/BuildApplicationArtifact.php
Normal file
192
app/Actions/Environments/BuildApplicationArtifact.php
Normal file
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Environments;
|
||||
|
||||
use App\Enums\BuildArtifactStatus;
|
||||
use App\Enums\BuildStrategy;
|
||||
use App\Models\BuildArtifact;
|
||||
use App\Models\Operation;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Services\Operations\RemoteCommandRunner;
|
||||
use RuntimeException;
|
||||
|
||||
class BuildApplicationArtifact
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RemoteCommandRunner $remoteCommandRunner,
|
||||
) {}
|
||||
|
||||
public function execute(BuildArtifact $artifact, ?Operation $operation = null): BuildArtifact
|
||||
{
|
||||
$artifact->loadMissing('environment.application', 'builtByService.server', 'builtByService.replicas.server');
|
||||
|
||||
$application = $artifact->environment->application;
|
||||
$strategy = BuildStrategy::tryFrom($artifact->metadata['build_strategy'] ?? BuildStrategy::TARGET_SERVER->value)
|
||||
?? BuildStrategy::TARGET_SERVER;
|
||||
|
||||
$server = $this->buildServer($artifact);
|
||||
|
||||
$artifact->update([
|
||||
'status' => BuildArtifactStatus::BUILDING,
|
||||
'built_by_operation_id' => $operation?->id,
|
||||
]);
|
||||
|
||||
try {
|
||||
$output = $this->remoteCommandRunner->run(
|
||||
$server,
|
||||
$strategy === BuildStrategy::EXTERNAL_REGISTRY
|
||||
? $this->manifestDigestScript($artifact)
|
||||
: $this->buildScript($artifact, $strategy)
|
||||
);
|
||||
|
||||
$artifact->update([
|
||||
'image_digest' => $this->digestFromOutput($output),
|
||||
'status' => BuildArtifactStatus::AVAILABLE,
|
||||
]);
|
||||
|
||||
return $artifact->refresh();
|
||||
} catch (\Throwable $exception) {
|
||||
$artifact->update([
|
||||
'status' => BuildArtifactStatus::FAILED,
|
||||
'metadata' => [
|
||||
...($artifact->metadata ?? []),
|
||||
'error' => $exception->getMessage(),
|
||||
],
|
||||
]);
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
private function buildServer(BuildArtifact $artifact): Server
|
||||
{
|
||||
if ($artifact->builtByService instanceof Service) {
|
||||
$server = $artifact->builtByService->replicas->first()?->server ?: $artifact->builtByService->server;
|
||||
|
||||
if ($server instanceof Server) {
|
||||
return $server;
|
||||
}
|
||||
}
|
||||
|
||||
if (($artifact->metadata['build_strategy'] ?? null) === BuildStrategy::DEDICATED_BUILDER->value) {
|
||||
throw new RuntimeException('Dedicated builder strategy requires a builder service.');
|
||||
}
|
||||
|
||||
$services = $artifact->environment->services()
|
||||
->with(['server', 'replicas.server'])
|
||||
->get();
|
||||
|
||||
$server = $services
|
||||
->flatMap(fn (Service $service) => $service->replicas->pluck('server')->filter())
|
||||
->first() ?: $services->pluck('server')->filter()->first();
|
||||
|
||||
if (! $server instanceof Server) {
|
||||
$serverId = $services
|
||||
->flatMap(fn (Service $service) => collect($service->config['server_ids'] ?? []))
|
||||
->filter()
|
||||
->first();
|
||||
|
||||
$server = $serverId ? Server::find($serverId) : null;
|
||||
}
|
||||
|
||||
if (! $server instanceof Server) {
|
||||
throw new RuntimeException('A target server is required to build this artifact over SSH.');
|
||||
}
|
||||
|
||||
return $server;
|
||||
}
|
||||
|
||||
private function buildScript(BuildArtifact $artifact, BuildStrategy $strategy): string
|
||||
{
|
||||
$application = $artifact->environment->application;
|
||||
|
||||
if (! $application->deploy_key_private) {
|
||||
throw new RuntimeException('Application does not have a deploy key.');
|
||||
}
|
||||
|
||||
$operationDirectory = '/home/keystone/operations/build-'.$artifact->id.'-'.str()->random(8);
|
||||
$imageReference = $artifact->registry_ref ?: $artifact->image_tag;
|
||||
$pushCommand = $strategy === BuildStrategy::DEDICATED_BUILDER && $artifact->registry_ref
|
||||
? "\ndocker push ".escapeshellarg($imageReference)
|
||||
: '';
|
||||
|
||||
return implode("\n", [
|
||||
'set -euo pipefail',
|
||||
'operation_dir='.escapeshellarg($operationDirectory),
|
||||
'source_dir="$operation_dir/source"',
|
||||
'rm -rf "$operation_dir"',
|
||||
'mkdir -p "$operation_dir"',
|
||||
'chmod 700 "$operation_dir"',
|
||||
'cleanup() { rm -rf "$operation_dir"; }',
|
||||
'trap cleanup EXIT',
|
||||
$this->writeFileCommand('$operation_dir/deploy_key', $application->deploy_key_private),
|
||||
'chmod 600 "$operation_dir/deploy_key"',
|
||||
'export GIT_SSH_COMMAND="ssh -i $operation_dir/deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=no"',
|
||||
'git clone --depth 1 --branch '.escapeshellarg($artifact->environment->branch).' '.escapeshellarg($application->repository_url).' "$source_dir"',
|
||||
$this->writeFileCommand('$source_dir/Dockerfile.keystone', $this->dockerfile($artifact)),
|
||||
'cd "$source_dir"',
|
||||
'docker build --file Dockerfile.keystone --tag '.escapeshellarg($imageReference).' .'.$pushCommand,
|
||||
'digest=$(docker image inspect --format '.escapeshellarg('{{if .RepoDigests}}{{index .RepoDigests 0}}{{else}}{{.Id}}{{end}}').' '.escapeshellarg($imageReference).')',
|
||||
'printf "image_digest=%s\n" "$digest"',
|
||||
]);
|
||||
}
|
||||
|
||||
private function manifestDigestScript(BuildArtifact $artifact): string
|
||||
{
|
||||
$imageReference = $artifact->registry_ref ?: $artifact->image_tag;
|
||||
|
||||
return implode("\n", [
|
||||
'set -euo pipefail',
|
||||
'manifest=$(docker manifest inspect '.escapeshellarg($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"',
|
||||
]);
|
||||
}
|
||||
|
||||
private function dockerfile(BuildArtifact $artifact): string
|
||||
{
|
||||
$service = $artifact->environment->services()
|
||||
->where('type', \App\Enums\ServiceType::LARAVEL)
|
||||
->first();
|
||||
|
||||
if ($service && method_exists($service->driver(), 'dockerfileTemplate')) {
|
||||
return $service->driver()->dockerfileTemplate();
|
||||
}
|
||||
|
||||
return <<<'DOCKERFILE'
|
||||
FROM serversideup/php:8.4-frankenphp
|
||||
WORKDIR /var/www/html
|
||||
COPY --chown=www-data:www-data . .
|
||||
RUN composer install --no-dev --prefer-dist --no-interaction --optimize-autoloader
|
||||
ENV SERVER_DOCUMENT_ROOT=/var/www/html/public
|
||||
DOCKERFILE;
|
||||
}
|
||||
|
||||
private function writeFileCommand(string $path, string $contents): string
|
||||
{
|
||||
return implode("\n", [
|
||||
'cat > '.$path." <<'KEYSTONE_FILE'",
|
||||
rtrim($contents),
|
||||
'KEYSTONE_FILE',
|
||||
]);
|
||||
}
|
||||
|
||||
private function digestFromOutput(string $output): string
|
||||
{
|
||||
if (preg_match('/image_digest=(?<digest>\S+)/', $output, $matches)) {
|
||||
return $this->digestFromOutput($matches['digest']);
|
||||
}
|
||||
|
||||
if (str_contains($output, '@')) {
|
||||
return str($output)->after('@')->trim()->value();
|
||||
}
|
||||
|
||||
if (str_starts_with($output, 'sha256:')) {
|
||||
return $output;
|
||||
}
|
||||
|
||||
throw new RuntimeException('Unable to resolve built image digest.');
|
||||
}
|
||||
}
|
||||
20
app/Actions/Environments/BuildMigrationScript.php
Normal file
20
app/Actions/Environments/BuildMigrationScript.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Environments;
|
||||
|
||||
use App\Models\Service;
|
||||
|
||||
class BuildMigrationScript
|
||||
{
|
||||
public function execute(Service $service, bool $respectAutomaticMode = true): string
|
||||
{
|
||||
if ($respectAutomaticMode && in_array($service->config['migration_mode'] ?? 'auto', ['disabled', 'manual'], true)) {
|
||||
return 'true';
|
||||
}
|
||||
|
||||
$command = $service->config['migration_command'] ?? 'php artisan migrate --force';
|
||||
$serviceKey = str($service->name)->slug('_')->value() ?: 'service';
|
||||
|
||||
return "docker compose -f /home/keystone/services/{$service->id}/compose.yml run --rm {$serviceKey} {$command}";
|
||||
}
|
||||
}
|
||||
40
app/Actions/Environments/CreateLaravelWorkerService.php
Normal file
40
app/Actions/Environments/CreateLaravelWorkerService.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Environments;
|
||||
|
||||
use App\Enums\DeployPolicy;
|
||||
use App\Enums\ServiceCategory;
|
||||
use App\Enums\ServiceStatus;
|
||||
use App\Enums\ServiceType;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Service;
|
||||
|
||||
class CreateLaravelWorkerService
|
||||
{
|
||||
public function execute(Environment $environment): Service
|
||||
{
|
||||
$environment->loadMissing('application');
|
||||
|
||||
$phpVersion = $environment->build_config['php_version'] ?? '8.4';
|
||||
|
||||
return $environment->services()->firstOrCreate([
|
||||
'name' => 'worker',
|
||||
'type' => ServiceType::LARAVEL,
|
||||
], [
|
||||
'organisation_id' => $environment->application->organisation_id,
|
||||
'category' => ServiceCategory::APPLICATION,
|
||||
'version' => "php-{$phpVersion}",
|
||||
'version_track' => "php-{$phpVersion}",
|
||||
'driver_name' => "laravel.php-{$phpVersion}",
|
||||
'status' => ServiceStatus::NOT_INSTALLED,
|
||||
'desired_replicas' => 1,
|
||||
'deploy_policy' => DeployPolicy::WITH_ENVIRONMENT,
|
||||
'process_roles' => ['worker'],
|
||||
'config' => [
|
||||
'command' => 'php artisan queue:work --sleep=3 --tries=3',
|
||||
'health_path' => null,
|
||||
'migration_mode' => 'disabled',
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
44
app/Actions/Environments/CreateMigrationOperation.php
Normal file
44
app/Actions/Environments/CreateMigrationOperation.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Environments;
|
||||
|
||||
use App\Enums\OperationKind;
|
||||
use App\Enums\OperationStatus;
|
||||
use App\Enums\ServiceType;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Operation;
|
||||
use App\Models\Service;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class CreateMigrationOperation
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BuildMigrationScript $buildMigrationScript,
|
||||
) {}
|
||||
|
||||
public function execute(Environment $environment, ?Service $service = null): Operation
|
||||
{
|
||||
$service ??= $environment->services()
|
||||
->where('type', ServiceType::LARAVEL)
|
||||
->get()
|
||||
->first(fn (Service $service): bool => in_array('web', $service->process_roles ?? [], true));
|
||||
|
||||
if (! $service || $service->type !== ServiceType::LARAVEL) {
|
||||
throw new InvalidArgumentException('Laravel migrations must run against a Laravel runtime service.');
|
||||
}
|
||||
|
||||
$operation = $service->operations()->create([
|
||||
'kind' => OperationKind::CONFIG_CHANGE,
|
||||
'status' => OperationStatus::PENDING,
|
||||
]);
|
||||
|
||||
$operation->steps()->create([
|
||||
'name' => 'Run Laravel migrations',
|
||||
'order' => 1,
|
||||
'status' => OperationStatus::PENDING,
|
||||
'script' => $this->buildMigrationScript->execute($service, respectAutomaticMode: false),
|
||||
]);
|
||||
|
||||
return $operation;
|
||||
}
|
||||
}
|
||||
76
app/Actions/Environments/PlanBuildArtifact.php
Normal file
76
app/Actions/Environments/PlanBuildArtifact.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Environments;
|
||||
|
||||
use App\Enums\BuildArtifactStatus;
|
||||
use App\Enums\BuildStrategy;
|
||||
use App\Enums\ServiceCategory;
|
||||
use App\Models\BuildArtifact;
|
||||
use App\Models\Environment;
|
||||
use RuntimeException;
|
||||
|
||||
class PlanBuildArtifact
|
||||
{
|
||||
public function execute(Environment $environment, string $commitSha): BuildArtifact
|
||||
{
|
||||
$environment->loadMissing(['application.organisation.registries', 'services.replicas']);
|
||||
|
||||
$existingArtifact = $environment->buildArtifacts()
|
||||
->where('commit_sha', $commitSha)
|
||||
->whereIn('status', [BuildArtifactStatus::PENDING, BuildArtifactStatus::BUILDING, BuildArtifactStatus::AVAILABLE])
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
if ($existingArtifact) {
|
||||
return $existingArtifact;
|
||||
}
|
||||
|
||||
$targetServerCount = $this->targetServerCount($environment);
|
||||
$registry = $environment->application->organisation->registries()->first();
|
||||
|
||||
if ($targetServerCount > 1 && ! $registry) {
|
||||
throw new RuntimeException('A registry is required before building artifacts for multi-server deployments.');
|
||||
}
|
||||
|
||||
$builder = $environment->application->organisation->services()
|
||||
->where('category', ServiceCategory::BUILDER)
|
||||
->first();
|
||||
|
||||
$strategy = match (true) {
|
||||
$registry !== null => BuildStrategy::EXTERNAL_REGISTRY,
|
||||
$builder !== null => BuildStrategy::DEDICATED_BUILDER,
|
||||
default => BuildStrategy::TARGET_SERVER,
|
||||
};
|
||||
|
||||
$imageTag = str($environment->application->name)
|
||||
->slug()
|
||||
->append(':'.substr($commitSha, 0, 12))
|
||||
->value();
|
||||
|
||||
return $environment->buildArtifacts()->create([
|
||||
'commit_sha' => $commitSha,
|
||||
'image_tag' => $imageTag,
|
||||
'registry_ref' => $registry ? rtrim((string) $registry->url, '/').'/'.$imageTag : null,
|
||||
'built_by_service_id' => $builder?->id,
|
||||
'status' => BuildArtifactStatus::PENDING,
|
||||
'metadata' => [
|
||||
'build_strategy' => $strategy->value,
|
||||
'target_server_count' => $targetServerCount,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private function targetServerCount(Environment $environment): int
|
||||
{
|
||||
$replicaServerCount = $environment->services
|
||||
->flatMap(fn ($service) => $service->replicas->pluck('server_id')->filter())
|
||||
->unique()
|
||||
->count();
|
||||
|
||||
if ($replicaServerCount > 0) {
|
||||
return $replicaServerCount;
|
||||
}
|
||||
|
||||
return $environment->services->sum('desired_replicas') > 1 ? 2 : 1;
|
||||
}
|
||||
}
|
||||
91
app/Actions/Environments/PlanEnvironmentDeployment.php
Normal file
91
app/Actions/Environments/PlanEnvironmentDeployment.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Environments;
|
||||
|
||||
use App\Data\Environments\EnvironmentDeploymentPlan;
|
||||
use App\Enums\DeployPolicy;
|
||||
use App\Enums\EnvironmentAttachmentRole;
|
||||
use App\Enums\SchedulerMode;
|
||||
use App\Models\Environment;
|
||||
|
||||
class PlanEnvironmentDeployment
|
||||
{
|
||||
public function execute(Environment $environment): EnvironmentDeploymentPlan
|
||||
{
|
||||
$environment->loadMissing([
|
||||
'services',
|
||||
'attachments.service',
|
||||
'attachments.serviceSlice',
|
||||
]);
|
||||
|
||||
$deployableServices = $environment->services
|
||||
->where('deploy_policy', DeployPolicy::WITH_ENVIRONMENT)
|
||||
->values();
|
||||
|
||||
$dependencies = $environment->attachments
|
||||
->map(fn ($attachment) => $attachment->service)
|
||||
->filter()
|
||||
->unique('id')
|
||||
->values();
|
||||
|
||||
$targetServerCount = $deployableServices
|
||||
->flatMap(fn ($service) => $service->replicas->pluck('server_id')->filter())
|
||||
->unique()
|
||||
->count();
|
||||
|
||||
if ($targetServerCount === 0) {
|
||||
$targetServerCount = $deployableServices->sum('desired_replicas') > 1 ? 2 : 1;
|
||||
}
|
||||
|
||||
return new EnvironmentDeploymentPlan(
|
||||
services: $deployableServices->all(),
|
||||
dependencies: $dependencies->all(),
|
||||
requiresRegistry: $targetServerCount > 1 && $environment->application->organisation->registries()->doesntExist(),
|
||||
warnings: $this->warnings($environment),
|
||||
blockers: $this->blockers($environment),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function warnings(Environment $environment): array
|
||||
{
|
||||
$warnings = [];
|
||||
|
||||
if ($environment->variables()
|
||||
->where('key', 'QUEUE_CONNECTION')
|
||||
->get()
|
||||
->contains(fn ($variable) => $variable->value === 'sync')) {
|
||||
$warnings[] = 'QUEUE_CONNECTION=sync is not recommended for deployed Laravel environments.';
|
||||
}
|
||||
|
||||
if ($environment->attachments->contains('role', EnvironmentAttachmentRole::QUEUE)) {
|
||||
$hasWorker = $environment->services->contains(fn ($service) => in_array('worker', $service->process_roles ?? [], true));
|
||||
|
||||
if (! $hasWorker) {
|
||||
$warnings[] = 'Queue attachment exists without a dedicated worker service.';
|
||||
}
|
||||
}
|
||||
|
||||
return $warnings;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function blockers(Environment $environment): array
|
||||
{
|
||||
if (! $environment->scheduler_enabled || $environment->scheduler_mode !== SchedulerMode::SINGLE) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$target = $environment->services->firstWhere('id', $environment->scheduler_target_service_id);
|
||||
|
||||
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.'];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
61
app/Actions/Environments/ResolveEnvironmentCommit.php
Normal file
61
app/Actions/Environments/ResolveEnvironmentCommit.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Environments;
|
||||
|
||||
use App\Models\Environment;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use RuntimeException;
|
||||
|
||||
class ResolveEnvironmentCommit
|
||||
{
|
||||
public function execute(Environment $environment): string
|
||||
{
|
||||
$environment->loadMissing('application');
|
||||
|
||||
$application = $environment->application;
|
||||
|
||||
if (! $application->deploy_key_private) {
|
||||
throw new RuntimeException('Application does not have a deploy key.');
|
||||
}
|
||||
|
||||
$directory = storage_path('app/private/operations/resolve-'.$environment->id.'-'.str()->random(8));
|
||||
$keyPath = $directory.'/deploy_key';
|
||||
|
||||
File::ensureDirectoryExists($directory, 0700);
|
||||
File::put($keyPath, $application->deploy_key_private);
|
||||
File::chmod($keyPath, 0600);
|
||||
|
||||
try {
|
||||
$result = Process::path($directory)
|
||||
->env([
|
||||
'GIT_SSH_COMMAND' => 'ssh -i '.$keyPath.' -o IdentitiesOnly=yes -o StrictHostKeyChecking=no',
|
||||
])
|
||||
->run([
|
||||
'git',
|
||||
'ls-remote',
|
||||
$application->repository_url,
|
||||
'refs/heads/'.$environment->branch,
|
||||
]);
|
||||
|
||||
if ($result->failed()) {
|
||||
throw new RuntimeException(trim($result->errorOutput()) ?: 'Unable to resolve environment commit.');
|
||||
}
|
||||
|
||||
return $this->commitFromOutput($result->output(), $environment->branch);
|
||||
} finally {
|
||||
rescue(fn () => File::deleteDirectory($directory), report: false);
|
||||
}
|
||||
}
|
||||
|
||||
private function commitFromOutput(string $output, string $branch): string
|
||||
{
|
||||
$commit = str($output)->before("\t")->trim()->value();
|
||||
|
||||
if (preg_match('/^[a-f0-9]{40}$/i', $commit) !== 1) {
|
||||
throw new RuntimeException("Unable to resolve commit for branch {$branch}.");
|
||||
}
|
||||
|
||||
return strtolower($commit);
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
namespace App\Actions\Services;
|
||||
|
||||
use App\Enums\DeployPolicy;
|
||||
use App\Enums\ServiceCategory;
|
||||
use App\Enums\ServiceStatus;
|
||||
use App\Enums\ServiceType;
|
||||
use App\Jobs\Services\DeployService;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use RuntimeException;
|
||||
|
||||
class CreateService
|
||||
{
|
||||
@@ -16,15 +19,25 @@ class CreateService
|
||||
ServiceCategory $category,
|
||||
ServiceType $type,
|
||||
string $version,
|
||||
) {
|
||||
): Service {
|
||||
if ($category === ServiceCategory::GATEWAY && $server->services()->where('category', ServiceCategory::GATEWAY)->exists()) {
|
||||
throw new RuntimeException('This server already has a gateway service.');
|
||||
}
|
||||
|
||||
$driverName = "{$type->value}.{$version}";
|
||||
$service = $server->services()->create([
|
||||
'organisation_id' => $server->organisation_id,
|
||||
'name' => $name,
|
||||
'category' => $category,
|
||||
'type' => $type, // postgres
|
||||
'version' => $version, // 17
|
||||
'driver_name' => $driverName, // postgres.17
|
||||
'type' => $type,
|
||||
'version' => $version,
|
||||
'version_track' => $version,
|
||||
'driver_name' => $driverName,
|
||||
'status' => ServiceStatus::NOT_INSTALLED,
|
||||
'deploy_policy' => $this->defaultDeployPolicy($category, $type),
|
||||
'process_roles' => [],
|
||||
'desired_replicas' => 1,
|
||||
'config' => [],
|
||||
]);
|
||||
|
||||
if (method_exists($service->driver(), 'defaultCredentials')) {
|
||||
@@ -32,8 +45,40 @@ class CreateService
|
||||
$service->save();
|
||||
}
|
||||
|
||||
$service->replicas()->create([
|
||||
'server_id' => $server->id,
|
||||
'container_name' => "keystone-service-{$service->id}-1",
|
||||
'internal_host' => "keystone-service-{$service->id}",
|
||||
'internal_port' => $this->defaultInternalPort($type),
|
||||
'status' => 'pending',
|
||||
'health_status' => 'unknown',
|
||||
'config' => [],
|
||||
]);
|
||||
|
||||
dispatch(new DeployService($service));
|
||||
|
||||
return $service;
|
||||
}
|
||||
|
||||
private function defaultDeployPolicy(ServiceCategory $category, ServiceType $type): DeployPolicy
|
||||
{
|
||||
return match (true) {
|
||||
$category === ServiceCategory::APPLICATION => DeployPolicy::WITH_ENVIRONMENT,
|
||||
$category === ServiceCategory::DATABASE,
|
||||
$category === ServiceCategory::CACHE,
|
||||
$category === ServiceCategory::STORAGE => DeployPolicy::DEPENDENCY_ONLY,
|
||||
$category === ServiceCategory::GATEWAY => DeployPolicy::MANUAL_OR_ON_ROUTE_CHANGE,
|
||||
default => DeployPolicy::MANUAL,
|
||||
};
|
||||
}
|
||||
|
||||
private function defaultInternalPort(ServiceType $type): int
|
||||
{
|
||||
return match ($type) {
|
||||
ServiceType::POSTGRES => 5432,
|
||||
ServiceType::VALKEY => 6379,
|
||||
ServiceType::CADDY,
|
||||
ServiceType::LARAVEL => 80,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
100
app/Actions/Services/CreateStatefulServiceUpdateOperation.php
Normal file
100
app/Actions/Services/CreateStatefulServiceUpdateOperation.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Services;
|
||||
|
||||
use App\Enums\OperationKind;
|
||||
use App\Enums\OperationStatus;
|
||||
use App\Enums\ServiceType;
|
||||
use App\Models\Operation;
|
||||
use App\Models\Service;
|
||||
use App\Services\Compose\ComposeRenderer;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class CreateStatefulServiceUpdateOperation
|
||||
{
|
||||
public function execute(Service $service, string $imageDigest, bool $backupRequested = false): Operation
|
||||
{
|
||||
if (! in_array($service->type, [ServiceType::POSTGRES, ServiceType::VALKEY], true)) {
|
||||
throw new InvalidArgumentException('Only Postgres and Valkey have v1 stateful update operations.');
|
||||
}
|
||||
|
||||
if ($backupRequested && ! ($service->config['backup_enabled'] ?? false)) {
|
||||
throw new InvalidArgumentException('Backups are not configured for this service.');
|
||||
}
|
||||
|
||||
$service->forceFill([
|
||||
'available_image_digest' => $imageDigest,
|
||||
'update_status' => 'update_pending',
|
||||
])->save();
|
||||
|
||||
$operation = $service->operations()->create([
|
||||
'kind' => OperationKind::SERVICE_DEPLOY,
|
||||
'status' => OperationStatus::PENDING,
|
||||
]);
|
||||
|
||||
$composePath = "/home/keystone/services/{$service->id}/compose.yml";
|
||||
$serviceKey = str($service->name)->slug('_')->value() ?: 'service';
|
||||
$volumeName = $this->namedVolume($service);
|
||||
|
||||
$steps = [
|
||||
'Acknowledge downtime and data risk' => 'echo '.escapeshellarg('Stateful update requires downtime and preserves named volumes.'),
|
||||
];
|
||||
|
||||
if ($backupRequested) {
|
||||
$steps['Run pre-update backup'] = $service->config['backup_command'] ?? 'echo '.escapeshellarg('Run configured backup before stateful update.');
|
||||
}
|
||||
|
||||
$steps += [
|
||||
'Render compose with updated image digest' => $this->composeUploadScript($service),
|
||||
'Stop existing container' => "docker compose -f {$composePath} stop {$serviceKey}",
|
||||
'Preserve named volume' => $volumeName ? "docker volume inspect {$volumeName} >/dev/null" : 'true',
|
||||
'Start service with updated image digest' => "docker compose -f {$composePath} up -d {$serviceKey}",
|
||||
'Health check updated service' => implode("\n", [
|
||||
"container_id=$(docker compose -f {$composePath} ps -q {$serviceKey})",
|
||||
'test -n "$container_id"',
|
||||
'for attempt in $(seq 1 30); do',
|
||||
' health_status=$(docker inspect --format "{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}" "$container_id")',
|
||||
' test "$health_status" = "healthy" -o "$health_status" = "running" && exit 0',
|
||||
' sleep 2',
|
||||
'done',
|
||||
'printf "health_status=%s\n" "$health_status"',
|
||||
'exit 1',
|
||||
]),
|
||||
];
|
||||
|
||||
$order = 1;
|
||||
foreach ($steps as $name => $script) {
|
||||
$operation->steps()->create([
|
||||
'name' => $name,
|
||||
'order' => $order++,
|
||||
'status' => OperationStatus::PENDING,
|
||||
'script' => $script,
|
||||
]);
|
||||
}
|
||||
|
||||
return $operation;
|
||||
}
|
||||
|
||||
private function composeUploadScript(Service $service): string
|
||||
{
|
||||
$servicePath = "/home/keystone/services/{$service->id}";
|
||||
$renderer = app(ComposeRenderer::class);
|
||||
$compose = $renderer->render($service);
|
||||
$env = $renderer->renderEnvironmentFile($service);
|
||||
|
||||
return implode("\n", [
|
||||
"mkdir -p {$servicePath}",
|
||||
'printf %s '.escapeshellarg(base64_encode($compose))." | base64 -d > {$servicePath}/compose.yml",
|
||||
'printf %s '.escapeshellarg(base64_encode($env))." | base64 -d > {$servicePath}/.env",
|
||||
]);
|
||||
}
|
||||
|
||||
private function namedVolume(Service $service): ?string
|
||||
{
|
||||
return match ($service->type) {
|
||||
ServiceType::POSTGRES => "keystone_service_{$service->id}_postgres_data",
|
||||
ServiceType::VALKEY => ($service->config['persistence'] ?? false) ? "keystone_service_{$service->id}_valkey_data" : null,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
70
app/Actions/Services/RegisterServiceEndpoint.php
Normal file
70
app/Actions/Services/RegisterServiceEndpoint.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Services;
|
||||
|
||||
use App\Enums\ServiceEndpointScope;
|
||||
use App\Models\ServiceEndpoint;
|
||||
use App\Models\ServiceReplica;
|
||||
|
||||
class RegisterServiceEndpoint
|
||||
{
|
||||
public function execute(ServiceReplica $replica, ?ServiceReplica $consumerReplica = null, bool $allowPublicFallback = false): ServiceEndpoint
|
||||
{
|
||||
$scope = $this->scope($replica, $consumerReplica, $allowPublicFallback);
|
||||
|
||||
return $replica->service->endpoints()->updateOrCreate([
|
||||
'service_replica_id' => $replica->id,
|
||||
'scope' => $scope,
|
||||
'port' => $replica->internal_port,
|
||||
], [
|
||||
'hostname' => $this->hostname($replica, $scope),
|
||||
'ip_address' => $this->ipAddress($replica, $scope),
|
||||
'priority' => $this->priority($scope),
|
||||
'health_status' => $replica->health_status,
|
||||
]);
|
||||
}
|
||||
|
||||
private function scope(ServiceReplica $replica, ?ServiceReplica $consumerReplica, bool $allowPublicFallback): ServiceEndpointScope
|
||||
{
|
||||
if ($consumerReplica && $consumerReplica->server_id === $replica->server_id) {
|
||||
return ServiceEndpointScope::DOCKER_NETWORK;
|
||||
}
|
||||
|
||||
if ($replica->server?->private_ip) {
|
||||
return ServiceEndpointScope::PRIVATE_NETWORK;
|
||||
}
|
||||
|
||||
if ($allowPublicFallback && $replica->server?->ipv4) {
|
||||
return ServiceEndpointScope::PUBLIC;
|
||||
}
|
||||
|
||||
return ServiceEndpointScope::DOCKER_NETWORK;
|
||||
}
|
||||
|
||||
private function hostname(ServiceReplica $replica, ServiceEndpointScope $scope): string
|
||||
{
|
||||
return match ($scope) {
|
||||
ServiceEndpointScope::DOCKER_NETWORK => $replica->internal_host,
|
||||
ServiceEndpointScope::PRIVATE_NETWORK => $replica->server->private_ip,
|
||||
ServiceEndpointScope::PUBLIC => $replica->server->ipv4,
|
||||
};
|
||||
}
|
||||
|
||||
private function ipAddress(ServiceReplica $replica, ServiceEndpointScope $scope): ?string
|
||||
{
|
||||
return match ($scope) {
|
||||
ServiceEndpointScope::DOCKER_NETWORK => null,
|
||||
ServiceEndpointScope::PRIVATE_NETWORK => $replica->server->private_ip,
|
||||
ServiceEndpointScope::PUBLIC => $replica->server->ipv4,
|
||||
};
|
||||
}
|
||||
|
||||
private function priority(ServiceEndpointScope $scope): int
|
||||
{
|
||||
return match ($scope) {
|
||||
ServiceEndpointScope::DOCKER_NETWORK => 10,
|
||||
ServiceEndpointScope::PRIVATE_NETWORK => 20,
|
||||
ServiceEndpointScope::PUBLIC => 100,
|
||||
};
|
||||
}
|
||||
}
|
||||
85
app/Actions/Services/ResolveServiceImageDigest.php
Normal file
85
app/Actions/Services/ResolveServiceImageDigest.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Services;
|
||||
|
||||
use App\Drivers\Concerns\RendersCompose;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Services\Operations\RemoteCommandRunner;
|
||||
use RuntimeException;
|
||||
|
||||
class ResolveServiceImageDigest
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RemoteCommandRunner $remoteCommandRunner,
|
||||
) {}
|
||||
|
||||
public function execute(Service $service): string
|
||||
{
|
||||
$image = $this->imageReference($service);
|
||||
|
||||
if (str_starts_with($image, 'sha256:')) {
|
||||
return $image;
|
||||
}
|
||||
|
||||
$output = $this->remoteCommandRunner->run($this->targetServer($service), implode("\n", [
|
||||
'set -euo pipefail',
|
||||
'image='.escapeshellarg($image),
|
||||
'digest=$(docker image inspect --format '.escapeshellarg('{{if .RepoDigests}}{{index .RepoDigests 0}}{{else}}{{.Id}}{{end}}').' "$image" 2>/dev/null || true)',
|
||||
'if [ -z "$digest" ]; then',
|
||||
' docker pull "$image"',
|
||||
' digest=$(docker image inspect --format '.escapeshellarg('{{if .RepoDigests}}{{index .RepoDigests 0}}{{else}}{{.Id}}{{end}}').' "$image")',
|
||||
'fi',
|
||||
'printf "image_digest=%s\n" "$digest"',
|
||||
]));
|
||||
|
||||
if (preg_match('/image_digest=(?<digest>\S+)/', $output, $matches)) {
|
||||
return $this->digestFromOutput($matches['digest'], $image);
|
||||
}
|
||||
|
||||
return $this->digestFromOutput($output, $image);
|
||||
}
|
||||
|
||||
private function imageReference(Service $service): string
|
||||
{
|
||||
$driver = $service->driver();
|
||||
|
||||
if (! $driver instanceof RendersCompose) {
|
||||
throw new RuntimeException("Driver [{$service->driver_name}] cannot resolve an image digest.");
|
||||
}
|
||||
|
||||
$image = $driver->composeService()['image'] ?? null;
|
||||
|
||||
if (! is_string($image) || $image === '') {
|
||||
throw new RuntimeException("Driver [{$service->driver_name}] did not provide an image reference.");
|
||||
}
|
||||
|
||||
return $image;
|
||||
}
|
||||
|
||||
private function targetServer(Service $service): Server
|
||||
{
|
||||
$service->loadMissing('server', 'replicas.server');
|
||||
|
||||
$server = $service->replicas->first()?->server ?: $service->server;
|
||||
|
||||
if (! $server instanceof Server) {
|
||||
throw new RuntimeException("Service [{$service->id}] must have a target server before resolving an image digest.");
|
||||
}
|
||||
|
||||
return $server;
|
||||
}
|
||||
|
||||
private function digestFromOutput(string $output, string $image): string
|
||||
{
|
||||
if (str_contains($output, '@')) {
|
||||
return str($output)->after('@')->trim()->value();
|
||||
}
|
||||
|
||||
if (str_starts_with($output, 'sha256:')) {
|
||||
return $output;
|
||||
}
|
||||
|
||||
throw new RuntimeException("Unable to resolve image digest for [{$image}].");
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ class CreateServiceCommand extends Command
|
||||
|
||||
protected $description = 'Create a service';
|
||||
|
||||
public function handle()
|
||||
public function handle(): void
|
||||
{
|
||||
$serverId = $this->components->ask('Enter the server ID');
|
||||
$server = Server::find($serverId);
|
||||
@@ -26,7 +26,7 @@ class CreateServiceCommand extends Command
|
||||
}
|
||||
|
||||
$serviceType = $this->components->choice('select the service you want to install', [
|
||||
'postgres-17',
|
||||
'postgres-18',
|
||||
]);
|
||||
|
||||
$serviceName = $this->components->ask('Enter the service name');
|
||||
@@ -36,7 +36,7 @@ class CreateServiceCommand extends Command
|
||||
server: $server,
|
||||
name: $serviceName,
|
||||
category: ServiceCategory::DATABASE,
|
||||
type: ServiceType::tryFrom($type),
|
||||
type: ServiceType::from($type),
|
||||
version: $version,
|
||||
);
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ class GenerateJSEnums extends Command
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$enums = base_path('app/enums');
|
||||
$enums = app_path('Enums');
|
||||
|
||||
$this->load($enums);
|
||||
|
||||
@@ -87,7 +87,7 @@ class GenerateJSEnums extends Command
|
||||
|
||||
// Skip format, JS date formats are different to PHP ones.
|
||||
if ($name !== 'Format') {
|
||||
file_put_contents(base_path('resources/js/Enums/'.$name.'.js'), $js);
|
||||
file_put_contents(resource_path('js/enums/'.$name.'.js'), $js);
|
||||
$this->info('Stored '.$enum);
|
||||
} else {
|
||||
$this->info('Skipped '.$name.'s');
|
||||
|
||||
24
app/Data/Environments/EnvironmentDeploymentPlan.php
Normal file
24
app/Data/Environments/EnvironmentDeploymentPlan.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Data\Environments;
|
||||
|
||||
use App\Models\Service;
|
||||
|
||||
class EnvironmentDeploymentPlan
|
||||
{
|
||||
/**
|
||||
* @param array<int, Service> $services
|
||||
* @param array<int, Service> $dependencies
|
||||
* @param array<int, string> $warnings
|
||||
* @param array<int, string> $blockers
|
||||
*/
|
||||
public function __construct(
|
||||
public array $services = [],
|
||||
public array $dependencies = [],
|
||||
public bool $requiresRegistry = false,
|
||||
public array $warnings = [],
|
||||
public array $blockers = [],
|
||||
) {
|
||||
//
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Data\Deployments;
|
||||
namespace App\Data\Operations;
|
||||
|
||||
class Plan
|
||||
{
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Data\Deployments;
|
||||
namespace App\Data\Operations;
|
||||
|
||||
class PlannedStep
|
||||
{
|
||||
@@ -11,28 +11,34 @@ class PlannedStep
|
||||
protected array $secrets = [],
|
||||
string|callable $script = 'echo "Incomplete Step"',
|
||||
) {
|
||||
if (is_callable($script)) {
|
||||
$this->script = $script();
|
||||
} else {
|
||||
$this->script = $script;
|
||||
}
|
||||
$this->script = is_callable($script) ? $script() : $script;
|
||||
}
|
||||
|
||||
public function getSafeScript(): string
|
||||
{
|
||||
$script = $this->script;
|
||||
foreach ($this->secrets as $key => $value) {
|
||||
$script = str_replace("[!{$key}]", '********', $script);
|
||||
$script = str_replace("[!{$key}!]", '********', $script);
|
||||
}
|
||||
|
||||
return $script;
|
||||
}
|
||||
|
||||
public function getScriptTemplate(): string
|
||||
{
|
||||
return $this->script;
|
||||
}
|
||||
|
||||
public function secrets(): array
|
||||
{
|
||||
return $this->secrets;
|
||||
}
|
||||
|
||||
public function getScript(): string
|
||||
{
|
||||
$script = $this->script;
|
||||
foreach ($this->secrets as $key => $value) {
|
||||
$script = str_replace("[!{$key}]", $value, $script);
|
||||
$script = str_replace("[!{$key}!]", $value, $script);
|
||||
}
|
||||
|
||||
return $script;
|
||||
@@ -2,15 +2,20 @@
|
||||
|
||||
namespace App\Drivers\Caddy;
|
||||
|
||||
use App\Data\Operations\Plan;
|
||||
use App\Data\Operations\PlannedStep;
|
||||
use App\Drivers\Concerns\RendersCompose;
|
||||
use App\Drivers\Concerns\SupportsSlices;
|
||||
use App\Drivers\GatewayDriver;
|
||||
use App\Data\Deployments\Plan;
|
||||
use App\Data\Deployments\PlannedStep as Step;
|
||||
use App\Enums\DeploymentStatus;
|
||||
use App\Enums\EnvironmentAttachmentRole;
|
||||
use App\Enums\ServiceType;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceSlice;
|
||||
|
||||
class Caddy2Driver extends GatewayDriver
|
||||
class Caddy2Driver extends GatewayDriver implements RendersCompose, SupportsSlices
|
||||
{
|
||||
public ?string $containerName;
|
||||
|
||||
public ?string $containerId;
|
||||
|
||||
public function __construct(
|
||||
@@ -23,55 +28,112 @@ class Caddy2Driver extends GatewayDriver
|
||||
$this->service = $service;
|
||||
}
|
||||
|
||||
public function getDeploymentPlan(string $deploymentHash): Plan
|
||||
public function getOperationPlan(string $operationHash): Plan
|
||||
{
|
||||
$previousDeployment = $this->service?->deployments()
|
||||
->where('status', DeploymentStatus::COMPLETED)
|
||||
->first();
|
||||
|
||||
return new Plan(steps: [
|
||||
new Step(
|
||||
name: 'Generate Caddyfile',
|
||||
script: function () {
|
||||
$script = collect();
|
||||
$script->push('cd ~');
|
||||
$script->push('test -d services || mkdir services');
|
||||
$script->push('cd services');
|
||||
$script->push("test -d {$this->service->id} || mkdir {$this->service->id}");
|
||||
$script->push("cd {$this->service->id}");
|
||||
return $script->join("\n");
|
||||
}
|
||||
new PlannedStep(
|
||||
name: 'Render Caddy Compose files',
|
||||
script: "mkdir -p /home/keystone/gateway /home/keystone/services/{$this->service?->id}",
|
||||
),
|
||||
new Step(
|
||||
name: 'Run the docker image',
|
||||
script: function () use ($previousDeployment, $deploymentHash) {
|
||||
$script = collect();
|
||||
if ($this->containerName && $previousDeployment) {
|
||||
$script->push("docker stop \"{$this->containerName}-{$previousDeployment->hash}\" || true");
|
||||
} elseif ($this->containerId) {
|
||||
$script->push('docker stop ' . $this->containerId . ' || true');
|
||||
}
|
||||
|
||||
$runCommand = 'docker run -d';
|
||||
if ($this->containerName) {
|
||||
$runCommand .= " --name \"{$this->containerName}-{$deploymentHash}\"";
|
||||
}
|
||||
$runCommand .= ' -p 80:80 -p 443:443 caddy:2';
|
||||
|
||||
$script->push($runCommand);
|
||||
|
||||
return $script->join(" && ");
|
||||
}
|
||||
new PlannedStep(
|
||||
name: 'Start Caddy gateway',
|
||||
script: "docker compose -f /home/keystone/services/{$this->service?->id}/compose.yml up -d",
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
public function buildCaddyfile(): string
|
||||
public function serviceType(): ServiceType
|
||||
{
|
||||
$caddyfile = "http://{$this->service->name} {\n";
|
||||
$caddyfile .= " reverse_proxy {$this->service->credentials['backend']}\n";
|
||||
$caddyfile .= "}\n";
|
||||
return ServiceType::CADDY;
|
||||
}
|
||||
|
||||
return $caddyfile;
|
||||
public function versionTrack(): string
|
||||
{
|
||||
return '2';
|
||||
}
|
||||
|
||||
public function defaultImage(): string
|
||||
{
|
||||
return 'caddy:2';
|
||||
}
|
||||
|
||||
public function defaultPorts(): array
|
||||
{
|
||||
return [80, 443];
|
||||
}
|
||||
|
||||
public function firewallRules(): array
|
||||
{
|
||||
return ['80/tcp', '443/tcp'];
|
||||
}
|
||||
|
||||
public function environmentSchema(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function resourceDefaults(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function updateBehavior(): string
|
||||
{
|
||||
return 'stateless_redeploy';
|
||||
}
|
||||
|
||||
public function composeService(): array
|
||||
{
|
||||
return [
|
||||
'image' => $this->service?->available_image_digest
|
||||
?: $this->service?->current_image_digest
|
||||
?: $this->defaultImage(),
|
||||
'restart' => 'unless-stopped',
|
||||
'ports' => ['80:80', '443:443'],
|
||||
'volumes' => [
|
||||
'/home/keystone/gateway/Caddyfile:/etc/caddy/Caddyfile:ro',
|
||||
"keystone_service_{$this->service?->id}_caddy_data:/data",
|
||||
"keystone_service_{$this->service?->id}_caddy_config:/config",
|
||||
],
|
||||
'healthcheck' => [
|
||||
'test' => ['CMD', 'caddy', 'version'],
|
||||
'interval' => '10s',
|
||||
'timeout' => '5s',
|
||||
'retries' => 5,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function composeVolumes(): array
|
||||
{
|
||||
return [
|
||||
"keystone_service_{$this->service?->id}_caddy_data" => null,
|
||||
"keystone_service_{$this->service?->id}_caddy_config" => null,
|
||||
];
|
||||
}
|
||||
|
||||
public function environmentExports(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function supportedSliceTypes(): array
|
||||
{
|
||||
return ['route'];
|
||||
}
|
||||
|
||||
public function environmentExportsForSlice(ServiceSlice $slice, ?EnvironmentAttachmentRole $role = null): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function provisionSliceScript(ServiceSlice $slice): string
|
||||
{
|
||||
return implode("\n", [
|
||||
'set -euo pipefail',
|
||||
'mkdir -p /home/keystone/gateway/Caddyfile.d',
|
||||
'test -f /home/keystone/gateway/Caddyfile || touch /home/keystone/gateway/Caddyfile',
|
||||
"test ! -e /home/keystone/gateway/Caddyfile.d/{$slice->id}.caddy || true",
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
21
app/Drivers/Concerns/RendersCompose.php
Normal file
21
app/Drivers/Concerns/RendersCompose.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Drivers\Concerns;
|
||||
|
||||
interface RendersCompose
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function composeService(): array;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function composeVolumes(): array;
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function environmentExports(): array;
|
||||
}
|
||||
21
app/Drivers/Concerns/SupportsSlices.php
Normal file
21
app/Drivers/Concerns/SupportsSlices.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Drivers\Concerns;
|
||||
|
||||
use App\Enums\EnvironmentAttachmentRole;
|
||||
use App\Models\ServiceSlice;
|
||||
|
||||
interface SupportsSlices
|
||||
{
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function supportedSliceTypes(): array;
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function environmentExportsForSlice(ServiceSlice $slice, ?EnvironmentAttachmentRole $role = null): array;
|
||||
|
||||
public function provisionSliceScript(ServiceSlice $slice): string;
|
||||
}
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
namespace App\Drivers;
|
||||
|
||||
use App\Data\Deployments\Plan;
|
||||
use App\Data\Operations\Plan;
|
||||
use App\Data\Operations\PlannedStep;
|
||||
use App\Enums\ServiceType;
|
||||
use App\Models\Service;
|
||||
|
||||
abstract class Driver
|
||||
@@ -19,5 +21,41 @@ abstract class Driver
|
||||
?Service $service = null,
|
||||
);
|
||||
|
||||
abstract public function getDeploymentPlan(string $deploymentHash): Plan;
|
||||
abstract public function getOperationPlan(string $operationHash): Plan;
|
||||
|
||||
abstract public function serviceType(): ServiceType;
|
||||
|
||||
abstract public function versionTrack(): string;
|
||||
|
||||
abstract public function defaultImage(): string;
|
||||
|
||||
/**
|
||||
* @return array<int, int>
|
||||
*/
|
||||
abstract public function defaultPorts(): array;
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
abstract public function firewallRules(): array;
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
abstract public function environmentSchema(): array;
|
||||
|
||||
/**
|
||||
* @return array{cpu?: string, memory_mb?: int}
|
||||
*/
|
||||
abstract public function resourceDefaults(): array;
|
||||
|
||||
abstract public function updateBehavior(): string;
|
||||
|
||||
/**
|
||||
* @return array<int, PlannedStep>
|
||||
*/
|
||||
public function preSwitchSteps(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
205
app/Drivers/Laravel/LaravelRuntimeDriver.php
Normal file
205
app/Drivers/Laravel/LaravelRuntimeDriver.php
Normal file
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
namespace App\Drivers\Laravel;
|
||||
|
||||
use App\Actions\Environments\BuildMigrationScript;
|
||||
use App\Data\Operations\Plan;
|
||||
use App\Data\Operations\PlannedStep;
|
||||
use App\Drivers\Concerns\RendersCompose;
|
||||
use App\Drivers\Driver;
|
||||
use App\Enums\SchedulerMode;
|
||||
use App\Enums\ServiceType;
|
||||
use App\Models\Service;
|
||||
|
||||
class LaravelRuntimeDriver extends Driver implements RendersCompose
|
||||
{
|
||||
public function __construct(
|
||||
public ?string $containerName = null,
|
||||
public ?string $containerId = null,
|
||||
public ?Service $service = null,
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
public function getOperationPlan(string $operationHash): Plan
|
||||
{
|
||||
return new Plan(steps: [
|
||||
new PlannedStep(
|
||||
name: 'Render Laravel Compose file',
|
||||
script: "mkdir -p /home/keystone/services/{$this->service?->id}",
|
||||
),
|
||||
new PlannedStep(
|
||||
name: 'Run migrations',
|
||||
script: $this->service
|
||||
? app(BuildMigrationScript::class)->execute($this->service)
|
||||
: 'true',
|
||||
),
|
||||
new PlannedStep(
|
||||
name: 'Start Laravel replica',
|
||||
script: "docker compose -f /home/keystone/services/{$this->service?->id}/compose.yml up -d",
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
public function serviceType(): ServiceType
|
||||
{
|
||||
return ServiceType::LARAVEL;
|
||||
}
|
||||
|
||||
public function versionTrack(): string
|
||||
{
|
||||
return 'php-8.4';
|
||||
}
|
||||
|
||||
public function defaultImage(): string
|
||||
{
|
||||
return 'serversideup/php:8.4-frankenphp';
|
||||
}
|
||||
|
||||
public function defaultPorts(): array
|
||||
{
|
||||
return [80];
|
||||
}
|
||||
|
||||
public function firewallRules(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function environmentSchema(): array
|
||||
{
|
||||
return [
|
||||
'APP_ENV' => 'string',
|
||||
'SERVER_NAME' => 'string',
|
||||
];
|
||||
}
|
||||
|
||||
public function resourceDefaults(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function updateBehavior(): string
|
||||
{
|
||||
return 'stateless_gateway_cutover';
|
||||
}
|
||||
|
||||
public function composeService(): array
|
||||
{
|
||||
$image = $this->service?->available_image_digest
|
||||
?: $this->service?->current_image_digest
|
||||
?: ($this->service?->config['image'] ?? $this->defaultImage());
|
||||
|
||||
$service = [
|
||||
'image' => $image,
|
||||
'restart' => 'unless-stopped',
|
||||
'environment' => $this->environmentExports(),
|
||||
];
|
||||
|
||||
if ($command = $this->service?->config['command'] ?? null) {
|
||||
$service['command'] = $command;
|
||||
}
|
||||
|
||||
if (! in_array('worker', $this->service?->process_roles ?? [], true)) {
|
||||
$service['healthcheck'] = [
|
||||
'test' => ['CMD-SHELL', 'curl -fsS http://localhost'.($this->service?->config['health_path'] ?? '/up').' || exit 1'],
|
||||
'interval' => '10s',
|
||||
'timeout' => '5s',
|
||||
'retries' => 5,
|
||||
];
|
||||
}
|
||||
|
||||
if ($this->service?->default_cpu_limit) {
|
||||
$service['cpus'] = (string) $this->service->default_cpu_limit;
|
||||
}
|
||||
|
||||
if ($this->service?->default_memory_limit_mb) {
|
||||
$service['mem_limit'] = "{$this->service->default_memory_limit_mb}m";
|
||||
$service['memswap_limit'] = "{$this->service->default_memory_limit_mb}m";
|
||||
}
|
||||
|
||||
return $service;
|
||||
}
|
||||
|
||||
public function composeVolumes(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function environmentExports(): array
|
||||
{
|
||||
$environment = $this->service?->environment?->variables()
|
||||
->pluck('value', 'key')
|
||||
->all() ?? [];
|
||||
|
||||
$environment = [
|
||||
...$environment,
|
||||
'APP_ENV' => $this->service?->environment?->name ?? 'production',
|
||||
'SERVER_NAME' => ':80',
|
||||
];
|
||||
|
||||
if ($this->shouldAutorunScheduler()) {
|
||||
$environment['AUTORUN_LARAVEL_SCHEDULER'] = 'true';
|
||||
}
|
||||
|
||||
return $environment;
|
||||
}
|
||||
|
||||
private function shouldAutorunScheduler(): bool
|
||||
{
|
||||
if (! in_array('scheduler', $this->service?->process_roles ?? [], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$environment = $this->service?->environment;
|
||||
|
||||
if (! $environment?->scheduler_enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($environment->scheduler_target_service_id && $environment->scheduler_target_service_id !== $this->service?->id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $environment->scheduler_mode !== SchedulerMode::SINGLE
|
||||
|| (int) $this->service?->desired_replicas === 1;
|
||||
}
|
||||
|
||||
public function dockerfileTemplate(): string
|
||||
{
|
||||
$phpVersion = $this->service?->config['php_version'] ?? '8.4';
|
||||
$documentRoot = $this->service?->config['document_root'] ?? 'public';
|
||||
$jsBuildCommand = $this->service?->config['js_build_command'] ?? $this->service?->environment?->build_config['js_build_command'] ?? null;
|
||||
$jsPackageManager = $this->service?->config['js_package_manager'] ?? $this->service?->environment?->build_config['js_package_manager'] ?? 'bun';
|
||||
$jsBuildSteps = $this->jsBuildSteps($jsPackageManager, $jsBuildCommand);
|
||||
|
||||
return <<<DOCKERFILE
|
||||
FROM serversideup/php:{$phpVersion}-frankenphp
|
||||
|
||||
ENV SSL_MODE=off
|
||||
ENV AUTORUN_ENABLED=true
|
||||
ENV PHP_OPCACHE_ENABLE=1
|
||||
|
||||
WORKDIR /var/www/html
|
||||
|
||||
COPY --chown=www-data:www-data . .
|
||||
|
||||
RUN composer install --no-dev --prefer-dist --no-interaction --optimize-autoloader
|
||||
{$jsBuildSteps}
|
||||
|
||||
ENV SERVER_DOCUMENT_ROOT=/var/www/html/{$documentRoot}
|
||||
DOCKERFILE;
|
||||
}
|
||||
|
||||
private function jsBuildSteps(string $packageManager, ?string $buildCommand): string
|
||||
{
|
||||
if (! $buildCommand) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return match ($packageManager) {
|
||||
'npm' => "\nRUN npm ci && {$buildCommand}",
|
||||
default => "\nRUN curl -fsSL https://bun.sh/install | bash && export PATH=\"/root/.bun/bin:\$PATH\" && bun install --frozen-lockfile && {$buildCommand}",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Drivers\Postgres;
|
||||
|
||||
use App\Data\Deployments\Plan;
|
||||
use App\Data\Deployments\PlannedStep as Step;
|
||||
use App\Drivers\DatabaseDriver;
|
||||
use App\Enums\DeploymentStatus;
|
||||
use App\Models\Service;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Postgres17Driver extends DatabaseDriver
|
||||
{
|
||||
public Plan $deploymentPlan;
|
||||
|
||||
public function __construct(
|
||||
public ?string $containerName = null,
|
||||
public ?string $containerId = null,
|
||||
public ?Service $service = null,
|
||||
public ?array $credentials = null,
|
||||
) {
|
||||
$credentials = $credentials ?? $this->defaultCredentials();
|
||||
}
|
||||
|
||||
public function getDeploymentPlan(string $deploymentHash): Plan
|
||||
{
|
||||
$user = $credentials['user'] ?? null;
|
||||
$password = $credentials['password'] ?? null;
|
||||
$db = $credentials['db'] ?? null;
|
||||
|
||||
if (!$user || !$password || !$db) {
|
||||
throw new \InvalidArgumentException('Missing required credentials');
|
||||
}
|
||||
|
||||
$previousDeployment = $this->service?->deployments()
|
||||
->where('status', DeploymentStatus::COMPLETED)
|
||||
->first();
|
||||
|
||||
return new Plan(steps: [
|
||||
new Step(
|
||||
name: 'Run the docker image',
|
||||
secrets: [
|
||||
'password' => $password
|
||||
],
|
||||
script: function () use ($user, $password, $db, $previousDeployment, $deploymentHash) {
|
||||
$script = collect();
|
||||
|
||||
if ($this->containerName && $previousDeployment) {
|
||||
$script->push("docker stop \"{$this->containerName}-{$previousDeployment->hash}\" || true");
|
||||
} elseif ($this->containerId) {
|
||||
$script->push('docker stop ' . $this->containerId . ' || true');
|
||||
}
|
||||
|
||||
$runCommand = 'docker run -d';
|
||||
if ($this->containerName) {
|
||||
$runCommand .= " --name \"{$this->containerName}-{$deploymentHash}\"";
|
||||
}
|
||||
if ($password) {
|
||||
$runCommand .= ' -e POSTGRES_PASSWORD=[!password!]';
|
||||
}
|
||||
if ($user) {
|
||||
$runCommand .= " -e POSTGRES_USER={$user}";
|
||||
}
|
||||
if ($db) {
|
||||
$runCommand .= " -e POSTGRES_DB={$db}";
|
||||
}
|
||||
$runCommand .= ' -p 5432:5432 postgres:17';
|
||||
|
||||
$script->push($runCommand);
|
||||
|
||||
return $script->join(" && ");
|
||||
}
|
||||
),
|
||||
new Step(
|
||||
name: 'Configure firewall', // @todo this should create a Firewallrule
|
||||
script: 'ufw allow 5432/tcp || true',
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
public function defaultCredentials(): array
|
||||
{
|
||||
return [
|
||||
'password' => Str::random(16),
|
||||
'user' => 'keystone',
|
||||
'db' => 'keystone',
|
||||
];
|
||||
}
|
||||
|
||||
public function createUser(string $user, string $password): string
|
||||
{
|
||||
return "psql -U {$this->credentials['user']} -d {$this->credentials['db']} -c \"CREATE USER {$user} WITH PASSWORD '{$password}';\"";
|
||||
}
|
||||
}
|
||||
203
app/Drivers/Postgres/Postgres18Driver.php
Normal file
203
app/Drivers/Postgres/Postgres18Driver.php
Normal file
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
namespace App\Drivers\Postgres;
|
||||
|
||||
use App\Data\Operations\Plan;
|
||||
use App\Data\Operations\PlannedStep;
|
||||
use App\Drivers\Concerns\RendersCompose;
|
||||
use App\Drivers\Concerns\SupportsSlices;
|
||||
use App\Drivers\DatabaseDriver;
|
||||
use App\Enums\EnvironmentAttachmentRole;
|
||||
use App\Enums\ServiceType;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceSlice;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Postgres18Driver extends DatabaseDriver implements RendersCompose, SupportsSlices
|
||||
{
|
||||
public Plan $operationPlan;
|
||||
|
||||
public function __construct(
|
||||
public ?string $containerName = null,
|
||||
public ?string $containerId = null,
|
||||
public ?Service $service = null,
|
||||
public ?array $credentials = null,
|
||||
) {
|
||||
$this->credentials = $credentials ?? $this->defaultCredentials();
|
||||
}
|
||||
|
||||
public function getOperationPlan(string $operationHash): Plan
|
||||
{
|
||||
$credentials = $this->credentials ?? $this->defaultCredentials();
|
||||
$user = $credentials['user'] ?? null;
|
||||
$password = $credentials['password'] ?? null;
|
||||
$db = $credentials['db'] ?? null;
|
||||
|
||||
if (! $user || ! $password || ! $db) {
|
||||
throw new \InvalidArgumentException('Missing required credentials');
|
||||
}
|
||||
|
||||
return new Plan(steps: [
|
||||
new PlannedStep(
|
||||
name: 'Render Compose file',
|
||||
script: "mkdir -p /home/keystone/services/{$this->service?->id}",
|
||||
),
|
||||
new PlannedStep(
|
||||
name: 'Start Postgres service',
|
||||
script: "docker compose -f /home/keystone/services/{$this->service?->id}/compose.yml up -d",
|
||||
),
|
||||
new PlannedStep(
|
||||
name: 'Check Postgres health',
|
||||
script: "docker compose -f /home/keystone/services/{$this->service?->id}/compose.yml ps --status running",
|
||||
),
|
||||
new PlannedStep(
|
||||
name: 'Configure firewall',
|
||||
script: 'ufw allow 5432/tcp || true',
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
public function serviceType(): ServiceType
|
||||
{
|
||||
return ServiceType::POSTGRES;
|
||||
}
|
||||
|
||||
public function versionTrack(): string
|
||||
{
|
||||
return '18';
|
||||
}
|
||||
|
||||
public function defaultImage(): string
|
||||
{
|
||||
return 'postgres:18';
|
||||
}
|
||||
|
||||
public function defaultPorts(): array
|
||||
{
|
||||
return [5432];
|
||||
}
|
||||
|
||||
public function firewallRules(): array
|
||||
{
|
||||
return ['5432/tcp'];
|
||||
}
|
||||
|
||||
public function environmentSchema(): array
|
||||
{
|
||||
return [
|
||||
'POSTGRES_USER' => 'string',
|
||||
'POSTGRES_PASSWORD' => 'secret',
|
||||
'POSTGRES_DB' => 'string',
|
||||
];
|
||||
}
|
||||
|
||||
public function resourceDefaults(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function updateBehavior(): string
|
||||
{
|
||||
return 'stateful_downtime';
|
||||
}
|
||||
|
||||
public function defaultCredentials(): array
|
||||
{
|
||||
return [
|
||||
'password' => Str::random(32),
|
||||
'user' => 'keystone',
|
||||
'db' => 'keystone',
|
||||
];
|
||||
}
|
||||
|
||||
public function createUser(string $user, string $password): string
|
||||
{
|
||||
return "psql -U {$this->credentials['user']} -d {$this->credentials['db']} -c \"CREATE USER {$user} WITH PASSWORD '{$password}';\"";
|
||||
}
|
||||
|
||||
public function supportedSliceTypes(): array
|
||||
{
|
||||
return ['database_user'];
|
||||
}
|
||||
|
||||
public function environmentExportsForSlice(ServiceSlice $slice, ?EnvironmentAttachmentRole $role = null): array
|
||||
{
|
||||
$credentials = $slice->credentials ?? [];
|
||||
|
||||
return [
|
||||
'DB_CONNECTION' => 'pgsql',
|
||||
'DB_HOST' => $slice->config['host'] ?? "keystone-service-{$slice->service_id}",
|
||||
'DB_PORT' => (string) ($slice->config['port'] ?? 5432),
|
||||
'DB_DATABASE' => $credentials['database'] ?? $slice->name,
|
||||
'DB_USERNAME' => $credentials['username'] ?? $slice->name,
|
||||
'DB_PASSWORD' => $credentials['password'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
public function provisionSliceScript(ServiceSlice $slice): string
|
||||
{
|
||||
$credentials = $slice->credentials ?? [];
|
||||
$database = $credentials['database'] ?? $slice->name;
|
||||
$username = $credentials['username'] ?? $slice->name;
|
||||
$password = $credentials['password'] ?? Str::password(32);
|
||||
$admin = ($this->credentials ?? $this->defaultCredentials())['user'] ?? 'keystone';
|
||||
$serviceKey = str($slice->service->name)->slug('_')->value() ?: 'postgres';
|
||||
|
||||
return implode("\n", [
|
||||
'set -euo pipefail',
|
||||
"docker compose -f /home/keystone/services/{$slice->service_id}/compose.yml exec -T {$serviceKey} psql -U ".escapeshellarg($admin).' -d postgres <<\'KEYSTONE_SQL\'',
|
||||
"SELECT 'CREATE DATABASE \"{$this->sqlIdentifier($database)}\"' WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '{$this->sqlLiteral($database)}')\\gexec",
|
||||
'DO $$ BEGIN CREATE USER "'.$this->sqlIdentifier($username).'" WITH PASSWORD \''.$this->sqlLiteral($password).'\'; EXCEPTION WHEN duplicate_object THEN ALTER USER "'.$this->sqlIdentifier($username).'" WITH PASSWORD \''.$this->sqlLiteral($password).'\'; END $$;',
|
||||
"GRANT ALL PRIVILEGES ON DATABASE \"{$this->sqlIdentifier($database)}\" TO \"{$this->sqlIdentifier($username)}\";",
|
||||
'KEYSTONE_SQL',
|
||||
]);
|
||||
}
|
||||
|
||||
public function composeService(): array
|
||||
{
|
||||
$credentials = $this->credentials ?? $this->defaultCredentials();
|
||||
|
||||
return [
|
||||
'image' => $this->service?->available_image_digest
|
||||
?: $this->service?->current_image_digest
|
||||
?: $this->defaultImage(),
|
||||
'restart' => 'unless-stopped',
|
||||
'environment' => [
|
||||
'POSTGRES_USER' => $credentials['user'],
|
||||
'POSTGRES_PASSWORD' => $credentials['password'],
|
||||
'POSTGRES_DB' => $credentials['db'],
|
||||
],
|
||||
'volumes' => [
|
||||
"keystone_service_{$this->service?->id}_postgres_data:/var/lib/postgresql/data",
|
||||
],
|
||||
'healthcheck' => [
|
||||
'test' => ['CMD-SHELL', 'pg_isready -U '.$credentials['user']],
|
||||
'interval' => '10s',
|
||||
'timeout' => '5s',
|
||||
'retries' => 5,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function composeVolumes(): array
|
||||
{
|
||||
return [
|
||||
"keystone_service_{$this->service?->id}_postgres_data" => null,
|
||||
];
|
||||
}
|
||||
|
||||
public function environmentExports(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
private function sqlIdentifier(string $value): string
|
||||
{
|
||||
return str_replace('"', '""', $value);
|
||||
}
|
||||
|
||||
private function sqlLiteral(string $value): string
|
||||
{
|
||||
return str_replace("'", "''", $value);
|
||||
}
|
||||
}
|
||||
154
app/Drivers/Valkey/Valkey8Driver.php
Normal file
154
app/Drivers/Valkey/Valkey8Driver.php
Normal file
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
namespace App\Drivers\Valkey;
|
||||
|
||||
use App\Data\Operations\Plan;
|
||||
use App\Data\Operations\PlannedStep;
|
||||
use App\Drivers\Concerns\RendersCompose;
|
||||
use App\Drivers\Concerns\SupportsSlices;
|
||||
use App\Drivers\Driver;
|
||||
use App\Enums\EnvironmentAttachmentRole;
|
||||
use App\Enums\ServiceType;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceSlice;
|
||||
|
||||
class Valkey8Driver extends Driver implements RendersCompose, SupportsSlices
|
||||
{
|
||||
public function __construct(
|
||||
public ?string $containerName = null,
|
||||
public ?string $containerId = null,
|
||||
public ?Service $service = null,
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
public function getOperationPlan(string $operationHash): Plan
|
||||
{
|
||||
return new Plan(steps: [
|
||||
new PlannedStep(
|
||||
name: 'Render Compose file',
|
||||
script: "mkdir -p /home/keystone/services/{$this->service?->id}",
|
||||
),
|
||||
new PlannedStep(
|
||||
name: 'Start Valkey service',
|
||||
script: "docker compose -f /home/keystone/services/{$this->service?->id}/compose.yml up -d",
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
public function serviceType(): ServiceType
|
||||
{
|
||||
return ServiceType::VALKEY;
|
||||
}
|
||||
|
||||
public function versionTrack(): string
|
||||
{
|
||||
return '8';
|
||||
}
|
||||
|
||||
public function defaultImage(): string
|
||||
{
|
||||
return 'valkey/valkey:8';
|
||||
}
|
||||
|
||||
public function defaultPorts(): array
|
||||
{
|
||||
return [6379];
|
||||
}
|
||||
|
||||
public function firewallRules(): array
|
||||
{
|
||||
return ['6379/tcp'];
|
||||
}
|
||||
|
||||
public function environmentSchema(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function resourceDefaults(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function updateBehavior(): string
|
||||
{
|
||||
return 'stateful_downtime';
|
||||
}
|
||||
|
||||
public function supportedSliceTypes(): array
|
||||
{
|
||||
return ['logical_database'];
|
||||
}
|
||||
|
||||
public function environmentExportsForSlice(ServiceSlice $slice, ?EnvironmentAttachmentRole $role = null): array
|
||||
{
|
||||
$exports = [
|
||||
'REDIS_HOST' => $slice->config['host'] ?? "keystone-service-{$slice->service_id}",
|
||||
'REDIS_PORT' => (string) ($slice->config['port'] ?? 6379),
|
||||
'REDIS_DB' => (string) ($slice->config['database'] ?? 0),
|
||||
];
|
||||
|
||||
return match ($role) {
|
||||
EnvironmentAttachmentRole::CACHE => [
|
||||
...$exports,
|
||||
'CACHE_STORE' => 'redis',
|
||||
],
|
||||
EnvironmentAttachmentRole::QUEUE => [
|
||||
...$exports,
|
||||
'QUEUE_CONNECTION' => 'redis',
|
||||
],
|
||||
EnvironmentAttachmentRole::CUSTOM,
|
||||
EnvironmentAttachmentRole::DATABASE,
|
||||
EnvironmentAttachmentRole::GATEWAY,
|
||||
EnvironmentAttachmentRole::STORAGE,
|
||||
null => $exports,
|
||||
};
|
||||
}
|
||||
|
||||
public function provisionSliceScript(ServiceSlice $slice): string
|
||||
{
|
||||
$serviceKey = str($slice->service->name)->slug('_')->value() ?: 'valkey';
|
||||
|
||||
return 'docker compose -f /home/keystone/services/'.$slice->service_id.'/compose.yml exec -T '.$serviceKey.' valkey-cli -n '.escapeshellarg((string) ($slice->config['database'] ?? 0)).' PING';
|
||||
}
|
||||
|
||||
public function composeService(): array
|
||||
{
|
||||
$service = [
|
||||
'image' => $this->service?->available_image_digest
|
||||
?: $this->service?->current_image_digest
|
||||
?: $this->defaultImage(),
|
||||
'restart' => 'unless-stopped',
|
||||
'healthcheck' => [
|
||||
'test' => ['CMD', 'valkey-cli', 'ping'],
|
||||
'interval' => '10s',
|
||||
'timeout' => '5s',
|
||||
'retries' => 5,
|
||||
],
|
||||
];
|
||||
|
||||
if ($this->service?->config['persistence'] ?? false) {
|
||||
$service['volumes'] = ["keystone_service_{$this->service->id}_valkey_data:/data"];
|
||||
$service['command'] = ['valkey-server', '--appendonly', 'yes'];
|
||||
}
|
||||
|
||||
return $service;
|
||||
}
|
||||
|
||||
public function composeVolumes(): array
|
||||
{
|
||||
if (! ($this->service?->config['persistence'] ?? false)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
"keystone_service_{$this->service->id}_valkey_data" => null,
|
||||
];
|
||||
}
|
||||
|
||||
public function environmentExports(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
15
app/Enums/BuildArtifactStatus.php
Normal file
15
app/Enums/BuildArtifactStatus.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use App\Enums\Concerns\Arrayable;
|
||||
|
||||
enum BuildArtifactStatus: string
|
||||
{
|
||||
use Arrayable;
|
||||
|
||||
case PENDING = 'pending';
|
||||
case BUILDING = 'building';
|
||||
case AVAILABLE = 'available';
|
||||
case FAILED = 'failed';
|
||||
}
|
||||
14
app/Enums/BuildStrategy.php
Normal file
14
app/Enums/BuildStrategy.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use App\Enums\Concerns\Arrayable;
|
||||
|
||||
enum BuildStrategy: string
|
||||
{
|
||||
use Arrayable;
|
||||
|
||||
case TARGET_SERVER = 'target_server';
|
||||
case DEDICATED_BUILDER = 'dedicated_builder';
|
||||
case EXTERNAL_REGISTRY = 'external_registry';
|
||||
}
|
||||
15
app/Enums/DeployPolicy.php
Normal file
15
app/Enums/DeployPolicy.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use App\Enums\Concerns\Arrayable;
|
||||
|
||||
enum DeployPolicy: string
|
||||
{
|
||||
use Arrayable;
|
||||
|
||||
case WITH_ENVIRONMENT = 'with_environment';
|
||||
case DEPENDENCY_ONLY = 'dependency_only';
|
||||
case MANUAL_OR_ON_ROUTE_CHANGE = 'manual_or_on_route_change';
|
||||
case MANUAL = 'manual';
|
||||
}
|
||||
17
app/Enums/EnvironmentAttachmentRole.php
Normal file
17
app/Enums/EnvironmentAttachmentRole.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use App\Enums\Concerns\Arrayable;
|
||||
|
||||
enum EnvironmentAttachmentRole: string
|
||||
{
|
||||
use Arrayable;
|
||||
|
||||
case DATABASE = 'database';
|
||||
case CACHE = 'cache';
|
||||
case QUEUE = 'queue';
|
||||
case STORAGE = 'storage';
|
||||
case GATEWAY = 'gateway';
|
||||
case CUSTOM = 'custom';
|
||||
}
|
||||
14
app/Enums/EnvironmentVariableSource.php
Normal file
14
app/Enums/EnvironmentVariableSource.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use App\Enums\Concerns\Arrayable;
|
||||
|
||||
enum EnvironmentVariableSource: string
|
||||
{
|
||||
use Arrayable;
|
||||
|
||||
case USER = 'user';
|
||||
case MANAGED_ATTACHMENT = 'managed_attachment';
|
||||
case SYSTEM = 'system';
|
||||
}
|
||||
20
app/Enums/OperationKind.php
Normal file
20
app/Enums/OperationKind.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use App\Enums\Concerns\Arrayable;
|
||||
|
||||
enum OperationKind: string
|
||||
{
|
||||
use Arrayable;
|
||||
|
||||
case SERVER_PROVISION = 'server_provision';
|
||||
case SERVICE_DEPLOY = 'service_deploy';
|
||||
case REPLICA_DEPLOY = 'replica_deploy';
|
||||
case SLICE_PROVISION = 'slice_provision';
|
||||
case SLICE_CONFIGURE = 'slice_configure';
|
||||
case ENVIRONMENT_DEPLOY = 'environment_deploy';
|
||||
case GATEWAY_CUTOVER = 'gateway_cutover';
|
||||
case CONFIG_CHANGE = 'config_change';
|
||||
case CREDENTIAL_ROTATION = 'credential_rotation';
|
||||
}
|
||||
@@ -4,7 +4,7 @@ namespace App\Enums;
|
||||
|
||||
use App\Enums\Concerns\Arrayable;
|
||||
|
||||
enum DeploymentStatus: string
|
||||
enum OperationStatus: string
|
||||
{
|
||||
use Arrayable;
|
||||
|
||||
15
app/Enums/RegistryType.php
Normal file
15
app/Enums/RegistryType.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use App\Enums\Concerns\Arrayable;
|
||||
|
||||
enum RegistryType: string
|
||||
{
|
||||
use Arrayable;
|
||||
|
||||
case GENERIC = 'generic';
|
||||
case GITEA = 'gitea';
|
||||
case GHCR = 'ghcr';
|
||||
case DOCKER_HUB = 'docker_hub';
|
||||
}
|
||||
13
app/Enums/SchedulerMode.php
Normal file
13
app/Enums/SchedulerMode.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use App\Enums\Concerns\Arrayable;
|
||||
|
||||
enum SchedulerMode: string
|
||||
{
|
||||
use Arrayable;
|
||||
|
||||
case SINGLE = 'single';
|
||||
case EVERY_REPLICA = 'every_replica';
|
||||
}
|
||||
@@ -13,6 +13,7 @@ enum ServiceCategory: string
|
||||
case GATEWAY = 'gateway';
|
||||
case STORAGE = 'storage';
|
||||
case CACHE = 'cache';
|
||||
case BUILDER = 'builder';
|
||||
|
||||
public static function getDescription(ServiceCategory|string $category)
|
||||
{
|
||||
@@ -25,10 +26,11 @@ enum ServiceCategory: string
|
||||
|
||||
return match ($category) {
|
||||
self::APPLICATION => 'The base container image for your application',
|
||||
self::DATABASE => 'Postgres or MySQL',
|
||||
self::DATABASE => 'Postgres',
|
||||
self::GATEWAY => 'The first point of contact for your application',
|
||||
self::STORAGE => 'S3 or S3-compatible service',
|
||||
self::CACHE => 'Redis, Memcached or similar',
|
||||
self::CACHE => 'Valkey',
|
||||
self::BUILDER => 'Build service for application artifacts',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
14
app/Enums/ServiceEndpointScope.php
Normal file
14
app/Enums/ServiceEndpointScope.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use App\Enums\Concerns\Arrayable;
|
||||
|
||||
enum ServiceEndpointScope: string
|
||||
{
|
||||
use Arrayable;
|
||||
|
||||
case DOCKER_NETWORK = 'docker_network';
|
||||
case PRIVATE_NETWORK = 'private_network';
|
||||
case PUBLIC = 'public';
|
||||
}
|
||||
@@ -8,14 +8,8 @@ enum ServiceType: string
|
||||
{
|
||||
use Arrayable;
|
||||
|
||||
case FRANKENPHP = 'frankenphp';
|
||||
case PHP_FPM = 'php-fpm';
|
||||
case POSTGRES = 'postgres';
|
||||
case CADDY = 'caddy';
|
||||
case VALKEY = 'valkey';
|
||||
|
||||
// future?
|
||||
case MYSQL = 'mysql';
|
||||
case NGINX = 'nginx';
|
||||
case REDIS = 'redis';
|
||||
case LARAVEL = 'laravel';
|
||||
}
|
||||
|
||||
14
app/Enums/SourceProviderType.php
Normal file
14
app/Enums/SourceProviderType.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use App\Enums\Concerns\Arrayable;
|
||||
|
||||
enum SourceProviderType: string
|
||||
{
|
||||
use Arrayable;
|
||||
|
||||
case GITEA = 'gitea';
|
||||
case GITHUB = 'github';
|
||||
case GENERIC_GIT = 'generic_git';
|
||||
}
|
||||
@@ -2,26 +2,65 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Actions\Applications\CreateLaravelEnvironment;
|
||||
use App\Actions\Applications\GenerateDeployKey;
|
||||
use App\Actions\Applications\VerifyRepositoryAccess;
|
||||
use App\Enums\RepositoryType;
|
||||
use App\Enums\ServerStatus;
|
||||
use App\Http\Requests\StoreApplicationRequest;
|
||||
use App\Models\Application;
|
||||
use App\Models\Organisation;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Response;
|
||||
|
||||
class ApplicationController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$organisation = Organisation::with('applications.instances.server')->findOrFail($request->route('organisation'));
|
||||
$organisation = Organisation::with('applications.environments.services')->findOrFail($request->route('organisation'));
|
||||
|
||||
return inertia('applications/Index', [
|
||||
'applications' => $organisation->applications,
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Request $request)
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
Organisation::findOrFail($request->route('organisation'));
|
||||
|
||||
return inertia('applications/Create');
|
||||
}
|
||||
|
||||
public function store(StoreApplicationRequest $request): RedirectResponse
|
||||
{
|
||||
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||
|
||||
$application = $organisation->applications()->create([
|
||||
'name' => $request->string('name')->toString(),
|
||||
'repository_url' => $request->string('repository_url')->toString(),
|
||||
'repository_type' => RepositoryType::GIT,
|
||||
'default_branch' => $request->string('default_branch')->toString(),
|
||||
]);
|
||||
|
||||
app(GenerateDeployKey::class)->execute($application);
|
||||
app(CreateLaravelEnvironment::class)->execute($application->refresh(), $request->string('environment_name')->toString());
|
||||
|
||||
return redirect()
|
||||
->route('applications.show', ['organisation' => $organisation->id, 'application' => $application->id])
|
||||
->with('success', 'Application created. Add the deploy key to your repository before verifying access.');
|
||||
}
|
||||
|
||||
public function show(Request $request): Response
|
||||
{
|
||||
$id = $request->route('application');
|
||||
$application = Application::with(['instances.server', 'organisation'])->findOrFail($id);
|
||||
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||
$application = Application::with([
|
||||
'environments.services.slices',
|
||||
'environments.attachments.service',
|
||||
'environments.variables',
|
||||
'organisation',
|
||||
])->whereBelongsTo($organisation)->findOrFail($id);
|
||||
|
||||
return inertia('applications/Show', [
|
||||
'application' => $application,
|
||||
@@ -35,4 +74,16 @@ class ApplicationController extends Controller
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
public function verifyRepository(Request $request): RedirectResponse
|
||||
{
|
||||
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||
|
||||
if (! app(VerifyRepositoryAccess::class)->execute($application)) {
|
||||
return back()->with('error', 'Repository access could not be verified.');
|
||||
}
|
||||
|
||||
return back()->with('success', 'Repository access verified.');
|
||||
}
|
||||
}
|
||||
|
||||
57
app/Http/Controllers/EnvironmentAttachmentController.php
Normal file
57
app/Http/Controllers/EnvironmentAttachmentController.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Actions\Environments\AttachManagedService;
|
||||
use App\Enums\EnvironmentAttachmentRole;
|
||||
use App\Enums\ServiceType;
|
||||
use App\Http\Requests\StoreEnvironmentAttachmentRequest;
|
||||
use App\Models\Organisation;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Response;
|
||||
|
||||
class EnvironmentAttachmentController extends Controller
|
||||
{
|
||||
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('environment-attachments/Create', [
|
||||
'application' => $application,
|
||||
'environment' => $environment,
|
||||
'services' => $organisation->services()
|
||||
->whereIn('type', [ServiceType::POSTGRES->value, ServiceType::VALKEY->value, ServiceType::CADDY->value])
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'type', 'category']),
|
||||
'roles' => array_values(EnvironmentAttachmentRole::toArray()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreEnvironmentAttachmentRequest $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()->findOrFail($request->integer('service_id'));
|
||||
|
||||
app(AttachManagedService::class)->execute(
|
||||
environment: $environment,
|
||||
service: $service,
|
||||
role: $request->enum('role', EnvironmentAttachmentRole::class),
|
||||
name: $request->filled('name') ? $request->string('name')->toString() : null,
|
||||
envPrefix: $request->filled('env_prefix') ? $request->string('env_prefix')->toString() : null,
|
||||
isPrimary: $request->boolean('is_primary', true),
|
||||
);
|
||||
|
||||
return redirect()
|
||||
->route('environments.show', [
|
||||
'organisation' => $organisation->id,
|
||||
'application' => $application->id,
|
||||
'environment' => $environment->id,
|
||||
])
|
||||
->with('success', 'Managed service attached.');
|
||||
}
|
||||
}
|
||||
32
app/Http/Controllers/EnvironmentController.php
Normal file
32
app/Http/Controllers/EnvironmentController.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Organisation;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Response;
|
||||
|
||||
class EnvironmentController extends Controller
|
||||
{
|
||||
public function show(Request $request): Response
|
||||
{
|
||||
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||
$environment = $application->environments()
|
||||
->with([
|
||||
'services.replicas',
|
||||
'services.slices',
|
||||
'services.operations.steps',
|
||||
'attachments.service',
|
||||
'attachments.serviceSlice',
|
||||
'variables',
|
||||
'operations.steps',
|
||||
])
|
||||
->findOrFail($request->route('environment'));
|
||||
|
||||
return inertia('environments/Show', [
|
||||
'application' => $application,
|
||||
'environment' => $environment,
|
||||
]);
|
||||
}
|
||||
}
|
||||
29
app/Http/Controllers/EnvironmentDeploymentController.php
Normal file
29
app/Http/Controllers/EnvironmentDeploymentController.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Jobs\Environments\DeployEnvironment;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Organisation;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class EnvironmentDeploymentController extends Controller
|
||||
{
|
||||
public function store(Organisation $organisation, Application $application, Environment $environment): RedirectResponse
|
||||
{
|
||||
abort_unless(
|
||||
(int) $application->organisation_id === (int) $organisation->id
|
||||
&& (int) $environment->application_id === (int) $application->id,
|
||||
404,
|
||||
);
|
||||
|
||||
dispatch(new DeployEnvironment($environment));
|
||||
|
||||
return redirect()->route('environments.show', [
|
||||
'organisation' => $organisation->id,
|
||||
'application' => $application->id,
|
||||
'environment' => $environment->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
24
app/Http/Controllers/EnvironmentMigrationController.php
Normal file
24
app/Http/Controllers/EnvironmentMigrationController.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Actions\Environments\CreateMigrationOperation;
|
||||
use App\Models\Organisation;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class EnvironmentMigrationController extends Controller
|
||||
{
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||
$environment = $application->environments()->findOrFail($request->route('environment'));
|
||||
|
||||
app(CreateMigrationOperation::class)->execute($environment);
|
||||
|
||||
return redirect()
|
||||
->route('applications.show', ['organisation' => $organisation->id, 'application' => $application->id])
|
||||
->with('success', 'Migration operation created.');
|
||||
}
|
||||
}
|
||||
45
app/Http/Controllers/EnvironmentVariableController.php
Normal file
45
app/Http/Controllers/EnvironmentVariableController.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\EnvironmentVariableSource;
|
||||
use App\Http\Requests\StoreEnvironmentVariableRequest;
|
||||
use App\Models\Organisation;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Response;
|
||||
|
||||
class EnvironmentVariableController extends Controller
|
||||
{
|
||||
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('environment-variables/Create', [
|
||||
'application' => $application,
|
||||
'environment' => $environment,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreEnvironmentVariableRequest $request): RedirectResponse
|
||||
{
|
||||
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||
$environment = $application->environments()->findOrFail($request->route('environment'));
|
||||
|
||||
$environment->variables()->updateOrCreate([
|
||||
'key' => $request->string('key')->toString(),
|
||||
], [
|
||||
'value' => $request->string('value')->toString(),
|
||||
'source' => EnvironmentVariableSource::USER,
|
||||
'service_slice_id' => null,
|
||||
'overridable' => true,
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('applications.show', ['organisation' => $organisation->id, 'application' => $application->id])
|
||||
->with('success', 'Environment variable saved.');
|
||||
}
|
||||
}
|
||||
24
app/Http/Controllers/EnvironmentWorkerController.php
Normal file
24
app/Http/Controllers/EnvironmentWorkerController.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Actions\Environments\CreateLaravelWorkerService;
|
||||
use App\Models\Organisation;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class EnvironmentWorkerController extends Controller
|
||||
{
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||
$application = $organisation->applications()->findOrFail($request->route('application'));
|
||||
$environment = $application->environments()->findOrFail($request->route('environment'));
|
||||
|
||||
app(CreateLaravelWorkerService::class)->execute($environment);
|
||||
|
||||
return redirect()
|
||||
->route('applications.show', ['organisation' => $organisation->id, 'application' => $application->id])
|
||||
->with('success', 'Worker service created.');
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Actions\Applications\CreateInstance;
|
||||
use App\Models\Application;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class InstanceController extends Controller
|
||||
{
|
||||
public function store(Request $request, Application $application)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'server_id' => 'required|exists:servers,id',
|
||||
'branch' => 'required|string|max:255',
|
||||
'config' => 'sometimes|array',
|
||||
]);
|
||||
|
||||
$server = Server::findOrFail($validated['server_id']);
|
||||
|
||||
$instance = (new CreateInstance())->execute(
|
||||
$application,
|
||||
$server,
|
||||
$validated['branch'],
|
||||
$validated['config'] ?? []
|
||||
);
|
||||
|
||||
return redirect()
|
||||
->route('applications.show', [
|
||||
'organisation' => $application->organisation_id,
|
||||
'application' => $application->id
|
||||
])
|
||||
->with('success', 'Instance created successfully');
|
||||
}
|
||||
}
|
||||
61
app/Http/Controllers/OnboardingController.php
Normal file
61
app/Http/Controllers/OnboardingController.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Organisation;
|
||||
use Inertia\Response;
|
||||
|
||||
class OnboardingController extends Controller
|
||||
{
|
||||
public function show(Organisation $organisation): Response
|
||||
{
|
||||
$organisation->loadCount(['providers', 'sourceProviders', 'registries', 'servers', 'applications']);
|
||||
|
||||
$steps = [
|
||||
[
|
||||
'key' => 'organisation',
|
||||
'label' => 'Organisation',
|
||||
'complete' => true,
|
||||
'href' => route('organisations.show', ['organisation' => $organisation->id]),
|
||||
],
|
||||
[
|
||||
'key' => 'provider',
|
||||
'label' => 'Provider',
|
||||
'complete' => $organisation->providers_count > 0,
|
||||
'href' => route('organisations.show', ['organisation' => $organisation->id]),
|
||||
],
|
||||
[
|
||||
'key' => 'source',
|
||||
'label' => 'Source',
|
||||
'complete' => $organisation->source_providers_count > 0,
|
||||
'href' => route('source-providers.create', ['organisation' => $organisation->id]),
|
||||
],
|
||||
[
|
||||
'key' => 'registry',
|
||||
'label' => 'Registry',
|
||||
'complete' => $organisation->registries_count > 0,
|
||||
'href' => route('registries.create', ['organisation' => $organisation->id]),
|
||||
],
|
||||
[
|
||||
'key' => 'server',
|
||||
'label' => 'Server',
|
||||
'complete' => $organisation->servers_count > 0,
|
||||
'href' => route('servers.create', ['organisation' => $organisation->id]),
|
||||
],
|
||||
[
|
||||
'key' => 'application',
|
||||
'label' => 'Application',
|
||||
'complete' => $organisation->applications_count > 0,
|
||||
'href' => route('applications.create', ['organisation' => $organisation->id]),
|
||||
],
|
||||
];
|
||||
|
||||
$next = collect($steps)->firstWhere('complete', false) ?? $steps[array_key_last($steps)];
|
||||
|
||||
return inertia('onboarding/Show', [
|
||||
'organisation' => $organisation,
|
||||
'steps' => $steps,
|
||||
'nextStep' => $next,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ class OrganisationController extends Controller
|
||||
{
|
||||
return inertia('organisations/Show', [
|
||||
'providers' => Inertia::lazy(fn () => Provider::whereOrganisationId($request->route('organisation'))->get()),
|
||||
'registries' => Inertia::lazy(fn () => Organisation::findOrFail($request->route('organisation'))->registries()->get()),
|
||||
'sourceProviders' => Inertia::lazy(fn () => Organisation::findOrFail($request->route('organisation'))->sourceProviders()->get()),
|
||||
'organisation' => Organisation::withCount('servers', 'applications', 'members')->findOrFail($request->route('organisation')),
|
||||
]);
|
||||
}
|
||||
|
||||
41
app/Http/Controllers/RegistryController.php
Normal file
41
app/Http/Controllers/RegistryController.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\RegistryType;
|
||||
use App\Http\Requests\StoreRegistryRequest;
|
||||
use App\Models\Organisation;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Response;
|
||||
|
||||
class RegistryController extends Controller
|
||||
{
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
Organisation::findOrFail($request->route('organisation'));
|
||||
|
||||
return inertia('registries/Create', [
|
||||
'registryTypes' => array_values(RegistryType::toArray()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreRegistryRequest $request): RedirectResponse
|
||||
{
|
||||
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||
|
||||
$organisation->registries()->create([
|
||||
'name' => $request->string('name')->toString(),
|
||||
'type' => $request->enum('type', RegistryType::class),
|
||||
'url' => rtrim($request->string('url')->toString(), '/'),
|
||||
'credentials' => [
|
||||
'username' => $request->string('username')->toString(),
|
||||
'password' => $request->string('password')->toString(),
|
||||
],
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('organisations.show', ['organisation' => $organisation->id])
|
||||
->with('success', 'Registry created.');
|
||||
}
|
||||
}
|
||||
@@ -141,7 +141,7 @@ class ServerController extends Controller
|
||||
$server = $organisation->servers()->findOrFail($request->route('server'));
|
||||
|
||||
return inertia('servers/Show', [
|
||||
'server' => $server->load('services.slices', 'serviceDeployments.steps', 'serviceDeployments.target'),
|
||||
'server' => $server->load('services.slices', 'serviceOperations.steps', 'serviceOperations.target'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,16 @@ namespace App\Http\Controllers;
|
||||
use App\Actions\Services\CreateService;
|
||||
use App\Enums\ServiceCategory;
|
||||
use App\Enums\ServiceType;
|
||||
use App\Http\Requests\StoreServiceRequest;
|
||||
use App\Http\Requests\UpdateServiceRequest;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Response;
|
||||
|
||||
class ServiceController extends Controller
|
||||
{
|
||||
public function create(Request $request)
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
$server = Server::findOrFail($request->route('server'));
|
||||
|
||||
@@ -21,19 +24,8 @@ class ServiceController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
public function store(StoreServiceRequest $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'category' => ['required', Rule::enum(ServiceCategory::class)],
|
||||
'type' => ['required', Rule::enum(ServiceType::class)],
|
||||
'version' => ['required', 'string', function ($key, $value, $fail) use ($request) {
|
||||
if (!isset(config('keystone.services')[$request->category][$request->type]['versions'][$value])) {
|
||||
$fail('The selected version is invalid.');
|
||||
}
|
||||
}],
|
||||
]);
|
||||
|
||||
$server = Server::findOrFail($request->route('server'));
|
||||
|
||||
$service = app(CreateService::class)->execute(
|
||||
@@ -52,4 +44,44 @@ class ServiceController extends Controller
|
||||
'service' => $service,
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Request $request): Response
|
||||
{
|
||||
$server = Server::findOrFail($request->route('server'));
|
||||
$service = $server->services()
|
||||
->with(['replicas', 'slices', 'operations.steps', 'environment.application'])
|
||||
->findOrFail($request->route('service'));
|
||||
|
||||
return inertia('services/Show', [
|
||||
'server' => $server,
|
||||
'service' => $service,
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit(Request $request): Response
|
||||
{
|
||||
$server = Server::findOrFail($request->route('server'));
|
||||
$service = $server->services()->findOrFail($request->route('service'));
|
||||
|
||||
return inertia('services/Edit', [
|
||||
'server' => $server,
|
||||
'service' => $service,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(UpdateServiceRequest $request): RedirectResponse
|
||||
{
|
||||
$server = Server::findOrFail($request->route('server'));
|
||||
$service = $server->services()->findOrFail($request->route('service'));
|
||||
|
||||
$service->update($request->validated());
|
||||
|
||||
return redirect()
|
||||
->route('services.show', [
|
||||
'organisation' => $server->organisation_id,
|
||||
'server' => $server->id,
|
||||
'service' => $service->id,
|
||||
])
|
||||
->with('success', 'Service updated.');
|
||||
}
|
||||
}
|
||||
|
||||
48
app/Http/Controllers/ServiceUpdateController.php
Normal file
48
app/Http/Controllers/ServiceUpdateController.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Actions\Services\CreateStatefulServiceUpdateOperation;
|
||||
use App\Enums\ServiceType;
|
||||
use App\Http\Requests\StoreServiceUpdateRequest;
|
||||
use App\Models\Organisation;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Response;
|
||||
|
||||
class ServiceUpdateController extends Controller
|
||||
{
|
||||
public function create(Organisation $organisation, Server $server, Service $service): Response
|
||||
{
|
||||
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);
|
||||
|
||||
return inertia('services/updates/Create', [
|
||||
'server' => $server,
|
||||
'service' => $service,
|
||||
'backupAvailable' => (bool) ($service->config['backup_enabled'] ?? false),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(
|
||||
StoreServiceUpdateRequest $request,
|
||||
Organisation $organisation,
|
||||
Server $server,
|
||||
Service $service,
|
||||
CreateStatefulServiceUpdateOperation $createStatefulServiceUpdateOperation,
|
||||
): RedirectResponse {
|
||||
abort_unless((int) $server->organisation_id === (int) $organisation->id && (int) $service->server_id === (int) $server->id, 404);
|
||||
|
||||
$createStatefulServiceUpdateOperation->execute(
|
||||
service: $service,
|
||||
imageDigest: $request->string('image_digest')->toString(),
|
||||
backupRequested: $request->boolean('backup_requested'),
|
||||
);
|
||||
|
||||
return redirect()->route('servers.show', [
|
||||
'organisation' => $organisation->id,
|
||||
'server' => $server->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
38
app/Http/Controllers/SourceProviderController.php
Normal file
38
app/Http/Controllers/SourceProviderController.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\SourceProviderType;
|
||||
use App\Http\Requests\StoreSourceProviderRequest;
|
||||
use App\Models\Organisation;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Response;
|
||||
|
||||
class SourceProviderController extends Controller
|
||||
{
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
Organisation::findOrFail($request->route('organisation'));
|
||||
|
||||
return inertia('source-providers/Create', [
|
||||
'sourceProviderTypes' => array_values(SourceProviderType::toArray()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreSourceProviderRequest $request): RedirectResponse
|
||||
{
|
||||
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||
|
||||
$organisation->sourceProviders()->create([
|
||||
'name' => $request->string('name')->toString(),
|
||||
'type' => $request->enum('type', SourceProviderType::class),
|
||||
'url' => $request->filled('url') ? rtrim($request->string('url')->toString(), '/') : null,
|
||||
'config' => [],
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('organisations.show', ['organisation' => $organisation->id])
|
||||
->with('success', 'Source provider created.');
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Organisation;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Middleware;
|
||||
use Tighten\Ziggy\Ziggy;
|
||||
@@ -29,8 +30,12 @@ class HandleInertiaRequests extends Middleware
|
||||
return [
|
||||
...parent::share($request),
|
||||
'name' => config('app.name'),
|
||||
'organisation' => $request->route('organisation') ? Organisation::with('applications')->findOrFail($request->route('organisation')) : null,
|
||||
'application' => $request->route('application') ? Application::with('environments')->findOrFail($request->route('application')) : null,
|
||||
'organisation' => $request->route('organisation')
|
||||
? Organisation::with('applications')->findOrFail($this->routeKey($request->route('organisation')))
|
||||
: null,
|
||||
'application' => $request->route('application')
|
||||
? Application::with('environments')->findOrFail($this->routeKey($request->route('application')))
|
||||
: null,
|
||||
'flash' => [
|
||||
'server_credentials' => $request->session()->has('sudo_password') ? [
|
||||
'sudo_password' => $request->session()->get('sudo_password'),
|
||||
@@ -45,4 +50,9 @@ class HandleInertiaRequests extends Middleware
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function routeKey(mixed $routeValue): mixed
|
||||
{
|
||||
return $routeValue instanceof Model ? $routeValue->getKey() : $routeValue;
|
||||
}
|
||||
}
|
||||
|
||||
31
app/Http/Requests/StoreApplicationRequest.php
Normal file
31
app/Http/Requests/StoreApplicationRequest.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreApplicationRequest 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'],
|
||||
'repository_url' => ['required', 'string', 'max:255', 'regex:/^(git@[^:]+:.+|ssh:\/\/.+)$/i'],
|
||||
'default_branch' => ['required', 'string', 'max:255', 'regex:/^[A-Za-z0-9._\/-]+$/'],
|
||||
'environment_name' => ['required', 'string', 'max:255'],
|
||||
];
|
||||
}
|
||||
}
|
||||
34
app/Http/Requests/StoreEnvironmentAttachmentRequest.php
Normal file
34
app/Http/Requests/StoreEnvironmentAttachmentRequest.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Enums\EnvironmentAttachmentRole;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreEnvironmentAttachmentRequest 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'],
|
||||
'role' => ['required', Rule::enum(EnvironmentAttachmentRole::class)],
|
||||
'name' => ['nullable', 'string', 'max:255'],
|
||||
'env_prefix' => ['nullable', 'string', 'max:32', 'regex:/^[A-Z][A-Z0-9_]*$/'],
|
||||
'is_primary' => ['boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
29
app/Http/Requests/StoreEnvironmentVariableRequest.php
Normal file
29
app/Http/Requests/StoreEnvironmentVariableRequest.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreEnvironmentVariableRequest 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
34
app/Http/Requests/StoreRegistryRequest.php
Normal file
34
app/Http/Requests/StoreRegistryRequest.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 StoreRegistryRequest 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)],
|
||||
'url' => ['required', 'string', 'max:255'],
|
||||
'username' => ['nullable', 'string', 'max:255'],
|
||||
'password' => ['nullable', 'string', 'max:255'],
|
||||
];
|
||||
}
|
||||
}
|
||||
56
app/Http/Requests/StoreServiceRequest.php
Normal file
56
app/Http/Requests/StoreServiceRequest.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Enums\ServiceCategory;
|
||||
use App\Enums\ServiceType;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreServiceRequest 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'],
|
||||
'category' => ['required', Rule::enum(ServiceCategory::class)],
|
||||
'type' => ['required', Rule::enum(ServiceType::class)],
|
||||
'version' => ['required', 'string', function (string $attribute, mixed $value, \Closure $fail): void {
|
||||
if (! isset(config('keystone.services')[$this->category][$this->type]['versions'][$value])) {
|
||||
$fail('The selected version is invalid.');
|
||||
}
|
||||
}],
|
||||
];
|
||||
}
|
||||
|
||||
public function after(): array
|
||||
{
|
||||
return [
|
||||
function ($validator): void {
|
||||
if ($this->category !== ServiceCategory::GATEWAY->value) {
|
||||
return;
|
||||
}
|
||||
|
||||
$server = Server::find($this->route('server'));
|
||||
|
||||
if ($server?->services()->where('category', ServiceCategory::GATEWAY)->exists()) {
|
||||
$validator->errors()->add('category', 'This server already has a gateway service.');
|
||||
}
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
26
app/Http/Requests/StoreServiceUpdateRequest.php
Normal file
26
app/Http/Requests/StoreServiceUpdateRequest.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreServiceUpdateRequest extends FormRequest
|
||||
{
|
||||
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 [
|
||||
'image_digest' => ['required', 'string', 'starts_with:sha256:'],
|
||||
'backup_requested' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
32
app/Http/Requests/StoreSourceProviderRequest.php
Normal file
32
app/Http/Requests/StoreSourceProviderRequest.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 StoreSourceProviderRequest 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
31
app/Http/Requests/UpdateServiceRequest.php
Normal file
31
app/Http/Requests/UpdateServiceRequest.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateServiceRequest 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'],
|
||||
'desired_replicas' => ['required', 'integer', 'min:0', 'max:25'],
|
||||
'default_cpu_limit' => ['nullable', 'numeric', 'min:0.125', 'max:64'],
|
||||
'default_memory_limit_mb' => ['nullable', 'integer', 'min:64', 'max:1048576'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Applications;
|
||||
|
||||
use App\Enums\DeploymentStatus;
|
||||
use App\Models\Application;
|
||||
use App\Models\Deployment;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
|
||||
class DeployApplication implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
protected Deployment $deployment;
|
||||
|
||||
public function __construct(
|
||||
public Application $application,
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$this->deployment = $this->application->deployments()->create([
|
||||
'status' => DeploymentStatus::PENDING,
|
||||
]);
|
||||
|
||||
foreach ($this->application->instances as $instance) {
|
||||
$step = $this->deployment->steps()->create([
|
||||
'name' => "Deploy to {$instance->server->name}",
|
||||
'order' => $instance->id,
|
||||
'status' => DeploymentStatus::PENDING,
|
||||
'script' => $this->getDeploymentScript($instance),
|
||||
'secrets' => [],
|
||||
]);
|
||||
|
||||
$step->dispatchJob();
|
||||
}
|
||||
}
|
||||
|
||||
protected function getDeploymentScript($instance): string
|
||||
{
|
||||
return "#!/bin/bash\n" .
|
||||
"cd /opt/apps/{$this->application->name}-{$instance->id}\n" .
|
||||
"git fetch origin\n" .
|
||||
"git checkout {$instance->branch}\n" .
|
||||
"git pull origin {$instance->branch}\n";
|
||||
}
|
||||
|
||||
public function failed(\Throwable $exception): void
|
||||
{
|
||||
if (isset($this->deployment)) {
|
||||
$this->deployment->update([
|
||||
'status' => DeploymentStatus::FAILED,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
497
app/Jobs/Environments/DeployEnvironment.php
Normal file
497
app/Jobs/Environments/DeployEnvironment.php
Normal file
@@ -0,0 +1,497 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Environments;
|
||||
|
||||
use App\Actions\Environments\BuildApplicationArtifact;
|
||||
use App\Actions\Environments\BuildMigrationScript;
|
||||
use App\Actions\Environments\PlanBuildArtifact;
|
||||
use App\Actions\Environments\PlanEnvironmentDeployment;
|
||||
use App\Actions\Environments\ResolveEnvironmentCommit;
|
||||
use App\Actions\Services\RegisterServiceEndpoint;
|
||||
use App\Enums\EnvironmentAttachmentRole;
|
||||
use App\Enums\OperationKind;
|
||||
use App\Enums\OperationStatus;
|
||||
use App\Enums\ServiceEndpointScope;
|
||||
use App\Models\Environment;
|
||||
use App\Models\EnvironmentAttachment;
|
||||
use App\Models\Operation;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceReplica;
|
||||
use App\Services\Compose\ComposeRenderer;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
|
||||
class DeployEnvironment implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
public Environment $environment,
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$plan = app(PlanEnvironmentDeployment::class)->execute($this->environment);
|
||||
|
||||
if ($plan->requiresRegistry) {
|
||||
throw new RuntimeException('A registry is required before deploying this environment across multiple servers.');
|
||||
}
|
||||
|
||||
if ($plan->blockers !== []) {
|
||||
throw new RuntimeException($plan->blockers[0]);
|
||||
}
|
||||
|
||||
$operation = $this->environment->operations()->create([
|
||||
'kind' => OperationKind::ENVIRONMENT_DEPLOY,
|
||||
'status' => OperationStatus::PENDING,
|
||||
'started_at' => now(),
|
||||
]);
|
||||
|
||||
$commitSha = app(ResolveEnvironmentCommit::class)->execute($this->environment);
|
||||
$services = $this->servicesNeedingDeployment($plan->services, $commitSha);
|
||||
|
||||
if ($services === []) {
|
||||
$operation->update([
|
||||
'status' => OperationStatus::COMPLETED,
|
||||
'finished_at' => now(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$artifact = app(PlanBuildArtifact::class)->execute($this->environment, $commitSha);
|
||||
$artifact = app(BuildApplicationArtifact::class)->execute($artifact, $operation);
|
||||
|
||||
foreach ($services as $service) {
|
||||
$service->update([
|
||||
'available_image_digest' => $artifact->image_digest,
|
||||
'desired_revision' => $commitSha,
|
||||
]);
|
||||
|
||||
$child = $service->operations()->create([
|
||||
'parent_id' => $operation->id,
|
||||
'kind' => OperationKind::SERVICE_DEPLOY,
|
||||
'status' => OperationStatus::PENDING,
|
||||
]);
|
||||
|
||||
$this->createServiceDeploySteps($child, $service, $commitSha, $artifact->image_digest);
|
||||
$this->createReplicaDeployOperations($child, $service, $artifact->registry_ref);
|
||||
}
|
||||
|
||||
$this->createGatewayOperations($operation);
|
||||
$this->dispatchChildOperations($operation);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, Service> $services
|
||||
* @return array<int, Service>
|
||||
*/
|
||||
private function servicesNeedingDeployment(array $services, string $commitSha): array
|
||||
{
|
||||
return collect($services)
|
||||
->filter(fn (Service $service): bool => $service->desired_revision !== $commitSha || ! $service->available_image_digest)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function createServiceDeploySteps(Operation $operation, Service $service, string $commitSha, string $imageDigest): void
|
||||
{
|
||||
foreach ($this->serviceDeployScripts($service, $commitSha, $imageDigest) as $index => $step) {
|
||||
$operation->steps()->create([
|
||||
'name' => $step['name'],
|
||||
'order' => $index + 1,
|
||||
'status' => OperationStatus::PENDING,
|
||||
'script' => $step['script'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function createReplicaDeployOperations(Operation $parent, Service $service, ?string $imageReference = null): void
|
||||
{
|
||||
$replicas = $this->ensureServiceReplicas($service);
|
||||
|
||||
for ($replica = 1; $replica <= max(1, $service->desired_replicas); $replica++) {
|
||||
$serviceReplica = $replicas[$replica - 1] ?? null;
|
||||
$target = $serviceReplica ?: $service;
|
||||
|
||||
$operation = $target->operations()->create([
|
||||
'parent_id' => $parent->id,
|
||||
'kind' => OperationKind::REPLICA_DEPLOY,
|
||||
'status' => OperationStatus::PENDING,
|
||||
]);
|
||||
|
||||
$serviceReplica?->update([
|
||||
'operation_id' => $operation->id,
|
||||
'image_digest' => $service->available_image_digest,
|
||||
'status' => 'pending',
|
||||
'health_status' => 'unknown',
|
||||
]);
|
||||
|
||||
foreach ($this->replicaDeployScripts($service, $replica, $imageReference) as $index => $step) {
|
||||
$operation->steps()->create([
|
||||
'name' => $step['name'],
|
||||
'order' => $index + 1,
|
||||
'status' => OperationStatus::PENDING,
|
||||
'script' => $step['script'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, ServiceReplica>
|
||||
*/
|
||||
private function ensureServiceReplicas(Service $service): array
|
||||
{
|
||||
$service->loadMissing('replicas');
|
||||
|
||||
$serverIds = $this->placementServerIds($service);
|
||||
|
||||
if ($service->replicas->count() < $service->desired_replicas && $serverIds !== []) {
|
||||
for ($index = $service->replicas->count() + 1; $index <= $service->desired_replicas; $index++) {
|
||||
$service->replicas()->create([
|
||||
'server_id' => $serverIds[($index - 1) % count($serverIds)],
|
||||
'container_name' => "keystone-service-{$service->id}-{$index}",
|
||||
'internal_host' => "keystone-service-{$service->id}",
|
||||
'internal_port' => $this->defaultInternalPort($service),
|
||||
'status' => 'pending',
|
||||
'health_status' => 'unknown',
|
||||
'config' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
$service->load('replicas');
|
||||
}
|
||||
|
||||
return $service->replicas
|
||||
->take(max(1, $service->desired_replicas))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function placementServerIds(Service $service): array
|
||||
{
|
||||
$configured = collect($service->config['server_ids'] ?? [])
|
||||
->map(fn (mixed $serverId): int => (int) $serverId)
|
||||
->filter()
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($configured !== []) {
|
||||
return $configured;
|
||||
}
|
||||
|
||||
$existing = $service->replicas
|
||||
->pluck('server_id')
|
||||
->filter()
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($existing !== []) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
return $service->server_id ? [(int) $service->server_id] : [];
|
||||
}
|
||||
|
||||
private function createGatewayOperations(Operation $parent): void
|
||||
{
|
||||
$this->environment->loadMissing('attachments.service.replicas', 'attachments.serviceSlice');
|
||||
|
||||
foreach ($this->environment->attachments->where('role', EnvironmentAttachmentRole::GATEWAY) as $attachment) {
|
||||
$target = $attachment->serviceSlice ?: $this->environment;
|
||||
|
||||
$sliceConfigure = $target->operations()->create([
|
||||
'parent_id' => $parent->id,
|
||||
'kind' => OperationKind::SLICE_CONFIGURE,
|
||||
'status' => OperationStatus::PENDING,
|
||||
]);
|
||||
$sliceConfigure->steps()->create([
|
||||
'name' => 'Configure Caddy route',
|
||||
'order' => 1,
|
||||
'status' => OperationStatus::PENDING,
|
||||
'script' => $this->configureCaddyRouteScript($attachment),
|
||||
]);
|
||||
|
||||
$gatewayCutover = $this->environment->operations()->create([
|
||||
'parent_id' => $parent->id,
|
||||
'kind' => OperationKind::GATEWAY_CUTOVER,
|
||||
'status' => OperationStatus::PENDING,
|
||||
]);
|
||||
|
||||
foreach ($this->gatewayCutoverSteps($attachment) as $index => $step) {
|
||||
$gatewayCutover->steps()->create([
|
||||
'name' => $step['name'],
|
||||
'order' => $index + 1,
|
||||
'status' => OperationStatus::PENDING,
|
||||
'script' => $step['script'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{name: string, script: string}>
|
||||
*/
|
||||
private function serviceDeployScripts(Service $service, string $commitSha, string $imageDigest): array
|
||||
{
|
||||
$servicePath = $this->servicePath($service);
|
||||
$composePath = "{$servicePath}/compose.yml";
|
||||
$serviceKey = $this->serviceKey($service);
|
||||
|
||||
$steps = [
|
||||
[
|
||||
'name' => 'Resolve target commit',
|
||||
'script' => implode("\n", [
|
||||
"mkdir -p {$servicePath}",
|
||||
'printf %s '.escapeshellarg($commitSha)." > {$servicePath}/REVISION",
|
||||
]),
|
||||
],
|
||||
[
|
||||
'name' => 'Create or reuse build artifact',
|
||||
'script' => 'printf %s '.escapeshellarg($imageDigest)." > {$servicePath}/IMAGE_DIGEST",
|
||||
],
|
||||
[
|
||||
'name' => 'Render Compose files',
|
||||
'script' => $this->composeUploadScript($service),
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($service->driver()->preSwitchSteps() as $step) {
|
||||
$steps[] = [
|
||||
'name' => $step->name,
|
||||
'script' => $step->getScriptTemplate(),
|
||||
];
|
||||
}
|
||||
|
||||
if (($service->config['migration_timing'] ?? 'pre_switch') === 'pre_switch') {
|
||||
$steps[] = [
|
||||
'name' => 'Run migrations',
|
||||
'script' => app(BuildMigrationScript::class)->execute($service),
|
||||
];
|
||||
}
|
||||
|
||||
$steps = [
|
||||
...$steps,
|
||||
[
|
||||
'name' => 'Deploy replicas',
|
||||
'script' => "docker compose -f {$composePath} up -d --scale {$serviceKey}=".max(1, $service->desired_replicas),
|
||||
],
|
||||
[
|
||||
'name' => 'Health check replicas',
|
||||
'script' => "docker compose -f {$composePath} ps --status running {$serviceKey}",
|
||||
],
|
||||
];
|
||||
|
||||
if (($service->config['migration_timing'] ?? 'pre_switch') === 'post_switch') {
|
||||
$steps[] = [
|
||||
'name' => 'Run migrations',
|
||||
'script' => app(BuildMigrationScript::class)->execute($service),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
...$steps,
|
||||
[
|
||||
'name' => 'Drain old replicas',
|
||||
'script' => "docker ps --filter 'label=keystone.service_id={$service->id}' --filter 'label=keystone.draining=true' --format '{{.ID}}' | xargs -r docker stop",
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{name: string, script: string}>
|
||||
*/
|
||||
private function replicaDeployScripts(Service $service, int $replica, ?string $imageReference = null): array
|
||||
{
|
||||
$composePath = $this->servicePath($service).'/compose.yml';
|
||||
$project = "keystone_service_{$service->id}_replica_{$replica}";
|
||||
$serviceKey = $this->serviceKey($service);
|
||||
|
||||
$steps = [];
|
||||
|
||||
if ($imageReference && $service->available_image_digest) {
|
||||
$steps[] = [
|
||||
'name' => "Pull image for replica {$replica}",
|
||||
'script' => 'docker pull '.escapeshellarg($imageReference.'@'.$service->available_image_digest),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
...$steps,
|
||||
[
|
||||
'name' => "Render replica {$replica}",
|
||||
'script' => "docker compose -p {$project} -f {$composePath} config --quiet",
|
||||
],
|
||||
[
|
||||
'name' => "Start replica {$replica}",
|
||||
'script' => implode("\n", [
|
||||
"docker compose -p {$project} -f {$composePath} up -d {$serviceKey}",
|
||||
"container_id=$(docker compose -p {$project} -f {$composePath} ps -q {$serviceKey})",
|
||||
'printf "container_id=%s\n" "$container_id"',
|
||||
]),
|
||||
],
|
||||
[
|
||||
'name' => "Health check replica {$replica}",
|
||||
'script' => implode("\n", [
|
||||
"docker compose -p {$project} -f {$composePath} ps --status running {$serviceKey}",
|
||||
"container_id=$(docker compose -p {$project} -f {$composePath} ps -q {$serviceKey})",
|
||||
'health_status=$(docker inspect --format "{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}" "$container_id")',
|
||||
'printf "health_status=%s\n" "$health_status"',
|
||||
]),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function composeUploadScript(Service $service): string
|
||||
{
|
||||
$servicePath = $this->servicePath($service);
|
||||
|
||||
try {
|
||||
$renderer = app(ComposeRenderer::class);
|
||||
$compose = $renderer->render($service);
|
||||
$env = $renderer->renderEnvironmentFile($service);
|
||||
} catch (InvalidArgumentException) {
|
||||
$compose = "services:\n {$this->serviceKey($service)}:\n image: \"{$service->available_image_digest}\"\n";
|
||||
$env = '';
|
||||
}
|
||||
|
||||
return implode("\n", [
|
||||
"mkdir -p {$servicePath}",
|
||||
'printf %s '.escapeshellarg(base64_encode($compose))." | base64 -d > {$servicePath}/compose.yml",
|
||||
'printf %s '.escapeshellarg(base64_encode($env))." | base64 -d > {$servicePath}/.env",
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{name: string, script: string}>
|
||||
*/
|
||||
private function gatewayCutoverSteps(EnvironmentAttachment $attachment): array
|
||||
{
|
||||
$containerName = $attachment->service->replicas()->first()?->container_name;
|
||||
$reloadCommand = $containerName
|
||||
? '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";
|
||||
|
||||
return [
|
||||
[
|
||||
'name' => 'Validate Caddy route configuration',
|
||||
'script' => 'test -s /home/keystone/gateway/Caddyfile',
|
||||
],
|
||||
[
|
||||
'name' => 'Reload Caddy',
|
||||
'script' => $reloadCommand,
|
||||
],
|
||||
[
|
||||
'name' => 'Verify new upstreams are reachable',
|
||||
'script' => 'curl --fail --silent --show-error http://127.0.0.1/ >/dev/null || true',
|
||||
],
|
||||
[
|
||||
'name' => 'Drain old upstreams',
|
||||
'script' => implode("\n", [
|
||||
"docker ps --filter 'label=keystone.environment_id={$this->environment->id}' --filter 'label=keystone.draining=true' --format '{{.ID}}' | xargs -r docker stop --time 30",
|
||||
]),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function configureCaddyRouteScript(EnvironmentAttachment $attachment): string
|
||||
{
|
||||
$route = $attachment->serviceSlice?->name ?? $this->environment->name;
|
||||
$upstreams = $this->gatewayUpstreams($attachment);
|
||||
|
||||
return implode("\n", [
|
||||
'mkdir -p /home/keystone/gateway/Caddyfile.d',
|
||||
"cat > /home/keystone/gateway/Caddyfile.d/{$attachment->id}.caddy <<'KEYSTONE_CADDY_ROUTE'",
|
||||
"{$route} {",
|
||||
' reverse_proxy '.implode(' ', $upstreams),
|
||||
'}',
|
||||
'KEYSTONE_CADDY_ROUTE',
|
||||
'cat /home/keystone/gateway/Caddyfile.d/*.caddy > /home/keystone/gateway/Caddyfile',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function gatewayUpstreams(EnvironmentAttachment $attachment): array
|
||||
{
|
||||
$gatewayReplica = $attachment->service->replicas()->first();
|
||||
|
||||
return $this->environment->services()
|
||||
->where('type', \App\Enums\ServiceType::LARAVEL)
|
||||
->get()
|
||||
->filter(fn (Service $service): bool => in_array('web', $service->process_roles ?? [], true))
|
||||
->flatMap(function (Service $service) use ($gatewayReplica) {
|
||||
return $service->replicas
|
||||
->map(function (ServiceReplica $replica) use ($gatewayReplica) {
|
||||
$endpoint = app(RegisterServiceEndpoint::class)->execute(
|
||||
replica: $replica,
|
||||
consumerReplica: $gatewayReplica,
|
||||
allowPublicFallback: false,
|
||||
);
|
||||
|
||||
return [
|
||||
'priority' => $endpoint->priority,
|
||||
'target' => $this->endpointTarget($endpoint->scope, $endpoint->hostname, $endpoint->port),
|
||||
];
|
||||
});
|
||||
})
|
||||
->sortBy('priority')
|
||||
->pluck('target')
|
||||
->values()
|
||||
->whenEmpty(fn ($targets) => $targets->push('web:80'))
|
||||
->all();
|
||||
}
|
||||
|
||||
private function endpointTarget(ServiceEndpointScope $scope, string $hostname, int $port): string
|
||||
{
|
||||
return $scope === ServiceEndpointScope::DOCKER_NETWORK
|
||||
? $hostname.':'.$port
|
||||
: 'http://'.$hostname.':'.$port;
|
||||
}
|
||||
|
||||
private function dispatchChildOperations(Operation $operation): void
|
||||
{
|
||||
$operation->update(['status' => OperationStatus::IN_PROGRESS]);
|
||||
|
||||
$operation->children()
|
||||
->with('steps')
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->first(fn (Operation $child): bool => $child->steps->isNotEmpty())
|
||||
?->steps
|
||||
->sortBy('order')
|
||||
->first()
|
||||
?->dispatchJob();
|
||||
}
|
||||
|
||||
private function servicePath(Service $service): string
|
||||
{
|
||||
return "/home/keystone/services/{$service->id}";
|
||||
}
|
||||
|
||||
private function serviceKey(Service $service): string
|
||||
{
|
||||
return str($service->name)->slug('_')->value() ?: 'service';
|
||||
}
|
||||
|
||||
private function defaultInternalPort(Service $service): int
|
||||
{
|
||||
return match ($service->type) {
|
||||
\App\Enums\ServiceType::POSTGRES => 5432,
|
||||
\App\Enums\ServiceType::VALKEY => 6379,
|
||||
\App\Enums\ServiceType::CADDY,
|
||||
\App\Enums\ServiceType::LARAVEL => 80,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2,18 +2,23 @@
|
||||
|
||||
namespace App\Jobs\Services;
|
||||
|
||||
use App\Enums\DeploymentStatus;
|
||||
use App\Actions\Services\ResolveServiceImageDigest;
|
||||
use App\Data\Operations\PlannedStep;
|
||||
use App\Enums\OperationKind;
|
||||
use App\Enums\OperationStatus;
|
||||
use App\Enums\ServiceStatus;
|
||||
use App\Models\Deployment;
|
||||
use App\Models\Operation;
|
||||
use App\Models\Service;
|
||||
use App\Services\Compose\ComposeRenderer;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class DeployService implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
protected Deployment $deployment;
|
||||
protected Operation $operation;
|
||||
|
||||
public function __construct(
|
||||
public Service $service,
|
||||
@@ -24,20 +29,26 @@ class DeployService implements ShouldQueue
|
||||
public function handle(): void
|
||||
{
|
||||
$driver = $this->service->driver();
|
||||
$this->service->forceFill([
|
||||
'available_image_digest' => app(ResolveServiceImageDigest::class)->execute($this->service),
|
||||
])->save();
|
||||
$this->service->update([
|
||||
'status' => ServiceStatus::INSTALLING,
|
||||
]);
|
||||
$this->deployment = $this->service->deployments()->create([
|
||||
'status' => DeploymentStatus::PENDING,
|
||||
$this->operation = $this->service->operations()->create([
|
||||
'kind' => OperationKind::SERVICE_DEPLOY,
|
||||
'status' => OperationStatus::PENDING,
|
||||
]);
|
||||
$deploymentPlan = $driver->getDeploymentPlan($this->deployment->hash);
|
||||
foreach ($deploymentPlan->steps as $index => $plannedStep) {
|
||||
$step = $this->deployment->steps()->create([
|
||||
$operationPlan = $driver->getOperationPlan($this->operation->hash);
|
||||
$steps = $this->stepsWithComposeUpload($operationPlan->steps);
|
||||
|
||||
foreach ($steps as $index => $plannedStep) {
|
||||
$step = $this->operation->steps()->create([
|
||||
'name' => $plannedStep->name,
|
||||
'order' => $index + 1,
|
||||
'status' => DeploymentStatus::PENDING,
|
||||
'script' => $plannedStep->getSafeScript(),
|
||||
'secrets' => $this->service->credentials,
|
||||
'status' => OperationStatus::PENDING,
|
||||
'script' => $plannedStep->getScriptTemplate(),
|
||||
'secrets' => $plannedStep->secrets(),
|
||||
]);
|
||||
if ($index === 0) {
|
||||
$step->dispatchJob();
|
||||
@@ -45,11 +56,45 @@ class DeployService implements ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, \App\Data\Operations\PlannedStep> $steps
|
||||
* @return array<int, \App\Data\Operations\PlannedStep>
|
||||
*/
|
||||
private function stepsWithComposeUpload(array $steps): array
|
||||
{
|
||||
try {
|
||||
$renderer = app(ComposeRenderer::class);
|
||||
$compose = $renderer->render($this->service);
|
||||
$env = $renderer->renderEnvironmentFile($this->service);
|
||||
} catch (InvalidArgumentException) {
|
||||
return $steps;
|
||||
}
|
||||
|
||||
return [
|
||||
new PlannedStep(
|
||||
name: 'Upload Compose file',
|
||||
script: $this->composeUploadScript($compose, $env),
|
||||
),
|
||||
...$steps,
|
||||
];
|
||||
}
|
||||
|
||||
private function composeUploadScript(string $compose, string $env): string
|
||||
{
|
||||
$servicePath = "/home/keystone/services/{$this->service->id}";
|
||||
|
||||
return implode("\n", [
|
||||
"mkdir -p {$servicePath}",
|
||||
'printf %s '.escapeshellarg(base64_encode($compose))." | base64 -d > {$servicePath}/compose.yml",
|
||||
'printf %s '.escapeshellarg(base64_encode($env))." | base64 -d > {$servicePath}/.env",
|
||||
]);
|
||||
}
|
||||
|
||||
public function failed(\Throwable $exception): void
|
||||
{
|
||||
if (isset($this->deployment)) {
|
||||
$this->deployment->update([
|
||||
'status' => DeploymentStatus::FAILED,
|
||||
if (isset($this->operation)) {
|
||||
$this->operation->update([
|
||||
'status' => OperationStatus::FAILED,
|
||||
]);
|
||||
$this->service->update([
|
||||
'status' => ServiceStatus::ERROR,
|
||||
|
||||
@@ -2,95 +2,251 @@
|
||||
|
||||
namespace App\Jobs\Services;
|
||||
|
||||
use App\Enums\DeploymentStatus;
|
||||
use App\Enums\OperationStatus;
|
||||
use App\Enums\ServiceStatus;
|
||||
use App\Models\Step;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Operation;
|
||||
use App\Models\OperationStep;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceReplica;
|
||||
use App\Models\ServiceSlice;
|
||||
use App\Services\Operations\RemoteCommandRunner;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
class RunStep implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
protected Step $step,
|
||||
protected OperationStep $step,
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$this->step->load('deployment.target.server');
|
||||
$this->step->load('operation.target');
|
||||
$this->step->operation->update([
|
||||
'status' => OperationStatus::IN_PROGRESS,
|
||||
]);
|
||||
$this->step->update([
|
||||
'status' => DeploymentStatus::IN_PROGRESS,
|
||||
'status' => OperationStatus::IN_PROGRESS,
|
||||
'started_at' => now(),
|
||||
]);
|
||||
|
||||
$server = $this->step->deployment->target->server;
|
||||
$server = $this->targetServer();
|
||||
|
||||
$ssh = $server->sshClient()
|
||||
->onOutput(function ($type, $output) {
|
||||
if (trim($output) === '') {
|
||||
return;
|
||||
}
|
||||
if ($type === Process::OUT) {
|
||||
$this->step->update([
|
||||
'logs' => $this->step->logs . "\n" . trim($output),
|
||||
]);
|
||||
} else {
|
||||
$this->step->update([
|
||||
'error_logs' => $this->step->error_logs . "\n" . trim($output),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
$result = $ssh->execute($this->step->script);
|
||||
|
||||
if (! $result->isSuccessful()) {
|
||||
$this->step->update([
|
||||
'status' => DeploymentStatus::FAILED,
|
||||
'finished_at' => now(),
|
||||
'error_logs' => $this->step->error_logs . "\n" . trim($result->getErrorOutput()),
|
||||
]);
|
||||
try {
|
||||
$output = app(RemoteCommandRunner::class)->run($server, $this->step->scriptForExecution());
|
||||
} catch (\Throwable $exception) {
|
||||
$this->failStep($exception->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->step->update([
|
||||
'status' => DeploymentStatus::COMPLETED,
|
||||
'status' => OperationStatus::COMPLETED,
|
||||
'finished_at' => now(),
|
||||
'logs' => trim($this->step->logs."\n".$output),
|
||||
'secrets' => null,
|
||||
]);
|
||||
$this->captureRuntimeState();
|
||||
|
||||
// Dispatch the next step if available
|
||||
if ($nextStep = $this->step->deployment->steps()->where('order', '>', $this->step->order)->orderBy('order', 'asc')->first()) {
|
||||
if ($nextStep = $this->step->operation->steps()->where('order', '>', $this->step->order)->orderBy('order', 'asc')->first()) {
|
||||
$nextStep->dispatchJob();
|
||||
} elseif ($this->dispatchNextChildOperation($this->step->operation)) {
|
||||
return;
|
||||
} else {
|
||||
$this->step->deployment->update([
|
||||
'status' => DeploymentStatus::COMPLETED,
|
||||
'finished_at' => now(),
|
||||
]);
|
||||
$this->step->deployment->target->update([
|
||||
$this->completeOperation($this->step->operation);
|
||||
$this->dispatchNextOperationAfter($this->step->operation);
|
||||
}
|
||||
}
|
||||
|
||||
private function captureRuntimeState(): void
|
||||
{
|
||||
$target = $this->step->operation->target;
|
||||
|
||||
if (! $target instanceof ServiceReplica) {
|
||||
return;
|
||||
}
|
||||
|
||||
$state = $this->step->refresh()->capturedRuntimeState();
|
||||
|
||||
if ($state !== []) {
|
||||
$target->update($state);
|
||||
}
|
||||
}
|
||||
|
||||
private function markTargetCompleted(): void
|
||||
{
|
||||
$target = $this->step->operation->target;
|
||||
|
||||
if ($target instanceof Service) {
|
||||
$target->update([
|
||||
'status' => ServiceStatus::RUNNING,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($target instanceof ServiceReplica) {
|
||||
$target->update([
|
||||
'status' => 'running',
|
||||
'health_status' => $target->health_status === 'unknown' ? 'healthy' : $target->health_status,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function completeOperation(Operation $operation): void
|
||||
{
|
||||
$operation->update([
|
||||
'status' => OperationStatus::COMPLETED,
|
||||
'finished_at' => now(),
|
||||
]);
|
||||
|
||||
if ($operation->is($this->step->operation)) {
|
||||
$this->markTargetCompleted();
|
||||
}
|
||||
}
|
||||
|
||||
private function dispatchNextChildOperation(Operation $operation): bool
|
||||
{
|
||||
$child = $operation->children()
|
||||
->where('status', OperationStatus::PENDING)
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->first(fn (Operation $child): bool => $child->steps()->exists());
|
||||
|
||||
if (! $child) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$child->steps()->orderBy('order')->first()?->dispatchJob();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function dispatchNextOperationAfter(Operation $operation): void
|
||||
{
|
||||
$operation->loadMissing('parent');
|
||||
|
||||
if (! $operation->parent_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$nextSibling = $operation->parent
|
||||
?->children()
|
||||
->where('id', '>', $operation->id)
|
||||
->where('status', OperationStatus::PENDING)
|
||||
->orderBy('id')
|
||||
->first();
|
||||
|
||||
if ($nextSibling) {
|
||||
$nextSibling->steps()->orderBy('order')->first()?->dispatchJob();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$parent = $operation->parent;
|
||||
|
||||
if ($parent && $parent->status === OperationStatus::IN_PROGRESS) {
|
||||
$this->completeOperation($parent);
|
||||
$this->dispatchNextOperationAfter($parent);
|
||||
}
|
||||
}
|
||||
|
||||
private function targetServer(): Server
|
||||
{
|
||||
$target = $this->step->operation->target;
|
||||
|
||||
$server = match (true) {
|
||||
$target instanceof ServiceReplica => $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 Environment => $target->services()->with(['server', 'replicas.server'])->get()
|
||||
->flatMap(fn (Service $service) => $service->replicas->pluck('server')->filter())
|
||||
->first() ?: $target->services()->with('server')->get()->pluck('server')->filter()->first(),
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (! $server instanceof Server) {
|
||||
throw new \RuntimeException('Operation target does not have a server for SSH execution.');
|
||||
}
|
||||
|
||||
return $server;
|
||||
}
|
||||
|
||||
public function failed(\Throwable $exception): void
|
||||
{
|
||||
$this->failStep($exception->getMessage());
|
||||
}
|
||||
|
||||
private function failStep(string $message): void
|
||||
{
|
||||
$this->step->update([
|
||||
'status' => DeploymentStatus::FAILED,
|
||||
'status' => OperationStatus::FAILED,
|
||||
'finished_at' => now(),
|
||||
'error_logs' => $this->step->error_logs . "\n" . trim($exception->getMessage()),
|
||||
'error_logs' => $this->step->error_logs."\n".trim($message),
|
||||
]);
|
||||
|
||||
$this->step->deployment->steps()->where('order', '>', $this->step->order)->update([
|
||||
'status' => DeploymentStatus::CANCELLED,
|
||||
$this->step->operation->steps()->where('order', '>', $this->step->order)->update([
|
||||
'status' => OperationStatus::CANCELLED,
|
||||
]);
|
||||
|
||||
$this->step->deployment->update([
|
||||
'status' => DeploymentStatus::FAILED,
|
||||
$this->step->operation->update([
|
||||
'status' => OperationStatus::FAILED,
|
||||
'finished_at' => now(),
|
||||
]);
|
||||
|
||||
$this->cancelDescendants($this->step->operation);
|
||||
$this->cancelPendingSiblingsAndAncestors($this->step->operation);
|
||||
}
|
||||
|
||||
private function cancelDescendants(Operation $operation): void
|
||||
{
|
||||
$operation->children()->with('children')->get()->each(function (Operation $child): void {
|
||||
$child->steps()->where('status', OperationStatus::PENDING)->update([
|
||||
'status' => OperationStatus::CANCELLED,
|
||||
]);
|
||||
$child->update([
|
||||
'status' => OperationStatus::CANCELLED,
|
||||
'finished_at' => now(),
|
||||
]);
|
||||
$this->cancelDescendants($child);
|
||||
});
|
||||
}
|
||||
|
||||
private function cancelPendingSiblingsAndAncestors(Operation $operation): void
|
||||
{
|
||||
$operation->loadMissing('parent');
|
||||
|
||||
if (! $operation->parent) {
|
||||
return;
|
||||
}
|
||||
|
||||
$operation->parent->children()
|
||||
->where('id', '!=', $operation->id)
|
||||
->whereIn('status', [OperationStatus::PENDING, OperationStatus::IN_PROGRESS])
|
||||
->get()
|
||||
->each(function (Operation $sibling): void {
|
||||
$sibling->steps()->where('status', OperationStatus::PENDING)->update([
|
||||
'status' => OperationStatus::CANCELLED,
|
||||
]);
|
||||
$sibling->update([
|
||||
'status' => OperationStatus::CANCELLED,
|
||||
'finished_at' => now(),
|
||||
]);
|
||||
$this->cancelDescendants($sibling);
|
||||
});
|
||||
|
||||
$operation->parent->update([
|
||||
'status' => OperationStatus::FAILED,
|
||||
'finished_at' => now(),
|
||||
]);
|
||||
|
||||
$this->cancelPendingSiblingsAndAncestors($operation->parent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,20 +3,24 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\RepositoryType;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
|
||||
class Application extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'repository_type' => RepositoryType::class,
|
||||
'deploy_key_private' => 'encrypted',
|
||||
'deploy_key_installed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -25,18 +29,13 @@ class Application extends Model
|
||||
return $this->belongsTo(Organisation::class);
|
||||
}
|
||||
|
||||
public function instances(): HasMany
|
||||
public function environments(): HasMany
|
||||
{
|
||||
return $this->hasMany(Instance::class);
|
||||
return $this->hasMany(Environment::class);
|
||||
}
|
||||
|
||||
public function servers(): HasManyThrough
|
||||
public function operations(): MorphMany
|
||||
{
|
||||
return $this->hasManyThrough(Server::class, Instance::class);
|
||||
}
|
||||
|
||||
public function deployments(): MorphMany
|
||||
{
|
||||
return $this->morphMany(Deployment::class, 'target');
|
||||
return $this->morphMany(Operation::class, 'target');
|
||||
}
|
||||
}
|
||||
|
||||
35
app/Models/BuildArtifact.php
Normal file
35
app/Models/BuildArtifact.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\BuildArtifactStatus;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class BuildArtifact extends Model
|
||||
{
|
||||
protected $guarded = [];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status' => BuildArtifactStatus::class,
|
||||
'metadata' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function environment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Environment::class);
|
||||
}
|
||||
|
||||
public function builtByOperation(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Operation::class, 'built_by_operation_id');
|
||||
}
|
||||
|
||||
public function builtByService(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Service::class, 'built_by_service_id');
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
class Deployment extends Model
|
||||
{
|
||||
protected $guarded = [];
|
||||
|
||||
public static function boot(): void
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function (self $deployment) {
|
||||
$deployment->hash = str()->random(16);
|
||||
});
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'started_at' => 'datetime',
|
||||
'finished_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function steps(): HasMany
|
||||
{
|
||||
return $this->hasMany(Step::class);
|
||||
}
|
||||
|
||||
public function target(): MorphTo
|
||||
{
|
||||
return $this->morphTo('target');
|
||||
}
|
||||
}
|
||||
56
app/Models/Environment.php
Normal file
56
app/Models/Environment.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\SchedulerMode;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
|
||||
class Environment extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'scheduler_enabled' => 'boolean',
|
||||
'scheduler_mode' => SchedulerMode::class,
|
||||
'build_config' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function application(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Application::class);
|
||||
}
|
||||
|
||||
public function services(): HasMany
|
||||
{
|
||||
return $this->hasMany(Service::class);
|
||||
}
|
||||
|
||||
public function attachments(): HasMany
|
||||
{
|
||||
return $this->hasMany(EnvironmentAttachment::class);
|
||||
}
|
||||
|
||||
public function variables(): HasMany
|
||||
{
|
||||
return $this->hasMany(EnvironmentVariable::class);
|
||||
}
|
||||
|
||||
public function buildArtifacts(): HasMany
|
||||
{
|
||||
return $this->hasMany(BuildArtifact::class);
|
||||
}
|
||||
|
||||
public function operations(): MorphMany
|
||||
{
|
||||
return $this->morphMany(Operation::class, 'target');
|
||||
}
|
||||
}
|
||||
35
app/Models/EnvironmentAttachment.php
Normal file
35
app/Models/EnvironmentAttachment.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\EnvironmentAttachmentRole;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class EnvironmentAttachment extends Model
|
||||
{
|
||||
protected $guarded = [];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_primary' => 'boolean',
|
||||
'role' => EnvironmentAttachmentRole::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function environment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Environment::class);
|
||||
}
|
||||
|
||||
public function service(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Service::class);
|
||||
}
|
||||
|
||||
public function serviceSlice(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ServiceSlice::class);
|
||||
}
|
||||
}
|
||||
31
app/Models/EnvironmentVariable.php
Normal file
31
app/Models/EnvironmentVariable.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\EnvironmentVariableSource;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class EnvironmentVariable extends Model
|
||||
{
|
||||
protected $guarded = [];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'value' => 'encrypted',
|
||||
'source' => EnvironmentVariableSource::class,
|
||||
'overridable' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function environment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Environment::class);
|
||||
}
|
||||
|
||||
public function serviceSlice(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ServiceSlice::class);
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
|
||||
class Instance extends Model
|
||||
{
|
||||
protected $guarded = [];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'config' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function application(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Application::class);
|
||||
}
|
||||
|
||||
public function server(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Server::class);
|
||||
}
|
||||
|
||||
public function deployments(): MorphMany
|
||||
{
|
||||
return $this->morphMany(Deployment::class, 'target');
|
||||
}
|
||||
}
|
||||
57
app/Models/Operation.php
Normal file
57
app/Models/Operation.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\OperationKind;
|
||||
use App\Enums\OperationStatus;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
class Operation extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
public static function boot(): void
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function (self $operation) {
|
||||
$operation->hash ??= str()->random(16);
|
||||
});
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'kind' => OperationKind::class,
|
||||
'status' => OperationStatus::class,
|
||||
'started_at' => 'datetime',
|
||||
'finished_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function parent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Operation::class, 'parent_id');
|
||||
}
|
||||
|
||||
public function children(): HasMany
|
||||
{
|
||||
return $this->hasMany(Operation::class, 'parent_id');
|
||||
}
|
||||
|
||||
public function steps(): HasMany
|
||||
{
|
||||
return $this->hasMany(OperationStep::class);
|
||||
}
|
||||
|
||||
public function target(): MorphTo
|
||||
{
|
||||
return $this->morphTo('target');
|
||||
}
|
||||
}
|
||||
85
app/Models/OperationStep.php
Normal file
85
app/Models/OperationStep.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\OperationStatus;
|
||||
use App\Jobs\Services\RunStep;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class OperationStep extends Model
|
||||
{
|
||||
protected $guarded = [];
|
||||
|
||||
protected $appends = [
|
||||
'logs_excerpt',
|
||||
'error_logs_excerpt',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status' => OperationStatus::class,
|
||||
'started_at' => 'datetime',
|
||||
'finished_at' => 'datetime',
|
||||
'secrets' => 'encrypted:array',
|
||||
];
|
||||
}
|
||||
|
||||
public function operation(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Operation::class);
|
||||
}
|
||||
|
||||
public function logsExcerpt(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->logs ? Str::afterLast($this->logs, "\n") : null,
|
||||
);
|
||||
}
|
||||
|
||||
public function errorLogsExcerpt(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->error_logs ? Str::afterLast($this->error_logs, "\n") : null,
|
||||
);
|
||||
}
|
||||
|
||||
public function dispatchJob(): void
|
||||
{
|
||||
dispatch(new RunStep($this));
|
||||
}
|
||||
|
||||
public function scriptForExecution(): string
|
||||
{
|
||||
$script = $this->script;
|
||||
|
||||
foreach (($this->secrets ?? []) as $key => $value) {
|
||||
$script = str_replace("[!{$key}!]", $value, $script);
|
||||
}
|
||||
|
||||
return $script;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{container_id?: string, health_status?: string}
|
||||
*/
|
||||
public function capturedRuntimeState(): array
|
||||
{
|
||||
$state = [];
|
||||
|
||||
foreach (explode("\n", (string) $this->logs) as $line) {
|
||||
if (str_starts_with($line, 'container_id=')) {
|
||||
$state['container_id'] = trim((string) str($line)->after('container_id='));
|
||||
}
|
||||
|
||||
if (str_starts_with($line, 'health_status=')) {
|
||||
$state['health_status'] = trim((string) str($line)->after('health_status='));
|
||||
}
|
||||
}
|
||||
|
||||
return array_filter($state, fn (string $value): bool => $value !== '');
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,21 @@ class Organisation extends Model
|
||||
return $this->hasMany(Application::class);
|
||||
}
|
||||
|
||||
public function services(): HasMany
|
||||
{
|
||||
return $this->hasMany(Service::class);
|
||||
}
|
||||
|
||||
public function registries(): HasMany
|
||||
{
|
||||
return $this->hasMany(Registry::class);
|
||||
}
|
||||
|
||||
public function sourceProviders(): HasMany
|
||||
{
|
||||
return $this->hasMany(SourceProvider::class);
|
||||
}
|
||||
|
||||
public function providers(): HasMany
|
||||
{
|
||||
return $this->hasMany(Provider::class);
|
||||
|
||||
27
app/Models/Registry.php
Normal file
27
app/Models/Registry.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\RegistryType;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Registry extends Model
|
||||
{
|
||||
protected $guarded = [];
|
||||
|
||||
protected $hidden = ['credentials'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'type' => RegistryType::class,
|
||||
'credentials' => 'encrypted:array',
|
||||
];
|
||||
}
|
||||
|
||||
public function organisation(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Organisation::class);
|
||||
}
|
||||
}
|
||||
@@ -44,14 +44,9 @@ class Server extends Model
|
||||
return $this->hasMany(Service::class);
|
||||
}
|
||||
|
||||
public function instances(): HasMany
|
||||
public function serviceReplicas(): HasMany
|
||||
{
|
||||
return $this->hasMany(Instance::class);
|
||||
}
|
||||
|
||||
public function applications(): HasManyThrough
|
||||
{
|
||||
return $this->hasManyThrough(Application::class, Instance::class);
|
||||
return $this->hasMany(ServiceReplica::class);
|
||||
}
|
||||
|
||||
public function firewallRules(): HasMany
|
||||
@@ -64,26 +59,16 @@ class Server extends Model
|
||||
return $this->belongsTo(Provider::class);
|
||||
}
|
||||
|
||||
public function serviceDeployments(): HasManyThrough
|
||||
public function serviceOperations(): HasManyThrough
|
||||
{
|
||||
return $this->hasManyThrough(
|
||||
Deployment::class,
|
||||
Operation::class,
|
||||
Service::class,
|
||||
'server_id',
|
||||
'target_id',
|
||||
)->where('target_type', (new Service)->getMorphClass());
|
||||
}
|
||||
|
||||
public function applicationDeployments(): HasManyThrough
|
||||
{
|
||||
return $this->hasManyThrough(
|
||||
Deployment::class,
|
||||
Application::class,
|
||||
'server_id',
|
||||
'target_id',
|
||||
)->where('target_type', (new Application)->getMorphClass());
|
||||
}
|
||||
|
||||
public function sshClient(string $user = 'root'): Ssh
|
||||
{
|
||||
return Ssh::create($user, $this->ipv4)
|
||||
|
||||
@@ -4,16 +4,21 @@ namespace App\Models;
|
||||
|
||||
use App\Drivers\DatabaseDriver;
|
||||
use App\Drivers\Driver;
|
||||
use App\Enums\DeployPolicy;
|
||||
use App\Enums\ServiceCategory;
|
||||
use App\Enums\ServiceStatus;
|
||||
use App\Enums\ServiceType;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
|
||||
class Service extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $hidden = ['credentials', 'container_name', 'container_id'];
|
||||
@@ -24,6 +29,10 @@ class Service extends Model
|
||||
'status' => ServiceStatus::class,
|
||||
'category' => ServiceCategory::class,
|
||||
'type' => ServiceType::class,
|
||||
'deploy_policy' => DeployPolicy::class,
|
||||
'process_roles' => 'array',
|
||||
'default_cpu_limit' => 'decimal:3',
|
||||
'config' => 'array',
|
||||
'credentials' => 'encrypted:array',
|
||||
];
|
||||
}
|
||||
@@ -40,14 +49,41 @@ class Service extends Model
|
||||
return $this->belongsTo(Server::class);
|
||||
}
|
||||
|
||||
public function deployments(): MorphMany
|
||||
public function organisation(): BelongsTo
|
||||
{
|
||||
return $this->morphMany(Deployment::class, 'target');
|
||||
return $this->belongsTo(Organisation::class);
|
||||
}
|
||||
|
||||
public function environment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Environment::class);
|
||||
}
|
||||
|
||||
public function replicas(): HasMany
|
||||
{
|
||||
return $this->hasMany(ServiceReplica::class);
|
||||
}
|
||||
|
||||
public function slices(): HasMany
|
||||
{
|
||||
return $this->hasMany(ServiceSlice::class);
|
||||
}
|
||||
|
||||
public function endpoints(): HasMany
|
||||
{
|
||||
return $this->hasMany(ServiceEndpoint::class);
|
||||
}
|
||||
|
||||
public function operations(): MorphMany
|
||||
{
|
||||
return $this->morphMany(Operation::class, 'target');
|
||||
}
|
||||
|
||||
public function driver(): Driver
|
||||
{
|
||||
$class = config("keystone.drivers.{$this->driver_name}");
|
||||
[$driverType, $versionTrack] = array_pad(explode('.', $this->driver_name, 2), 2, null);
|
||||
$class = config('keystone.drivers')[$driverType][$versionTrack] ?? null;
|
||||
|
||||
if (! class_exists($class)) {
|
||||
throw new \Exception("Driver class {$class} not found");
|
||||
}
|
||||
|
||||
29
app/Models/ServiceEndpoint.php
Normal file
29
app/Models/ServiceEndpoint.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\ServiceEndpointScope;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ServiceEndpoint extends Model
|
||||
{
|
||||
protected $guarded = [];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'scope' => ServiceEndpointScope::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function service(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Service::class);
|
||||
}
|
||||
|
||||
public function serviceReplica(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ServiceReplica::class);
|
||||
}
|
||||
}
|
||||
43
app/Models/ServiceReplica.php
Normal file
43
app/Models/ServiceReplica.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
|
||||
class ServiceReplica extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'cpu_limit' => 'decimal:3',
|
||||
'config' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function service(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Service::class);
|
||||
}
|
||||
|
||||
public function server(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Server::class);
|
||||
}
|
||||
|
||||
public function operation(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Operation::class);
|
||||
}
|
||||
|
||||
public function operations(): MorphMany
|
||||
{
|
||||
return $this->morphMany(Operation::class, 'target');
|
||||
}
|
||||
}
|
||||
44
app/Models/ServiceSlice.php
Normal file
44
app/Models/ServiceSlice.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
|
||||
class ServiceSlice extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'config' => 'array',
|
||||
'credentials' => 'encrypted:array',
|
||||
];
|
||||
}
|
||||
|
||||
public function service(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Service::class);
|
||||
}
|
||||
|
||||
public function environment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Environment::class);
|
||||
}
|
||||
|
||||
public function attachments(): HasMany
|
||||
{
|
||||
return $this->hasMany(EnvironmentAttachment::class);
|
||||
}
|
||||
|
||||
public function operations(): MorphMany
|
||||
{
|
||||
return $this->morphMany(Operation::class, 'target');
|
||||
}
|
||||
}
|
||||
25
app/Models/SourceProvider.php
Normal file
25
app/Models/SourceProvider.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\SourceProviderType;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class SourceProvider extends Model
|
||||
{
|
||||
protected $guarded = [];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'type' => SourceProviderType::class,
|
||||
'config' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function organisation(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Organisation::class);
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Jobs\Services\RunStep;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Step extends Model
|
||||
{
|
||||
protected $guarded = [];
|
||||
|
||||
protected $appends = [
|
||||
'logs_excerpt',
|
||||
'error_logs_excerpt',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'started_at' => 'datetime',
|
||||
'finished_at' => 'datetime',
|
||||
'secrets' => 'encrypted:array',
|
||||
];
|
||||
}
|
||||
|
||||
public function deployment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Deployment::class);
|
||||
}
|
||||
|
||||
public function logsExcerpt(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->logs ? Str::afterLast($this->logs, "\n"): null,
|
||||
);
|
||||
}
|
||||
|
||||
public function errorLogsExcerpt(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->error_logs ? Str::afterLast($this->error_logs, "\n"): null,
|
||||
);
|
||||
}
|
||||
|
||||
public function dispatchJob(): void
|
||||
{
|
||||
dispatch(new RunStep($this));
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,18 @@
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Deployment;
|
||||
use App\Models\Instance;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Operation;
|
||||
use App\Models\OperationStep;
|
||||
use App\Models\Organisation;
|
||||
use App\Models\OrganisationUser;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\Step;
|
||||
use App\Models\ServiceReplica;
|
||||
use App\Models\ServiceSlice;
|
||||
use App\Models\User;
|
||||
use App\Services\Operations\RemoteCommandRunner;
|
||||
use App\Services\Operations\SshRemoteCommandRunner;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
@@ -21,7 +25,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
$this->app->bind(RemoteCommandRunner::class, SshRemoteCommandRunner::class);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,13 +35,15 @@ class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
Relation::enforceMorphMap([
|
||||
'application' => Application::class,
|
||||
'deployment' => Deployment::class,
|
||||
'instance' => Instance::class,
|
||||
'environment' => Environment::class,
|
||||
'organisation' => Organisation::class,
|
||||
'organisation-user' => OrganisationUser::class,
|
||||
'operation' => Operation::class,
|
||||
'server' => Server::class,
|
||||
'service' => Service::class,
|
||||
'step' => Step::class,
|
||||
'service-replica' => ServiceReplica::class,
|
||||
'service-slice' => ServiceSlice::class,
|
||||
'operation-step' => OperationStep::class,
|
||||
'user' => User::class,
|
||||
]);
|
||||
}
|
||||
|
||||
149
app/Services/Compose/ComposeRenderer.php
Normal file
149
app/Services/Compose/ComposeRenderer.php
Normal file
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Compose;
|
||||
|
||||
use App\Drivers\Concerns\RendersCompose;
|
||||
use App\Models\Service;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class ComposeRenderer
|
||||
{
|
||||
public function render(Service $service): string
|
||||
{
|
||||
$driver = $service->driver();
|
||||
|
||||
if (! $driver instanceof RendersCompose) {
|
||||
throw new InvalidArgumentException("Driver [{$service->driver_name}] cannot render Docker Compose.");
|
||||
}
|
||||
|
||||
$composeService = $driver->composeService();
|
||||
|
||||
if ($service->default_cpu_limit && ! isset($composeService['cpus'])) {
|
||||
$composeService['cpus'] = (string) $service->default_cpu_limit;
|
||||
}
|
||||
|
||||
if ($service->default_memory_limit_mb && ! isset($composeService['mem_limit'])) {
|
||||
$composeService['mem_limit'] = "{$service->default_memory_limit_mb}m";
|
||||
$composeService['memswap_limit'] = "{$service->default_memory_limit_mb}m";
|
||||
}
|
||||
|
||||
$document = [
|
||||
'services' => [
|
||||
$this->serviceKey($service) => $composeService,
|
||||
],
|
||||
];
|
||||
|
||||
$volumes = array_filter($driver->composeVolumes(), fn (mixed $volume): bool => $volume !== false);
|
||||
|
||||
if ($volumes !== []) {
|
||||
$document['volumes'] = $volumes;
|
||||
}
|
||||
|
||||
return $this->toYaml($document);
|
||||
}
|
||||
|
||||
public function renderEnvironmentFile(Service $service): string
|
||||
{
|
||||
$driver = $service->driver();
|
||||
|
||||
if (! method_exists($driver, 'environmentExports')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return collect($driver->environmentExports())
|
||||
->map(fn (mixed $value, string $key): string => $key.'='.$this->formatEnvValue($value))
|
||||
->implode("\n");
|
||||
}
|
||||
|
||||
private function serviceKey(Service $service): string
|
||||
{
|
||||
return str($service->name)->slug('_')->value() ?: 'service';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $document
|
||||
*/
|
||||
private function toYaml(array $document, int $indent = 0): string
|
||||
{
|
||||
$lines = [];
|
||||
|
||||
foreach ($document as $key => $value) {
|
||||
$prefix = str_repeat(' ', $indent);
|
||||
|
||||
if (is_array($value)) {
|
||||
if ($value === []) {
|
||||
$lines[] = "{$prefix}{$key}: []";
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$lines[] = "{$prefix}{$key}:";
|
||||
$lines[] = $this->arrayToYaml($value, $indent + 1);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($value === null) {
|
||||
$lines[] = "{$prefix}{$key}: {}";
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$lines[] = "{$prefix}{$key}: ".$this->formatScalar($value);
|
||||
}
|
||||
|
||||
return implode("\n", array_filter($lines))."\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed> $items
|
||||
*/
|
||||
private function arrayToYaml(array $items, int $indent): string
|
||||
{
|
||||
if (array_is_list($items)) {
|
||||
return $this->listToYaml($items, $indent);
|
||||
}
|
||||
|
||||
return $this->toYaml($items, $indent);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $items
|
||||
*/
|
||||
private function listToYaml(array $items, int $indent): string
|
||||
{
|
||||
$lines = [];
|
||||
$prefix = str_repeat(' ', $indent);
|
||||
|
||||
foreach ($items as $item) {
|
||||
if (is_array($item)) {
|
||||
$lines[] = "{$prefix}-";
|
||||
$lines[] = $this->arrayToYaml($item, $indent + 1);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$lines[] = "{$prefix}- ".$this->formatScalar($item);
|
||||
}
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
private function formatScalar(mixed $value): string
|
||||
{
|
||||
if (is_bool($value)) {
|
||||
return $value ? 'true' : 'false';
|
||||
}
|
||||
|
||||
if (is_numeric($value)) {
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
return '"'.str_replace('"', '\"', (string) $value).'"';
|
||||
}
|
||||
|
||||
private function formatEnvValue(mixed $value): string
|
||||
{
|
||||
return str_replace(["\n", "\r"], ['\n', ''], (string) $value);
|
||||
}
|
||||
}
|
||||
10
app/Services/Operations/RemoteCommandRunner.php
Normal file
10
app/Services/Operations/RemoteCommandRunner.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Operations;
|
||||
|
||||
use App\Models\Server;
|
||||
|
||||
interface RemoteCommandRunner
|
||||
{
|
||||
public function run(Server $server, string $script): string;
|
||||
}
|
||||
20
app/Services/Operations/SshRemoteCommandRunner.php
Normal file
20
app/Services/Operations/SshRemoteCommandRunner.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Operations;
|
||||
|
||||
use App\Models\Server;
|
||||
use RuntimeException;
|
||||
|
||||
class SshRemoteCommandRunner implements RemoteCommandRunner
|
||||
{
|
||||
public function run(Server $server, string $script): string
|
||||
{
|
||||
$result = $server->sshClient()->execute($script);
|
||||
|
||||
if (! $result->isSuccessful()) {
|
||||
throw new RuntimeException(trim($result->getErrorOutput()) ?: 'Remote command failed.');
|
||||
}
|
||||
|
||||
return trim($result->getOutput());
|
||||
}
|
||||
}
|
||||
12
composer.lock
generated
12
composer.lock
generated
@@ -6602,16 +6602,16 @@
|
||||
},
|
||||
{
|
||||
"name": "laravel/boost",
|
||||
"version": "v1.1.4",
|
||||
"version": "v1.1.5",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/boost.git",
|
||||
"reference": "70f909465bf73dad7e791fad8b7716b3b2712076"
|
||||
"reference": "4bd1692c064b2135eb2f8f7bd8bcb710e5e75f86"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/boost/zipball/70f909465bf73dad7e791fad8b7716b3b2712076",
|
||||
"reference": "70f909465bf73dad7e791fad8b7716b3b2712076",
|
||||
"url": "https://api.github.com/repos/laravel/boost/zipball/4bd1692c064b2135eb2f8f7bd8bcb710e5e75f86",
|
||||
"reference": "4bd1692c064b2135eb2f8f7bd8bcb710e5e75f86",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -6663,7 +6663,7 @@
|
||||
"issues": "https://github.com/laravel/boost/issues",
|
||||
"source": "https://github.com/laravel/boost"
|
||||
},
|
||||
"time": "2025-09-04T12:16:09+00:00"
|
||||
"time": "2025-09-18T07:33:27+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/mcp",
|
||||
@@ -9569,5 +9569,5 @@
|
||||
"php": "^8.2"
|
||||
},
|
||||
"platform-dev": {},
|
||||
"plugin-api-version": "2.6.0"
|
||||
"plugin-api-version": "2.9.0"
|
||||
}
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
<?php
|
||||
|
||||
use App\Drivers\Caddy\Caddy2Driver;
|
||||
use App\Drivers\Postgres\Postgres17Driver;
|
||||
use App\Drivers\Laravel\LaravelRuntimeDriver;
|
||||
use App\Drivers\Postgres\Postgres18Driver;
|
||||
use App\Drivers\Valkey\Valkey8Driver;
|
||||
use App\Enums\ServiceCategory;
|
||||
use App\Enums\ServiceType;
|
||||
|
||||
return [
|
||||
'drivers' => [
|
||||
'postgres' => [
|
||||
'17' => Postgres17Driver::class,
|
||||
'18' => Postgres18Driver::class,
|
||||
],
|
||||
'caddy' => [
|
||||
'2' => Caddy2Driver::class,
|
||||
]
|
||||
],
|
||||
'valkey' => [
|
||||
'8' => Valkey8Driver::class,
|
||||
],
|
||||
'laravel' => [
|
||||
'php-8.4' => LaravelRuntimeDriver::class,
|
||||
],
|
||||
],
|
||||
|
||||
'services' => [
|
||||
@@ -21,37 +29,15 @@ return [
|
||||
'name' => ServiceType::POSTGRES,
|
||||
'description' => 'PostgreSQL',
|
||||
'versions' => [
|
||||
'17' => [
|
||||
'name' => 'PostgreSQL 17',
|
||||
'description' => 'PostgreSQL 17',
|
||||
'image' => 'postgres:17',
|
||||
],
|
||||
],
|
||||
],
|
||||
ServiceType::MYSQL->value => [
|
||||
'name' => ServiceType::MYSQL,
|
||||
'description' => 'MySQL',
|
||||
'versions' => [
|
||||
'9.0' => [
|
||||
'name' => 'MySQL 9.2',
|
||||
'description' => 'MySQL 9.2',
|
||||
'image' => 'mysql:9.2'
|
||||
'18' => [
|
||||
'name' => 'PostgreSQL 18',
|
||||
'description' => 'PostgreSQL 18',
|
||||
'image' => 'postgres:18',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
ServiceCategory::GATEWAY->value => [
|
||||
ServiceType::NGINX->value => [
|
||||
'name' => ServiceType::NGINX,
|
||||
'description' => 'Nginx',
|
||||
'versions' => [
|
||||
'1.27' => [
|
||||
'name' => 'Nginx 1.27',
|
||||
'description' => 'Nginx 1.27',
|
||||
'image' => 'nginx:1.27',
|
||||
],
|
||||
],
|
||||
],
|
||||
ServiceType::CADDY->value => [
|
||||
'name' => ServiceType::CADDY,
|
||||
'description' => 'Caddy',
|
||||
@@ -59,60 +45,38 @@ return [
|
||||
'2' => [
|
||||
'name' => 'Caddy 2',
|
||||
'description' => 'Caddy 2',
|
||||
'image' => 'caddy:2'
|
||||
'image' => 'caddy:2',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
ServiceCategory::APPLICATION->value => [
|
||||
ServiceType::PHP_FPM->value => [
|
||||
'name' => ServiceType::PHP_FPM,
|
||||
'description' => 'PHP-FPM',
|
||||
ServiceType::LARAVEL->value => [
|
||||
'name' => ServiceType::LARAVEL,
|
||||
'description' => 'Laravel managed runtime',
|
||||
'versions' => [
|
||||
'8.4' => [
|
||||
'name' => 'PHP 8.4',
|
||||
'description' => 'PHP 8.4',
|
||||
'image' => 'serversideup/php:8.4-fpm-nginx',
|
||||
],
|
||||
],
|
||||
],
|
||||
ServiceType::FRANKENPHP->value => [
|
||||
'name' => ServiceType::FRANKENPHP,
|
||||
'description' => 'FrankenPHP',
|
||||
'versions' => [
|
||||
'1.5' => [
|
||||
'name' => 'FrankenPHP 1.5',
|
||||
'description' => 'FrankenPHP 1.5',
|
||||
'image' => 'dunglas/frankenphp:1.5-php8.4-bookworm',
|
||||
'php-8.4' => [
|
||||
'name' => 'Laravel PHP 8.4 FrankenPHP',
|
||||
'description' => 'serversideup/php FrankenPHP Laravel runtime',
|
||||
'image' => 'serversideup/php:8.4-frankenphp',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
ServiceCategory::CACHE->value => [
|
||||
ServiceType::REDIS->value => [
|
||||
'name' => ServiceType::REDIS,
|
||||
'description' => 'Redis',
|
||||
'versions' => [
|
||||
'7.4' => [
|
||||
'name' => 'Redis 7.4',
|
||||
'description' => 'Redis 7.4',
|
||||
'image' => 'redis:7.4',
|
||||
],
|
||||
],
|
||||
],
|
||||
ServiceType::VALKEY->value => [
|
||||
'name' => ServiceType::VALKEY,
|
||||
'description' => 'Valkey',
|
||||
'versions' => [
|
||||
'8.1' => [
|
||||
'name' => 'Valkey 8.1',
|
||||
'description' => 'Valkey 8.1',
|
||||
'image' => 'valkey/valkey:8.1',
|
||||
'8' => [
|
||||
'name' => 'Valkey 8',
|
||||
'description' => 'Valkey 8',
|
||||
'image' => 'valkey/valkey:8',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
ServiceCategory::STORAGE->value => [
|
||||
],
|
||||
]
|
||||
],
|
||||
];
|
||||
|
||||
29
database/factories/ApplicationFactory.php
Normal file
29
database/factories/ApplicationFactory.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\RepositoryType;
|
||||
use App\Models\Organisation;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Application>
|
||||
*/
|
||||
class ApplicationFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'organisation_id' => Organisation::factory(),
|
||||
'name' => $this->faker->words(2, true),
|
||||
'repository_url' => 'git@example.com:org/'.$this->faker->slug().'.git',
|
||||
'repository_type' => RepositoryType::GIT,
|
||||
'default_branch' => 'main',
|
||||
];
|
||||
}
|
||||
}
|
||||
31
database/factories/EnvironmentFactory.php
Normal file
31
database/factories/EnvironmentFactory.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\SchedulerMode;
|
||||
use App\Models\Application;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Environment>
|
||||
*/
|
||||
class EnvironmentFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'application_id' => Application::factory(),
|
||||
'name' => 'production',
|
||||
'branch' => 'main',
|
||||
'status' => 'active',
|
||||
'scheduler_enabled' => true,
|
||||
'scheduler_mode' => SchedulerMode::SINGLE,
|
||||
'build_config' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
29
database/factories/OperationFactory.php
Normal file
29
database/factories/OperationFactory.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\OperationKind;
|
||||
use App\Enums\OperationStatus;
|
||||
use App\Models\Service;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Operation>
|
||||
*/
|
||||
class OperationFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'kind' => OperationKind::SERVICE_DEPLOY,
|
||||
'target_type' => (new Service)->getMorphClass(),
|
||||
'target_id' => Service::factory(),
|
||||
'status' => OperationStatus::PENDING,
|
||||
];
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user