Add managed registry provisioning, pruning, and readiness tracking
This commit is contained in:
76
AGENTS.md
76
AGENTS.md
@@ -6,6 +6,7 @@
|
||||
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
|
||||
|
||||
## 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
|
||||
@@ -18,49 +19,59 @@ This application is a Laravel application and its main Laravel ecosystems packag
|
||||
- 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.
|
||||
|
||||
- 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.
|
||||
@@ -69,6 +80,7 @@ This application is a Laravel application and its main Laravel ecosystems packag
|
||||
- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
|
||||
|
||||
### 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'
|
||||
@@ -77,7 +89,6 @@ This application is a Laravel application and its main Laravel ecosystems packag
|
||||
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit"
|
||||
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
|
||||
|
||||
|
||||
=== php rules ===
|
||||
|
||||
## PHP
|
||||
@@ -85,11 +96,13 @@ This application is a Laravel application and its main Laravel ecosystems packag
|
||||
- Always use curly braces for control structures, even if it has one line.
|
||||
|
||||
### Constructors
|
||||
|
||||
- Use PHP 8 constructor property promotion in `__construct()`.
|
||||
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
|
||||
- <code-snippet>public function \_\_construct(public GitHub $github) { }</code-snippet>
|
||||
- Do not allow empty `__construct()` methods with zero parameters.
|
||||
|
||||
### Type Declarations
|
||||
|
||||
- Always use explicit return type declarations for methods and functions.
|
||||
- Use appropriate PHP type hints for method parameters.
|
||||
|
||||
@@ -101,14 +114,16 @@ 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`.
|
||||
|
||||
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||
|
||||
=== inertia-laravel/core rules ===
|
||||
|
||||
@@ -127,7 +142,6 @@ Route::get('/users', function () {
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== inertia-laravel/v2 rules ===
|
||||
|
||||
## Inertia v2
|
||||
@@ -135,6 +149,7 @@ Route::get('/users', function () {
|
||||
- Make use of all Inertia features from v1 & v2. Check the documentation before making any changes to ensure we are taking the correct approach.
|
||||
|
||||
### Inertia v2 New Features
|
||||
|
||||
- Polling
|
||||
- Prefetching
|
||||
- Deferred props
|
||||
@@ -142,11 +157,12 @@ Route::get('/users', function () {
|
||||
- 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.
|
||||
|
||||
- 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 ===
|
||||
|
||||
@@ -157,6 +173,7 @@ Route::get('/users', function () {
|
||||
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
||||
|
||||
### 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.
|
||||
@@ -164,35 +181,43 @@ Route::get('/users', function () {
|
||||
- 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`.
|
||||
|
||||
- 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 ===
|
||||
|
||||
@@ -202,6 +227,7 @@ Route::get('/users', function () {
|
||||
- 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.
|
||||
@@ -209,12 +235,13 @@ Route::get('/users', function () {
|
||||
- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration.
|
||||
|
||||
### 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.
|
||||
|
||||
- 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 ===
|
||||
|
||||
@@ -223,27 +250,29 @@ Route::get('/users', function () {
|
||||
- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
|
||||
- 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 () {
|
||||
<code-snippet name="Basic Pest Test Example" lang="php">
|
||||
it('is true', function () {
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
</code-snippet>
|
||||
});
|
||||
</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`.
|
||||
@@ -251,21 +280,25 @@ it('is true', function () {
|
||||
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
|
||||
|
||||
### 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 () {
|
||||
<code-snippet name="Pest Example Asserting postJson Response" lang="php">
|
||||
it('returns all', function () {
|
||||
$response = $this->postJson('/api/docs', []);
|
||||
|
||||
$response->assertSuccessful();
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
});
|
||||
</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">
|
||||
@@ -277,11 +310,14 @@ it('has emails', function (string $email) {
|
||||
]);
|
||||
</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>
|
||||
</laravel-boost-guidelines>
|
||||
|
||||
# Keystone
|
||||
|
||||
- Keep CHANGELOG.md up to date.
|
||||
|
||||
21
CHANGELOG.md
Normal file
21
CHANGELOG.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Expanded the managed registry plan with HTTPS registry requirements, image naming, credential handling, health checks, and build-node safeguards.
|
||||
- Added managed registry build planning defaults, stable managed image references, and digest-based Compose rendering for registry-backed deployments.
|
||||
- Hardened managed registry planning so config-only registry URLs are not treated as ready registry records and pushed artifact digests come from Docker push output.
|
||||
- Preserved external registry override behavior when a managed registry is present.
|
||||
- Added managed registry provisioning, readiness metadata, scoped encrypted registry credentials, build-enabled control node selection, secure Docker auth operation steps, and first-pass retention marking.
|
||||
- Switched managed registry image paths to application and environment UUIDs.
|
||||
- Hardened registry auth execution so operation secrets stay hidden, are cleared after use or cancellation, and Docker auth is installed for the root SSH execution context used by build and deploy steps.
|
||||
- Added managed registry provision, smoke-check, and maintenance operations that generate registry:2, htpasswd, Caddy proxy, push/pull readiness, manifest deletion, and garbage-collection scripts without persisting raw registry passwords.
|
||||
- Blocked managed registry build/deploy planning until represented smoke checks pass, and marked pruned registry artifacts after successful maintenance.
|
||||
- Scoped managed registry maintenance operations to their selected artifact batch, added build/maintenance locking, scheduled daily pruning, and removed registry secret exposure through htpasswd and manifest-delete process arguments.
|
||||
76
CLAUDE.md
76
CLAUDE.md
@@ -6,6 +6,7 @@
|
||||
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
|
||||
|
||||
## 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
|
||||
@@ -18,49 +19,59 @@ This application is a Laravel application and its main Laravel ecosystems packag
|
||||
- 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.
|
||||
|
||||
- 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.
|
||||
@@ -69,6 +80,7 @@ This application is a Laravel application and its main Laravel ecosystems packag
|
||||
- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
|
||||
|
||||
### 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'
|
||||
@@ -77,7 +89,6 @@ This application is a Laravel application and its main Laravel ecosystems packag
|
||||
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit"
|
||||
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
|
||||
|
||||
|
||||
=== php rules ===
|
||||
|
||||
## PHP
|
||||
@@ -85,11 +96,13 @@ This application is a Laravel application and its main Laravel ecosystems packag
|
||||
- Always use curly braces for control structures, even if it has one line.
|
||||
|
||||
### Constructors
|
||||
|
||||
- Use PHP 8 constructor property promotion in `__construct()`.
|
||||
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
|
||||
- <code-snippet>public function \_\_construct(public GitHub $github) { }</code-snippet>
|
||||
- Do not allow empty `__construct()` methods with zero parameters.
|
||||
|
||||
### Type Declarations
|
||||
|
||||
- Always use explicit return type declarations for methods and functions.
|
||||
- Use appropriate PHP type hints for method parameters.
|
||||
|
||||
@@ -101,14 +114,16 @@ 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`.
|
||||
|
||||
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||
|
||||
=== inertia-laravel/core rules ===
|
||||
|
||||
@@ -127,7 +142,6 @@ Route::get('/users', function () {
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== inertia-laravel/v2 rules ===
|
||||
|
||||
## Inertia v2
|
||||
@@ -135,6 +149,7 @@ Route::get('/users', function () {
|
||||
- Make use of all Inertia features from v1 & v2. Check the documentation before making any changes to ensure we are taking the correct approach.
|
||||
|
||||
### Inertia v2 New Features
|
||||
|
||||
- Polling
|
||||
- Prefetching
|
||||
- Deferred props
|
||||
@@ -142,11 +157,12 @@ Route::get('/users', function () {
|
||||
- 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.
|
||||
|
||||
- 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 ===
|
||||
|
||||
@@ -157,6 +173,7 @@ Route::get('/users', function () {
|
||||
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
||||
|
||||
### 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.
|
||||
@@ -164,35 +181,43 @@ Route::get('/users', function () {
|
||||
- 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`.
|
||||
|
||||
- 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 ===
|
||||
|
||||
@@ -202,6 +227,7 @@ Route::get('/users', function () {
|
||||
- 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.
|
||||
@@ -209,12 +235,13 @@ Route::get('/users', function () {
|
||||
- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration.
|
||||
|
||||
### 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.
|
||||
|
||||
- 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 ===
|
||||
|
||||
@@ -223,27 +250,29 @@ Route::get('/users', function () {
|
||||
- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
|
||||
- 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 () {
|
||||
<code-snippet name="Basic Pest Test Example" lang="php">
|
||||
it('is true', function () {
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
</code-snippet>
|
||||
});
|
||||
</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`.
|
||||
@@ -251,21 +280,25 @@ it('is true', function () {
|
||||
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
|
||||
|
||||
### 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 () {
|
||||
<code-snippet name="Pest Example Asserting postJson Response" lang="php">
|
||||
it('returns all', function () {
|
||||
$response = $this->postJson('/api/docs', []);
|
||||
|
||||
$response->assertSuccessful();
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
});
|
||||
</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">
|
||||
@@ -277,11 +310,14 @@ it('has emails', function (string $email) {
|
||||
]);
|
||||
</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>
|
||||
</laravel-boost-guidelines>
|
||||
|
||||
# Keystone
|
||||
|
||||
- Keep CHANGELOG.md up to date.
|
||||
|
||||
@@ -4,11 +4,15 @@ namespace App\Actions\Environments;
|
||||
|
||||
use App\Enums\BuildArtifactStatus;
|
||||
use App\Enums\BuildStrategy;
|
||||
use App\Enums\RegistryType;
|
||||
use App\Models\BuildArtifact;
|
||||
use App\Models\Operation;
|
||||
use App\Models\Registry;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Services\Operations\RemoteCommandRunner;
|
||||
use App\Services\Registries\RegistryDockerAuthScript;
|
||||
use App\Services\Registries\RegistryResolver;
|
||||
use RuntimeException;
|
||||
|
||||
class BuildApplicationArtifact
|
||||
@@ -61,6 +65,18 @@ class BuildApplicationArtifact
|
||||
|
||||
private function buildServer(BuildArtifact $artifact): Server
|
||||
{
|
||||
$buildServerId = (int) ($artifact->metadata['build_server_id'] ?? 0);
|
||||
|
||||
if ($buildServerId > 0) {
|
||||
$server = Server::find($buildServerId);
|
||||
|
||||
if ($server instanceof Server && $server->build_enabled) {
|
||||
return $server;
|
||||
}
|
||||
|
||||
throw new RuntimeException('Configured build server is missing or not build-enabled.');
|
||||
}
|
||||
|
||||
if ($artifact->builtByService instanceof Service) {
|
||||
$server = $artifact->builtByService->replicas->first()?->server ?: $artifact->builtByService->server;
|
||||
|
||||
@@ -70,7 +86,7 @@ class BuildApplicationArtifact
|
||||
}
|
||||
|
||||
if (($artifact->metadata['build_strategy'] ?? null) === BuildStrategy::DEDICATED_BUILDER->value) {
|
||||
throw new RuntimeException('Dedicated builder strategy requires a builder service.');
|
||||
throw new RuntimeException('Dedicated builder strategy requires a builder service or build-enabled server.');
|
||||
}
|
||||
|
||||
$services = $artifact->environment->services()
|
||||
@@ -107,9 +123,13 @@ class BuildApplicationArtifact
|
||||
|
||||
$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)
|
||||
: '';
|
||||
$publishCommands = $artifact->registry_ref && $strategy !== BuildStrategy::EXTERNAL_REGISTRY
|
||||
? [
|
||||
...$this->pushDigestCommands($imageReference),
|
||||
]
|
||||
: [
|
||||
'digest=$(docker image inspect --format '.escapeshellarg('{{if .RepoDigests}}{{index .RepoDigests 0}}{{else}}{{.Id}}{{end}}').' '.escapeshellarg($imageReference).')',
|
||||
];
|
||||
|
||||
return implode("\n", [
|
||||
'set -euo pipefail',
|
||||
@@ -126,25 +146,91 @@ class BuildApplicationArtifact
|
||||
'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).')',
|
||||
...$this->registryMaintenanceLockCommands($artifact),
|
||||
...$this->buildAuthCommands($artifact),
|
||||
'docker build --file Dockerfile.keystone --tag '.escapeshellarg($imageReference).' .',
|
||||
...$publishCommands,
|
||||
'printf "image_digest=%s\n" "$digest"',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function registryMaintenanceLockCommands(BuildArtifact $artifact): array
|
||||
{
|
||||
if (($artifact->metadata['registry_type'] ?? null) !== RegistryType::MANAGED->value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'install -d -m 700 -o root -g root /home/keystone/registry',
|
||||
'exec 9>/home/keystone/registry/maintenance.lock',
|
||||
'flock 9',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function buildAuthCommands(BuildArtifact $artifact): array
|
||||
{
|
||||
if (($artifact->metadata['registry_type'] ?? null) !== RegistryType::MANAGED->value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$registry = app(RegistryResolver::class)->buildRegistryFor($artifact->environment->application->organisation);
|
||||
|
||||
if (! $registry instanceof Registry || $registry->type !== RegistryType::MANAGED || ! $registry->credentials) {
|
||||
throw new RuntimeException('Managed registry build credentials are not configured.');
|
||||
}
|
||||
|
||||
$auth = app(RegistryDockerAuthScript::class)->forBuild($registry, 'root');
|
||||
$script = $auth['script'];
|
||||
|
||||
foreach ($auth['secrets'] as $key => $value) {
|
||||
$script = str_replace("[!{$key}!]", $value, $script);
|
||||
}
|
||||
|
||||
return [$script];
|
||||
}
|
||||
|
||||
private function manifestDigestScript(BuildArtifact $artifact): string
|
||||
{
|
||||
$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"',
|
||||
...$this->manifestDigestCommands($imageReference),
|
||||
'printf "image_digest=%s\n" "$digest"',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function manifestDigestCommands(string $imageReference): array
|
||||
{
|
||||
return [
|
||||
'inspect_output=$(docker buildx imagetools inspect '.escapeshellarg($imageReference).')',
|
||||
'digest=$(printf "%s\n" "$inspect_output" | sed -n '.escapeshellarg('s/^Digest:[[:space:]]*\(sha256:[^[:space:]]*\).*/\1/p').' | head -n 1)',
|
||||
'test -n "$digest"',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function pushDigestCommands(string $imageReference): array
|
||||
{
|
||||
return [
|
||||
'push_output=$(docker push '.escapeshellarg($imageReference).')',
|
||||
'printf "%s\n" "$push_output"',
|
||||
'digest=$(printf "%s\n" "$push_output" | sed -n '.escapeshellarg('s/.*digest: \(sha256:[^[:space:]]*\).*/\1/p').' | tail -n 1)',
|
||||
'test -n "$digest"',
|
||||
];
|
||||
}
|
||||
|
||||
private function dockerfile(BuildArtifact $artifact): string
|
||||
{
|
||||
$service = $artifact->environment->services()
|
||||
@@ -176,7 +262,7 @@ DOCKERFILE;
|
||||
private function digestFromOutput(string $output): string
|
||||
{
|
||||
if (preg_match('/image_digest=(?<digest>\S+)/', $output, $matches)) {
|
||||
return $this->digestFromOutput($matches['digest']);
|
||||
$output = $matches['digest'];
|
||||
}
|
||||
|
||||
if (str_contains($output, '@')) {
|
||||
|
||||
@@ -4,13 +4,21 @@ namespace App\Actions\Environments;
|
||||
|
||||
use App\Enums\BuildArtifactStatus;
|
||||
use App\Enums\BuildStrategy;
|
||||
use App\Enums\RegistryType;
|
||||
use App\Enums\ServiceCategory;
|
||||
use App\Models\BuildArtifact;
|
||||
use App\Models\Environment;
|
||||
use App\Services\Registries\ImageReference;
|
||||
use App\Services\Registries\RegistryResolver;
|
||||
use RuntimeException;
|
||||
|
||||
class PlanBuildArtifact
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RegistryResolver $registryResolver,
|
||||
private readonly ImageReference $imageReference,
|
||||
) {}
|
||||
|
||||
public function execute(Environment $environment, string $commitSha): BuildArtifact
|
||||
{
|
||||
$environment->loadMissing(['application.organisation.registries', 'services.replicas']);
|
||||
@@ -26,36 +34,48 @@ class PlanBuildArtifact
|
||||
}
|
||||
|
||||
$targetServerCount = $this->targetServerCount($environment);
|
||||
$registry = $environment->application->organisation->registries()->first();
|
||||
$registry = $this->registryResolver->buildRegistryFor($environment->application->organisation);
|
||||
$registryType = $this->registryType($registry);
|
||||
|
||||
if ($targetServerCount > 1 && ! $registry) {
|
||||
throw new RuntimeException('A registry is required before building artifacts for multi-server deployments.');
|
||||
$blocker = $this->registryResolver->managedRegistryBlockerFor($environment->application->organisation);
|
||||
|
||||
throw new RuntimeException($blocker ?: 'A registry is required before building artifacts for multi-server deployments.');
|
||||
}
|
||||
|
||||
$builder = $environment->application->organisation->services()
|
||||
->where('category', ServiceCategory::BUILDER)
|
||||
->first();
|
||||
$buildServerId = null;
|
||||
|
||||
if ($registryType === RegistryType::MANAGED) {
|
||||
$buildServerId = (int) $registry->control_server_id;
|
||||
|
||||
if ($buildServerId <= 0) {
|
||||
throw new RuntimeException('A control/build server is required for managed registry builds.');
|
||||
}
|
||||
}
|
||||
|
||||
$strategy = match (true) {
|
||||
$registryType === RegistryType::MANAGED => BuildStrategy::DEDICATED_BUILDER,
|
||||
$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();
|
||||
$imageTag = $this->imageReference->tagFor($environment, $commitSha, $registry);
|
||||
|
||||
return $environment->buildArtifacts()->create([
|
||||
'commit_sha' => $commitSha,
|
||||
'image_tag' => $imageTag,
|
||||
'registry_ref' => $registry ? rtrim((string) $registry->url, '/').'/'.$imageTag : null,
|
||||
'registry_ref' => $registry ? $this->imageReference->registryReference($registry, $imageTag) : null,
|
||||
'built_by_service_id' => $builder?->id,
|
||||
'status' => BuildArtifactStatus::PENDING,
|
||||
'metadata' => [
|
||||
'build_strategy' => $strategy->value,
|
||||
'registry_type' => $registryType?->value,
|
||||
'target_server_count' => $targetServerCount,
|
||||
'build_server_id' => $buildServerId,
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -73,4 +93,17 @@ class PlanBuildArtifact
|
||||
|
||||
return $environment->services->sum('desired_replicas') > 1 ? 2 : 1;
|
||||
}
|
||||
|
||||
private function registryType(mixed $registry): ?RegistryType
|
||||
{
|
||||
if (! $registry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($registry->type instanceof RegistryType) {
|
||||
return $registry->type;
|
||||
}
|
||||
|
||||
return RegistryType::tryFrom((string) $registry->type);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,14 @@ use App\Enums\DeployPolicy;
|
||||
use App\Enums\EnvironmentAttachmentRole;
|
||||
use App\Enums\SchedulerMode;
|
||||
use App\Models\Environment;
|
||||
use App\Services\Registries\RegistryResolver;
|
||||
|
||||
class PlanEnvironmentDeployment
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RegistryResolver $registryResolver,
|
||||
) {}
|
||||
|
||||
public function execute(Environment $environment): EnvironmentDeploymentPlan
|
||||
{
|
||||
$environment->loadMissing([
|
||||
@@ -40,9 +45,9 @@ class PlanEnvironmentDeployment
|
||||
return new EnvironmentDeploymentPlan(
|
||||
services: $deployableServices->all(),
|
||||
dependencies: $dependencies->all(),
|
||||
requiresRegistry: $targetServerCount > 1 && $environment->application->organisation->registries()->doesntExist(),
|
||||
requiresRegistry: $targetServerCount > 1 && ! $this->registryResolver->buildRegistryFor($environment->application->organisation),
|
||||
warnings: $this->warnings($environment),
|
||||
blockers: $this->blockers($environment),
|
||||
blockers: $this->blockers($environment, $targetServerCount),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -74,18 +79,28 @@ class PlanEnvironmentDeployment
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function blockers(Environment $environment): array
|
||||
private function blockers(Environment $environment, int $targetServerCount): array
|
||||
{
|
||||
$blockers = [];
|
||||
|
||||
if ($targetServerCount > 1 && ! $this->registryResolver->buildRegistryFor($environment->application->organisation)) {
|
||||
$blocker = $this->registryResolver->managedRegistryBlockerFor($environment->application->organisation);
|
||||
|
||||
if ($blocker !== null) {
|
||||
$blockers[] = $blocker;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $environment->scheduler_enabled || $environment->scheduler_mode !== SchedulerMode::SINGLE) {
|
||||
return [];
|
||||
return $blockers;
|
||||
}
|
||||
|
||||
$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.'];
|
||||
$blockers[] = 'Scheduler mode single requires the scheduler target service to run exactly one replica.';
|
||||
}
|
||||
|
||||
return [];
|
||||
return $blockers;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Registries;
|
||||
|
||||
use App\Enums\BuildArtifactStatus;
|
||||
use App\Enums\OperationKind;
|
||||
use App\Enums\OperationStatus;
|
||||
use App\Models\BuildArtifact;
|
||||
use App\Models\Operation;
|
||||
use App\Models\Registry;
|
||||
use App\Models\Server;
|
||||
use App\Services\Registries\ManagedRegistryOperationScripts;
|
||||
use App\Services\Registries\ManagedRegistryRetention;
|
||||
use RuntimeException;
|
||||
|
||||
class CreateManagedRegistryMaintenanceOperation
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ManagedRegistryRetention $retention,
|
||||
private readonly ManagedRegistryOperationScripts $scripts,
|
||||
) {}
|
||||
|
||||
public function execute(Registry $registry): Operation
|
||||
{
|
||||
$server = $registry->controlServer;
|
||||
|
||||
if (! $server instanceof Server) {
|
||||
throw new RuntimeException('A control/build server is required to prune the managed registry.');
|
||||
}
|
||||
|
||||
$activeBuilds = $registry->organisation->applications()
|
||||
->whereHas('environments.buildArtifacts', fn ($query) => $query
|
||||
->where('status', BuildArtifactStatus::BUILDING)
|
||||
->where('registry_ref', 'like', rtrim((string) $registry->url, '/').'/%'))
|
||||
->exists();
|
||||
|
||||
if ($activeBuilds) {
|
||||
throw new RuntimeException('Managed registry pruning cannot run while builds are active.');
|
||||
}
|
||||
|
||||
$this->retention->markPrunable($registry);
|
||||
$artifacts = $this->prunableArtifacts($registry);
|
||||
$maintenance = $this->scripts->maintenance($registry, $artifacts);
|
||||
|
||||
$operation = $server->operations()->create([
|
||||
'kind' => OperationKind::REGISTRY_MAINTENANCE,
|
||||
'status' => OperationStatus::PENDING,
|
||||
'metadata' => [
|
||||
'registry_id' => $registry->id,
|
||||
'artifact_ids' => $artifacts->pluck('id')->values()->all(),
|
||||
],
|
||||
]);
|
||||
|
||||
$operation->steps()->create([
|
||||
'name' => 'Delete prunable manifests and run registry GC',
|
||||
'order' => 1,
|
||||
'status' => OperationStatus::PENDING,
|
||||
'script' => $maintenance['script'],
|
||||
'secrets' => $maintenance['secrets'],
|
||||
]);
|
||||
|
||||
return $operation->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Illuminate\Support\Collection<int, BuildArtifact>
|
||||
*/
|
||||
private function prunableArtifacts(Registry $registry): \Illuminate\Support\Collection
|
||||
{
|
||||
return $registry->organisation->applications()
|
||||
->with(['environments.buildArtifacts' => fn ($query) => $query
|
||||
->where('status', BuildArtifactStatus::PRUNABLE)
|
||||
->where('registry_ref', 'like', rtrim((string) $registry->url, '/').'/%')])
|
||||
->get()
|
||||
->flatMap(fn ($application) => $application->environments)
|
||||
->flatMap(fn ($environment) => $environment->buildArtifacts)
|
||||
->values();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Registries;
|
||||
|
||||
use App\Enums\OperationKind;
|
||||
use App\Enums\OperationStatus;
|
||||
use App\Models\Operation;
|
||||
use App\Models\Registry;
|
||||
use App\Models\Server;
|
||||
use App\Services\Registries\ManagedRegistryOperationScripts;
|
||||
use RuntimeException;
|
||||
|
||||
class CreateManagedRegistryProvisionOperation
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ManagedRegistryOperationScripts $scripts,
|
||||
) {}
|
||||
|
||||
public function execute(Registry $registry): Operation
|
||||
{
|
||||
$server = $registry->controlServer;
|
||||
|
||||
if (! $server instanceof Server) {
|
||||
throw new RuntimeException('A control/build server is required to provision the managed registry.');
|
||||
}
|
||||
|
||||
$provision = $this->scripts->provision($registry);
|
||||
|
||||
$operation = $server->operations()->create([
|
||||
'kind' => OperationKind::REGISTRY_PROVISION,
|
||||
'status' => OperationStatus::PENDING,
|
||||
]);
|
||||
|
||||
$operation->steps()->create([
|
||||
'name' => 'Install managed Docker registry',
|
||||
'order' => 1,
|
||||
'status' => OperationStatus::PENDING,
|
||||
'script' => $provision['script'],
|
||||
'secrets' => $provision['secrets'],
|
||||
]);
|
||||
|
||||
return $operation->refresh();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Registries;
|
||||
|
||||
use App\Enums\OperationKind;
|
||||
use App\Enums\OperationStatus;
|
||||
use App\Models\Operation;
|
||||
use App\Models\Registry;
|
||||
use App\Models\Server;
|
||||
use App\Services\Registries\ManagedRegistryOperationScripts;
|
||||
use RuntimeException;
|
||||
|
||||
class CreateManagedRegistrySmokeCheckOperation
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ManagedRegistryOperationScripts $scripts,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param iterable<int, Server> $runtimeServers
|
||||
*/
|
||||
public function execute(Registry $registry, ?Server $buildServer = null, iterable $runtimeServers = []): Operation
|
||||
{
|
||||
$controlServer = $registry->controlServer;
|
||||
|
||||
if (! $controlServer instanceof Server) {
|
||||
throw new RuntimeException('A control/build server is required to check the managed registry.');
|
||||
}
|
||||
|
||||
$buildServer ??= $controlServer;
|
||||
$smokeRef = rtrim((string) $registry->url, '/').'/keystone/smoke/server-'.$buildServer->id.':latest';
|
||||
$checks = [
|
||||
'control_https' => 'pending',
|
||||
'build_push' => 'pending',
|
||||
];
|
||||
|
||||
foreach ($runtimeServers as $server) {
|
||||
$checks['runtime_pull_server_'.$server->id] = 'pending';
|
||||
}
|
||||
|
||||
$registry->forceFill([
|
||||
'readiness_checks' => $checks,
|
||||
'health_status' => 'pending',
|
||||
'ready_at' => null,
|
||||
])->save();
|
||||
|
||||
$operation = $controlServer->operations()->create([
|
||||
'kind' => OperationKind::REGISTRY_HEALTH_CHECK,
|
||||
'status' => OperationStatus::PENDING,
|
||||
'metadata' => [
|
||||
'registry_id' => $registry->id,
|
||||
],
|
||||
]);
|
||||
|
||||
$build = $this->scripts->smokeCheck($registry, $buildServer, 'build', $smokeRef);
|
||||
$operation->steps()->create([
|
||||
'name' => 'Check registry HTTPS and build push',
|
||||
'order' => 1,
|
||||
'status' => OperationStatus::PENDING,
|
||||
'script' => $build['script'],
|
||||
'secrets' => $build['secrets'],
|
||||
]);
|
||||
|
||||
$order = 2;
|
||||
foreach ($runtimeServers as $server) {
|
||||
$runtime = $this->scripts->smokeCheck($registry, $server, 'runtime', $smokeRef);
|
||||
$child = $server->operations()->create([
|
||||
'kind' => OperationKind::REGISTRY_HEALTH_CHECK,
|
||||
'parent_id' => $operation->id,
|
||||
'status' => OperationStatus::PENDING,
|
||||
'metadata' => [
|
||||
'registry_id' => $registry->id,
|
||||
],
|
||||
]);
|
||||
$child->steps()->create([
|
||||
'name' => 'Check runtime registry pull on '.$server->name,
|
||||
'order' => $order++,
|
||||
'status' => OperationStatus::PENDING,
|
||||
'script' => $runtime['script'],
|
||||
'secrets' => $runtime['secrets'],
|
||||
]);
|
||||
}
|
||||
|
||||
return $operation->refresh();
|
||||
}
|
||||
}
|
||||
42
app/Actions/Registries/CreateRegistryAuthOperation.php
Normal file
42
app/Actions/Registries/CreateRegistryAuthOperation.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Registries;
|
||||
|
||||
use App\Enums\OperationKind;
|
||||
use App\Enums\OperationStatus;
|
||||
use App\Models\Operation;
|
||||
use App\Models\Registry;
|
||||
use App\Models\Server;
|
||||
use App\Services\Registries\RegistryDockerAuthScript;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class CreateRegistryAuthOperation
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RegistryDockerAuthScript $registryDockerAuthScript,
|
||||
) {}
|
||||
|
||||
public function execute(Registry $registry, Server $server, string $scope): Operation
|
||||
{
|
||||
$auth = match ($scope) {
|
||||
'build' => $this->registryDockerAuthScript->forBuild($registry, 'root'),
|
||||
'runtime' => $this->registryDockerAuthScript->forRuntime($registry, 'root'),
|
||||
default => throw new InvalidArgumentException('Registry auth scope must be build or runtime.'),
|
||||
};
|
||||
|
||||
$operation = $server->operations()->create([
|
||||
'kind' => OperationKind::CREDENTIAL_ROTATION,
|
||||
'status' => OperationStatus::PENDING,
|
||||
]);
|
||||
|
||||
$operation->steps()->create([
|
||||
'name' => 'Configure '.$scope.' registry auth',
|
||||
'order' => 1,
|
||||
'status' => OperationStatus::PENDING,
|
||||
'script' => $auth['script'],
|
||||
'secrets' => $auth['secrets'],
|
||||
]);
|
||||
|
||||
return $operation->refresh();
|
||||
}
|
||||
}
|
||||
49
app/Console/Commands/CheckManagedRegistry.php
Normal file
49
app/Console/Commands/CheckManagedRegistry.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Actions\Registries\CreateManagedRegistrySmokeCheckOperation;
|
||||
use App\Enums\RegistryType;
|
||||
use App\Models\Registry;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class CheckManagedRegistry extends Command
|
||||
{
|
||||
protected $signature = 'keystone:managed-registry:check
|
||||
{registry : Managed registry id}
|
||||
{--build-server= : Build server id, defaults to the registry control server}
|
||||
{--runtime-server=* : Runtime server id to pull the smoke image}
|
||||
{--dispatch : Dispatch the first operation step immediately}';
|
||||
|
||||
protected $description = 'Create managed registry HTTPS/auth/push/pull smoke-check operations.';
|
||||
|
||||
public function handle(CreateManagedRegistrySmokeCheckOperation $operations): int
|
||||
{
|
||||
$registry = Registry::query()
|
||||
->where('type', RegistryType::MANAGED->value)
|
||||
->findOrFail((int) $this->argument('registry'));
|
||||
|
||||
$buildServer = $this->option('build-server')
|
||||
? Server::query()
|
||||
->where('organisation_id', $registry->organisation_id)
|
||||
->findOrFail((int) $this->option('build-server'))
|
||||
: null;
|
||||
|
||||
$runtimeServers = collect($this->option('runtime-server'))
|
||||
->map(fn (string $serverId): Server => Server::query()
|
||||
->where('organisation_id', $registry->organisation_id)
|
||||
->findOrFail((int) $serverId));
|
||||
|
||||
$operation = $operations->execute($registry, $buildServer, $runtimeServers);
|
||||
|
||||
$this->info("Created registry smoke-check operation {$operation->id}.");
|
||||
|
||||
if ($this->option('dispatch')) {
|
||||
$operation->steps()->orderBy('order')->first()?->dispatchJob();
|
||||
$this->info('Dispatched registry smoke-check operation.');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
87
app/Console/Commands/ProvisionManagedRegistry.php
Normal file
87
app/Console/Commands/ProvisionManagedRegistry.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Actions\Registries\CreateManagedRegistryProvisionOperation;
|
||||
use App\Models\Organisation;
|
||||
use App\Models\Server;
|
||||
use App\Services\Registries\ManagedRegistryHealth;
|
||||
use App\Services\Registries\ManagedRegistryProvisioner;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class ProvisionManagedRegistry extends Command
|
||||
{
|
||||
protected $signature = 'keystone:managed-registry:provision
|
||||
{organisation : Organisation id or slug}
|
||||
{--url= : HTTPS registry hostname}
|
||||
{--control-server= : Control/build server id}
|
||||
{--storage-path= : Registry storage path}
|
||||
{--retention= : Successful artifacts to retain per environment}
|
||||
{--create-operation : Create the remote registry install/proxy operation}
|
||||
{--dispatch : Dispatch the first operation step immediately}
|
||||
{--mark-healthy : Mark the persisted registry ready after configuration validation}';
|
||||
|
||||
protected $description = 'Persist and optionally install a first-party managed Docker registry.';
|
||||
|
||||
public function handle(ManagedRegistryProvisioner $provisioner, ManagedRegistryHealth $health, CreateManagedRegistryProvisionOperation $operations): int
|
||||
{
|
||||
$organisationKey = (string) $this->argument('organisation');
|
||||
$organisation = Organisation::query()
|
||||
->where('id', $organisationKey)
|
||||
->orWhere('slug', $organisationKey)
|
||||
->firstOrFail();
|
||||
|
||||
$url = (string) ($this->option('url') ?: config('keystone.managed_registry.url'));
|
||||
|
||||
if ($url === '') {
|
||||
$this->error('Provide --url or KEYSTONE_MANAGED_REGISTRY_URL.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$controlServer = $this->option('control-server')
|
||||
? Server::query()
|
||||
->where('organisation_id', $organisation->id)
|
||||
->findOrFail((int) $this->option('control-server'))
|
||||
: null;
|
||||
|
||||
$registry = $provisioner->provision(
|
||||
organisation: $organisation,
|
||||
url: $url,
|
||||
controlServer: $controlServer,
|
||||
storagePath: $this->option('storage-path') ? (string) $this->option('storage-path') : null,
|
||||
retention: $this->option('retention') ? (int) $this->option('retention') : null,
|
||||
);
|
||||
|
||||
$blocker = $health->readinessBlocker($registry);
|
||||
|
||||
if ($this->option('mark-healthy') && $blocker !== null && $blocker !== 'Managed registry has not passed readiness checks.') {
|
||||
$this->error($blocker);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($this->option('mark-healthy')) {
|
||||
$registry->markHealthy('Marked ready by provisioning command.');
|
||||
$blocker = null;
|
||||
}
|
||||
|
||||
$this->info("Managed registry {$registry->url} persisted for {$organisation->name}.");
|
||||
|
||||
if ($blocker !== null) {
|
||||
$this->warn($blocker);
|
||||
}
|
||||
|
||||
if ($this->option('create-operation')) {
|
||||
$operation = $operations->execute($registry);
|
||||
$this->info("Created registry provision operation {$operation->id}.");
|
||||
|
||||
if ($this->option('dispatch')) {
|
||||
$operation->steps()->orderBy('order')->first()?->dispatchJob();
|
||||
$this->info('Dispatched registry provision operation.');
|
||||
}
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
40
app/Console/Commands/PruneManagedRegistry.php
Normal file
40
app/Console/Commands/PruneManagedRegistry.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Actions\Registries\CreateManagedRegistryMaintenanceOperation;
|
||||
use App\Enums\RegistryType;
|
||||
use App\Models\Registry;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class PruneManagedRegistry extends Command
|
||||
{
|
||||
protected $signature = 'keystone:managed-registry:prune
|
||||
{registry? : Managed registry id}
|
||||
{--dispatch : Dispatch the first operation step immediately}';
|
||||
|
||||
protected $description = 'Create managed registry manifest deletion and garbage-collection operations.';
|
||||
|
||||
public function handle(CreateManagedRegistryMaintenanceOperation $operations): int
|
||||
{
|
||||
$registries = Registry::query()
|
||||
->where('type', RegistryType::MANAGED->value)
|
||||
->when($this->argument('registry'), fn ($query) => $query->whereKey((int) $this->argument('registry')))
|
||||
->get();
|
||||
|
||||
$count = 0;
|
||||
|
||||
foreach ($registries as $registry) {
|
||||
$operation = $operations->execute($registry);
|
||||
$count++;
|
||||
|
||||
if ($this->option('dispatch')) {
|
||||
$operation->steps()->orderBy('order')->first()?->dispatchJob();
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Created {$count} managed registry maintenance operation(s).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -12,4 +12,6 @@ enum BuildArtifactStatus: string
|
||||
case BUILDING = 'building';
|
||||
case AVAILABLE = 'available';
|
||||
case FAILED = 'failed';
|
||||
case PRUNABLE = 'prunable';
|
||||
case PRUNED = 'pruned';
|
||||
}
|
||||
|
||||
@@ -17,4 +17,7 @@ enum OperationKind: string
|
||||
case GATEWAY_CUTOVER = 'gateway_cutover';
|
||||
case CONFIG_CHANGE = 'config_change';
|
||||
case CREDENTIAL_ROTATION = 'credential_rotation';
|
||||
case REGISTRY_PROVISION = 'registry_provision';
|
||||
case REGISTRY_HEALTH_CHECK = 'registry_health_check';
|
||||
case REGISTRY_MAINTENANCE = 'registry_maintenance';
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ enum RegistryType: string
|
||||
{
|
||||
use Arrayable;
|
||||
|
||||
case MANAGED = 'managed';
|
||||
case GENERIC = 'generic';
|
||||
case GITEA = 'gitea';
|
||||
case GHCR = 'ghcr';
|
||||
|
||||
@@ -7,11 +7,16 @@ use App\Jobs\Environments\DeployEnvironment;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Organisation;
|
||||
use App\Services\Registries\RegistryResolver;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class EnvironmentDeploymentController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RegistryResolver $registryResolver,
|
||||
) {}
|
||||
|
||||
public function store(StoreEnvironmentDeploymentRequest $request, Organisation $organisation, Application $application, Environment $environment): RedirectResponse
|
||||
{
|
||||
abort_unless(
|
||||
@@ -22,7 +27,7 @@ class EnvironmentDeploymentController extends Controller
|
||||
|
||||
$environment->loadMissing('services.replicas');
|
||||
|
||||
if ($organisation->registries()->doesntExist() && $this->serverIdsFor($environment)->count() > 1) {
|
||||
if (! $this->registryResolver->buildRegistryFor($organisation) && $this->serverIdsFor($environment)->count() > 1) {
|
||||
return back()->with('error', 'Configure a registry before deploying this environment to multiple servers.');
|
||||
}
|
||||
|
||||
|
||||
@@ -122,6 +122,7 @@ class OperationController extends Controller
|
||||
'status' => OperationStatus::CANCELLED,
|
||||
'finished_at' => now(),
|
||||
]);
|
||||
$this->clearOperationSecrets($operation);
|
||||
|
||||
return redirect()
|
||||
->route('operations.show', [
|
||||
@@ -168,4 +169,13 @@ class OperationController extends Controller
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
private function clearOperationSecrets(Operation $operation): void
|
||||
{
|
||||
$operation->steps()->update(['secrets' => null]);
|
||||
|
||||
$operation->children()->get()->each(function (Operation $child): void {
|
||||
$this->clearOperationSecrets($child);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ class RegistryController extends Controller
|
||||
Organisation::findOrFail($request->route('organisation'));
|
||||
|
||||
return inertia('registries/Create', [
|
||||
'registryTypes' => array_values(RegistryType::toArray()),
|
||||
'registryTypes' => $this->userConfigurableRegistryTypes(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ class RegistryController extends Controller
|
||||
|
||||
return inertia('registries/Edit', [
|
||||
'registry' => $registry,
|
||||
'registryTypes' => array_values(RegistryType::toArray()),
|
||||
'registryTypes' => $this->userConfigurableRegistryTypes(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -91,6 +91,8 @@ class RegistryController extends Controller
|
||||
/** @var Registry $registry */
|
||||
$registry = $organisation->registries()->findOrFail($request->route('registry'));
|
||||
|
||||
abort_if($registry->type === RegistryType::MANAGED, 403);
|
||||
|
||||
$credentials = $registry->credentials ?? [];
|
||||
$username = $request->string('username')->toString();
|
||||
|
||||
@@ -117,10 +119,23 @@ class RegistryController extends Controller
|
||||
$organisation = Organisation::findOrFail($request->route('organisation'));
|
||||
$registry = $organisation->registries()->findOrFail($request->route('registry'));
|
||||
|
||||
abort_if($registry->type === RegistryType::MANAGED, 403);
|
||||
|
||||
$registry->delete();
|
||||
|
||||
return redirect()
|
||||
->route('organisations.show', ['organisation' => $organisation->id])
|
||||
->with('success', 'Registry deleted.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function userConfigurableRegistryTypes(): array
|
||||
{
|
||||
return collect(RegistryType::toArray())
|
||||
->reject(fn (string $type) => $type === RegistryType::MANAGED->value)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,8 @@ class StoreRegistryRequest extends FormRequest
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'type' => ['required', Rule::enum(RegistryType::class)],
|
||||
'url' => ['required', 'string', 'max:255'],
|
||||
'type' => ['required', Rule::enum(RegistryType::class), Rule::notIn([RegistryType::MANAGED->value])],
|
||||
'url' => ['required', 'string', 'max:255', 'not_regex:#^https?://#i'],
|
||||
'username' => ['nullable', 'string', 'max:255'],
|
||||
'password' => ['nullable', 'string', 'max:255'],
|
||||
];
|
||||
|
||||
@@ -25,8 +25,8 @@ class UpdateRegistryRequest extends FormRequest
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'type' => ['required', Rule::enum(RegistryType::class)],
|
||||
'url' => ['required', 'string', 'max:255'],
|
||||
'type' => ['required', Rule::enum(RegistryType::class), Rule::notIn([RegistryType::MANAGED->value])],
|
||||
'url' => ['required', 'string', 'max:255', 'not_regex:#^https?://#i'],
|
||||
'username' => ['nullable', 'string', 'max:255'],
|
||||
'password' => ['nullable', 'string', 'max:255'],
|
||||
];
|
||||
|
||||
@@ -15,9 +15,13 @@ use App\Enums\ServiceEndpointScope;
|
||||
use App\Models\Environment;
|
||||
use App\Models\EnvironmentAttachment;
|
||||
use App\Models\Operation;
|
||||
use App\Models\Registry;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceReplica;
|
||||
use App\Services\Compose\ComposeRenderer;
|
||||
use App\Services\Registries\RegistryDockerAuthScript;
|
||||
use App\Services\Registries\RegistryResolver;
|
||||
use App\Support\CaddyRouteRenderer;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
@@ -67,6 +71,7 @@ class DeployEnvironment implements ShouldQueue
|
||||
|
||||
$artifact = app(PlanBuildArtifact::class)->execute($this->environment, $commitSha);
|
||||
$artifact = app(BuildApplicationArtifact::class)->execute($artifact, $operation);
|
||||
$registry = app(RegistryResolver::class)->buildRegistryFor($this->environment->application->organisation);
|
||||
|
||||
foreach ($services as $service) {
|
||||
$service->update([
|
||||
@@ -80,8 +85,8 @@ class DeployEnvironment implements ShouldQueue
|
||||
'status' => OperationStatus::PENDING,
|
||||
]);
|
||||
|
||||
$this->createServiceDeploySteps($child, $service, $commitSha, $artifact->image_digest);
|
||||
$this->createReplicaDeployOperations($child, $service, $artifact->registry_ref);
|
||||
$this->createServiceDeploySteps($child, $service, $commitSha, $artifact->image_digest, $artifact->registry_ref);
|
||||
$this->createReplicaDeployOperations($child, $service, $artifact->registry_ref, $registry);
|
||||
}
|
||||
|
||||
$this->createGatewayOperations($operation);
|
||||
@@ -100,19 +105,20 @@ class DeployEnvironment implements ShouldQueue
|
||||
->all();
|
||||
}
|
||||
|
||||
private function createServiceDeploySteps(Operation $operation, Service $service, string $commitSha, string $imageDigest): void
|
||||
private function createServiceDeploySteps(Operation $operation, Service $service, string $commitSha, string $imageDigest, ?string $imageReference = null): void
|
||||
{
|
||||
foreach ($this->serviceDeployScripts($service, $commitSha, $imageDigest) as $index => $step) {
|
||||
foreach ($this->serviceDeployScripts($service, $commitSha, $imageDigest, $imageReference) as $index => $step) {
|
||||
$operation->steps()->create([
|
||||
'name' => $step['name'],
|
||||
'order' => $index + 1,
|
||||
'status' => OperationStatus::PENDING,
|
||||
'script' => $step['script'],
|
||||
'secrets' => $step['secrets'] ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function createReplicaDeployOperations(Operation $parent, Service $service, ?string $imageReference = null): void
|
||||
private function createReplicaDeployOperations(Operation $parent, Service $service, ?string $imageReference = null, ?Registry $registry = null): void
|
||||
{
|
||||
$replicas = $this->ensureServiceReplicas($service);
|
||||
|
||||
@@ -133,12 +139,13 @@ class DeployEnvironment implements ShouldQueue
|
||||
'health_status' => 'unknown',
|
||||
]);
|
||||
|
||||
foreach ($this->replicaDeployScripts($service, $replica, $imageReference) as $index => $step) {
|
||||
foreach ($this->replicaDeployScripts($service, $replica, $imageReference, $registry, $serviceReplica) as $index => $step) {
|
||||
$operation->steps()->create([
|
||||
'name' => $step['name'],
|
||||
'order' => $index + 1,
|
||||
'status' => OperationStatus::PENDING,
|
||||
'script' => $step['script'],
|
||||
'secrets' => $step['secrets'] ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -244,7 +251,7 @@ class DeployEnvironment implements ShouldQueue
|
||||
/**
|
||||
* @return array<int, array{name: string, script: string}>
|
||||
*/
|
||||
private function serviceDeployScripts(Service $service, string $commitSha, string $imageDigest): array
|
||||
private function serviceDeployScripts(Service $service, string $commitSha, string $imageDigest, ?string $imageReference = null): array
|
||||
{
|
||||
$servicePath = $this->servicePath($service);
|
||||
$composePath = "{$servicePath}/compose.yml";
|
||||
@@ -264,7 +271,7 @@ class DeployEnvironment implements ShouldQueue
|
||||
],
|
||||
[
|
||||
'name' => 'Render Compose files',
|
||||
'script' => $this->composeUploadScript($service),
|
||||
'script' => $this->composeUploadScript($service, $this->fullImageReference($imageReference, $imageDigest)),
|
||||
],
|
||||
];
|
||||
|
||||
@@ -311,17 +318,32 @@ class DeployEnvironment implements ShouldQueue
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{name: string, script: string}>
|
||||
* @return array<int, array{name: string, script: string, secrets?: array<string, string>}>
|
||||
*/
|
||||
private function replicaDeployScripts(Service $service, int $replica, ?string $imageReference = null): array
|
||||
private function replicaDeployScripts(Service $service, int $replica, ?string $imageReference = null, ?Registry $registry = null, ?ServiceReplica $serviceReplica = null): array
|
||||
{
|
||||
$composePath = $this->servicePath($service).'/compose.yml';
|
||||
$project = "keystone_service_{$service->id}_replica_{$replica}";
|
||||
$serviceKey = $this->serviceKey($service);
|
||||
$targetServer = $serviceReplica?->server ?: $service->server;
|
||||
|
||||
$steps = [];
|
||||
$steps = [
|
||||
[
|
||||
'name' => "Render replica {$replica} Compose files",
|
||||
'script' => $this->composeUploadScript($service, $this->fullImageReference($imageReference, $service->available_image_digest)),
|
||||
],
|
||||
];
|
||||
|
||||
if ($imageReference && $service->available_image_digest) {
|
||||
if ($registry instanceof Registry && $registry->credentials) {
|
||||
$auth = app(RegistryDockerAuthScript::class)->forRuntime($registry, $this->dockerAuthUser($targetServer));
|
||||
$steps[] = [
|
||||
'name' => "Configure registry auth for replica {$replica}",
|
||||
'script' => $auth['script'],
|
||||
'secrets' => $auth['secrets'],
|
||||
];
|
||||
}
|
||||
|
||||
$steps[] = [
|
||||
'name' => "Pull image for replica {$replica}",
|
||||
'script' => 'docker pull '.escapeshellarg($imageReference.'@'.$service->available_image_digest),
|
||||
@@ -354,16 +376,21 @@ class DeployEnvironment implements ShouldQueue
|
||||
];
|
||||
}
|
||||
|
||||
private function composeUploadScript(Service $service): string
|
||||
private function dockerAuthUser(?Server $server): string
|
||||
{
|
||||
return 'root';
|
||||
}
|
||||
|
||||
private function composeUploadScript(Service $service, ?string $fullImageReference = null): string
|
||||
{
|
||||
$servicePath = $this->servicePath($service);
|
||||
|
||||
try {
|
||||
$renderer = app(ComposeRenderer::class);
|
||||
$compose = $renderer->render($service);
|
||||
$compose = $renderer->render($this->serviceForCompose($service, $fullImageReference));
|
||||
$env = $renderer->renderEnvironmentFile($service);
|
||||
} catch (InvalidArgumentException) {
|
||||
$compose = "services:\n {$this->serviceKey($service)}:\n image: \"{$service->available_image_digest}\"\n";
|
||||
$compose = "services:\n {$this->serviceKey($service)}:\n image: \"".($fullImageReference ?: $service->available_image_digest)."\"\n";
|
||||
$env = '';
|
||||
}
|
||||
|
||||
@@ -374,6 +401,27 @@ class DeployEnvironment implements ShouldQueue
|
||||
]);
|
||||
}
|
||||
|
||||
private function fullImageReference(?string $imageReference, ?string $imageDigest): ?string
|
||||
{
|
||||
if (! $imageReference || ! $imageDigest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $imageReference.'@'.$imageDigest;
|
||||
}
|
||||
|
||||
private function serviceForCompose(Service $service, ?string $fullImageReference): Service
|
||||
{
|
||||
if (! $fullImageReference) {
|
||||
return $service;
|
||||
}
|
||||
|
||||
$clone = clone $service;
|
||||
$clone->available_image_digest = $fullImageReference;
|
||||
|
||||
return $clone;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{name: string, script: string}>
|
||||
*/
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
|
||||
namespace App\Jobs\Services;
|
||||
|
||||
use App\Enums\BuildArtifactStatus;
|
||||
use App\Enums\OperationStatus;
|
||||
use App\Enums\RegistryType;
|
||||
use App\Enums\ServiceStatus;
|
||||
use App\Models\BuildArtifact;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Operation;
|
||||
use App\Models\OperationStep;
|
||||
use App\Models\Registry;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceReplica;
|
||||
@@ -110,6 +114,9 @@ class RunStep implements ShouldQueue
|
||||
if ($operation->is($this->step->operation)) {
|
||||
$this->markTargetCompleted();
|
||||
}
|
||||
|
||||
$this->markRegistryHealthOperationCompleted($operation);
|
||||
$this->markRegistryMaintenanceOperationCompleted($operation);
|
||||
}
|
||||
|
||||
private function dispatchNextChildOperation(Operation $operation): bool
|
||||
@@ -166,6 +173,7 @@ class RunStep implements ShouldQueue
|
||||
$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 Server => $target,
|
||||
$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(),
|
||||
@@ -190,10 +198,12 @@ class RunStep implements ShouldQueue
|
||||
'status' => OperationStatus::FAILED,
|
||||
'finished_at' => now(),
|
||||
'error_logs' => $this->step->error_logs."\n".trim($message),
|
||||
'secrets' => null,
|
||||
]);
|
||||
|
||||
$this->step->operation->steps()->where('order', '>', $this->step->order)->update([
|
||||
'status' => OperationStatus::CANCELLED,
|
||||
'secrets' => null,
|
||||
]);
|
||||
|
||||
$this->step->operation->update([
|
||||
@@ -203,13 +213,114 @@ class RunStep implements ShouldQueue
|
||||
|
||||
$this->cancelDescendants($this->step->operation);
|
||||
$this->cancelPendingSiblingsAndAncestors($this->step->operation);
|
||||
$this->markRegistryHealthOperationFailed($this->step->operation, trim($message));
|
||||
}
|
||||
|
||||
private function markRegistryHealthOperationCompleted(Operation $operation): void
|
||||
{
|
||||
if ($operation->kind !== \App\Enums\OperationKind::REGISTRY_HEALTH_CHECK) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($operation->parent_id !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$registry = $this->managedRegistryForOperation($operation);
|
||||
|
||||
if (! $registry instanceof Registry) {
|
||||
return;
|
||||
}
|
||||
|
||||
$checks = collect($registry->readiness_checks ?? [])
|
||||
->map(fn (): string => 'passed')
|
||||
->all();
|
||||
|
||||
$registry->forceFill([
|
||||
'readiness_checks' => $checks,
|
||||
])->save();
|
||||
$registry->markHealthy('Managed registry smoke checks passed.');
|
||||
}
|
||||
|
||||
private function markRegistryHealthOperationFailed(Operation $operation, string $message): void
|
||||
{
|
||||
if ($operation->kind !== \App\Enums\OperationKind::REGISTRY_HEALTH_CHECK) {
|
||||
return;
|
||||
}
|
||||
|
||||
$registry = $this->managedRegistryForOperation($operation);
|
||||
|
||||
$registry?->markUnhealthy('Managed registry smoke check failed: '.$message);
|
||||
}
|
||||
|
||||
private function markRegistryMaintenanceOperationCompleted(Operation $operation): void
|
||||
{
|
||||
if ($operation->kind !== \App\Enums\OperationKind::REGISTRY_MAINTENANCE) {
|
||||
return;
|
||||
}
|
||||
|
||||
$registry = $this->managedRegistryForOperation($operation);
|
||||
|
||||
if (! $registry instanceof Registry) {
|
||||
return;
|
||||
}
|
||||
|
||||
$artifactIds = collect($operation->metadata['artifact_ids'] ?? [])
|
||||
->filter(fn ($id): bool => is_numeric($id))
|
||||
->map(fn ($id): int => (int) $id)
|
||||
->values();
|
||||
|
||||
BuildArtifact::query()
|
||||
->whereIn('id', $artifactIds)
|
||||
->where('status', BuildArtifactStatus::PRUNABLE)
|
||||
->where('registry_ref', 'like', rtrim((string) $registry->url, '/').'/%')
|
||||
->each(function ($artifact): void {
|
||||
$artifact->update([
|
||||
'status' => BuildArtifactStatus::PRUNED,
|
||||
'metadata' => [
|
||||
...($artifact->metadata ?? []),
|
||||
'pruned_at' => now()->toIso8601String(),
|
||||
],
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
private function managedRegistryForOperation(Operation $operation): ?Registry
|
||||
{
|
||||
$registryId = $operation->metadata['registry_id'] ?? $operation->parent?->metadata['registry_id'] ?? null;
|
||||
|
||||
if ($registryId) {
|
||||
$registry = Registry::query()
|
||||
->where('type', RegistryType::MANAGED->value)
|
||||
->find($registryId);
|
||||
|
||||
if ($registry instanceof Registry) {
|
||||
return $registry;
|
||||
}
|
||||
}
|
||||
|
||||
$server = $operation->target;
|
||||
|
||||
if (! $server instanceof Server) {
|
||||
$server = $operation->parent?->target;
|
||||
}
|
||||
|
||||
if (! $server instanceof Server) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Registry::query()
|
||||
->where('type', RegistryType::MANAGED->value)
|
||||
->where('control_server_id', $server->id)
|
||||
->first();
|
||||
}
|
||||
|
||||
private function cancelDescendants(Operation $operation): void
|
||||
{
|
||||
$operation->children()->with('children')->get()->each(function (Operation $child): void {
|
||||
$child->steps()->where('status', OperationStatus::PENDING)->update([
|
||||
$child->steps()->whereIn('status', [OperationStatus::PENDING, OperationStatus::IN_PROGRESS])->update([
|
||||
'status' => OperationStatus::CANCELLED,
|
||||
'secrets' => null,
|
||||
]);
|
||||
$child->update([
|
||||
'status' => OperationStatus::CANCELLED,
|
||||
@@ -232,8 +343,9 @@ class RunStep implements ShouldQueue
|
||||
->whereIn('status', [OperationStatus::PENDING, OperationStatus::IN_PROGRESS])
|
||||
->get()
|
||||
->each(function (Operation $sibling): void {
|
||||
$sibling->steps()->where('status', OperationStatus::PENDING)->update([
|
||||
$sibling->steps()->whereIn('status', [OperationStatus::PENDING, OperationStatus::IN_PROGRESS])->update([
|
||||
'status' => OperationStatus::CANCELLED,
|
||||
'secrets' => null,
|
||||
]);
|
||||
$sibling->update([
|
||||
'status' => OperationStatus::CANCELLED,
|
||||
|
||||
@@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Application extends Model
|
||||
{
|
||||
@@ -15,6 +16,13 @@ class Application extends Model
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (Application $application): void {
|
||||
$application->uuid ??= (string) Str::uuid();
|
||||
});
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
|
||||
@@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Environment extends Model
|
||||
{
|
||||
@@ -15,6 +16,13 @@ class Environment extends Model
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (Environment $environment): void {
|
||||
$environment->uuid ??= (string) Str::uuid();
|
||||
});
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
|
||||
@@ -30,6 +30,7 @@ class Operation extends Model
|
||||
return [
|
||||
'kind' => OperationKind::class,
|
||||
'status' => OperationStatus::class,
|
||||
'metadata' => 'array',
|
||||
'started_at' => 'datetime',
|
||||
'finished_at' => 'datetime',
|
||||
];
|
||||
|
||||
@@ -13,6 +13,10 @@ class OperationStep extends Model
|
||||
{
|
||||
protected $guarded = [];
|
||||
|
||||
protected $hidden = [
|
||||
'secrets',
|
||||
];
|
||||
|
||||
protected $appends = [
|
||||
'logs_excerpt',
|
||||
'error_logs_excerpt',
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Models;
|
||||
use App\Enums\RegistryType;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class Registry extends Model
|
||||
{
|
||||
@@ -17,6 +18,9 @@ class Registry extends Model
|
||||
return [
|
||||
'type' => RegistryType::class,
|
||||
'credentials' => 'encrypted:array',
|
||||
'readiness_checks' => 'array',
|
||||
'health_checked_at' => 'datetime',
|
||||
'ready_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -24,4 +28,34 @@ class Registry extends Model
|
||||
{
|
||||
return $this->belongsTo(Organisation::class);
|
||||
}
|
||||
|
||||
public function controlServer(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Server::class, 'control_server_id');
|
||||
}
|
||||
|
||||
public function markHealthy(?string $message = null): void
|
||||
{
|
||||
$this->forceFill([
|
||||
'health_status' => 'healthy',
|
||||
'health_message' => $message,
|
||||
'health_checked_at' => Carbon::now(),
|
||||
'ready_at' => $this->ready_at ?? Carbon::now(),
|
||||
])->save();
|
||||
}
|
||||
|
||||
public function markUnhealthy(string $message): void
|
||||
{
|
||||
$this->forceFill([
|
||||
'health_status' => 'unhealthy',
|
||||
'health_message' => $message,
|
||||
'health_checked_at' => Carbon::now(),
|
||||
'ready_at' => null,
|
||||
])->save();
|
||||
}
|
||||
|
||||
public function isReady(): bool
|
||||
{
|
||||
return $this->type !== RegistryType::MANAGED || ($this->ready_at !== null && $this->health_status === 'healthy');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ class Server extends Model
|
||||
{
|
||||
return [
|
||||
'status' => ServerStatus::class,
|
||||
'is_control_node' => 'boolean',
|
||||
'build_enabled' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
54
app/Services/Registries/ImageReference.php
Normal file
54
app/Services/Registries/ImageReference.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Registries;
|
||||
|
||||
use App\Enums\RegistryType;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Registry;
|
||||
|
||||
class ImageReference
|
||||
{
|
||||
public function tagFor(Environment $environment, string $commitSha, ?Registry $registry = null): string
|
||||
{
|
||||
$tag = substr($commitSha, 0, 12);
|
||||
|
||||
if ($this->registryType($registry) === RegistryType::MANAGED) {
|
||||
$namespace = trim((string) config('keystone.managed_registry.namespace', 'keystone'), '/');
|
||||
$applicationId = $environment->application->uuid ?: 'app-'.$environment->application_id;
|
||||
$environmentId = $environment->uuid ?: 'env-'.$environment->id;
|
||||
$path = $namespace.'/'.$applicationId.'/'.$environmentId;
|
||||
|
||||
return $path.':'.$tag;
|
||||
}
|
||||
|
||||
return str($environment->application->name)
|
||||
->slug()
|
||||
->append(':'.$tag)
|
||||
->value();
|
||||
}
|
||||
|
||||
public function registryReference(Registry $registry, string $imageTag): string
|
||||
{
|
||||
return rtrim($this->registryHost((string) $registry->url), '/').'/'.ltrim($imageTag, '/');
|
||||
}
|
||||
|
||||
private function registryHost(string $url): string
|
||||
{
|
||||
$host = preg_replace('#^https?://#', '', trim($url));
|
||||
|
||||
return $host === null ? trim($url) : $host;
|
||||
}
|
||||
|
||||
private function registryType(?Registry $registry): ?RegistryType
|
||||
{
|
||||
if (! $registry instanceof Registry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($registry->type instanceof RegistryType) {
|
||||
return $registry->type;
|
||||
}
|
||||
|
||||
return RegistryType::tryFrom((string) $registry->type);
|
||||
}
|
||||
}
|
||||
128
app/Services/Registries/ManagedRegistryHealth.php
Normal file
128
app/Services/Registries/ManagedRegistryHealth.php
Normal file
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Registries;
|
||||
|
||||
use App\Enums\RegistryType;
|
||||
use App\Models\Registry;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class ManagedRegistryHealth
|
||||
{
|
||||
public function check(Registry $registry): bool
|
||||
{
|
||||
if ($registry->type !== RegistryType::MANAGED) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$message = $this->configurationBlocker($registry);
|
||||
|
||||
if ($message !== null) {
|
||||
$registry->markUnhealthy($message);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::timeout(5)->get('https://'.$registry->url.'/v2/');
|
||||
} catch (\Throwable $exception) {
|
||||
$registry->markUnhealthy('Registry URL is not reachable over HTTPS: '.$exception->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! in_array($response->status(), [200, 401], true)) {
|
||||
$registry->markUnhealthy('Registry HTTPS check returned HTTP '.$response->status().'.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$checks = $registry->readiness_checks ?? [];
|
||||
$checks['control_https'] = 'passed';
|
||||
|
||||
$registry->forceFill([
|
||||
'readiness_checks' => $checks,
|
||||
])->save();
|
||||
|
||||
if ($this->readinessChecksPassed($registry->refresh())) {
|
||||
$registry->markHealthy('Registry HTTPS endpoint and smoke checks passed.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$registry->markUnhealthy('Registry HTTPS endpoint is reachable, but smoke checks have not all passed.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function readinessBlocker(Registry $registry): ?string
|
||||
{
|
||||
if ($registry->type !== RegistryType::MANAGED) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$message = $this->configurationBlocker($registry);
|
||||
|
||||
if ($message !== null) {
|
||||
return $message;
|
||||
}
|
||||
|
||||
if ($registry->ready_at === null || $registry->health_status !== 'healthy') {
|
||||
return 'Managed registry has not passed readiness checks.';
|
||||
}
|
||||
|
||||
if (! $this->readinessChecksPassed($registry)) {
|
||||
return 'Managed registry smoke checks have not all passed.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function configurationBlocker(Registry $registry): ?string
|
||||
{
|
||||
if (! $registry->url) {
|
||||
return 'Managed registry URL is not configured.';
|
||||
}
|
||||
|
||||
if (! str_contains((string) $registry->url, '.')) {
|
||||
return 'Managed registry must use a resolvable HTTPS hostname.';
|
||||
}
|
||||
|
||||
$credentials = $registry->credentials ?? [];
|
||||
|
||||
foreach (['build_username', 'build_password', 'runtime_username', 'runtime_password'] as $key) {
|
||||
if (blank($credentials[$key] ?? null)) {
|
||||
return 'Managed registry credentials are incomplete.';
|
||||
}
|
||||
}
|
||||
|
||||
$controlServer = $registry->controlServer;
|
||||
|
||||
if (! $controlServer instanceof Server) {
|
||||
return 'A control/build server is required for managed registry builds.';
|
||||
}
|
||||
|
||||
if (! $controlServer->build_enabled) {
|
||||
return 'The managed registry control server is not build-enabled.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function readinessChecksPassed(Registry $registry): bool
|
||||
{
|
||||
$checks = $registry->readiness_checks ?? [];
|
||||
|
||||
if ($checks === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (['control_https', 'build_push'] as $requiredCheck) {
|
||||
if (! array_key_exists($requiredCheck, $checks)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return collect($checks)->every(fn (mixed $status): bool => $status === 'passed');
|
||||
}
|
||||
}
|
||||
189
app/Services/Registries/ManagedRegistryOperationScripts.php
Normal file
189
app/Services/Registries/ManagedRegistryOperationScripts.php
Normal file
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Registries;
|
||||
|
||||
use App\Models\BuildArtifact;
|
||||
use App\Models\Registry;
|
||||
use App\Models\Server;
|
||||
|
||||
class ManagedRegistryOperationScripts
|
||||
{
|
||||
/**
|
||||
* @return array{script: string, secrets: array<string, string>}
|
||||
*/
|
||||
public function provision(Registry $registry): array
|
||||
{
|
||||
$credentials = $registry->credentials ?? [];
|
||||
$host = $this->host($registry);
|
||||
$storagePath = $registry->storage_path ?: (string) config('keystone.managed_registry.storage_path');
|
||||
|
||||
return [
|
||||
'script' => implode("\n", [
|
||||
'set -euo pipefail',
|
||||
'storage_path='.escapeshellarg($storagePath),
|
||||
'registry_host='.escapeshellarg($host),
|
||||
'install -d -m 700 -o root -g root /home/keystone/registry/auth /home/keystone/registry/config',
|
||||
'install -d -m 755 -o root -g root "$storage_path"',
|
||||
'tmp_htpasswd=$(mktemp)',
|
||||
'cleanup() { rm -f "$tmp_htpasswd"; unset build_password runtime_password; }',
|
||||
'trap cleanup EXIT',
|
||||
'build_password=$(printf %s '.escapeshellarg('[!build_password_base64!]').' | base64 -d)',
|
||||
'runtime_password=$(printf %s '.escapeshellarg('[!runtime_password_base64!]').' | base64 -d)',
|
||||
'printf %s "$build_password" | docker run -i --rm --entrypoint htpasswd httpd:2.4-alpine -Bni '.escapeshellarg((string) ($credentials['build_username'] ?? 'keystone-build')).' > "$tmp_htpasswd"',
|
||||
'printf %s "$runtime_password" | docker run -i --rm --entrypoint htpasswd httpd:2.4-alpine -Bni '.escapeshellarg((string) ($credentials['runtime_username'] ?? 'keystone-runtime')).' >> "$tmp_htpasswd"',
|
||||
'install -m 600 -o root -g root "$tmp_htpasswd" /home/keystone/registry/auth/htpasswd',
|
||||
'cat > /home/keystone/registry/config/config.yml <<\'KEYSTONE_REGISTRY_CONFIG\'',
|
||||
'version: 0.1',
|
||||
'log:',
|
||||
' fields:',
|
||||
' service: registry',
|
||||
'storage:',
|
||||
' filesystem:',
|
||||
' rootdirectory: /var/lib/registry',
|
||||
' delete:',
|
||||
' enabled: true',
|
||||
'http:',
|
||||
' addr: :5000',
|
||||
'auth:',
|
||||
' htpasswd:',
|
||||
' realm: keystone-managed-registry',
|
||||
' path: /auth/htpasswd',
|
||||
'KEYSTONE_REGISTRY_CONFIG',
|
||||
'docker rm -f keystone-managed-registry >/dev/null 2>&1 || true',
|
||||
'docker run -d --name keystone-managed-registry --restart unless-stopped -p 127.0.0.1:5000:5000 -e REGISTRY_STORAGE_DELETE_ENABLED=true -v "$storage_path:/var/lib/registry" -v /home/keystone/registry/auth:/auth:ro -v /home/keystone/registry/config/config.yml:/etc/docker/registry/config.yml:ro registry:2',
|
||||
'install -d -m 755 /home/keystone/gateway/Caddyfile.d',
|
||||
'cat > /home/keystone/gateway/Caddyfile.d/managed-registry.caddy <<KEYSTONE_CADDY_REGISTRY',
|
||||
'$registry_host {',
|
||||
' reverse_proxy 127.0.0.1:5000',
|
||||
'}',
|
||||
'KEYSTONE_CADDY_REGISTRY',
|
||||
'if test -d /home/keystone/gateway/Caddyfile.d; then cat /home/keystone/gateway/Caddyfile.d/*.caddy > /home/keystone/gateway/Caddyfile; fi',
|
||||
'if docker ps --format \'{{.Names}}\' | grep -qx gateway-1; then docker exec gateway-1 caddy reload --config /etc/caddy/Caddyfile; fi',
|
||||
'if docker ps --format \'{{.Names}}\' | grep -qx caddy; then docker exec caddy caddy reload --config /etc/caddy/Caddyfile; fi',
|
||||
'if docker ps --format \'{{.Names}}\' | grep -Eqx \'(gateway-1|caddy)\'; then curl --fail --silent --show-error --location --head https://"$registry_host"/v2/ || test "$?" = "22"; else echo "Registry proxy reload skipped because no Caddy container is running."; fi',
|
||||
]),
|
||||
'secrets' => [
|
||||
'build_password_base64' => base64_encode((string) ($credentials['build_password'] ?? '')),
|
||||
'runtime_password_base64' => base64_encode((string) ($credentials['runtime_password'] ?? '')),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{script: string, secrets: array<string, string>}
|
||||
*/
|
||||
public function smokeCheck(Registry $registry, Server $server, string $scope, ?string $imageReference = null): array
|
||||
{
|
||||
$credentials = $registry->credentials ?? [];
|
||||
$username = (string) ($credentials[$scope.'_username'] ?? '');
|
||||
$password = (string) ($credentials[$scope.'_password'] ?? '');
|
||||
$host = $this->host($registry);
|
||||
$repository = $imageReference ?: $host.'/keystone/smoke/server-'.$server->id.':latest';
|
||||
|
||||
$commands = [
|
||||
'set -euo pipefail',
|
||||
'registry_host='.escapeshellarg($host),
|
||||
'image_ref='.escapeshellarg($repository),
|
||||
'username='.escapeshellarg($username),
|
||||
'password=$(printf %s '.escapeshellarg('[!registry_password_base64!]').' | base64 -d)',
|
||||
'curl --fail --silent --show-error --user "$username:$password" https://"$registry_host"/v2/ >/dev/null',
|
||||
'printf %s '.escapeshellarg('[!registry_password_base64!]').' | base64 -d | docker login "$registry_host" --username '.escapeshellarg($username).' --password-stdin >/dev/null',
|
||||
];
|
||||
|
||||
if ($scope === 'build') {
|
||||
$commands = [
|
||||
...$commands,
|
||||
'docker pull busybox:latest >/dev/null',
|
||||
'docker tag busybox:latest "$image_ref"',
|
||||
'docker push "$image_ref" >/dev/null',
|
||||
'docker buildx imagetools inspect "$image_ref" >/dev/null',
|
||||
'printf "smoke_ref=%s\n" "$image_ref"',
|
||||
'unset password',
|
||||
];
|
||||
} else {
|
||||
$commands = [
|
||||
...$commands,
|
||||
'docker pull "$image_ref" >/dev/null',
|
||||
'unset password',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'script' => implode("\n", $commands),
|
||||
'secrets' => [
|
||||
'registry_password_base64' => base64_encode($password),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable<int, BuildArtifact> $artifacts
|
||||
* @return array{script: string, secrets: array<string, string>}
|
||||
*/
|
||||
public function maintenance(Registry $registry, iterable $artifacts): array
|
||||
{
|
||||
$credentials = $registry->credentials ?? [];
|
||||
$host = $this->host($registry);
|
||||
$deletions = [];
|
||||
|
||||
foreach ($artifacts as $artifact) {
|
||||
$repository = $this->repositoryPath((string) $artifact->registry_ref);
|
||||
|
||||
if ($repository === '' || blank($artifact->image_digest)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$deletions[] = 'delete_manifest '.escapeshellarg($repository).' '.escapeshellarg((string) $artifact->image_digest).' || delete_failures=1';
|
||||
}
|
||||
|
||||
return [
|
||||
'script' => implode("\n", [
|
||||
'set -euo pipefail',
|
||||
'registry_host='.escapeshellarg($host),
|
||||
'lock_file=/home/keystone/registry/maintenance.lock',
|
||||
'exec 9>"$lock_file"',
|
||||
'flock -n 9',
|
||||
'username='.escapeshellarg((string) ($credentials['build_username'] ?? 'keystone-build')),
|
||||
'password=$(printf %s '.escapeshellarg('[!registry_password_base64!]').' | base64 -d)',
|
||||
'curl_config=$(mktemp)',
|
||||
'registry_was_stopped=0',
|
||||
'cleanup() { rm -f "$curl_config"; unset password auth_header; if test "$registry_was_stopped" = "1"; then docker start keystone-managed-registry >/dev/null 2>&1 || true; fi; }',
|
||||
'trap cleanup EXIT',
|
||||
'auth_header=$(printf "%s:%s" "$username" "$password" | base64 | tr -d "\n")',
|
||||
'printf "header = \"Authorization: Basic %s\"\n" "$auth_header" > "$curl_config"',
|
||||
'chmod 600 "$curl_config"',
|
||||
'delete_failures=0',
|
||||
'delete_manifest() {',
|
||||
' repository="$1"',
|
||||
' digest="$2"',
|
||||
' status=$(curl --silent --show-error --output /tmp/keystone-registry-delete-response --write-out "%{http_code}" --request DELETE --config "$curl_config" --header "Accept: application/vnd.docker.distribution.manifest.v2+json" "https://$registry_host/v2/$repository/manifests/$digest" || true)',
|
||||
' case "$status" in 2*|404) printf "deleted_manifest=%s@%s status=%s\n" "$repository" "$digest" "$status" ;; *) cat /tmp/keystone-registry-delete-response >&2; printf "delete_failed=%s@%s status=%s\n" "$repository" "$digest" "$status" >&2; return 1 ;; esac',
|
||||
'}',
|
||||
...$deletions,
|
||||
'test "$delete_failures" = "0"',
|
||||
'docker stop keystone-managed-registry',
|
||||
'registry_was_stopped=1',
|
||||
'docker run --rm -v /home/keystone/registry/config/config.yml:/etc/docker/registry/config.yml:ro -v '.escapeshellarg(($registry->storage_path ?: (string) config('keystone.managed_registry.storage_path')).':/var/lib/registry').' registry:2 garbage-collect --delete-untagged /etc/docker/registry/config.yml',
|
||||
'docker start keystone-managed-registry',
|
||||
'registry_was_stopped=0',
|
||||
'unset password',
|
||||
]),
|
||||
'secrets' => [
|
||||
'registry_password_base64' => base64_encode((string) ($credentials['build_password'] ?? '')),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function host(Registry $registry): string
|
||||
{
|
||||
return rtrim((string) preg_replace('#^https?://#', '', (string) $registry->url), '/');
|
||||
}
|
||||
|
||||
private function repositoryPath(string $reference): string
|
||||
{
|
||||
$withoutHost = str($reference)->after('/')->value();
|
||||
$withoutTag = preg_replace('/:[^:\/]+$/', '', $withoutHost);
|
||||
|
||||
return trim((string) $withoutTag, '/');
|
||||
}
|
||||
}
|
||||
64
app/Services/Registries/ManagedRegistryProvisioner.php
Normal file
64
app/Services/Registries/ManagedRegistryProvisioner.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Registries;
|
||||
|
||||
use App\Enums\RegistryType;
|
||||
use App\Models\Organisation;
|
||||
use App\Models\Registry;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ManagedRegistryProvisioner
|
||||
{
|
||||
public function provision(Organisation $organisation, string $url, ?Server $controlServer = null, ?string $storagePath = null, ?int $retention = null): Registry
|
||||
{
|
||||
$registry = $organisation->registries()->firstOrNew([
|
||||
'type' => RegistryType::MANAGED->value,
|
||||
]);
|
||||
|
||||
$registry->fill([
|
||||
'name' => 'Managed Registry',
|
||||
'url' => $this->registryHost($url),
|
||||
'storage_path' => $storagePath ?: (string) config('keystone.managed_registry.storage_path'),
|
||||
'retention_successful_artifacts' => $retention ?: (int) config('keystone.managed_registry.retention.successful_artifacts_per_environment', 3),
|
||||
'control_server_id' => $controlServer?->id,
|
||||
'credentials' => $this->credentials($registry->credentials ?? []),
|
||||
]);
|
||||
|
||||
if ($registry->health_status === null) {
|
||||
$registry->health_status = 'pending';
|
||||
}
|
||||
|
||||
$registry->save();
|
||||
|
||||
if ($controlServer instanceof Server) {
|
||||
$controlServer->forceFill([
|
||||
'is_control_node' => true,
|
||||
'build_enabled' => true,
|
||||
])->save();
|
||||
}
|
||||
|
||||
return $registry->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $existing
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function credentials(array $existing): array
|
||||
{
|
||||
return [
|
||||
'build_username' => (string) ($existing['build_username'] ?? 'keystone-build'),
|
||||
'build_password' => (string) ($existing['build_password'] ?? Str::password(40)),
|
||||
'runtime_username' => (string) ($existing['runtime_username'] ?? 'keystone-runtime'),
|
||||
'runtime_password' => (string) ($existing['runtime_password'] ?? Str::password(40)),
|
||||
];
|
||||
}
|
||||
|
||||
private function registryHost(string $url): string
|
||||
{
|
||||
$host = preg_replace('#^https?://#', '', trim($url));
|
||||
|
||||
return rtrim($host === null ? trim($url) : $host, '/');
|
||||
}
|
||||
}
|
||||
80
app/Services/Registries/ManagedRegistryRetention.php
Normal file
80
app/Services/Registries/ManagedRegistryRetention.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Registries;
|
||||
|
||||
use App\Enums\BuildArtifactStatus;
|
||||
use App\Enums\RegistryType;
|
||||
use App\Models\BuildArtifact;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Registry;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class ManagedRegistryRetention
|
||||
{
|
||||
/**
|
||||
* @return Collection<int, BuildArtifact>
|
||||
*/
|
||||
public function markPrunable(Registry $registry): Collection
|
||||
{
|
||||
if ($registry->type !== RegistryType::MANAGED) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$keep = max(1, (int) $registry->retention_successful_artifacts);
|
||||
$updated = collect();
|
||||
|
||||
Environment::query()
|
||||
->whereHas('buildArtifacts', fn ($query) => $query
|
||||
->where('status', BuildArtifactStatus::AVAILABLE)
|
||||
->where('registry_ref', 'like', rtrim((string) $registry->url, '/').'/%'))
|
||||
->with(['services', 'buildArtifacts' => fn ($query) => $query
|
||||
->where('status', BuildArtifactStatus::AVAILABLE)
|
||||
->where('registry_ref', 'like', rtrim((string) $registry->url, '/').'/%')
|
||||
->latest()])
|
||||
->each(function (Environment $environment) use ($keep, $updated): void {
|
||||
$activeDigests = $environment->services
|
||||
->flatMap(fn ($service): array => [
|
||||
$service->available_image_digest,
|
||||
$service->current_image_digest,
|
||||
])
|
||||
->filter()
|
||||
->all();
|
||||
|
||||
$environment->buildArtifacts
|
||||
->skip($keep)
|
||||
->filter(fn (BuildArtifact $artifact): bool => ! in_array($artifact->image_digest, $activeDigests, true))
|
||||
->each(function (BuildArtifact $artifact) use ($updated): void {
|
||||
$metadata = $artifact->metadata ?? [];
|
||||
$artifact->update([
|
||||
'status' => BuildArtifactStatus::PRUNABLE,
|
||||
'metadata' => [
|
||||
...$metadata,
|
||||
'prunable_at' => now()->toIso8601String(),
|
||||
'prune_command' => $this->deleteManifestCommand($artifact),
|
||||
],
|
||||
]);
|
||||
$updated->push($artifact->refresh());
|
||||
});
|
||||
});
|
||||
|
||||
return $updated;
|
||||
}
|
||||
|
||||
public function deleteManifestCommand(BuildArtifact $artifact): string
|
||||
{
|
||||
$reference = (string) ($artifact->registry_ref ?? '');
|
||||
$digest = (string) $artifact->image_digest;
|
||||
|
||||
return 'curl --fail --silent --show-error --request DELETE '.escapeshellarg('https://'.$this->manifestPath($reference, $digest));
|
||||
}
|
||||
|
||||
private function manifestPath(string $reference, string $digest): string
|
||||
{
|
||||
$hostAndPath = preg_replace('/:[^:\/]+$/', '', $reference);
|
||||
$path = str($hostAndPath)->after('/')->value();
|
||||
|
||||
return $path === ''
|
||||
? 'v2/'
|
||||
: str($reference)->before('/')->value().'/v2/'.$path.'/manifests/'.$digest;
|
||||
}
|
||||
}
|
||||
50
app/Services/Registries/RegistryDockerAuthScript.php
Normal file
50
app/Services/Registries/RegistryDockerAuthScript.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Registries;
|
||||
|
||||
use App\Models\Registry;
|
||||
|
||||
class RegistryDockerAuthScript
|
||||
{
|
||||
/**
|
||||
* @return array{script: string, secrets: array<string, string>}
|
||||
*/
|
||||
public function forBuild(Registry $registry, string $user = 'keystone'): array
|
||||
{
|
||||
return $this->forCredential($registry, 'build', $user);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{script: string, secrets: array<string, string>}
|
||||
*/
|
||||
public function forRuntime(Registry $registry, string $user = 'keystone'): array
|
||||
{
|
||||
return $this->forCredential($registry, 'runtime', $user);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{script: string, secrets: array<string, string>}
|
||||
*/
|
||||
private function forCredential(Registry $registry, string $scope, string $user): array
|
||||
{
|
||||
$credentials = $registry->credentials ?? [];
|
||||
$username = (string) ($credentials[$scope.'_username'] ?? $credentials['username'] ?? '');
|
||||
$password = (string) ($credentials[$scope.'_password'] ?? $credentials['password'] ?? '');
|
||||
$home = $user === 'root' ? '/root' : '/home/'.$user;
|
||||
$registryHost = rtrim((string) preg_replace('#^https?://#', '', (string) $registry->url), '/');
|
||||
|
||||
return [
|
||||
'script' => implode("\n", [
|
||||
'set -euo pipefail',
|
||||
'install -d -m 700 -o '.escapeshellarg($user).' -g '.escapeshellarg($user).' '.escapeshellarg($home.'/.docker'),
|
||||
'export DOCKER_CONFIG='.escapeshellarg($home.'/.docker'),
|
||||
'printf %s '.escapeshellarg('[!registry_password_base64!]').' | base64 -d | docker login '.escapeshellarg($registryHost).' --username '.escapeshellarg($username).' --password-stdin >/dev/null',
|
||||
'chown '.escapeshellarg($user.':'.$user).' '.escapeshellarg($home.'/.docker/config.json'),
|
||||
'chmod 600 '.escapeshellarg($home.'/.docker/config.json'),
|
||||
]),
|
||||
'secrets' => [
|
||||
'registry_password_base64' => base64_encode($password),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
47
app/Services/Registries/RegistryResolver.php
Normal file
47
app/Services/Registries/RegistryResolver.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Registries;
|
||||
|
||||
use App\Enums\RegistryType;
|
||||
use App\Models\Organisation;
|
||||
use App\Models\Registry;
|
||||
|
||||
class RegistryResolver
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ManagedRegistryHealth $managedRegistryHealth,
|
||||
) {}
|
||||
|
||||
public function buildRegistryFor(Organisation $organisation): ?Registry
|
||||
{
|
||||
$externalRegistry = $organisation->registries()
|
||||
->where('type', '!=', RegistryType::MANAGED->value)
|
||||
->first();
|
||||
|
||||
if ($externalRegistry instanceof Registry) {
|
||||
return $externalRegistry;
|
||||
}
|
||||
|
||||
$managedRegistry = $organisation->registries()
|
||||
->where('type', RegistryType::MANAGED->value)
|
||||
->first();
|
||||
|
||||
if ($managedRegistry instanceof Registry) {
|
||||
return $this->managedRegistryHealth->readinessBlocker($managedRegistry) === null ? $managedRegistry : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function managedRegistryBlockerFor(Organisation $organisation): ?string
|
||||
{
|
||||
$managedRegistry = $organisation->registries()
|
||||
->where('type', RegistryType::MANAGED->value)
|
||||
->with('controlServer')
|
||||
->first();
|
||||
|
||||
return $managedRegistry instanceof Registry
|
||||
? $this->managedRegistryHealth->readinessBlocker($managedRegistry)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,15 @@ use App\Enums\ServiceCategory;
|
||||
use App\Enums\ServiceType;
|
||||
|
||||
return [
|
||||
'managed_registry' => [
|
||||
'url' => env('KEYSTONE_MANAGED_REGISTRY_URL'),
|
||||
'namespace' => env('KEYSTONE_MANAGED_REGISTRY_NAMESPACE', 'keystone'),
|
||||
'storage_path' => env('KEYSTONE_MANAGED_REGISTRY_STORAGE_PATH', '/home/keystone/registry/data'),
|
||||
'retention' => [
|
||||
'successful_artifacts_per_environment' => (int) env('KEYSTONE_MANAGED_REGISTRY_RETAIN_SUCCESSFUL_ARTIFACTS', 3),
|
||||
],
|
||||
],
|
||||
|
||||
'drivers' => [
|
||||
'postgres' => [
|
||||
'18' => Postgres18Driver::class,
|
||||
|
||||
@@ -28,6 +28,8 @@ class ServerFactory extends Factory
|
||||
'os' => 'ubuntu',
|
||||
'plan' => '26',
|
||||
'user' => 'keystone',
|
||||
'is_control_node' => false,
|
||||
'build_enabled' => false,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('registries', function (Blueprint $table) {
|
||||
$table->string('storage_path')->nullable()->after('url');
|
||||
$table->unsignedInteger('retention_successful_artifacts')->default(3)->after('storage_path');
|
||||
$table->string('health_status')->default('pending')->after('retention_successful_artifacts');
|
||||
$table->text('health_message')->nullable()->after('health_status');
|
||||
$table->json('readiness_checks')->nullable()->after('health_message');
|
||||
$table->timestamp('health_checked_at')->nullable()->after('readiness_checks');
|
||||
$table->timestamp('ready_at')->nullable()->after('health_checked_at');
|
||||
$table->foreignId('control_server_id')->nullable()->after('ready_at')->constrained('servers')->nullOnDelete();
|
||||
});
|
||||
|
||||
Schema::table('servers', function (Blueprint $table) {
|
||||
$table->boolean('is_control_node')->default(false)->after('user');
|
||||
$table->boolean('build_enabled')->default(false)->after('is_control_node');
|
||||
});
|
||||
|
||||
Schema::table('operations', function (Blueprint $table) {
|
||||
$table->json('metadata')->nullable()->after('status');
|
||||
});
|
||||
|
||||
Schema::table('applications', function (Blueprint $table) {
|
||||
$table->uuid('uuid')->nullable()->after('id');
|
||||
});
|
||||
|
||||
Schema::table('environments', function (Blueprint $table) {
|
||||
$table->uuid('uuid')->nullable()->after('id');
|
||||
});
|
||||
|
||||
DB::table('applications')->whereNull('uuid')->orderBy('id')->each(function (object $application): void {
|
||||
DB::table('applications')->where('id', $application->id)->update(['uuid' => (string) Str::uuid()]);
|
||||
});
|
||||
|
||||
DB::table('environments')->whereNull('uuid')->orderBy('id')->each(function (object $environment): void {
|
||||
DB::table('environments')->where('id', $environment->id)->update(['uuid' => (string) Str::uuid()]);
|
||||
});
|
||||
|
||||
Schema::table('applications', function (Blueprint $table) {
|
||||
$table->unique('uuid');
|
||||
});
|
||||
|
||||
Schema::table('environments', function (Blueprint $table) {
|
||||
$table->unique('uuid');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('environments', function (Blueprint $table) {
|
||||
$table->dropUnique(['uuid']);
|
||||
$table->dropColumn('uuid');
|
||||
});
|
||||
|
||||
Schema::table('applications', function (Blueprint $table) {
|
||||
$table->dropUnique(['uuid']);
|
||||
$table->dropColumn('uuid');
|
||||
});
|
||||
|
||||
Schema::table('servers', function (Blueprint $table) {
|
||||
$table->dropColumn(['is_control_node', 'build_enabled']);
|
||||
});
|
||||
|
||||
Schema::table('operations', function (Blueprint $table) {
|
||||
$table->dropColumn('metadata');
|
||||
});
|
||||
|
||||
Schema::table('registries', function (Blueprint $table) {
|
||||
$table->dropConstrainedForeignId('control_server_id');
|
||||
$table->dropColumn([
|
||||
'storage_path',
|
||||
'retention_successful_artifacts',
|
||||
'health_status',
|
||||
'health_message',
|
||||
'readiness_checks',
|
||||
'health_checked_at',
|
||||
'ready_at',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,16 @@
|
||||
---
|
||||
status: in-progress
|
||||
implemented_slice: app-side-operation-generation-maintenance-readiness
|
||||
---
|
||||
|
||||
# Managed Registry Plan
|
||||
|
||||
## Implementation Status
|
||||
|
||||
Keystone now has app-side managed registry provisioning, auth, smoke-check, and pruning operations. The generated remote scripts install and run `registry:2` with local storage, htpasswd auth, deletion enabled, Caddy HTTPS proxy snippets, Docker auth setup, push/pull smoke checks, manifest deletion, registry garbage collection, and artifact pruning status transitions.
|
||||
|
||||
This document remains `in-progress` until the install flow runs these operations as part of first-run Keystone setup and the generated remote scripts have been validated against the supported production host layouts.
|
||||
|
||||
Keystone should be self-hosted first. A fresh install should include a working build and image pipeline without requiring the user to bring an external Docker registry, S3 bucket, or separate build server.
|
||||
|
||||
## Product Principles
|
||||
@@ -8,6 +19,7 @@ Keystone should be self-hosted first. A fresh install should include a working b
|
||||
- Keystone provides a first-party managed Docker registry by default.
|
||||
- The managed registry stores images on local disk first.
|
||||
- The registry storage path must be configurable for mounted VPS volumes.
|
||||
- Multi-server deployments using the managed registry require an HTTPS registry URL trusted by every build and runtime node.
|
||||
- External registries, S3-backed storage, and dedicated build nodes are optional advanced features.
|
||||
- Multi-server deployments should work out of the box after Keystone is installed.
|
||||
- Registry credentials must not be persisted in operation scripts, logs, or UI-visible output.
|
||||
@@ -38,10 +50,11 @@ Default settings:
|
||||
```text
|
||||
Build node: Keystone control node
|
||||
Registry: registry:2 managed by Keystone
|
||||
Registry URL: install-provided HTTPS hostname, for example registry.example.com
|
||||
Registry storage driver: local
|
||||
Registry storage path: /home/keystone/registry/data
|
||||
Image retention: latest 3 successful artifacts per environment
|
||||
Auth: generated htpasswd credentials managed by Keystone
|
||||
Auth: generated htpasswd build and runtime credentials managed by Keystone
|
||||
```
|
||||
|
||||
The install flow should allow overriding the storage path, for example:
|
||||
@@ -52,6 +65,8 @@ The install flow should allow overriding the storage path, for example:
|
||||
|
||||
This lets users place registry image data on a mounted VPS volume while keeping Keystone's default behavior simple.
|
||||
|
||||
The install flow should also require a registry hostname for normal multi-server managed-registry use. Keystone should configure that hostname with HTTPS, usually through the control node's web proxy. A plain HTTP `host:5000` registry should only be available as an explicit local development or advanced fallback because it requires insecure-registry configuration on every Docker daemon that builds or pulls images.
|
||||
|
||||
## Default Image Flow
|
||||
|
||||
```text
|
||||
@@ -78,25 +93,62 @@ If Keystone later supports HA control planes, the control node concept should be
|
||||
- The default build node.
|
||||
- Runtime nodes used for deployed applications.
|
||||
|
||||
## Registry Exposure
|
||||
## Image References
|
||||
|
||||
The managed registry should be exposed over HTTPS where possible, ideally behind the control node's web proxy, for example:
|
||||
Managed registry image names should be stable and collision-resistant. Use IDs in the repository path so renaming an application or environment does not move the image repository.
|
||||
|
||||
Default tag format:
|
||||
|
||||
```text
|
||||
registry.example.com/keystone/{application_uuid}/{environment_uuid}:{git_sha}
|
||||
```
|
||||
|
||||
Deployment reference format:
|
||||
|
||||
```text
|
||||
registry.example.com/keystone/{application_uuid}/{environment_uuid}@sha256:...
|
||||
```
|
||||
|
||||
Each successful build artifact should store:
|
||||
|
||||
- The registry host.
|
||||
- The full pushed tag.
|
||||
- The registry manifest digest.
|
||||
- The application and environment IDs.
|
||||
- The source commit SHA.
|
||||
|
||||
Deployments should consume the stored digest reference. Tags are useful for humans and registry lookup, but deployments should not depend on mutable tags such as `latest`.
|
||||
|
||||
## Registry URL And TLS
|
||||
|
||||
The managed registry must be exposed over HTTPS for the normal multi-server path, ideally behind the control node's web proxy, for example:
|
||||
|
||||
```text
|
||||
registry.example.com
|
||||
```
|
||||
|
||||
Avoid defaulting to a plain `host:5000` registry if possible. Plain HTTP registries require Docker daemon insecure-registry configuration on every build and target server, which adds onboarding friction.
|
||||
The install flow should ask for the registry hostname and configure TLS before marking the managed registry ready. Target servers and build nodes must be able to resolve the hostname and trust the certificate before they can build, push, or deploy images.
|
||||
|
||||
Avoid defaulting to a plain `host:5000` registry. Plain HTTP registries require Docker daemon insecure-registry configuration on every build and target server, which adds onboarding friction and weakens the default security posture. If Keystone supports this fallback, it should be clearly labelled as local development or advanced use.
|
||||
|
||||
Target servers must be able to reach the registry URL before they can deploy images built by Keystone.
|
||||
|
||||
Managed registry health checks should verify:
|
||||
|
||||
- The registry service is running.
|
||||
- The registry URL is reachable over HTTPS from the control node.
|
||||
- The registry URL is reachable over HTTPS from the selected build node.
|
||||
- The registry URL is reachable over HTTPS from target runtime servers.
|
||||
- Build credentials can log in and push a small test manifest or image.
|
||||
- Runtime credentials can log in and pull the pushed test artifact.
|
||||
|
||||
## Authentication
|
||||
|
||||
Use `registry:2` htpasswd authentication for the first version.
|
||||
|
||||
Keystone should:
|
||||
|
||||
- Generate registry credentials.
|
||||
- Generate separate build and runtime registry credentials.
|
||||
- Write the registry htpasswd file during provisioning.
|
||||
- Store credentials encrypted.
|
||||
- Configure build and target servers for registry access.
|
||||
@@ -104,12 +156,22 @@ Keystone should:
|
||||
|
||||
Do not inline registry passwords into persisted operation scripts. Operation steps are stored and may be visible in the UI or logs.
|
||||
|
||||
The build node should receive build credentials. Runtime target servers should receive runtime credentials. With `registry:2` htpasswd authentication alone, these credentials are not truly push- or pull-scoped; any authenticated registry user can push and pull. The separation is still useful for rotation, auditing, and limiting which credential is distributed to each machine, but Keystone should not present runtime credentials as read-only until it adds token auth or another authorization layer.
|
||||
|
||||
When Keystone configures Docker auth on a server, it should do so idempotently and with explicit ownership. For the default `keystone` user model, registry auth should live in that user's Docker config:
|
||||
|
||||
```text
|
||||
/home/keystone/.docker/config.json
|
||||
```
|
||||
|
||||
The file should be owned by `keystone:keystone` and readable only by that user where possible. If a root-owned Docker context is required for a specific operation, Keystone should write the equivalent root-owned config intentionally rather than relying on whichever user happened to run `docker login`.
|
||||
|
||||
Preferred approaches:
|
||||
|
||||
- Configure Docker auth on each server through a separate secure action.
|
||||
- Or write root-owned / user-owned credential files on the server and have deployment scripts read from those files.
|
||||
|
||||
Token auth can be considered later if Keystone needs per-repository or per-server scoped credentials. It should not be part of the first implementation.
|
||||
Token auth can be considered later if Keystone needs per-repository, per-server, or true push/pull scoped credentials. It should not be part of the first implementation.
|
||||
|
||||
## Build Planning
|
||||
|
||||
@@ -146,13 +208,21 @@ The default build execution should:
|
||||
7. Push the image.
|
||||
8. Resolve and store the registry manifest digest.
|
||||
|
||||
Control-node builds should have guardrails so the default path does not destabilize Keystone itself:
|
||||
|
||||
- Limit concurrent builds on the control node, defaulting to one at a time.
|
||||
- Check available disk before cloning, building, and pushing.
|
||||
- Remove temporary clone/build directories after each build.
|
||||
- Prune local build images and intermediate layers separately from registry artifact retention.
|
||||
- Surface disk pressure as a build-node health problem before accepting more builds.
|
||||
|
||||
Example flow:
|
||||
|
||||
```bash
|
||||
docker login registry.example.com --username keystone --password-stdin
|
||||
docker build --file Dockerfile.keystone --tag registry.example.com/application:aaaaaaaaaaaa .
|
||||
docker push registry.example.com/application:aaaaaaaaaaaa
|
||||
docker manifest inspect registry.example.com/application:aaaaaaaaaaaa
|
||||
docker login registry.example.com --username keystone-build --password-stdin
|
||||
docker build --file Dockerfile.keystone --tag registry.example.com/keystone/app-uuid/env-uuid:aaaaaaaaaaaa .
|
||||
docker push registry.example.com/keystone/app-uuid/env-uuid:aaaaaaaaaaaa
|
||||
docker manifest inspect registry.example.com/keystone/app-uuid/env-uuid:aaaaaaaaaaaa
|
||||
```
|
||||
|
||||
The stored digest must be the registry manifest digest, not a local image ID. Digest-based pulls and registry manifest deletion depend on this being correct.
|
||||
@@ -173,7 +243,7 @@ Deploy execution should:
|
||||
Example pull reference:
|
||||
|
||||
```text
|
||||
registry.example.com/application@sha256:...
|
||||
registry.example.com/keystone/app-uuid/env-uuid@sha256:...
|
||||
```
|
||||
|
||||
Compose should use the full registry reference, not only `sha256:...`.
|
||||
@@ -207,7 +277,7 @@ Pruning should remove old registry manifests first, then run registry garbage co
|
||||
REGISTRY_STORAGE_DELETE_ENABLED=true
|
||||
```
|
||||
|
||||
Garbage collection is safest when the registry is not accepting writes. The first implementation should run cleanup during a controlled maintenance window, using a lock so pruning does not race with active builds or pushes.
|
||||
Garbage collection is safest when the registry is not accepting writes. The first implementation should treat manifest deletion and blob garbage collection as separate steps: delete old manifests under the normal retention policy, then run blob garbage collection only during a controlled maintenance window, using a lock so pruning does not race with active builds or pushes.
|
||||
|
||||
Suggested cleanup flow:
|
||||
|
||||
@@ -227,7 +297,7 @@ These should be optional settings, not onboarding requirements:
|
||||
- Dedicated build nodes.
|
||||
- S3-compatible registry storage.
|
||||
- External registries such as GHCR, Gitea, Docker Hub, or generic registries.
|
||||
- Separate push and pull credentials.
|
||||
- True push- and pull-scoped credentials.
|
||||
- Credential rotation.
|
||||
- Per-server or per-repository scoped auth.
|
||||
- Configurable retention per application or environment.
|
||||
|
||||
@@ -43,5 +43,7 @@
|
||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||
<env name="SESSION_DRIVER" value="array"/>
|
||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||
<env name="KEYSTONE_MANAGED_REGISTRY_URL" value=""/>
|
||||
<env name="KEYSTONE_MANAGED_REGISTRY_NAMESPACE" value="keystone"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
|
||||
@@ -10,4 +10,7 @@ export default {
|
||||
GATEWAY_CUTOVER: "gateway_cutover",
|
||||
CONFIG_CHANGE: "config_change",
|
||||
CREDENTIAL_ROTATION: "credential_rotation",
|
||||
REGISTRY_PROVISION: "registry_provision",
|
||||
REGISTRY_HEALTH_CHECK: "registry_health_check",
|
||||
REGISTRY_MAINTENANCE: "registry_maintenance",
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// This is a generated file.
|
||||
|
||||
export default {
|
||||
MANAGED: "managed",
|
||||
GENERIC: "generic",
|
||||
GITEA: "gitea",
|
||||
GHCR: "ghcr",
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
Artisan::command('inspire', function () {
|
||||
$this->comment(Inspiring::quote());
|
||||
})->purpose('Display an inspiring quote');
|
||||
|
||||
Schedule::command('keystone:managed-registry:prune --dispatch')
|
||||
->daily()
|
||||
->withoutOverlapping();
|
||||
|
||||
@@ -5,6 +5,7 @@ use App\Actions\Applications\GenerateDeployKey;
|
||||
use App\Actions\Environments\BuildApplicationArtifact;
|
||||
use App\Actions\Environments\PlanBuildArtifact;
|
||||
use App\Enums\BuildArtifactStatus;
|
||||
use App\Enums\BuildStrategy;
|
||||
use App\Enums\RegistryType;
|
||||
use App\Models\Application;
|
||||
use App\Models\Network;
|
||||
@@ -23,7 +24,7 @@ beforeEach(function () {
|
||||
{
|
||||
$this->scripts[] = $script;
|
||||
|
||||
return str_contains($script, 'docker manifest inspect')
|
||||
return str_contains($script, 'docker buildx imagetools inspect') || str_contains($script, 'push_output=$(docker push')
|
||||
? "image_digest=sha256:registrydigest\n"
|
||||
: "image_digest=billing-api:aaaaaaaaaaaa@sha256:localdigest\n";
|
||||
}
|
||||
@@ -80,13 +81,64 @@ it('resolves external registry artifacts without building locally', function ()
|
||||
|
||||
expect($built->registry_ref)->toBe('ghcr.io/example/billing-api:bbbbbbbbbbbb')
|
||||
->and($built->image_digest)->toBe('sha256:registrydigest')
|
||||
->and($this->remoteRunner->scripts[0])->toContain('docker manifest inspect')
|
||||
->and($this->remoteRunner->scripts[0])->toContain('docker buildx imagetools inspect')
|
||||
->and($this->remoteRunner->scripts[0])->toContain('ghcr.io/example/billing-api:bbbbbbbbbbbb')
|
||||
->and($this->remoteRunner->scripts[0])->not->toContain('docker build')
|
||||
->and($this->remoteRunner->scripts[0])->not->toContain('docker build --file')
|
||||
->and($this->remoteRunner->scripts[0])->not->toContain('git clone');
|
||||
});
|
||||
|
||||
function buildServerFor(Organisation $organisation): Server
|
||||
it('builds and pushes managed registry artifacts without embedding registry credentials', function () {
|
||||
$organisation = Organisation::factory()->create();
|
||||
$server = buildServerFor($organisation, true);
|
||||
$organisation->registries()->create([
|
||||
'name' => 'Managed',
|
||||
'type' => RegistryType::MANAGED,
|
||||
'url' => 'registry.example.com',
|
||||
'credentials' => [
|
||||
'build_username' => 'keystone-build',
|
||||
'build_password' => 'super-secret-password',
|
||||
'runtime_username' => 'keystone-runtime',
|
||||
'runtime_password' => 'runtime-secret',
|
||||
],
|
||||
'control_server_id' => $server->id,
|
||||
'health_status' => 'healthy',
|
||||
'readiness_checks' => ['control_https' => 'passed', 'build_push' => 'passed'],
|
||||
'ready_at' => now(),
|
||||
]);
|
||||
$application = Application::factory()->for($organisation)->create([
|
||||
'name' => 'Billing API',
|
||||
'repository_url' => 'git@example.com:org/repo.git',
|
||||
]);
|
||||
app(GenerateDeployKey::class)->execute($application, [
|
||||
'public' => 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestPublicKey keystone',
|
||||
'private' => "-----BEGIN OPENSSH PRIVATE KEY-----\ntest\n-----END OPENSSH PRIVATE KEY-----",
|
||||
'fingerprint' => 'SHA256:test',
|
||||
]);
|
||||
$environment = app(CreateLaravelEnvironment::class)->execute($application->refresh(), 'production');
|
||||
$environment->services()->first()->update([
|
||||
'server_id' => $server->id,
|
||||
'desired_replicas' => 2,
|
||||
]);
|
||||
$artifact = app(PlanBuildArtifact::class)->execute($environment, str_repeat('c', 40));
|
||||
|
||||
$built = app(BuildApplicationArtifact::class)->execute($artifact);
|
||||
|
||||
expect($built->registry_ref)->toBe("registry.example.com/keystone/{$application->uuid}/{$environment->uuid}:cccccccccccc")
|
||||
->and($built->metadata['build_strategy'])->toBe(BuildStrategy::DEDICATED_BUILDER->value)
|
||||
->and($built->image_digest)->toBe('sha256:registrydigest')
|
||||
->and($this->remoteRunner->scripts[0])->toContain('flock 9')
|
||||
->and($this->remoteRunner->scripts[0])->toContain('docker login')
|
||||
->and($this->remoteRunner->scripts[0])->toContain(base64_encode('super-secret-password'))
|
||||
->and($this->remoteRunner->scripts[0])->toContain('docker build --file Dockerfile.keystone')
|
||||
->and($this->remoteRunner->scripts[0])->toContain('push_output=$(docker push')
|
||||
->and($this->remoteRunner->scripts[0])->toContain('digest: \\(sha256:')
|
||||
->and($this->remoteRunner->scripts[0])->not->toContain('docker manifest inspect')
|
||||
->and($this->remoteRunner->scripts[0])->not->toContain('"digest"')
|
||||
->and($this->remoteRunner->scripts[0])->not->toContain('super-secret-password')
|
||||
->and($this->remoteRunner->scripts[0])->toContain('DOCKER_CONFIG=\'/root/.docker\'');
|
||||
});
|
||||
|
||||
function buildServerFor(Organisation $organisation, bool $buildEnabled = false): Server
|
||||
{
|
||||
$provider = Provider::factory()->forOrganisation($organisation)->create();
|
||||
$network = Network::create([
|
||||
@@ -100,5 +152,8 @@ function buildServerFor(Organisation $organisation): Server
|
||||
->forOrganisation($organisation->id)
|
||||
->forProvider($provider->id)
|
||||
->forNetwork($network->id)
|
||||
->create();
|
||||
->create([
|
||||
'is_control_node' => $buildEnabled,
|
||||
'build_enabled' => $buildEnabled,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,10 @@ use App\Enums\ServiceCategory;
|
||||
use App\Enums\ServiceType;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Network;
|
||||
use App\Models\Organisation;
|
||||
use App\Models\Provider;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
|
||||
it('plans single-server builds on the target server without requiring a registry', function () {
|
||||
@@ -36,6 +39,8 @@ it('plans single-server builds on the target server without requiring a registry
|
||||
});
|
||||
|
||||
it('requires a registry before planning multi-server builds', function () {
|
||||
config(['keystone.managed_registry.url' => null]);
|
||||
|
||||
$organisation = Organisation::factory()->create();
|
||||
$application = Application::factory()->for($organisation)->create();
|
||||
$environment = Environment::factory()->for($application)->create();
|
||||
@@ -54,6 +59,100 @@ it('requires a registry before planning multi-server builds', function () {
|
||||
->toThrow(RuntimeException::class, 'A registry is required before building artifacts for multi-server deployments.');
|
||||
});
|
||||
|
||||
it('plans multi-server builds against a persisted managed registry', function () {
|
||||
$organisation = Organisation::factory()->create();
|
||||
$server = buildEnabledServerFor($organisation);
|
||||
$organisation->registries()->create([
|
||||
'name' => 'Managed',
|
||||
'type' => RegistryType::MANAGED,
|
||||
'url' => 'registry.example.com',
|
||||
'credentials' => [
|
||||
'build_username' => 'keystone-build',
|
||||
'build_password' => 'secret',
|
||||
'runtime_username' => 'keystone-runtime',
|
||||
'runtime_password' => 'runtime-secret',
|
||||
],
|
||||
'control_server_id' => $server->id,
|
||||
'health_status' => 'healthy',
|
||||
'readiness_checks' => ['control_https' => 'passed', 'build_push' => 'passed'],
|
||||
'ready_at' => now(),
|
||||
]);
|
||||
$application = Application::factory()->for($organisation)->create(['name' => 'Billing API']);
|
||||
$environment = Environment::factory()->for($application)->create();
|
||||
Service::factory()->for($environment)->create([
|
||||
'organisation_id' => $organisation->id,
|
||||
'category' => ServiceCategory::APPLICATION,
|
||||
'type' => ServiceType::LARAVEL,
|
||||
'version' => 'php-8.4',
|
||||
'version_track' => 'php-8.4',
|
||||
'driver_name' => 'laravel.php-8.4',
|
||||
'deploy_policy' => DeployPolicy::WITH_ENVIRONMENT,
|
||||
'desired_replicas' => 2,
|
||||
]);
|
||||
|
||||
$artifact = app(PlanBuildArtifact::class)->execute($environment, str_repeat('d', 40));
|
||||
|
||||
expect($artifact->image_tag)->toBe("keystone/{$application->uuid}/{$environment->uuid}:dddddddddddd")
|
||||
->and($artifact->registry_ref)->toBe("registry.example.com/keystone/{$application->uuid}/{$environment->uuid}:dddddddddddd")
|
||||
->and($artifact->metadata['build_strategy'])->toBe(BuildStrategy::DEDICATED_BUILDER->value)
|
||||
->and($artifact->metadata['build_server_id'])->toBe($server->id)
|
||||
->and($artifact->metadata['registry_type'])->toBe(RegistryType::MANAGED->value);
|
||||
});
|
||||
|
||||
it('blocks managed registry builds until the registry is ready', function () {
|
||||
$organisation = Organisation::factory()->create();
|
||||
$server = buildEnabledServerFor($organisation);
|
||||
$organisation->registries()->create([
|
||||
'name' => 'Managed',
|
||||
'type' => RegistryType::MANAGED,
|
||||
'url' => 'registry.example.com',
|
||||
'credentials' => [
|
||||
'build_username' => 'keystone-build',
|
||||
'build_password' => 'secret',
|
||||
'runtime_username' => 'keystone-runtime',
|
||||
'runtime_password' => 'runtime-secret',
|
||||
],
|
||||
'control_server_id' => $server->id,
|
||||
'health_status' => 'pending',
|
||||
]);
|
||||
$application = Application::factory()->for($organisation)->create();
|
||||
$environment = Environment::factory()->for($application)->create();
|
||||
Service::factory()->for($environment)->create([
|
||||
'organisation_id' => $organisation->id,
|
||||
'category' => ServiceCategory::APPLICATION,
|
||||
'type' => ServiceType::LARAVEL,
|
||||
'version' => 'php-8.4',
|
||||
'version_track' => 'php-8.4',
|
||||
'driver_name' => 'laravel.php-8.4',
|
||||
'deploy_policy' => DeployPolicy::WITH_ENVIRONMENT,
|
||||
'desired_replicas' => 2,
|
||||
]);
|
||||
|
||||
expect(fn () => app(PlanBuildArtifact::class)->execute($environment, str_repeat('g', 40)))
|
||||
->toThrow(RuntimeException::class, 'Managed registry has not passed readiness checks.');
|
||||
});
|
||||
|
||||
it('does not treat the managed registry config value as a ready registry record', function () {
|
||||
config(['keystone.managed_registry.url' => 'registry.example.com']);
|
||||
|
||||
$organisation = Organisation::factory()->create();
|
||||
$application = Application::factory()->for($organisation)->create();
|
||||
$environment = Environment::factory()->for($application)->create();
|
||||
Service::factory()->for($environment)->create([
|
||||
'organisation_id' => $organisation->id,
|
||||
'category' => ServiceCategory::APPLICATION,
|
||||
'type' => ServiceType::LARAVEL,
|
||||
'version' => 'php-8.4',
|
||||
'version_track' => 'php-8.4',
|
||||
'driver_name' => 'laravel.php-8.4',
|
||||
'deploy_policy' => DeployPolicy::WITH_ENVIRONMENT,
|
||||
'desired_replicas' => 2,
|
||||
]);
|
||||
|
||||
expect(fn () => app(PlanBuildArtifact::class)->execute($environment, str_repeat('e', 40)))
|
||||
->toThrow(RuntimeException::class, 'A registry is required before building artifacts for multi-server deployments.');
|
||||
});
|
||||
|
||||
it('plans multi-server builds against the configured external registry', function () {
|
||||
$organisation = Organisation::factory()->create();
|
||||
$organisation->registries()->create([
|
||||
@@ -79,3 +178,66 @@ it('plans multi-server builds against the configured external registry', functio
|
||||
expect($artifact->registry_ref)->toBe('ghcr.io/example/billing-api:cccccccccccc')
|
||||
->and($artifact->metadata['build_strategy'])->toBe(BuildStrategy::EXTERNAL_REGISTRY->value);
|
||||
});
|
||||
|
||||
it('prefers a configured external registry over the managed default', function () {
|
||||
$organisation = Organisation::factory()->create();
|
||||
$server = buildEnabledServerFor($organisation);
|
||||
$organisation->registries()->create([
|
||||
'name' => 'Managed',
|
||||
'type' => RegistryType::MANAGED,
|
||||
'url' => 'registry.example.com',
|
||||
'credentials' => [
|
||||
'build_username' => 'keystone-build',
|
||||
'build_password' => 'secret',
|
||||
'runtime_username' => 'keystone-runtime',
|
||||
'runtime_password' => 'runtime-secret',
|
||||
],
|
||||
'control_server_id' => $server->id,
|
||||
'health_status' => 'healthy',
|
||||
'readiness_checks' => ['control_https' => 'passed', 'build_push' => 'passed'],
|
||||
'ready_at' => now(),
|
||||
]);
|
||||
$organisation->registries()->create([
|
||||
'name' => 'GHCR',
|
||||
'type' => RegistryType::GHCR,
|
||||
'url' => 'ghcr.io/example',
|
||||
]);
|
||||
$application = Application::factory()->for($organisation)->create(['name' => 'Billing API']);
|
||||
$environment = Environment::factory()->for($application)->create();
|
||||
Service::factory()->for($environment)->create([
|
||||
'organisation_id' => $organisation->id,
|
||||
'category' => ServiceCategory::APPLICATION,
|
||||
'type' => ServiceType::LARAVEL,
|
||||
'version' => 'php-8.4',
|
||||
'version_track' => 'php-8.4',
|
||||
'driver_name' => 'laravel.php-8.4',
|
||||
'deploy_policy' => DeployPolicy::WITH_ENVIRONMENT,
|
||||
'desired_replicas' => 2,
|
||||
]);
|
||||
|
||||
$artifact = app(PlanBuildArtifact::class)->execute($environment, str_repeat('f', 40));
|
||||
|
||||
expect($artifact->registry_ref)->toBe('ghcr.io/example/billing-api:ffffffffffff')
|
||||
->and($artifact->metadata['build_strategy'])->toBe(BuildStrategy::EXTERNAL_REGISTRY->value)
|
||||
->and($artifact->metadata['registry_type'])->toBe(RegistryType::GHCR->value);
|
||||
});
|
||||
|
||||
function buildEnabledServerFor(Organisation $organisation): Server
|
||||
{
|
||||
$provider = Provider::factory()->forOrganisation($organisation)->create();
|
||||
$network = Network::create([
|
||||
'organisation_id' => $organisation->id,
|
||||
'provider_id' => $provider->id,
|
||||
'name' => 'test-network',
|
||||
'ip_range' => '10.0.0.0/24',
|
||||
]);
|
||||
|
||||
return Server::factory()
|
||||
->forOrganisation($organisation->id)
|
||||
->forProvider($provider->id)
|
||||
->forNetwork($network->id)
|
||||
->create([
|
||||
'is_control_node' => true,
|
||||
'build_enabled' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ it('creates a parent environment operation with child service deploy operations'
|
||||
->forProvider($provider->id)
|
||||
->forNetwork($network->id)
|
||||
->create();
|
||||
$application = Application::factory()->for($organisation)->create();
|
||||
$application = Application::factory()->for($organisation)->create(['name' => 'Keystone Test App']);
|
||||
generateDeployKey($application);
|
||||
$environment = Environment::factory()->for($application)->create();
|
||||
$service = Service::factory()->for($environment)->for($server)->create([
|
||||
@@ -113,7 +113,7 @@ it('creates replica route configure and gateway cutover child operations', funct
|
||||
->forProvider($provider->id)
|
||||
->forNetwork($network->id)
|
||||
->create();
|
||||
$application = Application::factory()->for($organisation)->create();
|
||||
$application = Application::factory()->for($organisation)->create(['name' => 'Keystone Test App']);
|
||||
generateDeployKey($application);
|
||||
$environment = Environment::factory()->for($application)->create();
|
||||
$web = Service::factory()->for($environment)->for($server)->create([
|
||||
@@ -191,6 +191,7 @@ it('creates replica route configure and gateway cutover child operations', funct
|
||||
->first();
|
||||
|
||||
$serviceDeploy = $parent->children()->where('kind', OperationKind::SERVICE_DEPLOY)->first();
|
||||
$compose = renderedComposeFrom($serviceDeploy->steps()->where('name', 'Render Compose files')->first()->script, $web->id);
|
||||
|
||||
expect($serviceDeploy->children()->where('kind', OperationKind::REPLICA_DEPLOY)->count())->toBe(2)
|
||||
->and($parent->children()->where('kind', OperationKind::SLICE_CONFIGURE)->count())->toBe(1)
|
||||
@@ -203,6 +204,8 @@ it('creates replica route configure and gateway cutover child operations', funct
|
||||
->toContain('docker pull')
|
||||
->and($serviceDeploy->children()->where('kind', OperationKind::REPLICA_DEPLOY)->first()->steps()->where('name', 'Pull image for replica 1')->first()->script)
|
||||
->toContain('@sha256:deploymentdigest')
|
||||
->and($compose)
|
||||
->toContain('image: "registry.example.com/keystone-test-app:aaaaaaaaaaaa@sha256:deploymentdigest"')
|
||||
->and($serviceDeploy->children()->where('kind', OperationKind::REPLICA_DEPLOY)->first()->steps()->where('name', 'Health check replica 1')->first()->script)
|
||||
->toContain('health_status=')
|
||||
->and($parent->children()->where('kind', OperationKind::SLICE_CONFIGURE)->first()->steps()->first()->script)
|
||||
@@ -364,6 +367,80 @@ it('places desired replicas across configured server placements', function () {
|
||||
->toBe($servers->pluck('id')->all());
|
||||
});
|
||||
|
||||
it('renders compose and root registry auth on each managed registry replica server', function () {
|
||||
$organisation = Organisation::factory()->create();
|
||||
$provider = Provider::factory()->forOrganisation($organisation)->create();
|
||||
$network = Network::create([
|
||||
'organisation_id' => $organisation->id,
|
||||
'provider_id' => $provider->id,
|
||||
'name' => 'test-network',
|
||||
'ip_range' => '10.0.0.0/24',
|
||||
]);
|
||||
$servers = Server::factory()
|
||||
->count(2)
|
||||
->forOrganisation($organisation->id)
|
||||
->forProvider($provider->id)
|
||||
->forNetwork($network->id)
|
||||
->create();
|
||||
$servers[0]->update([
|
||||
'is_control_node' => true,
|
||||
'build_enabled' => true,
|
||||
]);
|
||||
$organisation->registries()->create([
|
||||
'name' => 'Managed',
|
||||
'type' => 'managed',
|
||||
'url' => 'registry.example.com',
|
||||
'credentials' => [
|
||||
'build_username' => 'keystone-build',
|
||||
'build_password' => 'build-secret',
|
||||
'runtime_username' => 'keystone-runtime',
|
||||
'runtime_password' => 'runtime-secret',
|
||||
],
|
||||
'control_server_id' => $servers[0]->id,
|
||||
'health_status' => 'healthy',
|
||||
'readiness_checks' => ['control_https' => 'passed', 'build_push' => 'passed'],
|
||||
'ready_at' => now(),
|
||||
]);
|
||||
$application = Application::factory()->for($organisation)->create(['name' => 'Keystone Test App']);
|
||||
generateDeployKey($application);
|
||||
$environment = Environment::factory()->for($application)->create();
|
||||
$service = Service::factory()->for($environment)->create([
|
||||
'organisation_id' => $organisation->id,
|
||||
'name' => 'web',
|
||||
'category' => ServiceCategory::APPLICATION,
|
||||
'type' => ServiceType::LARAVEL,
|
||||
'version' => 'php-8.4',
|
||||
'version_track' => 'php-8.4',
|
||||
'driver_name' => 'laravel.php-8.4',
|
||||
'deploy_policy' => DeployPolicy::WITH_ENVIRONMENT,
|
||||
'desired_replicas' => 2,
|
||||
'config' => [
|
||||
'server_ids' => $servers->pluck('id')->all(),
|
||||
],
|
||||
]);
|
||||
|
||||
(new DeployEnvironment($environment))->handle();
|
||||
|
||||
$serviceDeploy = $service->operations()
|
||||
->where('kind', OperationKind::SERVICE_DEPLOY)
|
||||
->firstOrFail();
|
||||
$replicaOperations = $serviceDeploy->children()
|
||||
->where('kind', OperationKind::REPLICA_DEPLOY)
|
||||
->with('steps')
|
||||
->get();
|
||||
|
||||
expect($replicaOperations)->toHaveCount(2)
|
||||
->and($replicaOperations[0]->steps->pluck('name')->all())->toContain('Render replica 1 Compose files')
|
||||
->and($replicaOperations[1]->steps->pluck('name')->all())->toContain('Render replica 2 Compose files');
|
||||
|
||||
$authStep = $replicaOperations[0]->steps->firstWhere('name', 'Configure registry auth for replica 1');
|
||||
|
||||
expect($authStep->script)->toContain("DOCKER_CONFIG='/root/.docker'")
|
||||
->and($authStep->script)->toContain('[!registry_password_base64!]')
|
||||
->and($authStep->script)->not->toContain('runtime-secret')
|
||||
->and($authStep->secrets['registry_password_base64'])->toBe(base64_encode('runtime-secret'));
|
||||
});
|
||||
|
||||
it('skips environment service operations when the target revision is already available', function () {
|
||||
$organisation = Organisation::factory()->create();
|
||||
$application = Application::factory()->for($organisation)->create();
|
||||
@@ -404,6 +481,17 @@ function generateDeployKey(Application $application): void
|
||||
]);
|
||||
}
|
||||
|
||||
function renderedComposeFrom(string $script, int $serviceId): string
|
||||
{
|
||||
preg_match(
|
||||
'/printf %s \'(?<encoded>[^\']+)\' \| base64 -d > \/home\/keystone\/services\/'.$serviceId.'\/compose\.yml/',
|
||||
$script,
|
||||
$matches,
|
||||
);
|
||||
|
||||
return base64_decode($matches['encoded'] ?? '', true) ?: '';
|
||||
}
|
||||
|
||||
it('blocks multi-server deploys that do not have a registry', function () {
|
||||
$organisation = Organisation::factory()->create();
|
||||
$application = Application::factory()->for($organisation)->create();
|
||||
|
||||
@@ -99,6 +99,8 @@ it('runs an environment deployment from the application surface', function () {
|
||||
});
|
||||
|
||||
it('blocks multi-server environment deployment until a registry is configured', function () {
|
||||
config(['keystone.managed_registry.url' => null]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
|
||||
$provider = Provider::factory()->forOrganisation($organisation)->create();
|
||||
@@ -152,6 +154,64 @@ it('blocks multi-server environment deployment until a registry is configured',
|
||||
Bus::assertDispatched(DeployEnvironment::class);
|
||||
});
|
||||
|
||||
it('dispatches multi-server environment deployments when a managed registry exists', function () {
|
||||
$user = User::factory()->create();
|
||||
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
|
||||
$provider = Provider::factory()->forOrganisation($organisation)->create();
|
||||
$network = Network::create([
|
||||
'organisation_id' => $organisation->id,
|
||||
'provider_id' => $provider->id,
|
||||
'name' => 'test-network',
|
||||
'ip_range' => '10.0.0.0/24',
|
||||
]);
|
||||
$primaryServer = Server::factory()
|
||||
->forOrganisation($organisation->id)
|
||||
->forProvider($provider->id)
|
||||
->forNetwork($network->id)
|
||||
->create([
|
||||
'is_control_node' => true,
|
||||
'build_enabled' => true,
|
||||
]);
|
||||
$organisation->registries()->create([
|
||||
'name' => 'Managed',
|
||||
'type' => RegistryType::MANAGED,
|
||||
'url' => 'registry.example.com',
|
||||
'credentials' => [
|
||||
'build_username' => 'keystone-build',
|
||||
'build_password' => 'secret',
|
||||
'runtime_username' => 'keystone-runtime',
|
||||
'runtime_password' => 'runtime-secret',
|
||||
],
|
||||
'control_server_id' => $primaryServer->id,
|
||||
'health_status' => 'healthy',
|
||||
'readiness_checks' => ['control_https' => 'passed', 'build_push' => 'passed'],
|
||||
'ready_at' => now(),
|
||||
]);
|
||||
$secondaryServer = Server::factory()
|
||||
->forOrganisation($organisation->id)
|
||||
->forProvider($provider->id)
|
||||
->forNetwork($network->id)
|
||||
->create();
|
||||
$application = Application::factory()->for($organisation)->create();
|
||||
$environment = Environment::factory()->for($application)->create();
|
||||
$service = Service::factory()->for($environment)->for($primaryServer)->create([
|
||||
'organisation_id' => $organisation->id,
|
||||
'category' => ServiceCategory::APPLICATION,
|
||||
'deploy_policy' => DeployPolicy::WITH_ENVIRONMENT,
|
||||
]);
|
||||
ServiceReplica::factory()
|
||||
->for($service)
|
||||
->for($secondaryServer, 'server')
|
||||
->create();
|
||||
Bus::fake();
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('environment-deployments.store', [$organisation, $application, $environment]))
|
||||
->assertRedirect(route('environments.show', [$organisation, $application, $environment]));
|
||||
|
||||
Bus::assertDispatched(DeployEnvironment::class);
|
||||
});
|
||||
|
||||
it('deploys an environment at a specific commit when provided', function () {
|
||||
$user = User::factory()->create();
|
||||
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
|
||||
|
||||
@@ -3,12 +3,16 @@
|
||||
use App\Actions\Environments\PlanEnvironmentDeployment;
|
||||
use App\Enums\DeployPolicy;
|
||||
use App\Enums\EnvironmentAttachmentRole;
|
||||
use App\Enums\RegistryType;
|
||||
use App\Enums\SchedulerMode;
|
||||
use App\Enums\ServiceCategory;
|
||||
use App\Enums\ServiceType;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Network;
|
||||
use App\Models\Organisation;
|
||||
use App\Models\Provider;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
|
||||
it('deploys only with-environment services and checks dependency attachments', function () {
|
||||
@@ -47,6 +51,8 @@ it('deploys only with-environment services and checks dependency attachments', f
|
||||
});
|
||||
|
||||
it('blocks multi-server environment deployments when no registry exists', function () {
|
||||
config(['keystone.managed_registry.url' => null]);
|
||||
|
||||
$organisation = Organisation::factory()->create();
|
||||
$application = Application::factory()->for($organisation)->create();
|
||||
$environment = Environment::factory()->for($application)->create();
|
||||
@@ -68,6 +74,102 @@ it('blocks multi-server environment deployments when no registry exists', functi
|
||||
expect($plan->requiresRegistry)->toBeTrue();
|
||||
});
|
||||
|
||||
it('blocks multi-server environment deployments when managed registry smoke checks have not passed', function () {
|
||||
$organisation = Organisation::factory()->create();
|
||||
$server = deploymentPlanBuildServerFor($organisation);
|
||||
$organisation->registries()->create([
|
||||
'name' => 'Managed',
|
||||
'type' => RegistryType::MANAGED,
|
||||
'url' => 'registry.example.com',
|
||||
'credentials' => [
|
||||
'build_username' => 'keystone-build',
|
||||
'build_password' => 'secret',
|
||||
'runtime_username' => 'keystone-runtime',
|
||||
'runtime_password' => 'runtime-secret',
|
||||
],
|
||||
'control_server_id' => $server->id,
|
||||
'health_status' => 'pending',
|
||||
'readiness_checks' => ['control_https' => 'passed', 'build_push' => 'pending'],
|
||||
]);
|
||||
$application = Application::factory()->for($organisation)->create();
|
||||
$environment = Environment::factory()->for($application)->create();
|
||||
|
||||
Service::factory()->for($environment)->create([
|
||||
'organisation_id' => $organisation->id,
|
||||
'name' => 'web',
|
||||
'category' => ServiceCategory::APPLICATION,
|
||||
'type' => ServiceType::LARAVEL,
|
||||
'version' => 'php-8.4',
|
||||
'version_track' => 'php-8.4',
|
||||
'driver_name' => 'laravel.php-8.4',
|
||||
'deploy_policy' => DeployPolicy::WITH_ENVIRONMENT,
|
||||
'desired_replicas' => 2,
|
||||
]);
|
||||
|
||||
$plan = app(PlanEnvironmentDeployment::class)->execute($environment);
|
||||
|
||||
expect($plan->requiresRegistry)->toBeTrue()
|
||||
->and($plan->blockers)->toContain('Managed registry has not passed readiness checks.');
|
||||
});
|
||||
|
||||
it('allows multi-server environment deployments when a managed registry exists', function () {
|
||||
$organisation = Organisation::factory()->create();
|
||||
$server = deploymentPlanBuildServerFor($organisation);
|
||||
$organisation->registries()->create([
|
||||
'name' => 'Managed',
|
||||
'type' => RegistryType::MANAGED,
|
||||
'url' => 'registry.example.com',
|
||||
'credentials' => [
|
||||
'build_username' => 'keystone-build',
|
||||
'build_password' => 'secret',
|
||||
'runtime_username' => 'keystone-runtime',
|
||||
'runtime_password' => 'runtime-secret',
|
||||
],
|
||||
'control_server_id' => $server->id,
|
||||
'health_status' => 'healthy',
|
||||
'readiness_checks' => ['control_https' => 'passed', 'build_push' => 'passed'],
|
||||
'ready_at' => now(),
|
||||
]);
|
||||
$application = Application::factory()->for($organisation)->create();
|
||||
$environment = Environment::factory()->for($application)->create();
|
||||
|
||||
Service::factory()->for($environment)->create([
|
||||
'organisation_id' => $organisation->id,
|
||||
'name' => 'web',
|
||||
'category' => ServiceCategory::APPLICATION,
|
||||
'type' => ServiceType::LARAVEL,
|
||||
'version' => 'php-8.4',
|
||||
'version_track' => 'php-8.4',
|
||||
'driver_name' => 'laravel.php-8.4',
|
||||
'deploy_policy' => DeployPolicy::WITH_ENVIRONMENT,
|
||||
'desired_replicas' => 2,
|
||||
]);
|
||||
|
||||
$plan = app(PlanEnvironmentDeployment::class)->execute($environment);
|
||||
|
||||
expect($plan->requiresRegistry)->toBeFalse();
|
||||
});
|
||||
|
||||
function deploymentPlanBuildServerFor(Organisation $organisation): Server
|
||||
{
|
||||
$provider = Provider::factory()->forOrganisation($organisation)->create();
|
||||
$network = Network::create([
|
||||
'organisation_id' => $organisation->id,
|
||||
'provider_id' => $provider->id,
|
||||
'name' => 'test-network',
|
||||
'ip_range' => '10.0.0.0/24',
|
||||
]);
|
||||
|
||||
return Server::factory()
|
||||
->forOrganisation($organisation->id)
|
||||
->forProvider($provider->id)
|
||||
->forNetwork($network->id)
|
||||
->create([
|
||||
'is_control_node' => true,
|
||||
'build_enabled' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
it('warns about sync queues without creating worker services', function () {
|
||||
$organisation = Organisation::factory()->create();
|
||||
$application = Application::factory()->for($organisation)->create();
|
||||
|
||||
354
tests/Feature/ManagedRegistryTest.php
Normal file
354
tests/Feature/ManagedRegistryTest.php
Normal file
@@ -0,0 +1,354 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Registries\CreateManagedRegistryMaintenanceOperation;
|
||||
use App\Actions\Registries\CreateManagedRegistryProvisionOperation;
|
||||
use App\Actions\Registries\CreateManagedRegistrySmokeCheckOperation;
|
||||
use App\Actions\Registries\CreateRegistryAuthOperation;
|
||||
use App\Enums\BuildArtifactStatus;
|
||||
use App\Enums\OperationKind;
|
||||
use App\Enums\OperationStatus;
|
||||
use App\Jobs\Services\RunStep;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Network;
|
||||
use App\Models\Organisation;
|
||||
use App\Models\Provider;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Services\Operations\RemoteCommandRunner;
|
||||
use App\Services\Registries\ManagedRegistryHealth;
|
||||
use App\Services\Registries\ManagedRegistryProvisioner;
|
||||
use App\Services\Registries\ManagedRegistryRetention;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
it('provisions a persisted managed registry with encrypted scoped credentials and defaults', function () {
|
||||
config([
|
||||
'keystone.managed_registry.storage_path' => '/mnt/registry',
|
||||
'keystone.managed_registry.retention.successful_artifacts_per_environment' => 5,
|
||||
]);
|
||||
|
||||
$organisation = Organisation::factory()->create();
|
||||
$server = managedRegistryServerFor($organisation);
|
||||
|
||||
$registry = app(ManagedRegistryProvisioner::class)->provision(
|
||||
organisation: $organisation,
|
||||
url: 'https://registry.example.com',
|
||||
controlServer: $server,
|
||||
);
|
||||
|
||||
expect($registry->url)->toBe('registry.example.com')
|
||||
->and($registry->storage_path)->toBe('/mnt/registry')
|
||||
->and($registry->retention_successful_artifacts)->toBe(5)
|
||||
->and($registry->control_server_id)->toBe($server->id)
|
||||
->and($registry->credentials)->toHaveKeys([
|
||||
'build_username',
|
||||
'build_password',
|
||||
'runtime_username',
|
||||
'runtime_password',
|
||||
])
|
||||
->and($registry->getRawOriginal('credentials'))->not->toContain($registry->credentials['build_password'])
|
||||
->and($server->refresh()->is_control_node)->toBeTrue()
|
||||
->and($server->build_enabled)->toBeTrue();
|
||||
});
|
||||
|
||||
it('checks managed registry readiness over https and records health', function () {
|
||||
Http::fake([
|
||||
'https://registry.example.com/v2/' => Http::response('', 401),
|
||||
]);
|
||||
|
||||
$organisation = Organisation::factory()->create();
|
||||
$server = managedRegistryServerFor($organisation);
|
||||
$registry = app(ManagedRegistryProvisioner::class)->provision($organisation, 'registry.example.com', $server);
|
||||
$registry->forceFill([
|
||||
'readiness_checks' => [
|
||||
'control_https' => 'pending',
|
||||
'build_push' => 'passed',
|
||||
'runtime_pull_server_'.$server->id => 'passed',
|
||||
],
|
||||
])->save();
|
||||
|
||||
expect(app(ManagedRegistryHealth::class)->check($registry))->toBeTrue()
|
||||
->and($registry->refresh()->health_status)->toBe('healthy')
|
||||
->and($registry->ready_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('creates registry auth operations with encrypted secrets and placeholder scripts', function () {
|
||||
$organisation = Organisation::factory()->create();
|
||||
$server = managedRegistryServerFor($organisation);
|
||||
$registry = app(ManagedRegistryProvisioner::class)->provision($organisation, 'registry.example.com', $server);
|
||||
$registry->markHealthy();
|
||||
|
||||
$operation = app(CreateRegistryAuthOperation::class)->execute($registry, $server, 'runtime');
|
||||
$step = $operation->steps()->firstOrFail();
|
||||
|
||||
expect($operation->kind)->toBe(OperationKind::CREDENTIAL_ROTATION)
|
||||
->and($step->script)->toContain('docker login')
|
||||
->and($step->script)->toContain('[!registry_password_base64!]')
|
||||
->and($step->script)->not->toContain($registry->credentials['runtime_password'])
|
||||
->and($step->scriptForExecution())->toContain(base64_encode($registry->credentials['runtime_password']))
|
||||
->and($step->scriptForExecution())->not->toContain($registry->credentials['runtime_password'])
|
||||
->and($step->getRawOriginal('secrets'))->not->toContain($registry->credentials['runtime_password']);
|
||||
});
|
||||
|
||||
it('creates a managed registry provision operation with registry service, htpasswd auth, storage, deletion, and caddy proxy without raw passwords', function () {
|
||||
$organisation = Organisation::factory()->create();
|
||||
$server = managedRegistryServerFor($organisation);
|
||||
$registry = app(ManagedRegistryProvisioner::class)->provision(
|
||||
organisation: $organisation,
|
||||
url: 'https://registry.example.com',
|
||||
controlServer: $server,
|
||||
storagePath: '/mnt/keystone-registry',
|
||||
);
|
||||
|
||||
$operation = app(CreateManagedRegistryProvisionOperation::class)->execute($registry);
|
||||
$step = $operation->steps()->firstOrFail();
|
||||
|
||||
expect($operation->kind)->toBe(OperationKind::REGISTRY_PROVISION)
|
||||
->and($step->script)->toContain('registry:2')
|
||||
->and($step->script)->toContain('/mnt/keystone-registry')
|
||||
->and($step->script)->toContain('htpasswd')
|
||||
->and($step->script)->toContain('-Bni')
|
||||
->and($step->script)->not->toContain('"$build_password" > "$tmp_htpasswd"')
|
||||
->and($step->script)->not->toContain('set -x')
|
||||
->and($step->script)->toContain('REGISTRY_STORAGE_DELETE_ENABLED=true')
|
||||
->and($step->script)->toContain('reverse_proxy 127.0.0.1:5000')
|
||||
->and($step->script)->toContain('Registry proxy reload skipped')
|
||||
->and($step->script)->toContain('https://"$registry_host"/v2/')
|
||||
->and($step->script)->toContain('[!build_password_base64!]')
|
||||
->and($step->script)->toContain('[!runtime_password_base64!]')
|
||||
->and($step->script)->not->toContain($registry->credentials['build_password'])
|
||||
->and($step->script)->not->toContain($registry->credentials['runtime_password'])
|
||||
->and($step->getRawOriginal('secrets'))->not->toContain($registry->credentials['build_password'])
|
||||
->and($step->getRawOriginal('secrets'))->not->toContain($registry->credentials['runtime_password']);
|
||||
});
|
||||
|
||||
it('creates managed registry smoke checks for build and runtime nodes without raw passwords', function () {
|
||||
$organisation = Organisation::factory()->create();
|
||||
$control = managedRegistryServerFor($organisation);
|
||||
$runtime = managedRegistryServerFor($organisation);
|
||||
$registry = app(ManagedRegistryProvisioner::class)->provision($organisation, 'registry.example.com', $control);
|
||||
|
||||
$operation = app(CreateManagedRegistrySmokeCheckOperation::class)->execute($registry, $control, [$runtime]);
|
||||
$buildStep = $operation->steps()->firstOrFail();
|
||||
$runtimeStep = $operation->children()->firstOrFail()->steps()->firstOrFail();
|
||||
|
||||
expect($operation->kind)->toBe(OperationKind::REGISTRY_HEALTH_CHECK)
|
||||
->and($registry->refresh()->readiness_checks)->toHaveKeys(['control_https', 'build_push', 'runtime_pull_server_'.$runtime->id])
|
||||
->and($buildStep->script)->toContain('https://"$registry_host"/v2/')
|
||||
->and($buildStep->script)->toContain('docker push "$image_ref"')
|
||||
->and($runtimeStep->script)->toContain('docker pull "$image_ref"')
|
||||
->and($buildStep->script)->not->toContain($registry->credentials['build_password'])
|
||||
->and($runtimeStep->script)->not->toContain($registry->credentials['runtime_password']);
|
||||
});
|
||||
|
||||
it('marks the registry unhealthy when a runtime smoke check child operation fails', function () {
|
||||
$organisation = Organisation::factory()->create();
|
||||
$control = managedRegistryServerFor($organisation);
|
||||
$runtime = managedRegistryServerFor($organisation);
|
||||
$registry = app(ManagedRegistryProvisioner::class)->provision($organisation, 'registry.example.com', $control);
|
||||
|
||||
app()->instance(RemoteCommandRunner::class, new class($runtime->id) implements RemoteCommandRunner
|
||||
{
|
||||
public function __construct(private readonly int $runtimeServerId) {}
|
||||
|
||||
public function run(Server $server, string $script): string
|
||||
{
|
||||
if ($server->id === $this->runtimeServerId) {
|
||||
throw new RuntimeException('runtime pull failed');
|
||||
}
|
||||
|
||||
return 'ok';
|
||||
}
|
||||
});
|
||||
|
||||
$operation = app(CreateManagedRegistrySmokeCheckOperation::class)->execute($registry, $control, [$runtime]);
|
||||
$runtimeOperation = $operation->children()->firstOrFail();
|
||||
|
||||
(new RunStep($runtimeOperation->steps()->firstOrFail()))->handle();
|
||||
|
||||
expect($registry->refresh()->health_status)->toBe('unhealthy')
|
||||
->and($registry->health_message)->toContain('runtime pull failed');
|
||||
});
|
||||
|
||||
it('clears registry auth operation secrets after failed execution', function () {
|
||||
$organisation = Organisation::factory()->create();
|
||||
$server = managedRegistryServerFor($organisation);
|
||||
$registry = app(ManagedRegistryProvisioner::class)->provision($organisation, 'registry.example.com', $server);
|
||||
$registry->markHealthy();
|
||||
|
||||
app()->instance(RemoteCommandRunner::class, new class implements RemoteCommandRunner
|
||||
{
|
||||
public function run(Server $server, string $script): string
|
||||
{
|
||||
throw new RuntimeException('failed');
|
||||
}
|
||||
});
|
||||
|
||||
$operation = app(CreateRegistryAuthOperation::class)->execute($registry, $server, 'runtime');
|
||||
$step = $operation->steps()->firstOrFail();
|
||||
|
||||
(new RunStep($step))->handle();
|
||||
|
||||
expect($step->refresh()->status)->toBe(OperationStatus::FAILED)
|
||||
->and($step->secrets)->toBeNull();
|
||||
});
|
||||
|
||||
it('marks old successful managed registry artifacts prunable without touching active or retained artifacts', function () {
|
||||
$organisation = Organisation::factory()->create();
|
||||
$server = managedRegistryServerFor($organisation);
|
||||
$registry = app(ManagedRegistryProvisioner::class)->provision(
|
||||
organisation: $organisation,
|
||||
url: 'registry.example.com',
|
||||
controlServer: $server,
|
||||
retention: 3,
|
||||
);
|
||||
$registry->markHealthy();
|
||||
$application = Application::factory()->for($organisation)->create();
|
||||
$environment = Environment::factory()->for($application)->create();
|
||||
Service::factory()->for($environment)->create([
|
||||
'organisation_id' => $organisation->id,
|
||||
'available_image_digest' => 'sha256:old-active',
|
||||
'current_image_digest' => 'sha256:current-active',
|
||||
]);
|
||||
|
||||
foreach (range(1, 6) as $index) {
|
||||
$environment->buildArtifacts()->create([
|
||||
'commit_sha' => str_repeat((string) $index, 40),
|
||||
'image_tag' => "keystone/{$application->uuid}/{$environment->uuid}:{$index}",
|
||||
'registry_ref' => "registry.example.com/keystone/{$application->uuid}/{$environment->uuid}:{$index}",
|
||||
'image_digest' => match ($index) {
|
||||
1 => 'sha256:old-active',
|
||||
2 => 'sha256:current-active',
|
||||
default => 'sha256:artifact-'.$index,
|
||||
},
|
||||
'status' => BuildArtifactStatus::AVAILABLE,
|
||||
'created_at' => now()->subMinutes(12 - $index),
|
||||
'updated_at' => now()->subMinutes(12 - $index),
|
||||
]);
|
||||
}
|
||||
|
||||
$marked = app(ManagedRegistryRetention::class)->markPrunable($registry);
|
||||
|
||||
expect($marked)->toHaveCount(1)
|
||||
->and($marked->first()->image_digest)->toBe('sha256:artifact-3')
|
||||
->and($marked->first()->metadata['prune_command'])->toContain('/v2/keystone/')
|
||||
->and($environment->buildArtifacts()->where('image_digest', 'sha256:old-active')->first()->status)->toBe(BuildArtifactStatus::AVAILABLE)
|
||||
->and($environment->buildArtifacts()->where('image_digest', 'sha256:current-active')->first()->status)->toBe(BuildArtifactStatus::AVAILABLE);
|
||||
});
|
||||
|
||||
it('blocks managed registry maintenance while matching builds are active', function () {
|
||||
$organisation = Organisation::factory()->create();
|
||||
$server = managedRegistryServerFor($organisation);
|
||||
$registry = app(ManagedRegistryProvisioner::class)->provision($organisation, 'registry.example.com', $server);
|
||||
$application = Application::factory()->for($organisation)->create();
|
||||
$environment = Environment::factory()->for($application)->create();
|
||||
$environment->buildArtifacts()->create([
|
||||
'commit_sha' => str_repeat('a', 40),
|
||||
'image_tag' => "keystone/{$application->uuid}/{$environment->uuid}:aaaaaaaaaaaa",
|
||||
'registry_ref' => "registry.example.com/keystone/{$application->uuid}/{$environment->uuid}:aaaaaaaaaaaa",
|
||||
'status' => BuildArtifactStatus::BUILDING,
|
||||
]);
|
||||
|
||||
expect(fn () => app(CreateManagedRegistryMaintenanceOperation::class)->execute($registry))
|
||||
->toThrow(RuntimeException::class, 'Managed registry pruning cannot run while builds are active.');
|
||||
});
|
||||
|
||||
it('creates managed registry maintenance operations that delete manifests, run gc, and mark artifacts pruned after execution', function () {
|
||||
$organisation = Organisation::factory()->create();
|
||||
$server = managedRegistryServerFor($organisation);
|
||||
$registry = app(ManagedRegistryProvisioner::class)->provision($organisation, 'registry.example.com', $server, retention: 1);
|
||||
$application = Application::factory()->for($organisation)->create();
|
||||
$environment = Environment::factory()->for($application)->create();
|
||||
|
||||
foreach (range(1, 3) as $index) {
|
||||
$environment->buildArtifacts()->create([
|
||||
'commit_sha' => str_repeat((string) $index, 40),
|
||||
'image_tag' => "keystone/{$application->uuid}/{$environment->uuid}:{$index}",
|
||||
'registry_ref' => "registry.example.com/keystone/{$application->uuid}/{$environment->uuid}:{$index}",
|
||||
'image_digest' => 'sha256:artifact-'.$index,
|
||||
'status' => BuildArtifactStatus::AVAILABLE,
|
||||
'created_at' => now()->subMinutes(10 - $index),
|
||||
'updated_at' => now()->subMinutes(10 - $index),
|
||||
]);
|
||||
}
|
||||
|
||||
app()->instance(RemoteCommandRunner::class, new class implements RemoteCommandRunner
|
||||
{
|
||||
public function run(Server $server, string $script): string
|
||||
{
|
||||
return 'ok';
|
||||
}
|
||||
});
|
||||
|
||||
$operation = app(CreateManagedRegistryMaintenanceOperation::class)->execute($registry);
|
||||
$step = $operation->steps()->firstOrFail();
|
||||
$unselectedArtifact = $environment->buildArtifacts()->create([
|
||||
'commit_sha' => str_repeat('9', 40),
|
||||
'image_tag' => "keystone/{$application->uuid}/{$environment->uuid}:9",
|
||||
'registry_ref' => "registry.example.com/keystone/{$application->uuid}/{$environment->uuid}:9",
|
||||
'image_digest' => 'sha256:not-in-this-maintenance-batch',
|
||||
'status' => BuildArtifactStatus::PRUNABLE,
|
||||
]);
|
||||
|
||||
expect($step->script)->toContain('flock -n 9')
|
||||
->and($step->script)->toContain('--request DELETE')
|
||||
->and($step->script)->toContain('delete_failures=0')
|
||||
->and($step->script)->toContain('status=$(curl')
|
||||
->and($step->script)->not->toContain('--user "$username:$password"')
|
||||
->and($step->script)->toContain('Accept: application/vnd.docker.distribution.manifest.v2+json')
|
||||
->and($step->script)->toContain('garbage-collect --delete-untagged')
|
||||
->and($step->script)->toContain('docker stop keystone-managed-registry')
|
||||
->and($step->script)->toContain('docker start keystone-managed-registry')
|
||||
->and($step->script)->not->toContain($registry->credentials['build_password']);
|
||||
|
||||
(new RunStep($step))->handle();
|
||||
|
||||
expect($environment->buildArtifacts()->where('status', BuildArtifactStatus::PRUNED)->count())->toBe(2);
|
||||
expect($unselectedArtifact->refresh()->status)->toBe(BuildArtifactStatus::PRUNABLE);
|
||||
});
|
||||
|
||||
it('keeps managed registry builds blocked until represented smoke checks pass', function () {
|
||||
$organisation = Organisation::factory()->create();
|
||||
$server = managedRegistryServerFor($organisation);
|
||||
$runtime = managedRegistryServerFor($organisation);
|
||||
$registry = app(ManagedRegistryProvisioner::class)->provision($organisation, 'registry.example.com', $server);
|
||||
app(CreateManagedRegistrySmokeCheckOperation::class)->execute($registry, $server, [$runtime]);
|
||||
|
||||
expect(app(ManagedRegistryHealth::class)->readinessBlocker($registry->refresh()))
|
||||
->toBe('Managed registry has not passed readiness checks.');
|
||||
|
||||
app()->instance(RemoteCommandRunner::class, new class implements RemoteCommandRunner
|
||||
{
|
||||
public function run(Server $server, string $script): string
|
||||
{
|
||||
return 'ok';
|
||||
}
|
||||
});
|
||||
|
||||
$operation = $server->operations()->where('kind', OperationKind::REGISTRY_HEALTH_CHECK)->latest()->firstOrFail();
|
||||
(new RunStep($operation->steps()->firstOrFail()))->handle();
|
||||
|
||||
expect($registry->refresh()->health_status)->toBe('healthy')
|
||||
->and($registry->ready_at)->not->toBeNull()
|
||||
->and(app(ManagedRegistryHealth::class)->readinessBlocker($registry))->toBeNull();
|
||||
});
|
||||
|
||||
function managedRegistryServerFor(Organisation $organisation): Server
|
||||
{
|
||||
$provider = Provider::factory()->forOrganisation($organisation)->create();
|
||||
$network = Network::create([
|
||||
'organisation_id' => $organisation->id,
|
||||
'provider_id' => $provider->id,
|
||||
'name' => 'test-network',
|
||||
'ip_range' => '10.0.0.0/24',
|
||||
]);
|
||||
|
||||
return Server::factory()
|
||||
->forOrganisation($organisation->id)
|
||||
->forProvider($provider->id)
|
||||
->forNetwork($network->id)
|
||||
->create([
|
||||
'is_control_node' => true,
|
||||
'build_enabled' => true,
|
||||
]);
|
||||
}
|
||||
@@ -49,6 +49,7 @@ it('shows an operation with steps and children', function () {
|
||||
'status' => OperationStatus::COMPLETED,
|
||||
'script' => 'docker ps',
|
||||
'logs' => 'ok',
|
||||
'secrets' => ['registry_password_base64' => base64_encode('super-secret-password')],
|
||||
]);
|
||||
|
||||
Operation::factory()->create([
|
||||
@@ -63,10 +64,13 @@ it('shows an operation with steps and children', function () {
|
||||
]));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertDontSee('super-secret-password', false);
|
||||
$response->assertDontSee('registry_password_base64', false);
|
||||
$response->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('operations/Show', false)
|
||||
->where('operation.hash', $operation->hash)
|
||||
->has('operation.steps', 1)
|
||||
->missing('operation.steps.0.secrets')
|
||||
->has('operation.children', 1));
|
||||
});
|
||||
|
||||
@@ -131,6 +135,7 @@ it('cancels operations and downloads logs', function () {
|
||||
'script' => 'docker ps',
|
||||
'logs' => 'hello',
|
||||
'error_logs' => 'error',
|
||||
'secrets' => ['registry_password_base64' => base64_encode('super-secret-password')],
|
||||
]);
|
||||
|
||||
$this->actingAs($user)->post(route('operations.cancel', [
|
||||
@@ -142,7 +147,8 @@ it('cancels operations and downloads logs', function () {
|
||||
]));
|
||||
|
||||
expect($operation->refresh()->status)->toBe(OperationStatus::CANCELLED)
|
||||
->and($operation->finished_at)->not->toBeNull();
|
||||
->and($operation->finished_at)->not->toBeNull()
|
||||
->and($operation->steps()->first()->secrets)->toBeNull();
|
||||
|
||||
$response = $this->actingAs($user)->get(route('operations.logs', [
|
||||
'organisation' => $organisation->id,
|
||||
|
||||
@@ -19,7 +19,8 @@ it('shows the create registry page', function () {
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('registries/Create', false)
|
||||
->where('registryTypes.0', RegistryType::GENERIC->value));
|
||||
->where('registryTypes.0', RegistryType::GENERIC->value)
|
||||
->where('registryTypes', fn ($types) => ! in_array(RegistryType::MANAGED->value, $types->all(), true)));
|
||||
});
|
||||
|
||||
it('lists registries for an organisation', function () {
|
||||
@@ -70,6 +71,66 @@ it('stores a registry for multi-server build artifacts', function () {
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects user-created managed registries and scheme-prefixed registry urls', function (array $overrides, string $field) {
|
||||
$user = User::factory()->create();
|
||||
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
|
||||
|
||||
$this->actingAs($user)->post(route('registries.store', [
|
||||
'organisation' => $organisation->id,
|
||||
]), [
|
||||
'name' => 'Registry',
|
||||
'type' => RegistryType::GHCR->value,
|
||||
'url' => 'ghcr.io/example',
|
||||
'username' => 'keystone',
|
||||
'password' => 'secret',
|
||||
...$overrides,
|
||||
])->assertSessionHasErrors($field);
|
||||
})->with([
|
||||
'managed type' => [['type' => RegistryType::MANAGED->value], 'type'],
|
||||
'url scheme' => [['url' => 'https://registry.example.com'], 'url'],
|
||||
]);
|
||||
|
||||
it('does not delete managed registries through the user registry controller', function () {
|
||||
$user = User::factory()->create();
|
||||
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
|
||||
$registry = $organisation->registries()->create([
|
||||
'name' => 'Managed',
|
||||
'type' => RegistryType::MANAGED,
|
||||
'url' => 'registry.example.com',
|
||||
'credentials' => ['username' => 'keystone-build', 'password' => 'secret'],
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->delete(route('registries.destroy', [$organisation, $registry]))
|
||||
->assertForbidden();
|
||||
|
||||
expect($registry->fresh())->not->toBeNull();
|
||||
});
|
||||
|
||||
it('does not update managed registries through the user registry controller', function () {
|
||||
$user = User::factory()->create();
|
||||
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
|
||||
$registry = $organisation->registries()->create([
|
||||
'name' => 'Managed',
|
||||
'type' => RegistryType::MANAGED,
|
||||
'url' => 'registry.example.com',
|
||||
'credentials' => ['username' => 'keystone-build', 'password' => 'secret'],
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->put(route('registries.update', [$organisation, $registry]), [
|
||||
'name' => 'GHCR',
|
||||
'type' => RegistryType::GHCR->value,
|
||||
'url' => 'ghcr.io/example',
|
||||
'username' => 'keystone',
|
||||
'password' => 'secret',
|
||||
])
|
||||
->assertForbidden();
|
||||
|
||||
expect($registry->refresh()->type)->toBe(RegistryType::MANAGED)
|
||||
->and($registry->name)->toBe('Managed');
|
||||
});
|
||||
|
||||
it('shows registry usage from published build artifacts', function () {
|
||||
$user = User::factory()->create();
|
||||
$organisation = Organisation::factory()->create(['owner_id' => $user->id]);
|
||||
|
||||
Reference in New Issue
Block a user