Compare commits

...
Author SHA1 Message Date
harry 85c44296ac Restructure UX and seed a fully simulated organisation
CI / Tests (push) Failing after 56s
CI / Lint (push) Failing after 1m35s
Rework the dashboard, environment topology view, header navigation, and
status rendering, and standardise selects on a shadcn-vue component.

Replace the thin database seeder with a SimulatedEnvironmentSeeder that
builds a fully wired, mostly-running organisation (ACTIVE server fleet,
managed + GHCR registries, Gitea source provider, ClipBin app with
production/staging environments, services, slices, endpoints, managed
variables, build artifacts, and a completed/in-progress/failed operations
history) so the new UI renders against realistic data.
2026-06-08 22:09:57 +01:00
harry 3a851db08f Add managed registry provisioning, pruning, and readiness tracking 2026-06-08 20:44:16 +01:00
109 changed files with 4998 additions and 963 deletions
+60 -24
View File
@@ -6,6 +6,7 @@
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications. The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
## Foundational Context ## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.4.16 - php - 8.4.16
@@ -18,49 +19,59 @@ This application is a Laravel application and its main Laravel ecosystems packag
- pestphp/pest (PEST) - v3 - pestphp/pest (PEST) - v3
- phpunit/phpunit (PHPUNIT) - v11 - phpunit/phpunit (PHPUNIT) - v11
## Conventions ## Conventions
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming. - You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming.
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. - Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
- Check for existing components to reuse before writing a new one. - Check for existing components to reuse before writing a new one.
## Verification Scripts ## Verification Scripts
- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important. - Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
## Application Structure & Architecture ## Application Structure & Architecture
- Stick to existing directory structure - don't create new base folders without approval. - Stick to existing directory structure - don't create new base folders without approval.
- Do not change the application's dependencies without approval. - Do not change the application's dependencies without approval.
## Frontend Bundling ## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them. - If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
## Replies ## Replies
- Be concise in your explanations - focus on what's important rather than explaining obvious details. - Be concise in your explanations - focus on what's important rather than explaining obvious details.
## Documentation Files ## Documentation Files
- You must only create documentation files if explicitly requested by the user.
- You must only create documentation files if explicitly requested by the user.
=== boost rules === === boost rules ===
## Laravel Boost ## Laravel Boost
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them. - Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
## Artisan ## Artisan
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters. - Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters.
## URLs ## URLs
- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port. - Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port.
## Tinker / Debugging ## Tinker / Debugging
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly. - You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
- Use the `database-query` tool when you only need to read from the database. - Use the `database-query` tool when you only need to read from the database.
## Reading Browser Logs With the `browser-logs` Tool ## Reading Browser Logs With the `browser-logs` Tool
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost. - You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
- Only recent browser logs will be useful - ignore old logs. - Only recent browser logs will be useful - ignore old logs.
## Searching Documentation (Critically Important) ## Searching Documentation (Critically Important)
- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages. - Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc. - The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches. - You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches.
@@ -69,6 +80,7 @@ This application is a Laravel application and its main Laravel ecosystems packag
- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`. - Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
### Available Search Syntax ### Available Search Syntax
- You can and should pass multiple queries at once. The most relevant results will be returned first. - You can and should pass multiple queries at once. The most relevant results will be returned first.
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth' 1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'
@@ -77,7 +89,6 @@ This application is a Laravel application and its main Laravel ecosystems packag
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit" 4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit"
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms 5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
=== php rules === === php rules ===
## PHP ## PHP
@@ -85,11 +96,13 @@ This application is a Laravel application and its main Laravel ecosystems packag
- Always use curly braces for control structures, even if it has one line. - Always use curly braces for control structures, even if it has one line.
### Constructors ### Constructors
- Use PHP 8 constructor property promotion in `__construct()`. - Use PHP 8 constructor property promotion in `__construct()`.
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet> - <code-snippet>public function \_\_construct(public GitHub $github) { }</code-snippet>
- Do not allow empty `__construct()` methods with zero parameters. - Do not allow empty `__construct()` methods with zero parameters.
### Type Declarations ### Type Declarations
- Always use explicit return type declarations for methods and functions. - Always use explicit return type declarations for methods and functions.
- Use appropriate PHP type hints for method parameters. - Use appropriate PHP type hints for method parameters.
@@ -101,14 +114,16 @@ protected function isAccessible(User $user, ?string $path = null): bool
</code-snippet> </code-snippet>
## Comments ## Comments
- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on. - Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on.
## PHPDoc Blocks ## PHPDoc Blocks
- Add useful array shape type definitions for arrays when appropriate. - Add useful array shape type definitions for arrays when appropriate.
## Enums ## Enums
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
=== inertia-laravel/core rules === === inertia-laravel/core rules ===
@@ -127,7 +142,6 @@ Route::get('/users', function () {
}); });
</code-snippet> </code-snippet>
=== inertia-laravel/v2 rules === === inertia-laravel/v2 rules ===
## Inertia v2 ## Inertia v2
@@ -135,6 +149,7 @@ Route::get('/users', function () {
- Make use of all Inertia features from v1 & v2. Check the documentation before making any changes to ensure we are taking the correct approach. - Make use of all Inertia features from v1 & v2. Check the documentation before making any changes to ensure we are taking the correct approach.
### Inertia v2 New Features ### Inertia v2 New Features
- Polling - Polling
- Prefetching - Prefetching
- Deferred props - Deferred props
@@ -142,11 +157,12 @@ Route::get('/users', function () {
- Lazy loading data on scroll - Lazy loading data on scroll
### Deferred Props & Empty States ### Deferred Props & Empty States
- When using deferred props on the frontend, you should add a nice empty state with pulsing / animated skeleton. - When using deferred props on the frontend, you should add a nice empty state with pulsing / animated skeleton.
### Inertia Form General Guidance ### Inertia Form General Guidance
- Build forms using the `useForm` helper. Use the code examples and `search-docs` tool with a query of `useForm helper` for guidance.
- Build forms using the `useForm` helper. Use the code examples and `search-docs` tool with a query of `useForm helper` for guidance.
=== laravel/core rules === === laravel/core rules ===
@@ -157,6 +173,7 @@ Route::get('/users', function () {
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. - Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
### Database ### Database
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins. - Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
- Use Eloquent models and relationships before suggesting raw database queries - Use Eloquent models and relationships before suggesting raw database queries
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them. - Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
@@ -164,35 +181,43 @@ Route::get('/users', function () {
- Use Laravel's query builder for very complex database operations. - Use Laravel's query builder for very complex database operations.
### Model Creation ### Model Creation
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`. - When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
### APIs & Eloquent Resources ### APIs & Eloquent Resources
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. - For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
### Controllers & Validation ### Controllers & Validation
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages. - Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
- Check sibling Form Requests to see if the application uses array or string based validation rules. - Check sibling Form Requests to see if the application uses array or string based validation rules.
### Queues ### Queues
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface. - Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
### Authentication & Authorization ### Authentication & Authorization
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.). - Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
### URL Generation ### URL Generation
- When generating links to other pages, prefer named routes and the `route()` function. - When generating links to other pages, prefer named routes and the `route()` function.
### Configuration ### Configuration
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`. - Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
### Testing ### Testing
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. - When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. - Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
- When creating tests, make use of `php artisan make:test [options] <name>` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. - When creating tests, make use of `php artisan make:test [options] <name>` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
### Vite Error ### Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
=== laravel/v12 rules === === laravel/v12 rules ===
@@ -202,6 +227,7 @@ Route::get('/users', function () {
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses. - Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
### Laravel 12 Structure ### Laravel 12 Structure
- No middleware files in `app/Http/Middleware/`. - No middleware files in `app/Http/Middleware/`.
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files. - `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
- `bootstrap/providers.php` contains application specific service providers. - `bootstrap/providers.php` contains application specific service providers.
@@ -209,12 +235,13 @@ Route::get('/users', function () {
- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration. - **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration.
### Database ### Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. - When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. - Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models ### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== pint/core rules === === pint/core rules ===
@@ -223,27 +250,29 @@ Route::get('/users', function () {
- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style. - You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues. - Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues.
=== pest/core rules === === pest/core rules ===
## Pest ## Pest
### Testing ### Testing
- If you need to verify a feature is working, write or update a Unit / Feature test. - If you need to verify a feature is working, write or update a Unit / Feature test.
### Pest Tests ### Pest Tests
- All tests must be written using Pest. Use `php artisan make:test --pest <name>`. - All tests must be written using Pest. Use `php artisan make:test --pest <name>`.
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application. - You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application.
- Tests should test all of the happy paths, failure paths, and weird paths. - Tests should test all of the happy paths, failure paths, and weird paths.
- Tests live in the `tests/Feature` and `tests/Unit` directories. - Tests live in the `tests/Feature` and `tests/Unit` directories.
- Pest tests look and behave like this: - Pest tests look and behave like this:
<code-snippet name="Basic Pest Test Example" lang="php"> <code-snippet name="Basic Pest Test Example" lang="php">
it('is true', function () { it('is true', function () {
expect(true)->toBeTrue(); expect(true)->toBeTrue();
}); });
</code-snippet> </code-snippet>
### Running Tests ### Running Tests
- Run the minimal number of tests using an appropriate filter before finalizing code edits. - Run the minimal number of tests using an appropriate filter before finalizing code edits.
- To run all tests: `php artisan test`. - To run all tests: `php artisan test`.
- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`. - To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`.
@@ -251,21 +280,25 @@ it('is true', function () {
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing. - When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
### Pest Assertions ### Pest Assertions
- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.:
<code-snippet name="Pest Example Asserting postJson Response" lang="php">
it('returns all', function () {
$response = $this->postJson('/api/docs', []);
$response->assertSuccessful(); - When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.:
}); <code-snippet name="Pest Example Asserting postJson Response" lang="php">
</code-snippet> it('returns all', function () {
$response = $this->postJson('/api/docs', []);
$response->assertSuccessful();
});
</code-snippet>
### Mocking ### Mocking
- Mocking can be very helpful when appropriate. - Mocking can be very helpful when appropriate.
- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do. - When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do.
- You can also create partial mocks using the same import or self method. - You can also create partial mocks using the same import or self method.
### Datasets ### Datasets
- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules. - Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules.
<code-snippet name="Pest Dataset Example" lang="php"> <code-snippet name="Pest Dataset Example" lang="php">
@@ -277,11 +310,14 @@ it('has emails', function (string $email) {
]); ]);
</code-snippet> </code-snippet>
=== tests rules === === tests rules ===
## Test Enforcement ## Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. - Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter. - Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter.
</laravel-boost-guidelines> </laravel-boost-guidelines>
# Keystone
- Keep CHANGELOG.md up to date.
+32
View File
@@ -0,0 +1,32 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Changed
- Reworked the dashboard to lead with recent applications and a latest-deployments activity feed with relative timestamps, replacing the organisation-picker layout.
- Rebuilt the environment view as a Network / Compute / Resources topology with dense spec cards, moving the commit-SHA deploy form and raw Caddyfile previews behind disclosures.
- Added a header organisation switcher, reordered the primary nav, and removed the onboarding nav item.
- Shared a fallback organisation when no organisation is in the route so the header navigation and switcher render on the dashboard, and gave the dashboard a header with a "New application" action and actionable empty states.
- Introduced a shared `StatusIndicator` (coloured dot + label) and standardised status rendering across the dashboard and environment views.
- Added a shadcn-vue `Select` component (radix-vue based) and replaced every native HTML `<select>` across the app with it.
### Added
- Reworked the database seeder to generate a fully wired, mostly-running organisation: an ACTIVE server fleet with a control/build node, managed and GHCR registries, a Gitea source provider, and a ClipBin application with production and staging environments (web + postgres + valkey + caddy services, slices, endpoints, managed variables), plus build artifacts and an operations history covering completed, in-progress, and failed states.
- Expanded the managed registry plan with HTTPS registry requirements, image naming, credential handling, health checks, and build-node safeguards.
- Added managed registry build planning defaults, stable managed image references, and digest-based Compose rendering for registry-backed deployments.
- Hardened managed registry planning so config-only registry URLs are not treated as ready registry records and pushed artifact digests come from Docker push output.
- Preserved external registry override behavior when a managed registry is present.
- Added managed registry provisioning, readiness metadata, scoped encrypted registry credentials, build-enabled control node selection, secure Docker auth operation steps, and first-pass retention marking.
- Switched managed registry image paths to application and environment UUIDs.
- Hardened registry auth execution so operation secrets stay hidden, are cleared after use or cancellation, and Docker auth is installed for the root SSH execution context used by build and deploy steps.
- Added managed registry provision, smoke-check, and maintenance operations that generate registry:2, htpasswd, Caddy proxy, push/pull readiness, manifest deletion, and garbage-collection scripts without persisting raw registry passwords.
- Blocked managed registry build/deploy planning until represented smoke checks pass, and marked pruned registry artifacts after successful maintenance.
- Scoped managed registry maintenance operations to their selected artifact batch, added build/maintenance locking, scheduled daily pruning, and removed registry secret exposure through htpasswd and manifest-delete process arguments.
+60 -24
View File
@@ -6,6 +6,7 @@
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications. The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
## Foundational Context ## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.4.16 - php - 8.4.16
@@ -18,49 +19,59 @@ This application is a Laravel application and its main Laravel ecosystems packag
- pestphp/pest (PEST) - v3 - pestphp/pest (PEST) - v3
- phpunit/phpunit (PHPUNIT) - v11 - phpunit/phpunit (PHPUNIT) - v11
## Conventions ## Conventions
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming. - You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming.
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. - Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
- Check for existing components to reuse before writing a new one. - Check for existing components to reuse before writing a new one.
## Verification Scripts ## Verification Scripts
- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important. - Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
## Application Structure & Architecture ## Application Structure & Architecture
- Stick to existing directory structure - don't create new base folders without approval. - Stick to existing directory structure - don't create new base folders without approval.
- Do not change the application's dependencies without approval. - Do not change the application's dependencies without approval.
## Frontend Bundling ## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them. - If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
## Replies ## Replies
- Be concise in your explanations - focus on what's important rather than explaining obvious details. - Be concise in your explanations - focus on what's important rather than explaining obvious details.
## Documentation Files ## Documentation Files
- You must only create documentation files if explicitly requested by the user.
- You must only create documentation files if explicitly requested by the user.
=== boost rules === === boost rules ===
## Laravel Boost ## Laravel Boost
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them. - Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
## Artisan ## Artisan
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters. - Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters.
## URLs ## URLs
- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port. - Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port.
## Tinker / Debugging ## Tinker / Debugging
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly. - You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
- Use the `database-query` tool when you only need to read from the database. - Use the `database-query` tool when you only need to read from the database.
## Reading Browser Logs With the `browser-logs` Tool ## Reading Browser Logs With the `browser-logs` Tool
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost. - You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
- Only recent browser logs will be useful - ignore old logs. - Only recent browser logs will be useful - ignore old logs.
## Searching Documentation (Critically Important) ## Searching Documentation (Critically Important)
- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages. - Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc. - The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches. - You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches.
@@ -69,6 +80,7 @@ This application is a Laravel application and its main Laravel ecosystems packag
- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`. - Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
### Available Search Syntax ### Available Search Syntax
- You can and should pass multiple queries at once. The most relevant results will be returned first. - You can and should pass multiple queries at once. The most relevant results will be returned first.
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth' 1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'
@@ -77,7 +89,6 @@ This application is a Laravel application and its main Laravel ecosystems packag
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit" 4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit"
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms 5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
=== php rules === === php rules ===
## PHP ## PHP
@@ -85,11 +96,13 @@ This application is a Laravel application and its main Laravel ecosystems packag
- Always use curly braces for control structures, even if it has one line. - Always use curly braces for control structures, even if it has one line.
### Constructors ### Constructors
- Use PHP 8 constructor property promotion in `__construct()`. - Use PHP 8 constructor property promotion in `__construct()`.
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet> - <code-snippet>public function \_\_construct(public GitHub $github) { }</code-snippet>
- Do not allow empty `__construct()` methods with zero parameters. - Do not allow empty `__construct()` methods with zero parameters.
### Type Declarations ### Type Declarations
- Always use explicit return type declarations for methods and functions. - Always use explicit return type declarations for methods and functions.
- Use appropriate PHP type hints for method parameters. - Use appropriate PHP type hints for method parameters.
@@ -101,14 +114,16 @@ protected function isAccessible(User $user, ?string $path = null): bool
</code-snippet> </code-snippet>
## Comments ## Comments
- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on. - Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on.
## PHPDoc Blocks ## PHPDoc Blocks
- Add useful array shape type definitions for arrays when appropriate. - Add useful array shape type definitions for arrays when appropriate.
## Enums ## Enums
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
=== inertia-laravel/core rules === === inertia-laravel/core rules ===
@@ -127,7 +142,6 @@ Route::get('/users', function () {
}); });
</code-snippet> </code-snippet>
=== inertia-laravel/v2 rules === === inertia-laravel/v2 rules ===
## Inertia v2 ## Inertia v2
@@ -135,6 +149,7 @@ Route::get('/users', function () {
- Make use of all Inertia features from v1 & v2. Check the documentation before making any changes to ensure we are taking the correct approach. - Make use of all Inertia features from v1 & v2. Check the documentation before making any changes to ensure we are taking the correct approach.
### Inertia v2 New Features ### Inertia v2 New Features
- Polling - Polling
- Prefetching - Prefetching
- Deferred props - Deferred props
@@ -142,11 +157,12 @@ Route::get('/users', function () {
- Lazy loading data on scroll - Lazy loading data on scroll
### Deferred Props & Empty States ### Deferred Props & Empty States
- When using deferred props on the frontend, you should add a nice empty state with pulsing / animated skeleton. - When using deferred props on the frontend, you should add a nice empty state with pulsing / animated skeleton.
### Inertia Form General Guidance ### Inertia Form General Guidance
- Build forms using the `useForm` helper. Use the code examples and `search-docs` tool with a query of `useForm helper` for guidance.
- Build forms using the `useForm` helper. Use the code examples and `search-docs` tool with a query of `useForm helper` for guidance.
=== laravel/core rules === === laravel/core rules ===
@@ -157,6 +173,7 @@ Route::get('/users', function () {
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. - Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
### Database ### Database
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins. - Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
- Use Eloquent models and relationships before suggesting raw database queries - Use Eloquent models and relationships before suggesting raw database queries
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them. - Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
@@ -164,35 +181,43 @@ Route::get('/users', function () {
- Use Laravel's query builder for very complex database operations. - Use Laravel's query builder for very complex database operations.
### Model Creation ### Model Creation
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`. - When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
### APIs & Eloquent Resources ### APIs & Eloquent Resources
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. - For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
### Controllers & Validation ### Controllers & Validation
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages. - Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
- Check sibling Form Requests to see if the application uses array or string based validation rules. - Check sibling Form Requests to see if the application uses array or string based validation rules.
### Queues ### Queues
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface. - Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
### Authentication & Authorization ### Authentication & Authorization
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.). - Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
### URL Generation ### URL Generation
- When generating links to other pages, prefer named routes and the `route()` function. - When generating links to other pages, prefer named routes and the `route()` function.
### Configuration ### Configuration
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`. - Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
### Testing ### Testing
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. - When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. - Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
- When creating tests, make use of `php artisan make:test [options] <name>` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. - When creating tests, make use of `php artisan make:test [options] <name>` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
### Vite Error ### Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
=== laravel/v12 rules === === laravel/v12 rules ===
@@ -202,6 +227,7 @@ Route::get('/users', function () {
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses. - Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
### Laravel 12 Structure ### Laravel 12 Structure
- No middleware files in `app/Http/Middleware/`. - No middleware files in `app/Http/Middleware/`.
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files. - `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
- `bootstrap/providers.php` contains application specific service providers. - `bootstrap/providers.php` contains application specific service providers.
@@ -209,12 +235,13 @@ Route::get('/users', function () {
- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration. - **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration.
### Database ### Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. - When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. - Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models ### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== pint/core rules === === pint/core rules ===
@@ -223,27 +250,29 @@ Route::get('/users', function () {
- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style. - You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues. - Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues.
=== pest/core rules === === pest/core rules ===
## Pest ## Pest
### Testing ### Testing
- If you need to verify a feature is working, write or update a Unit / Feature test. - If you need to verify a feature is working, write or update a Unit / Feature test.
### Pest Tests ### Pest Tests
- All tests must be written using Pest. Use `php artisan make:test --pest <name>`. - All tests must be written using Pest. Use `php artisan make:test --pest <name>`.
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application. - You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application.
- Tests should test all of the happy paths, failure paths, and weird paths. - Tests should test all of the happy paths, failure paths, and weird paths.
- Tests live in the `tests/Feature` and `tests/Unit` directories. - Tests live in the `tests/Feature` and `tests/Unit` directories.
- Pest tests look and behave like this: - Pest tests look and behave like this:
<code-snippet name="Basic Pest Test Example" lang="php"> <code-snippet name="Basic Pest Test Example" lang="php">
it('is true', function () { it('is true', function () {
expect(true)->toBeTrue(); expect(true)->toBeTrue();
}); });
</code-snippet> </code-snippet>
### Running Tests ### Running Tests
- Run the minimal number of tests using an appropriate filter before finalizing code edits. - Run the minimal number of tests using an appropriate filter before finalizing code edits.
- To run all tests: `php artisan test`. - To run all tests: `php artisan test`.
- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`. - To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`.
@@ -251,21 +280,25 @@ it('is true', function () {
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing. - When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
### Pest Assertions ### Pest Assertions
- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.:
<code-snippet name="Pest Example Asserting postJson Response" lang="php">
it('returns all', function () {
$response = $this->postJson('/api/docs', []);
$response->assertSuccessful(); - When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.:
}); <code-snippet name="Pest Example Asserting postJson Response" lang="php">
</code-snippet> it('returns all', function () {
$response = $this->postJson('/api/docs', []);
$response->assertSuccessful();
});
</code-snippet>
### Mocking ### Mocking
- Mocking can be very helpful when appropriate. - Mocking can be very helpful when appropriate.
- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do. - When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do.
- You can also create partial mocks using the same import or self method. - You can also create partial mocks using the same import or self method.
### Datasets ### Datasets
- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules. - Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules.
<code-snippet name="Pest Dataset Example" lang="php"> <code-snippet name="Pest Dataset Example" lang="php">
@@ -277,11 +310,14 @@ it('has emails', function (string $email) {
]); ]);
</code-snippet> </code-snippet>
=== tests rules === === tests rules ===
## Test Enforcement ## Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. - Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter. - Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter.
</laravel-boost-guidelines> </laravel-boost-guidelines>
# Keystone
- Keep CHANGELOG.md up to date.
@@ -4,11 +4,15 @@ namespace App\Actions\Environments;
use App\Enums\BuildArtifactStatus; use App\Enums\BuildArtifactStatus;
use App\Enums\BuildStrategy; use App\Enums\BuildStrategy;
use App\Enums\RegistryType;
use App\Models\BuildArtifact; use App\Models\BuildArtifact;
use App\Models\Operation; use App\Models\Operation;
use App\Models\Registry;
use App\Models\Server; use App\Models\Server;
use App\Models\Service; use App\Models\Service;
use App\Services\Operations\RemoteCommandRunner; use App\Services\Operations\RemoteCommandRunner;
use App\Services\Registries\RegistryDockerAuthScript;
use App\Services\Registries\RegistryResolver;
use RuntimeException; use RuntimeException;
class BuildApplicationArtifact class BuildApplicationArtifact
@@ -61,6 +65,18 @@ class BuildApplicationArtifact
private function buildServer(BuildArtifact $artifact): Server private function buildServer(BuildArtifact $artifact): Server
{ {
$buildServerId = (int) ($artifact->metadata['build_server_id'] ?? 0);
if ($buildServerId > 0) {
$server = Server::find($buildServerId);
if ($server instanceof Server && $server->build_enabled) {
return $server;
}
throw new RuntimeException('Configured build server is missing or not build-enabled.');
}
if ($artifact->builtByService instanceof Service) { if ($artifact->builtByService instanceof Service) {
$server = $artifact->builtByService->replicas->first()?->server ?: $artifact->builtByService->server; $server = $artifact->builtByService->replicas->first()?->server ?: $artifact->builtByService->server;
@@ -70,7 +86,7 @@ class BuildApplicationArtifact
} }
if (($artifact->metadata['build_strategy'] ?? null) === BuildStrategy::DEDICATED_BUILDER->value) { if (($artifact->metadata['build_strategy'] ?? null) === BuildStrategy::DEDICATED_BUILDER->value) {
throw new RuntimeException('Dedicated builder strategy requires a builder service.'); throw new RuntimeException('Dedicated builder strategy requires a builder service or build-enabled server.');
} }
$services = $artifact->environment->services() $services = $artifact->environment->services()
@@ -107,9 +123,13 @@ class BuildApplicationArtifact
$operationDirectory = '/home/keystone/operations/build-'.$artifact->id.'-'.str()->random(8); $operationDirectory = '/home/keystone/operations/build-'.$artifact->id.'-'.str()->random(8);
$imageReference = $artifact->registry_ref ?: $artifact->image_tag; $imageReference = $artifact->registry_ref ?: $artifact->image_tag;
$pushCommand = $strategy === BuildStrategy::DEDICATED_BUILDER && $artifact->registry_ref $publishCommands = $artifact->registry_ref && $strategy !== BuildStrategy::EXTERNAL_REGISTRY
? "\ndocker push ".escapeshellarg($imageReference) ? [
: ''; ...$this->pushDigestCommands($imageReference),
]
: [
'digest=$(docker image inspect --format '.escapeshellarg('{{if .RepoDigests}}{{index .RepoDigests 0}}{{else}}{{.Id}}{{end}}').' '.escapeshellarg($imageReference).')',
];
return implode("\n", [ return implode("\n", [
'set -euo pipefail', 'set -euo pipefail',
@@ -126,25 +146,91 @@ class BuildApplicationArtifact
'git clone --depth 1 --branch '.escapeshellarg($artifact->environment->branch).' '.escapeshellarg($application->repository_url).' "$source_dir"', 'git clone --depth 1 --branch '.escapeshellarg($artifact->environment->branch).' '.escapeshellarg($application->repository_url).' "$source_dir"',
$this->writeFileCommand('$source_dir/Dockerfile.keystone', $this->dockerfile($artifact)), $this->writeFileCommand('$source_dir/Dockerfile.keystone', $this->dockerfile($artifact)),
'cd "$source_dir"', 'cd "$source_dir"',
'docker build --file Dockerfile.keystone --tag '.escapeshellarg($imageReference).' .'.$pushCommand, ...$this->registryMaintenanceLockCommands($artifact),
'digest=$(docker image inspect --format '.escapeshellarg('{{if .RepoDigests}}{{index .RepoDigests 0}}{{else}}{{.Id}}{{end}}').' '.escapeshellarg($imageReference).')', ...$this->buildAuthCommands($artifact),
'docker build --file Dockerfile.keystone --tag '.escapeshellarg($imageReference).' .',
...$publishCommands,
'printf "image_digest=%s\n" "$digest"', 'printf "image_digest=%s\n" "$digest"',
]); ]);
} }
/**
* @return array<int, string>
*/
private function registryMaintenanceLockCommands(BuildArtifact $artifact): array
{
if (($artifact->metadata['registry_type'] ?? null) !== RegistryType::MANAGED->value) {
return [];
}
return [
'install -d -m 700 -o root -g root /home/keystone/registry',
'exec 9>/home/keystone/registry/maintenance.lock',
'flock 9',
];
}
/**
* @return array<int, string>
*/
private function buildAuthCommands(BuildArtifact $artifact): array
{
if (($artifact->metadata['registry_type'] ?? null) !== RegistryType::MANAGED->value) {
return [];
}
$registry = app(RegistryResolver::class)->buildRegistryFor($artifact->environment->application->organisation);
if (! $registry instanceof Registry || $registry->type !== RegistryType::MANAGED || ! $registry->credentials) {
throw new RuntimeException('Managed registry build credentials are not configured.');
}
$auth = app(RegistryDockerAuthScript::class)->forBuild($registry, 'root');
$script = $auth['script'];
foreach ($auth['secrets'] as $key => $value) {
$script = str_replace("[!{$key}!]", $value, $script);
}
return [$script];
}
private function manifestDigestScript(BuildArtifact $artifact): string private function manifestDigestScript(BuildArtifact $artifact): string
{ {
$imageReference = $artifact->registry_ref ?: $artifact->image_tag; $imageReference = $artifact->registry_ref ?: $artifact->image_tag;
return implode("\n", [ return implode("\n", [
'set -euo pipefail', 'set -euo pipefail',
'manifest=$(docker manifest inspect '.escapeshellarg($imageReference).')', ...$this->manifestDigestCommands($imageReference),
'digest=$(printf "%s" "$manifest" | sed -n '.escapeshellarg('s/.*"digest": "\(sha256:[^"]*\)".*/\1/p').' | head -n 1)',
'test -n "$digest"',
'printf "image_digest=%s\n" "$digest"', 'printf "image_digest=%s\n" "$digest"',
]); ]);
} }
/**
* @return array<int, string>
*/
private function manifestDigestCommands(string $imageReference): array
{
return [
'inspect_output=$(docker buildx imagetools inspect '.escapeshellarg($imageReference).')',
'digest=$(printf "%s\n" "$inspect_output" | sed -n '.escapeshellarg('s/^Digest:[[:space:]]*\(sha256:[^[:space:]]*\).*/\1/p').' | head -n 1)',
'test -n "$digest"',
];
}
/**
* @return array<int, string>
*/
private function pushDigestCommands(string $imageReference): array
{
return [
'push_output=$(docker push '.escapeshellarg($imageReference).')',
'printf "%s\n" "$push_output"',
'digest=$(printf "%s\n" "$push_output" | sed -n '.escapeshellarg('s/.*digest: \(sha256:[^[:space:]]*\).*/\1/p').' | tail -n 1)',
'test -n "$digest"',
];
}
private function dockerfile(BuildArtifact $artifact): string private function dockerfile(BuildArtifact $artifact): string
{ {
$service = $artifact->environment->services() $service = $artifact->environment->services()
@@ -176,7 +262,7 @@ DOCKERFILE;
private function digestFromOutput(string $output): string private function digestFromOutput(string $output): string
{ {
if (preg_match('/image_digest=(?<digest>\S+)/', $output, $matches)) { if (preg_match('/image_digest=(?<digest>\S+)/', $output, $matches)) {
return $this->digestFromOutput($matches['digest']); $output = $matches['digest'];
} }
if (str_contains($output, '@')) { if (str_contains($output, '@')) {
+40 -7
View File
@@ -4,13 +4,21 @@ namespace App\Actions\Environments;
use App\Enums\BuildArtifactStatus; use App\Enums\BuildArtifactStatus;
use App\Enums\BuildStrategy; use App\Enums\BuildStrategy;
use App\Enums\RegistryType;
use App\Enums\ServiceCategory; use App\Enums\ServiceCategory;
use App\Models\BuildArtifact; use App\Models\BuildArtifact;
use App\Models\Environment; use App\Models\Environment;
use App\Services\Registries\ImageReference;
use App\Services\Registries\RegistryResolver;
use RuntimeException; use RuntimeException;
class PlanBuildArtifact class PlanBuildArtifact
{ {
public function __construct(
private readonly RegistryResolver $registryResolver,
private readonly ImageReference $imageReference,
) {}
public function execute(Environment $environment, string $commitSha): BuildArtifact public function execute(Environment $environment, string $commitSha): BuildArtifact
{ {
$environment->loadMissing(['application.organisation.registries', 'services.replicas']); $environment->loadMissing(['application.organisation.registries', 'services.replicas']);
@@ -26,36 +34,48 @@ class PlanBuildArtifact
} }
$targetServerCount = $this->targetServerCount($environment); $targetServerCount = $this->targetServerCount($environment);
$registry = $environment->application->organisation->registries()->first(); $registry = $this->registryResolver->buildRegistryFor($environment->application->organisation);
$registryType = $this->registryType($registry);
if ($targetServerCount > 1 && ! $registry) { if ($targetServerCount > 1 && ! $registry) {
throw new RuntimeException('A registry is required before building artifacts for multi-server deployments.'); $blocker = $this->registryResolver->managedRegistryBlockerFor($environment->application->organisation);
throw new RuntimeException($blocker ?: 'A registry is required before building artifacts for multi-server deployments.');
} }
$builder = $environment->application->organisation->services() $builder = $environment->application->organisation->services()
->where('category', ServiceCategory::BUILDER) ->where('category', ServiceCategory::BUILDER)
->first(); ->first();
$buildServerId = null;
if ($registryType === RegistryType::MANAGED) {
$buildServerId = (int) $registry->control_server_id;
if ($buildServerId <= 0) {
throw new RuntimeException('A control/build server is required for managed registry builds.');
}
}
$strategy = match (true) { $strategy = match (true) {
$registryType === RegistryType::MANAGED => BuildStrategy::DEDICATED_BUILDER,
$registry !== null => BuildStrategy::EXTERNAL_REGISTRY, $registry !== null => BuildStrategy::EXTERNAL_REGISTRY,
$builder !== null => BuildStrategy::DEDICATED_BUILDER, $builder !== null => BuildStrategy::DEDICATED_BUILDER,
default => BuildStrategy::TARGET_SERVER, default => BuildStrategy::TARGET_SERVER,
}; };
$imageTag = str($environment->application->name) $imageTag = $this->imageReference->tagFor($environment, $commitSha, $registry);
->slug()
->append(':'.substr($commitSha, 0, 12))
->value();
return $environment->buildArtifacts()->create([ return $environment->buildArtifacts()->create([
'commit_sha' => $commitSha, 'commit_sha' => $commitSha,
'image_tag' => $imageTag, 'image_tag' => $imageTag,
'registry_ref' => $registry ? rtrim((string) $registry->url, '/').'/'.$imageTag : null, 'registry_ref' => $registry ? $this->imageReference->registryReference($registry, $imageTag) : null,
'built_by_service_id' => $builder?->id, 'built_by_service_id' => $builder?->id,
'status' => BuildArtifactStatus::PENDING, 'status' => BuildArtifactStatus::PENDING,
'metadata' => [ 'metadata' => [
'build_strategy' => $strategy->value, 'build_strategy' => $strategy->value,
'registry_type' => $registryType?->value,
'target_server_count' => $targetServerCount, 'target_server_count' => $targetServerCount,
'build_server_id' => $buildServerId,
], ],
]); ]);
} }
@@ -73,4 +93,17 @@ class PlanBuildArtifact
return $environment->services->sum('desired_replicas') > 1 ? 2 : 1; return $environment->services->sum('desired_replicas') > 1 ? 2 : 1;
} }
private function registryType(mixed $registry): ?RegistryType
{
if (! $registry) {
return null;
}
if ($registry->type instanceof RegistryType) {
return $registry->type;
}
return RegistryType::tryFrom((string) $registry->type);
}
} }
@@ -7,9 +7,14 @@ use App\Enums\DeployPolicy;
use App\Enums\EnvironmentAttachmentRole; use App\Enums\EnvironmentAttachmentRole;
use App\Enums\SchedulerMode; use App\Enums\SchedulerMode;
use App\Models\Environment; use App\Models\Environment;
use App\Services\Registries\RegistryResolver;
class PlanEnvironmentDeployment class PlanEnvironmentDeployment
{ {
public function __construct(
private readonly RegistryResolver $registryResolver,
) {}
public function execute(Environment $environment): EnvironmentDeploymentPlan public function execute(Environment $environment): EnvironmentDeploymentPlan
{ {
$environment->loadMissing([ $environment->loadMissing([
@@ -40,9 +45,9 @@ class PlanEnvironmentDeployment
return new EnvironmentDeploymentPlan( return new EnvironmentDeploymentPlan(
services: $deployableServices->all(), services: $deployableServices->all(),
dependencies: $dependencies->all(), dependencies: $dependencies->all(),
requiresRegistry: $targetServerCount > 1 && $environment->application->organisation->registries()->doesntExist(), requiresRegistry: $targetServerCount > 1 && ! $this->registryResolver->buildRegistryFor($environment->application->organisation),
warnings: $this->warnings($environment), warnings: $this->warnings($environment),
blockers: $this->blockers($environment), blockers: $this->blockers($environment, $targetServerCount),
); );
} }
@@ -74,18 +79,28 @@ class PlanEnvironmentDeployment
/** /**
* @return array<int, string> * @return array<int, string>
*/ */
private function blockers(Environment $environment): array private function blockers(Environment $environment, int $targetServerCount): array
{ {
$blockers = [];
if ($targetServerCount > 1 && ! $this->registryResolver->buildRegistryFor($environment->application->organisation)) {
$blocker = $this->registryResolver->managedRegistryBlockerFor($environment->application->organisation);
if ($blocker !== null) {
$blockers[] = $blocker;
}
}
if (! $environment->scheduler_enabled || $environment->scheduler_mode !== SchedulerMode::SINGLE) { if (! $environment->scheduler_enabled || $environment->scheduler_mode !== SchedulerMode::SINGLE) {
return []; return $blockers;
} }
$target = $environment->services->firstWhere('id', $environment->scheduler_target_service_id); $target = $environment->services->firstWhere('id', $environment->scheduler_target_service_id);
if ($target && $target->desired_replicas > 1 && in_array('scheduler', $target->process_roles ?? [], true)) { if ($target && $target->desired_replicas > 1 && in_array('scheduler', $target->process_roles ?? [], true)) {
return ['Scheduler mode single requires the scheduler target service to run exactly one replica.']; $blockers[] = 'Scheduler mode single requires the scheduler target service to run exactly one replica.';
} }
return []; return $blockers;
} }
} }
@@ -0,0 +1,79 @@
<?php
namespace App\Actions\Registries;
use App\Enums\BuildArtifactStatus;
use App\Enums\OperationKind;
use App\Enums\OperationStatus;
use App\Models\BuildArtifact;
use App\Models\Operation;
use App\Models\Registry;
use App\Models\Server;
use App\Services\Registries\ManagedRegistryOperationScripts;
use App\Services\Registries\ManagedRegistryRetention;
use RuntimeException;
class CreateManagedRegistryMaintenanceOperation
{
public function __construct(
private readonly ManagedRegistryRetention $retention,
private readonly ManagedRegistryOperationScripts $scripts,
) {}
public function execute(Registry $registry): Operation
{
$server = $registry->controlServer;
if (! $server instanceof Server) {
throw new RuntimeException('A control/build server is required to prune the managed registry.');
}
$activeBuilds = $registry->organisation->applications()
->whereHas('environments.buildArtifacts', fn ($query) => $query
->where('status', BuildArtifactStatus::BUILDING)
->where('registry_ref', 'like', rtrim((string) $registry->url, '/').'/%'))
->exists();
if ($activeBuilds) {
throw new RuntimeException('Managed registry pruning cannot run while builds are active.');
}
$this->retention->markPrunable($registry);
$artifacts = $this->prunableArtifacts($registry);
$maintenance = $this->scripts->maintenance($registry, $artifacts);
$operation = $server->operations()->create([
'kind' => OperationKind::REGISTRY_MAINTENANCE,
'status' => OperationStatus::PENDING,
'metadata' => [
'registry_id' => $registry->id,
'artifact_ids' => $artifacts->pluck('id')->values()->all(),
],
]);
$operation->steps()->create([
'name' => 'Delete prunable manifests and run registry GC',
'order' => 1,
'status' => OperationStatus::PENDING,
'script' => $maintenance['script'],
'secrets' => $maintenance['secrets'],
]);
return $operation->refresh();
}
/**
* @return \Illuminate\Support\Collection<int, BuildArtifact>
*/
private function prunableArtifacts(Registry $registry): \Illuminate\Support\Collection
{
return $registry->organisation->applications()
->with(['environments.buildArtifacts' => fn ($query) => $query
->where('status', BuildArtifactStatus::PRUNABLE)
->where('registry_ref', 'like', rtrim((string) $registry->url, '/').'/%')])
->get()
->flatMap(fn ($application) => $application->environments)
->flatMap(fn ($environment) => $environment->buildArtifacts)
->values();
}
}
@@ -0,0 +1,44 @@
<?php
namespace App\Actions\Registries;
use App\Enums\OperationKind;
use App\Enums\OperationStatus;
use App\Models\Operation;
use App\Models\Registry;
use App\Models\Server;
use App\Services\Registries\ManagedRegistryOperationScripts;
use RuntimeException;
class CreateManagedRegistryProvisionOperation
{
public function __construct(
private readonly ManagedRegistryOperationScripts $scripts,
) {}
public function execute(Registry $registry): Operation
{
$server = $registry->controlServer;
if (! $server instanceof Server) {
throw new RuntimeException('A control/build server is required to provision the managed registry.');
}
$provision = $this->scripts->provision($registry);
$operation = $server->operations()->create([
'kind' => OperationKind::REGISTRY_PROVISION,
'status' => OperationStatus::PENDING,
]);
$operation->steps()->create([
'name' => 'Install managed Docker registry',
'order' => 1,
'status' => OperationStatus::PENDING,
'script' => $provision['script'],
'secrets' => $provision['secrets'],
]);
return $operation->refresh();
}
}
@@ -0,0 +1,86 @@
<?php
namespace App\Actions\Registries;
use App\Enums\OperationKind;
use App\Enums\OperationStatus;
use App\Models\Operation;
use App\Models\Registry;
use App\Models\Server;
use App\Services\Registries\ManagedRegistryOperationScripts;
use RuntimeException;
class CreateManagedRegistrySmokeCheckOperation
{
public function __construct(
private readonly ManagedRegistryOperationScripts $scripts,
) {}
/**
* @param iterable<int, Server> $runtimeServers
*/
public function execute(Registry $registry, ?Server $buildServer = null, iterable $runtimeServers = []): Operation
{
$controlServer = $registry->controlServer;
if (! $controlServer instanceof Server) {
throw new RuntimeException('A control/build server is required to check the managed registry.');
}
$buildServer ??= $controlServer;
$smokeRef = rtrim((string) $registry->url, '/').'/keystone/smoke/server-'.$buildServer->id.':latest';
$checks = [
'control_https' => 'pending',
'build_push' => 'pending',
];
foreach ($runtimeServers as $server) {
$checks['runtime_pull_server_'.$server->id] = 'pending';
}
$registry->forceFill([
'readiness_checks' => $checks,
'health_status' => 'pending',
'ready_at' => null,
])->save();
$operation = $controlServer->operations()->create([
'kind' => OperationKind::REGISTRY_HEALTH_CHECK,
'status' => OperationStatus::PENDING,
'metadata' => [
'registry_id' => $registry->id,
],
]);
$build = $this->scripts->smokeCheck($registry, $buildServer, 'build', $smokeRef);
$operation->steps()->create([
'name' => 'Check registry HTTPS and build push',
'order' => 1,
'status' => OperationStatus::PENDING,
'script' => $build['script'],
'secrets' => $build['secrets'],
]);
$order = 2;
foreach ($runtimeServers as $server) {
$runtime = $this->scripts->smokeCheck($registry, $server, 'runtime', $smokeRef);
$child = $server->operations()->create([
'kind' => OperationKind::REGISTRY_HEALTH_CHECK,
'parent_id' => $operation->id,
'status' => OperationStatus::PENDING,
'metadata' => [
'registry_id' => $registry->id,
],
]);
$child->steps()->create([
'name' => 'Check runtime registry pull on '.$server->name,
'order' => $order++,
'status' => OperationStatus::PENDING,
'script' => $runtime['script'],
'secrets' => $runtime['secrets'],
]);
}
return $operation->refresh();
}
}
@@ -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();
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
+2
View File
@@ -12,4 +12,6 @@ enum BuildArtifactStatus: string
case BUILDING = 'building'; case BUILDING = 'building';
case AVAILABLE = 'available'; case AVAILABLE = 'available';
case FAILED = 'failed'; case FAILED = 'failed';
case PRUNABLE = 'prunable';
case PRUNED = 'pruned';
} }
+3
View File
@@ -17,4 +17,7 @@ enum OperationKind: string
case GATEWAY_CUTOVER = 'gateway_cutover'; case GATEWAY_CUTOVER = 'gateway_cutover';
case CONFIG_CHANGE = 'config_change'; case CONFIG_CHANGE = 'config_change';
case CREDENTIAL_ROTATION = 'credential_rotation'; case CREDENTIAL_ROTATION = 'credential_rotation';
case REGISTRY_PROVISION = 'registry_provision';
case REGISTRY_HEALTH_CHECK = 'registry_health_check';
case REGISTRY_MAINTENANCE = 'registry_maintenance';
} }
+1
View File
@@ -8,6 +8,7 @@ enum RegistryType: string
{ {
use Arrayable; use Arrayable;
case MANAGED = 'managed';
case GENERIC = 'generic'; case GENERIC = 'generic';
case GITEA = 'gitea'; case GITEA = 'gitea';
case GHCR = 'ghcr'; case GHCR = 'ghcr';
@@ -7,11 +7,16 @@ use App\Jobs\Environments\DeployEnvironment;
use App\Models\Application; use App\Models\Application;
use App\Models\Environment; use App\Models\Environment;
use App\Models\Organisation; use App\Models\Organisation;
use App\Services\Registries\RegistryResolver;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
class EnvironmentDeploymentController extends Controller class EnvironmentDeploymentController extends Controller
{ {
public function __construct(
private readonly RegistryResolver $registryResolver,
) {}
public function store(StoreEnvironmentDeploymentRequest $request, Organisation $organisation, Application $application, Environment $environment): RedirectResponse public function store(StoreEnvironmentDeploymentRequest $request, Organisation $organisation, Application $application, Environment $environment): RedirectResponse
{ {
abort_unless( abort_unless(
@@ -22,7 +27,7 @@ class EnvironmentDeploymentController extends Controller
$environment->loadMissing('services.replicas'); $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.'); 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, 'status' => OperationStatus::CANCELLED,
'finished_at' => now(), 'finished_at' => now(),
]); ]);
$this->clearOperationSecrets($operation);
return redirect() return redirect()
->route('operations.show', [ ->route('operations.show', [
@@ -168,4 +169,13 @@ class OperationController extends Controller
default => false, default => false,
}; };
} }
private function clearOperationSecrets(Operation $operation): void
{
$operation->steps()->update(['secrets' => null]);
$operation->children()->get()->each(function (Operation $child): void {
$this->clearOperationSecrets($child);
});
}
} }
+17 -2
View File
@@ -30,7 +30,7 @@ class RegistryController extends Controller
Organisation::findOrFail($request->route('organisation')); Organisation::findOrFail($request->route('organisation'));
return inertia('registries/Create', [ return inertia('registries/Create', [
'registryTypes' => array_values(RegistryType::toArray()), 'registryTypes' => $this->userConfigurableRegistryTypes(),
]); ]);
} }
@@ -81,7 +81,7 @@ class RegistryController extends Controller
return inertia('registries/Edit', [ return inertia('registries/Edit', [
'registry' => $registry, 'registry' => $registry,
'registryTypes' => array_values(RegistryType::toArray()), 'registryTypes' => $this->userConfigurableRegistryTypes(),
]); ]);
} }
@@ -91,6 +91,8 @@ class RegistryController extends Controller
/** @var Registry $registry */ /** @var Registry $registry */
$registry = $organisation->registries()->findOrFail($request->route('registry')); $registry = $organisation->registries()->findOrFail($request->route('registry'));
abort_if($registry->type === RegistryType::MANAGED, 403);
$credentials = $registry->credentials ?? []; $credentials = $registry->credentials ?? [];
$username = $request->string('username')->toString(); $username = $request->string('username')->toString();
@@ -117,10 +119,23 @@ class RegistryController extends Controller
$organisation = Organisation::findOrFail($request->route('organisation')); $organisation = Organisation::findOrFail($request->route('organisation'));
$registry = $organisation->registries()->findOrFail($request->route('registry')); $registry = $organisation->registries()->findOrFail($request->route('registry'));
abort_if($registry->type === RegistryType::MANAGED, 403);
$registry->delete(); $registry->delete();
return redirect() return redirect()
->route('organisations.show', ['organisation' => $organisation->id]) ->route('organisations.show', ['organisation' => $organisation->id])
->with('success', 'Registry deleted.'); ->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();
}
} }
+15 -5
View File
@@ -30,11 +30,7 @@ class HandleInertiaRequests extends Middleware
return [ return [
...parent::share($request), ...parent::share($request),
'name' => config('app.name'), 'name' => config('app.name'),
'organisation' => $request->route('organisation') 'organisation' => $this->resolveOrganisation($request),
? Organisation::with('applications.environments')
->withCount(['providers', 'sourceProviders', 'registries', 'servers', 'applications'])
->findOrFail($this->routeKey($request->route('organisation')))
: null,
'application' => $request->route('application') 'application' => $request->route('application')
? Application::with('environments')->findOrFail($this->routeKey($request->route('application'))) ? Application::with('environments')->findOrFail($this->routeKey($request->route('application')))
: null, : null,
@@ -53,6 +49,20 @@ class HandleInertiaRequests extends Middleware
]; ];
} }
private function resolveOrganisation(Request $request): ?Organisation
{
$query = Organisation::with('applications.environments')
->withCount(['providers', 'sourceProviders', 'registries', 'servers', 'applications']);
if ($request->route('organisation')) {
return $query->findOrFail($this->routeKey($request->route('organisation')));
}
$organisationId = $request->user()?->organisations()->value('organisations.id');
return $organisationId ? $query->find($organisationId) : null;
}
private function routeKey(mixed $routeValue): mixed private function routeKey(mixed $routeValue): mixed
{ {
return $routeValue instanceof Model ? $routeValue->getKey() : $routeValue; return $routeValue instanceof Model ? $routeValue->getKey() : $routeValue;
+2 -2
View File
@@ -25,8 +25,8 @@ class StoreRegistryRequest extends FormRequest
{ {
return [ return [
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
'type' => ['required', Rule::enum(RegistryType::class)], 'type' => ['required', Rule::enum(RegistryType::class), Rule::notIn([RegistryType::MANAGED->value])],
'url' => ['required', 'string', 'max:255'], 'url' => ['required', 'string', 'max:255', 'not_regex:#^https?://#i'],
'username' => ['nullable', 'string', 'max:255'], 'username' => ['nullable', 'string', 'max:255'],
'password' => ['nullable', 'string', 'max:255'], 'password' => ['nullable', 'string', 'max:255'],
]; ];
+2 -2
View File
@@ -25,8 +25,8 @@ class UpdateRegistryRequest extends FormRequest
{ {
return [ return [
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
'type' => ['required', Rule::enum(RegistryType::class)], 'type' => ['required', Rule::enum(RegistryType::class), Rule::notIn([RegistryType::MANAGED->value])],
'url' => ['required', 'string', 'max:255'], 'url' => ['required', 'string', 'max:255', 'not_regex:#^https?://#i'],
'username' => ['nullable', 'string', 'max:255'], 'username' => ['nullable', 'string', 'max:255'],
'password' => ['nullable', 'string', 'max:255'], 'password' => ['nullable', 'string', 'max:255'],
]; ];
+62 -14
View File
@@ -15,9 +15,13 @@ use App\Enums\ServiceEndpointScope;
use App\Models\Environment; use App\Models\Environment;
use App\Models\EnvironmentAttachment; use App\Models\EnvironmentAttachment;
use App\Models\Operation; use App\Models\Operation;
use App\Models\Registry;
use App\Models\Server;
use App\Models\Service; use App\Models\Service;
use App\Models\ServiceReplica; use App\Models\ServiceReplica;
use App\Services\Compose\ComposeRenderer; use App\Services\Compose\ComposeRenderer;
use App\Services\Registries\RegistryDockerAuthScript;
use App\Services\Registries\RegistryResolver;
use App\Support\CaddyRouteRenderer; use App\Support\CaddyRouteRenderer;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable; use Illuminate\Foundation\Queue\Queueable;
@@ -67,6 +71,7 @@ class DeployEnvironment implements ShouldQueue
$artifact = app(PlanBuildArtifact::class)->execute($this->environment, $commitSha); $artifact = app(PlanBuildArtifact::class)->execute($this->environment, $commitSha);
$artifact = app(BuildApplicationArtifact::class)->execute($artifact, $operation); $artifact = app(BuildApplicationArtifact::class)->execute($artifact, $operation);
$registry = app(RegistryResolver::class)->buildRegistryFor($this->environment->application->organisation);
foreach ($services as $service) { foreach ($services as $service) {
$service->update([ $service->update([
@@ -80,8 +85,8 @@ class DeployEnvironment implements ShouldQueue
'status' => OperationStatus::PENDING, 'status' => OperationStatus::PENDING,
]); ]);
$this->createServiceDeploySteps($child, $service, $commitSha, $artifact->image_digest); $this->createServiceDeploySteps($child, $service, $commitSha, $artifact->image_digest, $artifact->registry_ref);
$this->createReplicaDeployOperations($child, $service, $artifact->registry_ref); $this->createReplicaDeployOperations($child, $service, $artifact->registry_ref, $registry);
} }
$this->createGatewayOperations($operation); $this->createGatewayOperations($operation);
@@ -100,19 +105,20 @@ class DeployEnvironment implements ShouldQueue
->all(); ->all();
} }
private function createServiceDeploySteps(Operation $operation, Service $service, string $commitSha, string $imageDigest): void private function createServiceDeploySteps(Operation $operation, Service $service, string $commitSha, string $imageDigest, ?string $imageReference = null): void
{ {
foreach ($this->serviceDeployScripts($service, $commitSha, $imageDigest) as $index => $step) { foreach ($this->serviceDeployScripts($service, $commitSha, $imageDigest, $imageReference) as $index => $step) {
$operation->steps()->create([ $operation->steps()->create([
'name' => $step['name'], 'name' => $step['name'],
'order' => $index + 1, 'order' => $index + 1,
'status' => OperationStatus::PENDING, 'status' => OperationStatus::PENDING,
'script' => $step['script'], 'script' => $step['script'],
'secrets' => $step['secrets'] ?? null,
]); ]);
} }
} }
private function createReplicaDeployOperations(Operation $parent, Service $service, ?string $imageReference = null): void private function createReplicaDeployOperations(Operation $parent, Service $service, ?string $imageReference = null, ?Registry $registry = null): void
{ {
$replicas = $this->ensureServiceReplicas($service); $replicas = $this->ensureServiceReplicas($service);
@@ -133,12 +139,13 @@ class DeployEnvironment implements ShouldQueue
'health_status' => 'unknown', 'health_status' => 'unknown',
]); ]);
foreach ($this->replicaDeployScripts($service, $replica, $imageReference) as $index => $step) { foreach ($this->replicaDeployScripts($service, $replica, $imageReference, $registry, $serviceReplica) as $index => $step) {
$operation->steps()->create([ $operation->steps()->create([
'name' => $step['name'], 'name' => $step['name'],
'order' => $index + 1, 'order' => $index + 1,
'status' => OperationStatus::PENDING, 'status' => OperationStatus::PENDING,
'script' => $step['script'], 'script' => $step['script'],
'secrets' => $step['secrets'] ?? null,
]); ]);
} }
} }
@@ -244,7 +251,7 @@ class DeployEnvironment implements ShouldQueue
/** /**
* @return array<int, array{name: string, script: string}> * @return array<int, array{name: string, script: string}>
*/ */
private function serviceDeployScripts(Service $service, string $commitSha, string $imageDigest): array private function serviceDeployScripts(Service $service, string $commitSha, string $imageDigest, ?string $imageReference = null): array
{ {
$servicePath = $this->servicePath($service); $servicePath = $this->servicePath($service);
$composePath = "{$servicePath}/compose.yml"; $composePath = "{$servicePath}/compose.yml";
@@ -264,7 +271,7 @@ class DeployEnvironment implements ShouldQueue
], ],
[ [
'name' => 'Render Compose files', 'name' => 'Render Compose files',
'script' => $this->composeUploadScript($service), 'script' => $this->composeUploadScript($service, $this->fullImageReference($imageReference, $imageDigest)),
], ],
]; ];
@@ -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'; $composePath = $this->servicePath($service).'/compose.yml';
$project = "keystone_service_{$service->id}_replica_{$replica}"; $project = "keystone_service_{$service->id}_replica_{$replica}";
$serviceKey = $this->serviceKey($service); $serviceKey = $this->serviceKey($service);
$targetServer = $serviceReplica?->server ?: $service->server;
$steps = []; $steps = [
[
'name' => "Render replica {$replica} Compose files",
'script' => $this->composeUploadScript($service, $this->fullImageReference($imageReference, $service->available_image_digest)),
],
];
if ($imageReference && $service->available_image_digest) { if ($imageReference && $service->available_image_digest) {
if ($registry instanceof Registry && $registry->credentials) {
$auth = app(RegistryDockerAuthScript::class)->forRuntime($registry, $this->dockerAuthUser($targetServer));
$steps[] = [
'name' => "Configure registry auth for replica {$replica}",
'script' => $auth['script'],
'secrets' => $auth['secrets'],
];
}
$steps[] = [ $steps[] = [
'name' => "Pull image for replica {$replica}", 'name' => "Pull image for replica {$replica}",
'script' => 'docker pull '.escapeshellarg($imageReference.'@'.$service->available_image_digest), 'script' => 'docker pull '.escapeshellarg($imageReference.'@'.$service->available_image_digest),
@@ -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); $servicePath = $this->servicePath($service);
try { try {
$renderer = app(ComposeRenderer::class); $renderer = app(ComposeRenderer::class);
$compose = $renderer->render($service); $compose = $renderer->render($this->serviceForCompose($service, $fullImageReference));
$env = $renderer->renderEnvironmentFile($service); $env = $renderer->renderEnvironmentFile($service);
} catch (InvalidArgumentException) { } catch (InvalidArgumentException) {
$compose = "services:\n {$this->serviceKey($service)}:\n image: \"{$service->available_image_digest}\"\n"; $compose = "services:\n {$this->serviceKey($service)}:\n image: \"".($fullImageReference ?: $service->available_image_digest)."\"\n";
$env = ''; $env = '';
} }
@@ -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}> * @return array<int, array{name: string, script: string}>
*/ */
+114 -2
View File
@@ -2,11 +2,15 @@
namespace App\Jobs\Services; namespace App\Jobs\Services;
use App\Enums\BuildArtifactStatus;
use App\Enums\OperationStatus; use App\Enums\OperationStatus;
use App\Enums\RegistryType;
use App\Enums\ServiceStatus; use App\Enums\ServiceStatus;
use App\Models\BuildArtifact;
use App\Models\Environment; use App\Models\Environment;
use App\Models\Operation; use App\Models\Operation;
use App\Models\OperationStep; use App\Models\OperationStep;
use App\Models\Registry;
use App\Models\Server; use App\Models\Server;
use App\Models\Service; use App\Models\Service;
use App\Models\ServiceReplica; use App\Models\ServiceReplica;
@@ -110,6 +114,9 @@ class RunStep implements ShouldQueue
if ($operation->is($this->step->operation)) { if ($operation->is($this->step->operation)) {
$this->markTargetCompleted(); $this->markTargetCompleted();
} }
$this->markRegistryHealthOperationCompleted($operation);
$this->markRegistryMaintenanceOperationCompleted($operation);
} }
private function dispatchNextChildOperation(Operation $operation): bool private function dispatchNextChildOperation(Operation $operation): bool
@@ -166,6 +173,7 @@ class RunStep implements ShouldQueue
$target instanceof ServiceReplica => $target->server, $target instanceof ServiceReplica => $target->server,
$target instanceof Service => $target->replicas()->with('server')->first()?->server ?: $target->server, $target instanceof Service => $target->replicas()->with('server')->first()?->server ?: $target->server,
$target instanceof ServiceSlice => $target->service->replicas()->with('server')->first()?->server ?: $target->service->server, $target instanceof ServiceSlice => $target->service->replicas()->with('server')->first()?->server ?: $target->service->server,
$target instanceof Server => $target,
$target instanceof Environment => $target->services()->with(['server', 'replicas.server'])->get() $target instanceof Environment => $target->services()->with(['server', 'replicas.server'])->get()
->flatMap(fn (Service $service) => $service->replicas->pluck('server')->filter()) ->flatMap(fn (Service $service) => $service->replicas->pluck('server')->filter())
->first() ?: $target->services()->with('server')->get()->pluck('server')->filter()->first(), ->first() ?: $target->services()->with('server')->get()->pluck('server')->filter()->first(),
@@ -190,10 +198,12 @@ class RunStep implements ShouldQueue
'status' => OperationStatus::FAILED, 'status' => OperationStatus::FAILED,
'finished_at' => now(), 'finished_at' => now(),
'error_logs' => $this->step->error_logs."\n".trim($message), 'error_logs' => $this->step->error_logs."\n".trim($message),
'secrets' => null,
]); ]);
$this->step->operation->steps()->where('order', '>', $this->step->order)->update([ $this->step->operation->steps()->where('order', '>', $this->step->order)->update([
'status' => OperationStatus::CANCELLED, 'status' => OperationStatus::CANCELLED,
'secrets' => null,
]); ]);
$this->step->operation->update([ $this->step->operation->update([
@@ -203,13 +213,114 @@ class RunStep implements ShouldQueue
$this->cancelDescendants($this->step->operation); $this->cancelDescendants($this->step->operation);
$this->cancelPendingSiblingsAndAncestors($this->step->operation); $this->cancelPendingSiblingsAndAncestors($this->step->operation);
$this->markRegistryHealthOperationFailed($this->step->operation, trim($message));
}
private function markRegistryHealthOperationCompleted(Operation $operation): void
{
if ($operation->kind !== \App\Enums\OperationKind::REGISTRY_HEALTH_CHECK) {
return;
}
if ($operation->parent_id !== null) {
return;
}
$registry = $this->managedRegistryForOperation($operation);
if (! $registry instanceof Registry) {
return;
}
$checks = collect($registry->readiness_checks ?? [])
->map(fn (): string => 'passed')
->all();
$registry->forceFill([
'readiness_checks' => $checks,
])->save();
$registry->markHealthy('Managed registry smoke checks passed.');
}
private function markRegistryHealthOperationFailed(Operation $operation, string $message): void
{
if ($operation->kind !== \App\Enums\OperationKind::REGISTRY_HEALTH_CHECK) {
return;
}
$registry = $this->managedRegistryForOperation($operation);
$registry?->markUnhealthy('Managed registry smoke check failed: '.$message);
}
private function markRegistryMaintenanceOperationCompleted(Operation $operation): void
{
if ($operation->kind !== \App\Enums\OperationKind::REGISTRY_MAINTENANCE) {
return;
}
$registry = $this->managedRegistryForOperation($operation);
if (! $registry instanceof Registry) {
return;
}
$artifactIds = collect($operation->metadata['artifact_ids'] ?? [])
->filter(fn ($id): bool => is_numeric($id))
->map(fn ($id): int => (int) $id)
->values();
BuildArtifact::query()
->whereIn('id', $artifactIds)
->where('status', BuildArtifactStatus::PRUNABLE)
->where('registry_ref', 'like', rtrim((string) $registry->url, '/').'/%')
->each(function ($artifact): void {
$artifact->update([
'status' => BuildArtifactStatus::PRUNED,
'metadata' => [
...($artifact->metadata ?? []),
'pruned_at' => now()->toIso8601String(),
],
]);
});
}
private function managedRegistryForOperation(Operation $operation): ?Registry
{
$registryId = $operation->metadata['registry_id'] ?? $operation->parent?->metadata['registry_id'] ?? null;
if ($registryId) {
$registry = Registry::query()
->where('type', RegistryType::MANAGED->value)
->find($registryId);
if ($registry instanceof Registry) {
return $registry;
}
}
$server = $operation->target;
if (! $server instanceof Server) {
$server = $operation->parent?->target;
}
if (! $server instanceof Server) {
return null;
}
return Registry::query()
->where('type', RegistryType::MANAGED->value)
->where('control_server_id', $server->id)
->first();
} }
private function cancelDescendants(Operation $operation): void private function cancelDescendants(Operation $operation): void
{ {
$operation->children()->with('children')->get()->each(function (Operation $child): void { $operation->children()->with('children')->get()->each(function (Operation $child): void {
$child->steps()->where('status', OperationStatus::PENDING)->update([ $child->steps()->whereIn('status', [OperationStatus::PENDING, OperationStatus::IN_PROGRESS])->update([
'status' => OperationStatus::CANCELLED, 'status' => OperationStatus::CANCELLED,
'secrets' => null,
]); ]);
$child->update([ $child->update([
'status' => OperationStatus::CANCELLED, 'status' => OperationStatus::CANCELLED,
@@ -232,8 +343,9 @@ class RunStep implements ShouldQueue
->whereIn('status', [OperationStatus::PENDING, OperationStatus::IN_PROGRESS]) ->whereIn('status', [OperationStatus::PENDING, OperationStatus::IN_PROGRESS])
->get() ->get()
->each(function (Operation $sibling): void { ->each(function (Operation $sibling): void {
$sibling->steps()->where('status', OperationStatus::PENDING)->update([ $sibling->steps()->whereIn('status', [OperationStatus::PENDING, OperationStatus::IN_PROGRESS])->update([
'status' => OperationStatus::CANCELLED, 'status' => OperationStatus::CANCELLED,
'secrets' => null,
]); ]);
$sibling->update([ $sibling->update([
'status' => OperationStatus::CANCELLED, 'status' => OperationStatus::CANCELLED,
+8
View File
@@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Support\Str;
class Application extends Model class Application extends Model
{ {
@@ -15,6 +16,13 @@ class Application extends Model
protected $guarded = []; protected $guarded = [];
protected static function booted(): void
{
static::creating(function (Application $application): void {
$application->uuid ??= (string) Str::uuid();
});
}
protected function casts(): array protected function casts(): array
{ {
return [ return [
+8
View File
@@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Support\Str;
class Environment extends Model class Environment extends Model
{ {
@@ -15,6 +16,13 @@ class Environment extends Model
protected $guarded = []; protected $guarded = [];
protected static function booted(): void
{
static::creating(function (Environment $environment): void {
$environment->uuid ??= (string) Str::uuid();
});
}
protected function casts(): array protected function casts(): array
{ {
return [ return [
+1
View File
@@ -30,6 +30,7 @@ class Operation extends Model
return [ return [
'kind' => OperationKind::class, 'kind' => OperationKind::class,
'status' => OperationStatus::class, 'status' => OperationStatus::class,
'metadata' => 'array',
'started_at' => 'datetime', 'started_at' => 'datetime',
'finished_at' => 'datetime', 'finished_at' => 'datetime',
]; ];
+4
View File
@@ -13,6 +13,10 @@ class OperationStep extends Model
{ {
protected $guarded = []; protected $guarded = [];
protected $hidden = [
'secrets',
];
protected $appends = [ protected $appends = [
'logs_excerpt', 'logs_excerpt',
'error_logs_excerpt', 'error_logs_excerpt',
+34
View File
@@ -5,6 +5,7 @@ namespace App\Models;
use App\Enums\RegistryType; use App\Enums\RegistryType;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
class Registry extends Model class Registry extends Model
{ {
@@ -17,6 +18,9 @@ class Registry extends Model
return [ return [
'type' => RegistryType::class, 'type' => RegistryType::class,
'credentials' => 'encrypted:array', 'credentials' => 'encrypted:array',
'readiness_checks' => 'array',
'health_checked_at' => 'datetime',
'ready_at' => 'datetime',
]; ];
} }
@@ -24,4 +28,34 @@ class Registry extends Model
{ {
return $this->belongsTo(Organisation::class); return $this->belongsTo(Organisation::class);
} }
public function controlServer(): BelongsTo
{
return $this->belongsTo(Server::class, 'control_server_id');
}
public function markHealthy(?string $message = null): void
{
$this->forceFill([
'health_status' => 'healthy',
'health_message' => $message,
'health_checked_at' => Carbon::now(),
'ready_at' => $this->ready_at ?? Carbon::now(),
])->save();
}
public function markUnhealthy(string $message): void
{
$this->forceFill([
'health_status' => 'unhealthy',
'health_message' => $message,
'health_checked_at' => Carbon::now(),
'ready_at' => null,
])->save();
}
public function isReady(): bool
{
return $this->type !== RegistryType::MANAGED || ($this->ready_at !== null && $this->health_status === 'healthy');
}
} }
+2
View File
@@ -22,6 +22,8 @@ class Server extends Model
{ {
return [ return [
'status' => ServerStatus::class, 'status' => ServerStatus::class,
'is_control_node' => 'boolean',
'build_enabled' => 'boolean',
]; ];
} }
@@ -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);
}
}
@@ -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');
}
}
@@ -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, '/');
}
}
@@ -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, '/');
}
}
@@ -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;
}
}
@@ -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),
],
];
}
}
@@ -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;
}
}
+9
View File
@@ -8,6 +8,15 @@ use App\Enums\ServiceCategory;
use App\Enums\ServiceType; use App\Enums\ServiceType;
return [ return [
'managed_registry' => [
'url' => env('KEYSTONE_MANAGED_REGISTRY_URL'),
'namespace' => env('KEYSTONE_MANAGED_REGISTRY_NAMESPACE', 'keystone'),
'storage_path' => env('KEYSTONE_MANAGED_REGISTRY_STORAGE_PATH', '/home/keystone/registry/data'),
'retention' => [
'successful_artifacts_per_environment' => (int) env('KEYSTONE_MANAGED_REGISTRY_RETAIN_SUCCESSFUL_ARTIFACTS', 3),
],
],
'drivers' => [ 'drivers' => [
'postgres' => [ 'postgres' => [
'18' => Postgres18Driver::class, '18' => Postgres18Driver::class,
+2
View File
@@ -28,6 +28,8 @@ class ServerFactory extends Factory
'os' => 'ubuntu', 'os' => 'ubuntu',
'plan' => '26', 'plan' => '26',
'user' => 'keystone', 'user' => 'keystone',
'is_control_node' => false,
'build_enabled' => false,
]; ];
} }
@@ -0,0 +1,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',
]);
});
}
};
+2 -30
View File
@@ -4,11 +4,8 @@ namespace Database\Seeders;
use App\Enums\OrganisationRole; use App\Enums\OrganisationRole;
use App\Enums\ProviderType; use App\Enums\ProviderType;
use App\Enums\RepositoryType;
use App\Models\Organisation; use App\Models\Organisation;
use App\Models\Server;
use App\Models\User; use App\Models\User;
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
@@ -36,36 +33,11 @@ class DatabaseSeeder extends Seeder
$provider = $organisation->providers()->create([ $provider = $organisation->providers()->create([
'name' => 'Hetzner', 'name' => 'Hetzner',
'type' => ProviderType::HETZNER, 'type' => ProviderType::HETZNER,
'token' => env('HETZNER_KEY'), 'token' => env('HETZNER_KEY') ?: 'local-placeholder-token',
]); ]);
if (! app()->isProduction()) { if (! app()->isProduction()) {
$network = $organisation->networks()->create([ app(SimulatedEnvironmentSeeder::class)->seed($organisation, $provider);
'name' => 'keystone',
'external_id' => 'net-12345',
'provider_id' => $provider->id,
'ip_range' => fake()->ipv4().'/24',
]);
$servers = Server::factory(40)
->forNetwork($network->id)
->forOrganisation($organisation->id)
->forProvider($provider->id)
->create();
$organisation->servers()->saveMany($servers);
} }
$application = $organisation->applications()->create([
'name' => 'ClipBin',
'repository_url' => 'git@github.com:hjbdev/clipbin.git',
'repository_type' => RepositoryType::GIT,
]);
$application->environments()->create([
'name' => 'Dev',
'branch' => 'main',
'status' => 'active',
]);
} }
} }
@@ -0,0 +1,441 @@
<?php
namespace Database\Seeders;
use App\Actions\Applications\CreateLaravelEnvironment;
use App\Actions\Environments\AttachManagedService;
use App\Actions\Services\RegisterServiceEndpoint;
use App\Enums\BuildArtifactStatus;
use App\Enums\DeployPolicy;
use App\Enums\EnvironmentAttachmentRole;
use App\Enums\EnvironmentVariableSource;
use App\Enums\OperationKind;
use App\Enums\OperationStatus;
use App\Enums\RegistryType;
use App\Enums\RepositoryType;
use App\Enums\ServerStatus;
use App\Enums\ServiceCategory;
use App\Enums\ServiceStatus;
use App\Enums\ServiceType;
use App\Enums\SourceProviderType;
use App\Models\Application;
use App\Models\Environment;
use App\Models\Network;
use App\Models\Organisation;
use App\Models\Provider;
use App\Models\Registry;
use App\Models\Server;
use App\Models\Service;
use Illuminate\Database\Seeder;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
class SimulatedEnvironmentSeeder extends Seeder
{
private Registry $managedRegistry;
/**
* Build a fully wired, mostly-running organisation: an ACTIVE server fleet,
* registries, a source provider, and one rich application with production +
* staging environments (web + postgres + valkey + caddy each), plus a
* believable operations history. Reuses the real domain actions so the graph
* is internally consistent, without dispatching any deployment jobs.
*/
public function seed(Organisation $organisation, Provider $provider): void
{
$network = $organisation->networks()->create([
'name' => 'keystone',
'external_id' => 'net-12345',
'provider_id' => $provider->id,
'ip_range' => '10.0.0.0/24',
]);
[$control, $workers] = $this->seedFleet($organisation, $provider, $network);
$this->seedRegistries($organisation, $control);
$sourceProvider = $this->seedSourceProvider($organisation);
$application = $this->seedApplication($organisation, $sourceProvider);
$production = $this->seedEnvironment($application, $control, $workers, 'production', 'main');
$staging = $this->seedEnvironment($application, $control, $workers, 'staging', 'develop');
$this->seedVariety($production, $staging);
$this->seedOperationsHistory($control, $production, $staging);
}
/**
* @return array{0: Server, 1: Collection<int, Server>}
*/
private function seedFleet(Organisation $organisation, Provider $provider, Network $network): array
{
$factory = fn (): \Database\Factories\ServerFactory => Server::factory()
->forNetwork($network->id)
->forOrganisation($organisation->id)
->forProvider($provider->id);
$control = $factory()->create([
'name' => 'keystone-control-1',
'status' => ServerStatus::ACTIVE,
'provider_status' => 'running',
'private_ip' => '10.0.0.10',
'is_control_node' => true,
'build_enabled' => true,
]);
$workers = collect(range(1, 3))->map(fn (int $index): Server => $factory()->create([
'name' => "keystone-worker-{$index}",
'status' => ServerStatus::ACTIVE,
'provider_status' => 'running',
'private_ip' => '10.0.0.'.(20 + $index),
]));
return [$control, $workers];
}
private function seedRegistries(Organisation $organisation, Server $control): void
{
$this->managedRegistry = $organisation->registries()->create([
'name' => 'Keystone Managed',
'type' => RegistryType::MANAGED,
'url' => 'registry.keystone.internal:5000',
'storage_path' => '/home/keystone/registry/data',
'control_server_id' => $control->id,
'readiness_checks' => [
'storage_writable' => true,
'http_reachable' => true,
'auth_configured' => true,
],
]);
$this->managedRegistry->markHealthy('Registry online and serving manifests');
$organisation->registries()->create([
'name' => 'GitHub Container Registry',
'type' => RegistryType::GHCR,
'url' => 'ghcr.io',
'credentials' => [
'username' => 'keystone-bot',
'token' => Str::password(40),
],
'health_status' => 'healthy',
'health_message' => 'Authenticated successfully',
'health_checked_at' => now(),
]);
}
private function seedSourceProvider(Organisation $organisation): \App\Models\SourceProvider
{
return $organisation->sourceProviders()->create([
'name' => 'Gitea',
'type' => SourceProviderType::GITEA,
'url' => 'https://git.keystone.internal',
'config' => [
'api_url' => 'https://git.keystone.internal/api/v1',
'organisation' => 'stratbucket',
],
]);
}
private function seedApplication(Organisation $organisation, \App\Models\SourceProvider $sourceProvider): Application
{
return $organisation->applications()->create([
'name' => 'ClipBin',
'source_provider_id' => $sourceProvider->id,
'repository_url' => 'git@git.keystone.internal:stratbucket/clipbin.git',
'repository_type' => RepositoryType::GIT,
'default_branch' => 'main',
'deploy_key_installed_at' => now()->subWeeks(2),
]);
}
private function seedEnvironment(
Application $application,
Server $control,
Collection $workers,
string $name,
string $branch,
): Environment {
$environment = app(CreateLaravelEnvironment::class)->execute($application, $name, $branch);
$web = $environment->services()->where('type', ServiceType::LARAVEL)->firstOrFail();
$web->forceFill(['server_id' => $workers->first()->id])->save();
$this->createReplica($web, $workers->first(), 80);
$postgres = $this->createDependencyService(
$environment, $workers->get(1), 'postgres',
ServiceCategory::DATABASE, ServiceType::POSTGRES, '18', 5432, DeployPolicy::DEPENDENCY_ONLY,
);
$valkey = $this->createDependencyService(
$environment, $workers->get(2), 'valkey',
ServiceCategory::CACHE, ServiceType::VALKEY, '8', 6379, DeployPolicy::DEPENDENCY_ONLY,
);
$caddy = $this->createDependencyService(
$environment, $control, 'gateway',
ServiceCategory::GATEWAY, ServiceType::CADDY, '2', 80, DeployPolicy::MANUAL_OR_ON_ROUTE_CHANGE,
);
app(AttachManagedService::class)->execute($environment, $postgres, EnvironmentAttachmentRole::DATABASE, isPrimary: true);
app(AttachManagedService::class)->execute($environment, $valkey, EnvironmentAttachmentRole::CACHE);
app(AttachManagedService::class)->execute($environment, $caddy, EnvironmentAttachmentRole::GATEWAY);
foreach ([$web, $postgres, $valkey, $caddy] as $service) {
foreach ($service->replicas()->get() as $replica) {
app(RegisterServiceEndpoint::class)->execute($replica);
}
}
$this->seedUserVariables($environment);
$this->advanceToRunning($environment);
return $environment->refresh();
}
private function createDependencyService(
Environment $environment,
Server $server,
string $name,
ServiceCategory $category,
ServiceType $type,
string $version,
int $port,
DeployPolicy $deployPolicy,
): Service {
$service = $environment->services()->create([
'organisation_id' => $environment->application->organisation_id,
'server_id' => $server->id,
'name' => $name,
'category' => $category,
'type' => $type,
'version' => $version,
'version_track' => $version,
'driver_name' => "{$type->value}.{$version}",
'status' => ServiceStatus::NOT_INSTALLED,
'deploy_policy' => $deployPolicy,
'process_roles' => [],
'desired_replicas' => 1,
'config' => [],
]);
if (method_exists($service->driver(), 'defaultCredentials')) {
$service->credentials = $service->driver()->defaultCredentials();
$service->save();
}
$this->createReplica($service, $server, $port);
return $service;
}
private function createReplica(Service $service, Server $server, int $port): void
{
$service->replicas()->create([
'server_id' => $server->id,
'container_name' => "keystone-service-{$service->id}-1",
'internal_host' => "keystone-service-{$service->id}",
'internal_port' => $port,
'status' => 'pending',
'health_status' => 'unknown',
'config' => [],
]);
}
private function seedUserVariables(Environment $environment): void
{
$values = [
'APP_NAME' => 'ClipBin',
'APP_ENV' => $environment->name,
'LOG_CHANNEL' => 'stderr',
];
foreach ($values as $key => $value) {
$environment->variables()->updateOrCreate(['key' => $key], [
'value' => $value,
'source' => EnvironmentVariableSource::USER,
'overridable' => true,
]);
}
}
private function advanceToRunning(Environment $environment): void
{
$environment->forceFill(['status' => 'active'])->save();
foreach ($environment->services()->with('replicas', 'slices')->get() as $service) {
$digest = $this->digest();
$service->forceFill([
'status' => ServiceStatus::RUNNING,
'current_image_digest' => $digest,
'available_image_digest' => $digest,
])->save();
$service->replicas->each->forceFill([
'status' => 'running',
'health_status' => 'healthy',
'image_digest' => $digest,
])->each->save();
$service->slices->each->forceFill(['status' => 'active'])->each->save();
}
$this->completeSliceProvisionOperations($environment);
}
private function completeSliceProvisionOperations(Environment $environment): void
{
foreach ($environment->services()->with('slices.operations.steps')->get() as $service) {
foreach ($service->slices as $slice) {
foreach ($slice->operations as $operation) {
$operation->forceFill([
'status' => OperationStatus::COMPLETED,
'started_at' => now()->subMinutes(8),
'finished_at' => now()->subMinutes(7),
])->save();
$operation->steps->each->forceFill([
'status' => OperationStatus::COMPLETED,
'logs' => 'Slice provisioned successfully.',
'started_at' => now()->subMinutes(8),
'finished_at' => now()->subMinutes(7),
])->each->save();
}
}
}
}
private function seedVariety(Environment $production, Environment $staging): void
{
$stagingValkey = $staging->services()->where('type', ServiceType::VALKEY)->firstOrFail();
$stagingValkey->replicas()->update(['health_status' => 'unhealthy']);
$production->buildArtifacts()->create([
'commit_sha' => $this->sha(),
'image_tag' => 'clipbin:production-'.Str::random(7),
'image_digest' => $this->digest(),
'registry_ref' => $this->managedRegistry->url.'/keystone/clipbin',
'built_by_service_id' => $production->services()->where('type', ServiceType::LARAVEL)->value('id'),
'status' => BuildArtifactStatus::AVAILABLE,
'metadata' => ['branch' => 'main', 'build_seconds' => 142],
]);
$staging->buildArtifacts()->create([
'commit_sha' => $this->sha(),
'image_tag' => 'clipbin:staging-'.Str::random(7),
'registry_ref' => $this->managedRegistry->url.'/keystone/clipbin',
'built_by_service_id' => $staging->services()->where('type', ServiceType::LARAVEL)->value('id'),
'status' => BuildArtifactStatus::BUILDING,
'metadata' => ['branch' => 'develop'],
]);
}
private function seedOperationsHistory(Server $control, Environment $production, Environment $staging): void
{
$registryOp = $control->operations()->create([
'kind' => OperationKind::REGISTRY_PROVISION,
'status' => OperationStatus::COMPLETED,
'started_at' => now()->subDays(5),
'finished_at' => now()->subDays(5)->addMinutes(4),
]);
$registryOp->steps()->create([
'name' => 'Provision managed registry',
'order' => 1,
'status' => OperationStatus::COMPLETED,
'script' => 'docker run -d --name keystone-registry registry:2',
'logs' => 'Registry container started and reachable.',
'started_at' => now()->subDays(5),
'finished_at' => now()->subDays(5)->addMinutes(4),
]);
$serverOp = $control->operations()->create([
'kind' => OperationKind::SERVER_PROVISION,
'status' => OperationStatus::COMPLETED,
'started_at' => now()->subDays(6),
'finished_at' => now()->subDays(6)->addMinutes(9),
]);
$serverOp->steps()->create([
'name' => 'Install container runtime',
'order' => 1,
'status' => OperationStatus::COMPLETED,
'script' => 'apt-get install -y docker-ce',
'logs' => 'Docker installed, daemon active.',
'started_at' => now()->subDays(6),
'finished_at' => now()->subDays(6)->addMinutes(9),
]);
$deployOp = $production->operations()->create([
'kind' => OperationKind::ENVIRONMENT_DEPLOY,
'status' => OperationStatus::COMPLETED,
'started_at' => now()->subHours(3),
'finished_at' => now()->subHours(3)->addMinutes(6),
]);
$deployOp->steps()->create([
'name' => 'Build application image',
'order' => 1,
'status' => OperationStatus::COMPLETED,
'script' => 'keystone build --env production',
'logs' => 'Image built and pushed to managed registry.',
'started_at' => now()->subHours(3),
'finished_at' => now()->subHours(3)->addMinutes(4),
]);
$web = $production->services()->where('type', ServiceType::LARAVEL)->firstOrFail();
$serviceOp = $web->operations()->create([
'parent_id' => $deployOp->id,
'kind' => OperationKind::SERVICE_DEPLOY,
'status' => OperationStatus::COMPLETED,
'started_at' => now()->subHours(3)->addMinutes(4),
'finished_at' => now()->subHours(3)->addMinutes(6),
]);
$serviceOp->steps()->create([
'name' => 'Roll out web replica',
'order' => 1,
'status' => OperationStatus::COMPLETED,
'script' => 'keystone service:deploy web',
'logs' => 'Replica healthy, traffic switched.',
'started_at' => now()->subHours(3)->addMinutes(4),
'finished_at' => now()->subHours(3)->addMinutes(6),
]);
$inProgress = $staging->operations()->create([
'kind' => OperationKind::ENVIRONMENT_DEPLOY,
'status' => OperationStatus::IN_PROGRESS,
'started_at' => now()->subMinutes(2),
]);
$inProgress->steps()->create([
'name' => 'Build application image',
'order' => 1,
'status' => OperationStatus::IN_PROGRESS,
'script' => 'keystone build --env staging',
'logs' => 'Compiling assets...',
'started_at' => now()->subMinutes(2),
]);
$stagingValkey = $staging->services()->where('type', ServiceType::VALKEY)->firstOrFail();
$failedOp = $stagingValkey->operations()->create([
'kind' => OperationKind::SERVICE_DEPLOY,
'status' => OperationStatus::FAILED,
'started_at' => now()->subHours(1),
'finished_at' => now()->subHours(1)->addMinutes(2),
]);
$failedOp->steps()->create([
'name' => 'Start valkey replica',
'order' => 1,
'status' => OperationStatus::FAILED,
'script' => 'keystone service:deploy valkey',
'logs' => 'Pulling image valkey/valkey:8...',
'error_logs' => 'Health check failed: connection refused on 6379 after 30s.',
'started_at' => now()->subHours(1),
'finished_at' => now()->subHours(1)->addMinutes(2),
]);
}
private function digest(): string
{
return 'sha256:'.hash('sha256', Str::uuid()->toString());
}
private function sha(): string
{
return substr(hash('sha1', Str::uuid()->toString()), 0, 40);
}
}
+1 -1
View File
@@ -30,7 +30,7 @@ class TestEnvironmentSeeder extends Seeder
$organisation->providers()->create([ $organisation->providers()->create([
'name' => 'Hetzner', 'name' => 'Hetzner',
'type' => ProviderType::HETZNER, 'type' => ProviderType::HETZNER,
'token' => env('HETZNER_KEY'), 'token' => env('HETZNER_KEY') ?: 'local-placeholder-token',
]); ]);
} }
} }
+83 -13
View File
@@ -1,5 +1,16 @@
---
status: in-progress
implemented_slice: app-side-operation-generation-maintenance-readiness
---
# Managed Registry Plan # 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. 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 ## 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. - Keystone provides a first-party managed Docker registry by default.
- The managed registry stores images on local disk first. - The managed registry stores images on local disk first.
- The registry storage path must be configurable for mounted VPS volumes. - 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. - 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. - 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. - Registry credentials must not be persisted in operation scripts, logs, or UI-visible output.
@@ -38,10 +50,11 @@ Default settings:
```text ```text
Build node: Keystone control node Build node: Keystone control node
Registry: registry:2 managed by Keystone Registry: registry:2 managed by Keystone
Registry URL: install-provided HTTPS hostname, for example registry.example.com
Registry storage driver: local Registry storage driver: local
Registry storage path: /home/keystone/registry/data Registry storage path: /home/keystone/registry/data
Image retention: latest 3 successful artifacts per environment 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: 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. 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 ## Default Image Flow
```text ```text
@@ -78,25 +93,62 @@ If Keystone later supports HA control planes, the control node concept should be
- The default build node. - The default build node.
- Runtime nodes used for deployed applications. - 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 ```text
registry.example.com 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. 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 ## Authentication
Use `registry:2` htpasswd authentication for the first version. Use `registry:2` htpasswd authentication for the first version.
Keystone should: Keystone should:
- Generate registry credentials. - Generate separate build and runtime registry credentials.
- Write the registry htpasswd file during provisioning. - Write the registry htpasswd file during provisioning.
- Store credentials encrypted. - Store credentials encrypted.
- Configure build and target servers for registry access. - 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. 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: Preferred approaches:
- Configure Docker auth on each server through a separate secure action. - 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. - 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 ## Build Planning
@@ -146,13 +208,21 @@ The default build execution should:
7. Push the image. 7. Push the image.
8. Resolve and store the registry manifest digest. 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: Example flow:
```bash ```bash
docker login registry.example.com --username keystone --password-stdin docker login registry.example.com --username keystone-build --password-stdin
docker build --file Dockerfile.keystone --tag registry.example.com/application:aaaaaaaaaaaa . docker build --file Dockerfile.keystone --tag registry.example.com/keystone/app-uuid/env-uuid:aaaaaaaaaaaa .
docker push registry.example.com/application:aaaaaaaaaaaa docker push registry.example.com/keystone/app-uuid/env-uuid:aaaaaaaaaaaa
docker manifest inspect registry.example.com/application: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. 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: Example pull reference:
```text ```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:...`. 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 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: Suggested cleanup flow:
@@ -227,7 +297,7 @@ These should be optional settings, not onboarding requirements:
- Dedicated build nodes. - Dedicated build nodes.
- S3-compatible registry storage. - S3-compatible registry storage.
- External registries such as GHCR, Gitea, Docker Hub, or generic registries. - External registries such as GHCR, Gitea, Docker Hub, or generic registries.
- Separate push and pull credentials. - True push- and pull-scoped credentials.
- Credential rotation. - Credential rotation.
- Per-server or per-repository scoped auth. - Per-server or per-repository scoped auth.
- Configurable retention per application or environment. - Configurable retention per application or environment.
+2
View File
@@ -43,5 +43,7 @@
<env name="QUEUE_CONNECTION" value="sync"/> <env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/> <env name="SESSION_DRIVER" value="array"/>
<env name="TELESCOPE_ENABLED" value="false"/> <env name="TELESCOPE_ENABLED" value="false"/>
<env name="KEYSTONE_MANAGED_REGISTRY_URL" value=""/>
<env name="KEYSTONE_MANAGED_REGISTRY_NAMESPACE" value="keystone"/>
</php> </php>
</phpunit> </phpunit>
+45 -33
View File
@@ -7,6 +7,9 @@ import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { import {
@@ -24,9 +27,9 @@ import type { BreadcrumbItem, NavItem } from "@/types";
import { Link, usePage } from "@inertiajs/vue3"; import { Link, usePage } from "@inertiajs/vue3";
import { import {
AppWindowIcon, AppWindowIcon,
BoltIcon,
BoxesIcon, BoxesIcon,
ClipboardListIcon, Check,
ChevronsUpDown,
Menu, Menu,
Search, Search,
ServerIcon, ServerIcon,
@@ -45,7 +48,9 @@ const props = withDefaults(defineProps<Props>(), {
const page = usePage(); const page = usePage();
const auth = computed(() => page.props.auth); const auth = computed(() => page.props.auth);
const isCurrentRoute = computed(() => (url: string) => page.url === url || page.url.startsWith(`${url}/`)); const isCurrentRoute = computed(
() => (url: string) => page.url === url || page.url.startsWith(`${url}/`),
);
const activeItemStyles = computed( const activeItemStyles = computed(
() => (url: string) => () => (url: string) =>
@@ -62,17 +67,21 @@ const mainNavItems: NavItem[] = [
// }, // },
]; ];
const activeOrganisation = computed(() => page.props.organisation);
const organisations = computed(() => auth.value.user?.organisations ?? []);
if (page.props.organisation) { if (page.props.organisation) {
const organisationId = page.props?.organisation?.id; const organisationId = page.props?.organisation?.id;
mainNavItems.push({ mainNavItems.push({
title: page.props.organisation.name, title: "Applications",
href: new URL( href: new URL(
route("organisations.show", { route("applications.index", {
organisation: organisationId, organisation: organisationId,
}), }),
).pathname, ).pathname,
icon: BoltIcon, icon: AppWindowIcon,
}); });
mainNavItems.push({ mainNavItems.push({
title: "Environments", title: "Environments",
@@ -83,15 +92,6 @@ if (page.props.organisation) {
).pathname, ).pathname,
icon: BoxesIcon, icon: BoxesIcon,
}); });
mainNavItems.push({
title: "Applications",
href: new URL(
route("applications.index", {
organisation: organisationId,
}),
).pathname,
icon: AppWindowIcon,
});
mainNavItems.push({ mainNavItems.push({
title: "Servers", title: "Servers",
href: new URL( href: new URL(
@@ -110,24 +110,6 @@ if (page.props.organisation) {
).pathname, ).pathname,
icon: WorkflowIcon, icon: WorkflowIcon,
}); });
if (
page.props.organisation.providers_count === 0 ||
page.props.organisation.source_providers_count === 0 ||
page.props.organisation.registries_count === 0 ||
page.props.organisation.servers_count === 0 ||
page.props.organisation.applications_count === 0
) {
mainNavItems.push({
title: "Onboarding",
href: new URL(
route("onboarding.show", {
organisation: organisationId,
}),
).pathname,
icon: ClipboardListIcon,
});
}
} }
const rightNavItems: NavItem[] = [ const rightNavItems: NavItem[] = [
@@ -206,6 +188,36 @@ const rightNavItems: NavItem[] = [
<AppLogo /> <AppLogo />
</Link> </Link>
<!-- Organisation switcher -->
<DropdownMenu v-if="activeOrganisation">
<DropdownMenuTrigger :as-child="true">
<Button
variant="ghost"
class="ml-2 h-9 max-w-[12rem] gap-1.5 px-2 text-sm font-medium"
>
<span class="truncate">{{ activeOrganisation.name }}</span>
<ChevronsUpDown class="size-4 shrink-0 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" class="w-60">
<DropdownMenuLabel>Organisations</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
v-for="organisation in organisations"
:key="organisation.id"
:as="Link"
:href="route('organisations.show', { organisation: organisation.id })"
class="cursor-pointer justify-between"
>
<span class="truncate">{{ organisation.name }}</span>
<Check
v-if="organisation.id === activeOrganisation.id"
class="size-4 shrink-0"
/>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<!-- Desktop Menu --> <!-- Desktop Menu -->
<div class="hidden h-full lg:flex lg:flex-1"> <div class="hidden h-full lg:flex lg:flex-1">
<NavigationMenu class="ml-10 flex h-full items-stretch"> <NavigationMenu class="ml-10 flex h-full items-stretch">
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { computed } from "vue";
const props = withDefaults(
defineProps<{
status?: string | null;
label?: string;
size?: "sm" | "md";
}>(),
{
status: "unknown",
size: "sm",
},
);
type Tone = "positive" | "negative" | "pending" | "neutral";
const tones: Record<string, Tone> = {
active: "positive",
running: "positive",
succeeded: "positive",
success: "positive",
completed: "positive",
ready: "positive",
verified: "positive",
enabled: "positive",
healthy: "positive",
stopped: "negative",
failed: "negative",
error: "negative",
errored: "negative",
unhealthy: "negative",
cancelled: "negative",
installing: "pending",
pending: "pending",
"in-progress": "pending",
building: "pending",
planning: "pending",
deploying: "pending",
provisioning: "pending",
queued: "pending",
};
const normalised = computed(() => (props.status ?? "unknown").toString().toLowerCase().trim());
const tone = computed<Tone>(() => tones[normalised.value] ?? "neutral");
const label = computed(
() =>
props.label ??
(props.status ?? "unknown").toString().replaceAll("-", " ").replaceAll("_", " "),
);
const dotClasses = computed(() => {
switch (tone.value) {
case "positive":
return "bg-green-500";
case "negative":
return "bg-red-500";
case "pending":
return "bg-amber-500 animate-pulse";
default:
return "bg-zinc-400 dark:bg-zinc-500";
}
});
const textClasses = computed(() => {
switch (tone.value) {
case "positive":
return "text-green-700 dark:text-green-400";
case "negative":
return "text-red-700 dark:text-red-400";
case "pending":
return "text-amber-700 dark:text-amber-400";
default:
return "text-muted-foreground";
}
});
</script>
<template>
<span class="inline-flex items-center gap-1.5">
<span
class="inline-block rounded-full"
:class="[dotClasses, size === 'md' ? 'size-2' : 'size-1.5']"
/>
<span class="capitalize" :class="[textClasses, size === 'md' ? 'text-sm' : 'text-xs']">{{
label
}}</span>
</span>
</template>
@@ -5,17 +5,20 @@ import ServiceStatus from "@/enums/ServiceStatus";
import ServiceType from "@/enums/ServiceType"; import ServiceType from "@/enums/ServiceType";
import { DoorOpenIcon } from "lucide-vue-next"; import { DoorOpenIcon } from "lucide-vue-next";
withDefaults(defineProps<{ withDefaults(
icon?: object | Function; defineProps<{
serviceType?: string; icon?: object | Function;
serviceCategory?: string; serviceType?: string;
status?: string; serviceCategory?: string;
}>(), { status?: string;
icon: () => DoorOpenIcon, }>(),
serviceType: ServiceType.GATEWAY, {
serviceCategory: ServiceCategory.DATABASE, icon: () => DoorOpenIcon,
status: ServiceStatus.UNKNOWN, serviceType: ServiceType.GATEWAY,
}); serviceCategory: ServiceCategory.DATABASE,
status: ServiceStatus.UNKNOWN,
},
);
</script> </script>
<template> <template>
<Card <Card
@@ -0,0 +1,19 @@
<script setup lang="ts">
defineProps<{
icon?: object | Function;
label: string;
value?: string | number | null;
}>();
</script>
<template>
<div class="flex items-center justify-between gap-3 text-sm">
<span class="flex items-center gap-2 text-muted-foreground">
<component :is="icon" v-if="icon" class="size-4 opacity-70" />
{{ label }}
</span>
<span class="truncate text-right font-medium">
<slot>{{ value ?? "—" }}</slot>
</span>
</div>
</template>
@@ -0,0 +1,29 @@
<script setup lang="ts">
import StatusIndicator from "@/components/StatusIndicator.vue";
import { Card } from "@/components/ui/card";
defineProps<{
title: string;
subtitle?: string | null;
icon?: object | Function;
status?: string | null;
}>();
</script>
<template>
<Card class="overflow-hidden">
<div class="flex items-center justify-between gap-3 border-b bg-muted/30 px-4 py-3">
<div class="flex min-w-0 items-center gap-2">
<component :is="icon" v-if="icon" class="size-4 shrink-0 opacity-70" />
<span class="truncate font-semibold">{{ title }}</span>
<span v-if="subtitle" class="truncate text-xs text-muted-foreground">{{
subtitle
}}</span>
</div>
<StatusIndicator v-if="status" :status="status" />
</div>
<div class="grid gap-2.5 p-4">
<slot />
</div>
</Card>
</template>
@@ -13,7 +13,8 @@ defineProps<{
const selectedStep = ref<Record<string, any> | null>(null); const selectedStep = ref<Record<string, any> | null>(null);
const label = (value?: string | null): string => value?.replaceAll("_", " ").replaceAll("-", " ") ?? ""; const label = (value?: string | null): string =>
value?.replaceAll("_", " ").replaceAll("-", " ") ?? "";
const targetLabel = (target?: Record<string, any> | null): string => { const targetLabel = (target?: Record<string, any> | null): string => {
if (!target) { if (!target) {
@@ -74,9 +75,7 @@ const targetLabel = (target?: Record<string, any> | null): string => {
<div class="min-w-0"> <div class="min-w-0">
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<div class="font-medium">{{ step.name ?? "Unnamed step" }}</div> <div class="font-medium">{{ step.name ?? "Unnamed step" }}</div>
<Badge <Badge :variant="step.status === 'completed' ? 'success' : 'secondary'">
:variant="step.status === 'completed' ? 'success' : 'secondary'"
>
{{ label(step.status) }} {{ label(step.status) }}
</Badge> </Badge>
</div> </div>
@@ -115,9 +114,7 @@ const targetLabel = (target?: Record<string, any> | null): string => {
> >
{{ label(child.kind) }} {{ label(child.kind) }}
</Link> </Link>
<Badge <Badge :variant="child.status === 'completed' ? 'success' : 'secondary'">
:variant="child.status === 'completed' ? 'success' : 'secondary'"
>
{{ label(child.status) }} {{ label(child.status) }}
</Badge> </Badge>
<span class="text-muted-foreground">{{ targetLabel(child.target) }}</span> <span class="text-muted-foreground">{{ targetLabel(child.target) }}</span>
@@ -126,23 +123,35 @@ const targetLabel = (target?: Record<string, any> | null): string => {
</div> </div>
</div> </div>
<div v-if="operations.length === 0" class="rounded-md border border-dashed p-6 text-sm text-muted-foreground"> <div
v-if="operations.length === 0"
class="rounded-md border border-dashed p-6 text-sm text-muted-foreground"
>
No operations recorded yet. No operations recorded yet.
</div> </div>
</div> </div>
<Dialog :open="!!selectedStep" @update:open="($event) => (!$event ? (selectedStep = null) : null)"> <Dialog
:open="!!selectedStep"
@update:open="($event) => (!$event ? (selectedStep = null) : null)"
>
<DialogContent class="md:max-w-3xl"> <DialogContent class="md:max-w-3xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Logs for {{ selectedStep?.name ?? "step" }}</DialogTitle> <DialogTitle>Logs for {{ selectedStep?.name ?? "step" }}</DialogTitle>
</DialogHeader> </DialogHeader>
<section v-if="selectedStep?.logs"> <section v-if="selectedStep?.logs">
<h3 class="text-sm font-medium">Logs</h3> <h3 class="text-sm font-medium">Logs</h3>
<pre class="max-h-80 overflow-auto whitespace-pre-wrap text-xs text-muted-foreground">{{ selectedStep.logs }}</pre> <pre
class="max-h-80 overflow-auto whitespace-pre-wrap text-xs text-muted-foreground"
>{{ selectedStep.logs }}</pre
>
</section> </section>
<section v-if="selectedStep?.error_logs"> <section v-if="selectedStep?.error_logs">
<h3 class="text-sm font-medium">Error Logs</h3> <h3 class="text-sm font-medium">Error Logs</h3>
<pre class="max-h-80 overflow-auto whitespace-pre-wrap text-xs text-muted-foreground">{{ selectedStep.error_logs }}</pre> <pre
class="max-h-80 overflow-auto whitespace-pre-wrap text-xs text-muted-foreground"
>{{ selectedStep.error_logs }}</pre
>
</section> </section>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -0,0 +1,19 @@
<script setup lang="ts">
import {
SelectRoot,
useForwardPropsEmits,
type SelectRootEmits,
type SelectRootProps,
} from "radix-vue";
const props = defineProps<SelectRootProps>();
const emits = defineEmits<SelectRootEmits>();
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<SelectRoot v-bind="forwarded">
<slot />
</SelectRoot>
</template>
@@ -0,0 +1,60 @@
<script setup lang="ts">
import { cn } from "@/lib/utils";
import SelectScrollDownButton from "./SelectScrollDownButton.vue";
import SelectScrollUpButton from "./SelectScrollUpButton.vue";
import {
SelectContent,
type SelectContentEmits,
type SelectContentProps,
SelectPortal,
SelectViewport,
useForwardPropsEmits,
} from "radix-vue";
import { computed, type HTMLAttributes } from "vue";
const props = withDefaults(
defineProps<SelectContentProps & { class?: HTMLAttributes["class"] }>(),
{
position: "popper",
},
);
const emits = defineEmits<SelectContentEmits>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<SelectPortal>
<SelectContent
v-bind="forwarded"
:class="
cn(
'relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
props.class,
)
"
>
<SelectScrollUpButton />
<SelectViewport
:class="
cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
)
"
>
<slot />
</SelectViewport>
<SelectScrollDownButton />
</SelectContent>
</SelectPortal>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import { cn } from "@/lib/utils";
import { SelectGroup, type SelectGroupProps, useForwardProps } from "radix-vue";
import { computed, type HTMLAttributes } from "vue";
const props = defineProps<SelectGroupProps & { class?: HTMLAttributes["class"] }>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<SelectGroup v-bind="forwardedProps" :class="cn('p-1', props.class)">
<slot />
</SelectGroup>
</template>
@@ -0,0 +1,43 @@
<script setup lang="ts">
import { cn } from "@/lib/utils";
import { Check } from "lucide-vue-next";
import {
SelectItem,
SelectItemIndicator,
type SelectItemProps,
SelectItemText,
useForwardProps,
} from "radix-vue";
import { computed, type HTMLAttributes } from "vue";
const props = defineProps<SelectItemProps & { class?: HTMLAttributes["class"] }>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<SelectItem
v-bind="forwardedProps"
:class="
cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
props.class,
)
"
>
<span class="absolute right-2 flex size-3.5 items-center justify-center">
<SelectItemIndicator>
<Check class="size-4" />
</SelectItemIndicator>
</span>
<SelectItemText>
<slot />
</SelectItemText>
</SelectItem>
</template>
@@ -0,0 +1,13 @@
<script setup lang="ts">
import { cn } from "@/lib/utils";
import { SelectLabel, type SelectLabelProps } from "radix-vue";
import { type HTMLAttributes } from "vue";
const props = defineProps<SelectLabelProps & { class?: HTMLAttributes["class"] }>();
</script>
<template>
<SelectLabel :class="cn('px-2 py-1.5 text-sm font-semibold', props.class)">
<slot />
</SelectLabel>
</template>
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { cn } from "@/lib/utils";
import { ChevronDown } from "lucide-vue-next";
import {
SelectScrollDownButton,
type SelectScrollDownButtonProps,
useForwardProps,
} from "radix-vue";
import { computed, type HTMLAttributes } from "vue";
const props = defineProps<SelectScrollDownButtonProps & { class?: HTMLAttributes["class"] }>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<SelectScrollDownButton
v-bind="forwardedProps"
:class="cn('flex cursor-default items-center justify-center py-1', props.class)"
>
<slot>
<ChevronDown class="size-4" />
</slot>
</SelectScrollDownButton>
</template>
@@ -0,0 +1,27 @@
<script setup lang="ts">
import { cn } from "@/lib/utils";
import { ChevronUp } from "lucide-vue-next";
import { SelectScrollUpButton, type SelectScrollUpButtonProps, useForwardProps } from "radix-vue";
import { computed, type HTMLAttributes } from "vue";
const props = defineProps<SelectScrollUpButtonProps & { class?: HTMLAttributes["class"] }>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<SelectScrollUpButton
v-bind="forwardedProps"
:class="cn('flex cursor-default items-center justify-center py-1', props.class)"
>
<slot>
<ChevronUp class="size-4" />
</slot>
</SelectScrollUpButton>
</template>
@@ -0,0 +1,11 @@
<script setup lang="ts">
import { cn } from "@/lib/utils";
import { SelectSeparator, type SelectSeparatorProps } from "radix-vue";
import { type HTMLAttributes } from "vue";
const props = defineProps<SelectSeparatorProps & { class?: HTMLAttributes["class"] }>();
</script>
<template>
<SelectSeparator :class="cn('-mx-1 my-1 h-px bg-muted', props.class)" />
</template>
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { cn } from "@/lib/utils";
import { ChevronDown } from "lucide-vue-next";
import { SelectIcon, SelectTrigger, type SelectTriggerProps, useForwardProps } from "radix-vue";
import { computed, type HTMLAttributes } from "vue";
const props = defineProps<SelectTriggerProps & { class?: HTMLAttributes["class"] }>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<SelectTrigger
v-bind="forwardedProps"
:class="
cn(
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1 data-[placeholder]:text-muted-foreground',
props.class,
)
"
>
<slot />
<SelectIcon as-child>
<ChevronDown class="size-4 opacity-50" />
</SelectIcon>
</SelectTrigger>
</template>
@@ -0,0 +1,11 @@
<script setup lang="ts">
import { SelectValue, type SelectValueProps } from "radix-vue";
const props = defineProps<SelectValueProps>();
</script>
<template>
<SelectValue v-bind="props">
<slot />
</SelectValue>
</template>
@@ -0,0 +1,10 @@
export { default as Select } from "./Select.vue";
export { default as SelectContent } from "./SelectContent.vue";
export { default as SelectGroup } from "./SelectGroup.vue";
export { default as SelectItem } from "./SelectItem.vue";
export { default as SelectLabel } from "./SelectLabel.vue";
export { default as SelectScrollDownButton } from "./SelectScrollDownButton.vue";
export { default as SelectScrollUpButton } from "./SelectScrollUpButton.vue";
export { default as SelectSeparator } from "./SelectSeparator.vue";
export { default as SelectTrigger } from "./SelectTrigger.vue";
export { default as SelectValue } from "./SelectValue.vue";
+3
View File
@@ -10,4 +10,7 @@ export default {
GATEWAY_CUTOVER: "gateway_cutover", GATEWAY_CUTOVER: "gateway_cutover",
CONFIG_CHANGE: "config_change", CONFIG_CHANGE: "config_change",
CREDENTIAL_ROTATION: "credential_rotation", CREDENTIAL_ROTATION: "credential_rotation",
REGISTRY_PROVISION: "registry_provision",
REGISTRY_HEALTH_CHECK: "registry_health_check",
REGISTRY_MAINTENANCE: "registry_maintenance",
}; };
+1
View File
@@ -1,6 +1,7 @@
// This is a generated file. // This is a generated file.
export default { export default {
MANAGED: "managed",
GENERIC: "generic", GENERIC: "generic",
GITEA: "gitea", GITEA: "gitea",
GHCR: "ghcr", GHCR: "ghcr",
+227 -70
View File
@@ -1,102 +1,259 @@
<script setup lang="ts"> <script setup lang="ts">
import StatusIndicator from "@/components/StatusIndicator.vue";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import AppLayout from "@/layouts/AppLayout.vue"; import AppLayout from "@/layouts/AppLayout.vue";
import { type BreadcrumbItem } from "@/types"; import { type BreadcrumbItem } from "@/types";
import { Head, Link } from "@inertiajs/vue3"; import { Head, Link, usePage } from "@inertiajs/vue3";
import { ChevronRightIcon } from "lucide-vue-next"; import { GitBranchIcon, PlusIcon } from "lucide-vue-next";
import { computed } from "vue";
defineProps<{ defineProps<{
organisations: Record<string, any>[]; organisations: Record<string, any>[];
recentOperations: Record<string, any>[]; recentApplications: Record<string, any>[];
deployments: Record<string, any>[];
unhealthyServices: Record<string, any>[]; unhealthyServices: Record<string, any>[];
}>(); }>();
const page = usePage();
const organisation = computed(() => page.props.organisation);
const breadcrumbs: BreadcrumbItem[] = [ const breadcrumbs: BreadcrumbItem[] = [
{ {
title: "Dashboard", title: "Dashboard",
href: "/dashboard", href: "/dashboard",
}, },
]; ];
const initials = (name: string): string =>
name
.split(" ")
.map((part) => part[0])
.filter(Boolean)
.slice(0, 2)
.join("")
.toUpperCase();
const timeAgo = (value?: string | null): string => {
if (!value) {
return "never";
}
const date = new Date(value);
const seconds = Math.round((Date.now() - date.getTime()) / 1000);
const formatter = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
const divisions: { amount: number; unit: Intl.RelativeTimeFormatUnit }[] = [
{ amount: 60, unit: "seconds" },
{ amount: 60, unit: "minutes" },
{ amount: 24, unit: "hours" },
{ amount: 7, unit: "days" },
{ amount: 4.34524, unit: "weeks" },
{ amount: 12, unit: "months" },
{ amount: Number.POSITIVE_INFINITY, unit: "years" },
];
let duration = -seconds;
for (const division of divisions) {
if (Math.abs(duration) < division.amount) {
return formatter.format(Math.round(duration), division.unit);
}
duration /= division.amount;
}
return "just now";
};
const deploymentTarget = (operation: Record<string, any>): Record<string, any> | null => {
const service = operation.target;
const environment = service?.environment;
const application = environment?.application;
if (!service || !environment || !application) {
return null;
}
return {
service,
environment,
application,
href: route("environments.show", {
organisation: application.organisation_id,
application: application.id,
environment: environment.id,
}),
};
};
</script> </script>
<template> <template>
<Head title="Dashboard" /> <Head title="Dashboard" />
<AppLayout :breadcrumbs="breadcrumbs"> <AppLayout :breadcrumbs="breadcrumbs">
<div class="grid h-full flex-1 gap-4 rounded-xl p-4 lg:grid-cols-3"> <div class="mx-auto w-full max-w-7xl space-y-8 p-4">
<Card class="lg:col-span-2"> <div class="flex flex-wrap items-center justify-between gap-3">
<CardHeader class="border-b-muted-background border-b"> <div>
<CardTitle>Organisations</CardTitle> <h1 class="text-2xl font-bold tracking-tight">Overview</h1>
<CardDescription>Select an organisation to view its environments.</CardDescription> <p v-if="organisation" class="text-sm text-muted-foreground">
</CardHeader> {{ organisation.name }}
<CardContent class="divide-y-muted-foreground divide-y p-0"> </p>
</div>
<Button
v-if="organisation"
:as="Link"
:href="route('applications.create', { organisation: organisation.id })"
>
<PlusIcon class="size-4" />
New application
</Button>
</div>
<section class="space-y-3">
<h2 class="text-sm font-medium text-muted-foreground">Recent applications</h2>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Link <Link
v-for="organisation in organisations" v-for="application in recentApplications"
:key="organisation.id" :key="application.id"
:href="route('organisations.show', { organisation: organisation.id })" :href="
class="flex items-center justify-between px-6 py-3 hover:bg-muted" route('applications.show', {
organisation: application.organisation_id,
application: application.id,
})
"
class="block"
> >
<div> <Card class="h-full transition-colors hover:border-foreground/20">
<div class="font-medium">{{ organisation.name }}</div> <CardContent class="flex flex-col gap-4 p-5">
<div class="text-sm text-muted-foreground"> <div class="flex items-start gap-3">
{{ organisation.applications_count }} applications · <div
{{ organisation.servers_count }} servers · class="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-sm font-semibold"
{{ organisation.services_count }} services >
</div> {{ initials(application.name) }}
</div> </div>
<ChevronRightIcon class="size-4 text-muted-foreground" /> <div class="min-w-0 flex-1">
<div class="truncate font-medium">
{{ application.name }}
</div>
<div class="truncate text-sm text-muted-foreground">
{{
application.source_provider?.name ??
"No source provider"
}}
</div>
</div>
<StatusIndicator
v-if="application.environments?.[0]"
:status="application.environments[0].status"
/>
</div>
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<GitBranchIcon class="size-3.5" />
<span class="rounded bg-muted px-1.5 py-0.5 font-mono">
{{ application.environments?.[0]?.branch ?? "—" }}
</span>
<span>·</span>
<span
>{{
application.environments?.length ?? 0
}}
environments</span
>
</div>
</CardContent>
</Card>
</Link> </Link>
</CardContent>
</Card>
<Card> <Card
<CardHeader> v-if="recentApplications.length === 0"
<CardTitle>Unhealthy services</CardTitle> class="col-span-full border-dashed"
<CardDescription>Services that need attention across your organisations.</CardDescription>
</CardHeader>
<CardContent class="grid gap-2">
<div
v-for="service in unhealthyServices"
:key="service.id"
class="rounded-md border p-3 text-sm"
> >
<div class="font-medium">{{ service.name }}</div> <CardContent class="flex flex-col items-center gap-3 p-8 text-center">
<div class="text-muted-foreground">{{ service.status }}</div> <p class="text-sm text-muted-foreground">No applications yet.</p>
</div> <Button
<div v-if="organisation"
v-if="unhealthyServices.length === 0" :as="Link"
class="rounded-md border border-dashed p-3 text-sm text-muted-foreground" size="sm"
> :href="
No unhealthy services. route('applications.create', { organisation: organisation.id })
</div> "
</CardContent> >
</Card> <PlusIcon class="size-4" />
Create your first application
</Button>
</CardContent>
</Card>
</div>
</section>
<Card class="lg:col-span-3"> <section class="grid gap-6 lg:grid-cols-3">
<CardHeader> <Card class="lg:col-span-2">
<CardTitle>Recent operations</CardTitle> <CardHeader>
<CardDescription>Latest service operations across your organisations.</CardDescription> <CardTitle>Latest deployments</CardTitle>
</CardHeader> <CardDescription
<CardContent class="grid gap-2"> >Recent operations across your organisations.</CardDescription
<div >
v-for="operation in recentOperations" </CardHeader>
:key="operation.id" <CardContent class="p-0">
class="flex flex-wrap items-center justify-between gap-3 rounded-md border p-3 text-sm" <ul class="divide-y">
> <li
<div> v-for="operation in deployments"
<div class="font-medium">{{ operation.kind.replace("_", " ") }}</div> :key="operation.id"
<div class="text-muted-foreground">{{ operation.hash }}</div> class="flex items-center gap-3 px-6 py-3 text-sm"
>
<StatusIndicator :status="operation.status" />
<span class="font-mono text-xs text-muted-foreground">
{{ operation.hash?.slice(0, 8) }}
</span>
<component
:is="deploymentTarget(operation) ? Link : 'span'"
:href="deploymentTarget(operation)?.href"
class="min-w-0 flex-1 truncate"
:class="deploymentTarget(operation) ? 'hover:underline' : ''"
>
<span class="font-medium">
{{ deploymentTarget(operation)?.application?.name ?? "—" }}
</span>
<span class="text-muted-foreground">
{{ operation.kind?.replaceAll("_", " ") }}
</span>
</component>
<span class="shrink-0 text-xs text-muted-foreground">
{{ timeAgo(operation.finished_at ?? operation.created_at) }}
</span>
</li>
<li
v-if="deployments.length === 0"
class="px-6 py-8 text-center text-sm text-muted-foreground"
>
No deployments recorded.
</li>
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Unhealthy services</CardTitle>
<CardDescription>Services that need attention.</CardDescription>
</CardHeader>
<CardContent class="grid gap-2">
<div
v-for="service in unhealthyServices"
:key="service.id"
class="flex items-center justify-between rounded-md border p-3 text-sm"
>
<span class="font-medium">{{ service.name }}</span>
<StatusIndicator :status="service.status" />
</div> </div>
<div class="text-muted-foreground">{{ operation.status.replace("-", " ") }}</div> <div
</div> v-if="unhealthyServices.length === 0"
<div class="rounded-md border border-dashed p-3 text-sm text-muted-foreground"
v-if="recentOperations.length === 0" >
class="rounded-md border border-dashed p-3 text-sm text-muted-foreground" No unhealthy services.
> </div>
No operations recorded. </CardContent>
</div> </Card>
</CardContent> </section>
</Card>
</div> </div>
</AppLayout> </AppLayout>
</template> </template>
+44 -30
View File
@@ -4,6 +4,13 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import AppLayout from "@/layouts/AppLayout.vue"; import AppLayout from "@/layouts/AppLayout.vue";
import { Head, Link, useForm } from "@inertiajs/vue3"; import { Head, Link, useForm } from "@inertiajs/vue3";
@@ -60,41 +67,44 @@ const form = useForm({
<CardContent class="grid gap-3 text-sm"> <CardContent class="grid gap-3 text-sm">
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="repository_type">Repository type</Label> <Label for="repository_type">Repository type</Label>
<select <Select v-model="form.repository_type" required>
id="repository_type" <SelectTrigger id="repository_type">
v-model="form.repository_type" <SelectValue placeholder="Select a type" />
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm" </SelectTrigger>
required <SelectContent>
> <SelectItem
<option v-for="(type, key) in repositoryTypes"
v-for="(type, key) in repositoryTypes" :key="key"
:key="key" :value="type"
:value="type" >
> {{ type }}
{{ type }} </SelectItem>
</option> </SelectContent>
</select> </Select>
<InputError :message="form.errors.repository_type" /> <InputError :message="form.errors.repository_type" />
</div> </div>
<div v-if="sourceProviders.length" class="grid gap-2"> <div v-if="sourceProviders.length" class="grid gap-2">
<Label for="source_provider_id">Source provider</Label> <Label for="source_provider_id">Source provider</Label>
<select <Select v-model="form.source_provider_id">
id="source_provider_id" <SelectTrigger id="source_provider_id">
v-model="form.source_provider_id" <SelectValue placeholder="No provider" />
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm" </SelectTrigger>
> <SelectContent>
<option value="">No provider</option> <SelectItem
<option v-for="provider in sourceProviders"
v-for="provider in sourceProviders" :key="provider.id"
:key="provider.id" :value="String(provider.id)"
:value="provider.id" >
> {{ provider.name }} · {{ provider.type }}
{{ provider.name }} · {{ provider.type }} </SelectItem>
</option> </SelectContent>
</select> </Select>
<InputError :message="form.errors.source_provider_id" /> <InputError :message="form.errors.source_provider_id" />
</div> </div>
<div v-else class="flex flex-wrap items-center justify-between gap-3 rounded-md border border-dashed p-3"> <div
v-else
class="flex flex-wrap items-center justify-between gap-3 rounded-md border border-dashed p-3"
>
<span class="text-muted-foreground"> <span class="text-muted-foreground">
No source provider is configured yet. SSH URLs still work, but adding a No source provider is configured yet. SSH URLs still work, but adding a
provider documents which Git host this repository belongs to. provider documents which Git host this repository belongs to.
@@ -103,7 +113,11 @@ const form = useForm({
:as="Link" :as="Link"
size="sm" size="sm"
variant="secondary" variant="secondary"
:href="route('source-providers.create', { organisation: $page.props.organisation.id })" :href="
route('source-providers.create', {
organisation: $page.props.organisation.id,
})
"
> >
Add provider Add provider
</Button> </Button>
+35 -28
View File
@@ -4,6 +4,13 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import AppLayout from "@/layouts/AppLayout.vue"; import AppLayout from "@/layouts/AppLayout.vue";
import { Head, router, useForm } from "@inertiajs/vue3"; import { Head, router, useForm } from "@inertiajs/vue3";
@@ -79,38 +86,38 @@ const destroyApplication = (): void => {
<CardContent class="grid gap-4"> <CardContent class="grid gap-4">
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="repository_type">Repository type</Label> <Label for="repository_type">Repository type</Label>
<select <Select v-model="form.repository_type" required>
id="repository_type" <SelectTrigger id="repository_type">
v-model="form.repository_type" <SelectValue placeholder="Select repository type" />
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm" </SelectTrigger>
required <SelectContent>
> <SelectItem
<option v-for="(type, key) in repositoryTypes"
v-for="(type, key) in repositoryTypes" :key="key"
:key="key" :value="type"
:value="type" >
> {{ type }}
{{ type }} </SelectItem>
</option> </SelectContent>
</select> </Select>
<InputError :message="form.errors.repository_type" /> <InputError :message="form.errors.repository_type" />
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="source_provider_id">Source provider</Label> <Label for="source_provider_id">Source provider</Label>
<select <Select v-model="form.source_provider_id">
id="source_provider_id" <SelectTrigger id="source_provider_id">
v-model="form.source_provider_id" <SelectValue placeholder="No provider" />
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm" </SelectTrigger>
> <SelectContent>
<option value="">No provider</option> <SelectItem
<option v-for="provider in sourceProviders"
v-for="provider in sourceProviders" :key="provider.id"
:key="provider.id" :value="String(provider.id)"
:value="provider.id" >
> {{ provider.name }} · {{ provider.type }}
{{ provider.name }} · {{ provider.type }} </SelectItem>
</option> </SelectContent>
</select> </Select>
<InputError :message="form.errors.source_provider_id" /> <InputError :message="form.errors.source_provider_id" />
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
+1 -5
View File
@@ -45,11 +45,7 @@ defineProps<{
</div> </div>
</div> </div>
<div class="grid gap-4 rounded-xl p-4 md:grid-cols-2 lg:grid-cols-3"> <div class="grid gap-4 rounded-xl p-4 md:grid-cols-2 lg:grid-cols-3">
<Card <Card v-for="application in applications" :key="application.id" class="relative w-full">
v-for="application in applications"
:key="application.id"
class="relative w-full"
>
<CardHeader> <CardHeader>
<CardTitle>{{ application.name }}</CardTitle> <CardTitle>{{ application.name }}</CardTitle>
<CardDescription <CardDescription
+12 -6
View File
@@ -79,7 +79,9 @@ defineProps<{
<CardTitle>Repository Deploy Key</CardTitle> <CardTitle>Repository Deploy Key</CardTitle>
<Badge <Badge
:variant=" :variant="
application.deploy_key_installed_at ? 'success' : 'secondary' application.deploy_key_installed_at
? 'success'
: 'secondary'
" "
> >
{{ {{
@@ -140,8 +142,8 @@ defineProps<{
<CardTitle>Registry required before deployment</CardTitle> <CardTitle>Registry required before deployment</CardTitle>
<CardDescription> <CardDescription>
This organisation has {{ deploymentRequirements.serverCount }} This organisation has {{ deploymentRequirements.serverCount }}
servers and no registry. Multi-server deployments need a registry servers and no registry. Multi-server deployments need a registry so
so every server can pull the same build artifact. every server can pull the same build artifact.
</CardDescription> </CardDescription>
</div> </div>
<Button <Button
@@ -310,7 +312,8 @@ defineProps<{
@click=" @click="
router.post( router.post(
route('environment-migrations.store', { route('environment-migrations.store', {
organisation: $page.props.organisation.id, organisation:
$page.props.organisation.id,
application: application.id, application: application.id,
environment: environment.id, environment: environment.id,
}), }),
@@ -335,7 +338,8 @@ defineProps<{
@click=" @click="
router.post( router.post(
route('environment-workers.store', { route('environment-workers.store', {
organisation: $page.props.organisation.id, organisation:
$page.props.organisation.id,
application: application.id, application: application.id,
environment: environment.id, environment: environment.id,
}), }),
@@ -372,7 +376,9 @@ defineProps<{
> >
<Badge variant="outline">{{ artifact.status }}</Badge> <Badge variant="outline">{{ artifact.status }}</Badge>
<span>{{ artifact.commit_sha }}</span> <span>{{ artifact.commit_sha }}</span>
<span v-if="artifact.image_digest">{{ artifact.image_digest }}</span> <span v-if="artifact.image_digest">{{
artifact.image_digest
}}</span>
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
+3 -1
View File
@@ -52,7 +52,9 @@ defineProps<{
<CardTitle>Metadata</CardTitle> <CardTitle>Metadata</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<pre class="overflow-x-auto rounded-md bg-muted p-3 text-xs">{{ JSON.stringify(artifact.metadata ?? {}, null, 2) }}</pre> <pre class="overflow-x-auto rounded-md bg-muted p-3 text-xs">{{
JSON.stringify(artifact.metadata ?? {}, null, 2)
}}</pre>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -3,6 +3,13 @@ import InputError from "@/components/InputError.vue";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import AppLayout from "@/layouts/AppLayout.vue"; import AppLayout from "@/layouts/AppLayout.vue";
import { Head, useForm } from "@inertiajs/vue3"; import { Head, useForm } from "@inertiajs/vue3";
import { computed, watch } from "vue"; import { computed, watch } from "vue";
@@ -16,7 +23,7 @@ const props = defineProps<{
}>(); }>();
const form = useForm({ const form = useForm({
service_id: props.services[0]?.id ?? null, service_id: String(props.services[0]?.id ?? ""),
role: "database", role: "database",
name: "", name: "",
env_prefix: "", env_prefix: "",
@@ -35,7 +42,7 @@ const compatibleServices = computed(() => {
}); });
const selectedService = computed(() => const selectedService = computed(() =>
props.services.find((service) => service.id === form.service_id), props.services.find((service) => String(service.id) === form.service_id),
); );
const generatedSliceType = computed(() => { const generatedSliceType = computed(() => {
if (selectedService.value?.type === "postgres") { if (selectedService.value?.type === "postgres") {
@@ -133,19 +140,20 @@ watch(
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="service_id">Service</Label> <Label for="service_id">Service</Label>
<select <Select v-model="form.service_id">
id="service_id" <SelectTrigger id="service_id">
v-model="form.service_id" <SelectValue placeholder="Select a service" />
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm" </SelectTrigger>
> <SelectContent>
<option <SelectItem
v-for="service in compatibleServices" v-for="service in compatibleServices"
:key="service.id" :key="service.id"
:value="service.id" :value="String(service.id)"
> >
{{ service.name }} · {{ service.type }} {{ service.name }} · {{ service.type }}
</option> </SelectItem>
</select> </SelectContent>
</Select>
<InputError :message="form.errors.service_id" /> <InputError :message="form.errors.service_id" />
</div> </div>
@@ -172,15 +180,16 @@ watch(
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="role">Role</Label> <Label for="role">Role</Label>
<select <Select v-model="form.role">
id="role" <SelectTrigger id="role">
v-model="form.role" <SelectValue placeholder="Select a role" />
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm" </SelectTrigger>
> <SelectContent>
<option v-for="role in roles" :key="role" :value="role"> <SelectItem v-for="role in roles" :key="role" :value="role">
{{ role.replace("_", " ") }} {{ role.replace("_", " ") }}
</option> </SelectItem>
</select> </SelectContent>
</Select>
<InputError :message="form.errors.role" /> <InputError :message="form.errors.role" />
</div> </div>
@@ -208,15 +217,28 @@ watch(
</div> </div>
</div> </div>
<div v-if="form.role === 'gateway'" class="grid gap-4 rounded-md border p-3 md:grid-cols-3"> <div
v-if="form.role === 'gateway'"
class="grid gap-4 rounded-md border p-3 md:grid-cols-3"
>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="domain">Domain</Label> <Label for="domain">Domain</Label>
<Input id="domain" v-model="form.domain" type="text" placeholder="app.example.com" /> <Input
id="domain"
v-model="form.domain"
type="text"
placeholder="app.example.com"
/>
<InputError :message="form.errors.domain" /> <InputError :message="form.errors.domain" />
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="path_prefix">Path prefix</Label> <Label for="path_prefix">Path prefix</Label>
<Input id="path_prefix" v-model="form.path_prefix" type="text" placeholder="/" /> <Input
id="path_prefix"
v-model="form.path_prefix"
type="text"
placeholder="/"
/>
<InputError :message="form.errors.path_prefix" /> <InputError :message="form.errors.path_prefix" />
</div> </div>
<label class="flex items-center gap-2 pt-7 text-sm"> <label class="flex items-center gap-2 pt-7 text-sm">
@@ -3,6 +3,13 @@ import InputError from "@/components/InputError.vue";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import AppLayout from "@/layouts/AppLayout.vue"; import AppLayout from "@/layouts/AppLayout.vue";
import { Head, router, useForm } from "@inertiajs/vue3"; import { Head, router, useForm } from "@inertiajs/vue3";
@@ -78,15 +85,16 @@ const detach = (): void => {
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="role">Role</Label> <Label for="role">Role</Label>
<select <Select v-model="form.role">
id="role" <SelectTrigger id="role">
v-model="form.role" <SelectValue placeholder="Select a role" />
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm" </SelectTrigger>
> <SelectContent>
<option v-for="role in roles" :key="role" :value="role"> <SelectItem v-for="role in roles" :key="role" :value="role">
{{ role.replace("_", " ") }} {{ role.replace("_", " ") }}
</option> </SelectItem>
</select> </SelectContent>
</Select>
<InputError :message="form.errors.role" /> <InputError :message="form.errors.role" />
</div> </div>
@@ -143,8 +143,10 @@ const destroyVariable = (variable: Record<string, any>): void => {
> >
{{ variable.key }} {{ variable.key }}
</Link> </Link>
<Badge :variant="variable.source === 'user' ? 'secondary' : 'outline'"> <Badge
{{ variable.source.replace('_', ' ') }} :variant="variable.source === 'user' ? 'secondary' : 'outline'"
>
{{ variable.source.replace("_", " ") }}
</Badge> </Badge>
<Badge v-if="!variable.overridable" variant="outline">locked</Badge> <Badge v-if="!variable.overridable" variant="outline">locked</Badge>
<Badge variant="outline">secret</Badge> <Badge variant="outline">secret</Badge>
@@ -159,7 +161,11 @@ const destroyVariable = (variable: Record<string, any>): void => {
<Button <Button
size="iconxs" size="iconxs"
variant="ghost" variant="ghost"
:aria-label="isRevealed(variable) ? `Hide ${variable.key}` : `Reveal ${variable.key}`" :aria-label="
isRevealed(variable)
? `Hide ${variable.key}`
: `Reveal ${variable.key}`
"
@click="toggleReveal(variable)" @click="toggleReveal(variable)"
> >
<EyeOffIcon v-if="isRevealed(variable)" class="size-3" /> <EyeOffIcon v-if="isRevealed(variable)" class="size-3" />
+52 -33
View File
@@ -4,6 +4,13 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import AppLayout from "@/layouts/AppLayout.vue"; import AppLayout from "@/layouts/AppLayout.vue";
import { Head, router, useForm } from "@inertiajs/vue3"; import { Head, router, useForm } from "@inertiajs/vue3";
@@ -123,33 +130,38 @@ const destroyEnvironment = (): void => {
</label> </label>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="scheduler_target_service_id">Target service</Label> <Label for="scheduler_target_service_id">Target service</Label>
<select <Select v-model="form.scheduler_target_service_id">
id="scheduler_target_service_id" <SelectTrigger id="scheduler_target_service_id">
v-model="form.scheduler_target_service_id" <SelectValue placeholder="No target" />
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm" </SelectTrigger>
> <SelectContent>
<option value="">No target</option> <SelectItem
<option v-for="service in environment.services"
v-for="service in environment.services" :key="service.id"
:key="service.id" :value="String(service.id)"
:value="service.id" >
> {{ service.name }}
{{ service.name }} </SelectItem>
</option> </SelectContent>
</select> </Select>
<InputError :message="form.errors.scheduler_target_service_id" /> <InputError :message="form.errors.scheduler_target_service_id" />
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="scheduler_mode">Mode</Label> <Label for="scheduler_mode">Mode</Label>
<select <Select v-model="form.scheduler_mode">
id="scheduler_mode" <SelectTrigger id="scheduler_mode">
v-model="form.scheduler_mode" <SelectValue placeholder="Select mode" />
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm" </SelectTrigger>
> <SelectContent>
<option v-for="mode in schedulerModes" :key="mode" :value="mode"> <SelectItem
{{ mode.replace("_", " ") }} v-for="mode in schedulerModes"
</option> :key="mode"
</select> :value="mode"
>
{{ mode.replace("_", " ") }}
</SelectItem>
</SelectContent>
</Select>
<InputError :message="form.errors.scheduler_mode" /> <InputError :message="form.errors.scheduler_mode" />
</div> </div>
</CardContent> </CardContent>
@@ -158,20 +170,27 @@ const destroyEnvironment = (): void => {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Build & Health</CardTitle> <CardTitle>Build & Health</CardTitle>
<CardDescription>Defaults used by deploy planning and runtime checks.</CardDescription> <CardDescription
>Defaults used by deploy planning and runtime checks.</CardDescription
>
</CardHeader> </CardHeader>
<CardContent class="grid gap-4 md:grid-cols-2"> <CardContent class="grid gap-4 md:grid-cols-2">
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="build_strategy">Build strategy</Label> <Label for="build_strategy">Build strategy</Label>
<select <Select v-model="form.build_strategy">
id="build_strategy" <SelectTrigger id="build_strategy">
v-model="form.build_strategy" <SelectValue placeholder="Select strategy" />
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm" </SelectTrigger>
> <SelectContent>
<option v-for="strategy in buildStrategies" :key="strategy" :value="strategy"> <SelectItem
{{ strategy.replace("_", " ") }} v-for="strategy in buildStrategies"
</option> :key="strategy"
</select> :value="strategy"
>
{{ strategy.replace("_", " ") }}
</SelectItem>
</SelectContent>
</Select>
<InputError :message="form.errors.build_strategy" /> <InputError :message="form.errors.build_strategy" />
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
+11 -5
View File
@@ -32,7 +32,9 @@ defineProps<{
</div> </div>
<Button <Button
:as="Link" :as="Link"
:href="route('applications.create', { organisation: $page.props.organisation.id })" :href="
route('applications.create', { organisation: $page.props.organisation.id })
"
> >
<PlusIcon class="size-4" /> <PlusIcon class="size-4" />
Application Application
@@ -63,13 +65,17 @@ defineProps<{
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<BoxesIcon class="size-4" /> <BoxesIcon class="size-4" />
<span class="font-medium">{{ environment.name }}</span> <span class="font-medium">{{ environment.name }}</span>
<Badge :variant="environment.status === 'active' ? 'success' : 'secondary'"> <Badge
{{ environment.status.replace('-', ' ') }} :variant="
environment.status === 'active' ? 'success' : 'secondary'
"
>
{{ environment.status.replace("-", " ") }}
</Badge> </Badge>
</div> </div>
<p class="mt-2 text-sm text-muted-foreground"> <p class="mt-2 text-sm text-muted-foreground">
{{ environment.branch }} · {{ environment.services_count }} services · {{ environment.branch }} · {{ environment.services_count }} services
{{ environment.build_artifacts_count }} builds · {{ environment.build_artifacts_count }} builds
</p> </p>
</Link> </Link>
</CardContent> </CardContent>
+370 -354
View File
@@ -1,22 +1,32 @@
<script setup lang="ts"> <script setup lang="ts">
import OperationTimeline from "@/components/operations/OperationTimeline.vue"; import SpecRow from "@/components/environments/SpecRow.vue";
import TopologyCard from "@/components/environments/TopologyCard.vue";
import InputError from "@/components/InputError.vue"; import InputError from "@/components/InputError.vue";
import OperationTimeline from "@/components/operations/OperationTimeline.vue";
import StatusIndicator from "@/components/StatusIndicator.vue";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import AppLayout from "@/layouts/AppLayout.vue"; import AppLayout from "@/layouts/AppLayout.vue";
import { Head, Link, router, useForm } from "@inertiajs/vue3"; import { Head, Link, router, useForm } from "@inertiajs/vue3";
import { import {
BoxIcon,
ChevronDownIcon,
DatabaseIcon, DatabaseIcon,
GitBranchIcon, GitBranchIcon,
GlobeIcon,
LayersIcon,
ListChecksIcon, ListChecksIcon,
PencilIcon, PencilIcon,
PlusIcon, PlusIcon,
RocketIcon, RocketIcon,
ServerIcon, ServerIcon,
SettingsIcon, SettingsIcon,
ShieldCheckIcon,
ZapIcon,
} from "lucide-vue-next"; } from "lucide-vue-next";
import { computed } from "vue"; import { computed } from "vue";
@@ -38,13 +48,24 @@ const gatewayAttachments = computed(() =>
props.environment.attachments.filter((attachment) => attachment.role === "gateway"), props.environment.attachments.filter((attachment) => attachment.role === "gateway"),
); );
const gatewayCutovers = computed(() => const resourceAttachments = computed(() =>
props.environment.operations.filter((operation) => operation.kind === "gateway_cutover"), props.environment.attachments.filter((attachment) => attachment.role !== "gateway"),
); );
const attachmentIcons: Record<string, object | Function> = {
database: DatabaseIcon,
cache: ZapIcon,
queue: ListChecksIcon,
storage: BoxIcon,
gateway: GlobeIcon,
custom: ServerIcon,
};
const attachmentIcon = (role: string): object | Function => attachmentIcons[role] ?? ServerIcon;
const caddyfilePreviewFor = (attachmentId: number): string => const caddyfilePreviewFor = (attachmentId: number): string =>
props.gatewayRoutePreviews.find((preview) => preview.attachment_id === attachmentId)?.caddyfile ?? props.gatewayRoutePreviews.find((preview) => preview.attachment_id === attachmentId)
"# No route preview available"; ?.caddyfile ?? "# No route preview available";
const deployForm = useForm({ const deployForm = useForm({
target_commit: "", target_commit: "",
@@ -83,37 +104,40 @@ const deployEnvironment = (): void => {
{ title: environment.name }, { title: environment.name },
]" ]"
> >
<div class="flex h-full flex-1 flex-col gap-4 p-4"> <div class="mx-auto w-full max-w-7xl space-y-6 p-4">
<div class="flex flex-wrap items-center justify-between gap-3"> <!-- Header -->
<div class="flex flex-wrap items-start justify-between gap-3">
<div> <div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-3">
<h2 class="text-3xl font-bold tracking-tight">{{ environment.name }}</h2> <h2 class="text-2xl font-bold tracking-tight">{{ environment.name }}</h2>
<Badge <StatusIndicator :status="environment.status" size="md" />
:variant="environment.status === 'active' ? 'success' : 'secondary'" </div>
>{{ environment.status.replace("-", " ") }}</Badge <div
> class="mt-1 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground"
>
<span class="flex items-center gap-1.5">
<GitBranchIcon class="size-4" />{{ environment.branch }}
</span>
<span>
Scheduler:
{{
environment.scheduler_enabled
? `${environment.scheduler_mode} on ${
environment.services?.find(
(service) =>
service.id ===
environment.scheduler_target_service_id,
)?.name ?? "selected service"
}`
: "disabled"
}}
</span>
</div> </div>
<p class="mt-1 text-sm text-muted-foreground">
<GitBranchIcon class="mr-1 inline size-4" />{{ environment.branch }}
</p>
<p class="mt-1 text-sm text-muted-foreground">
Scheduler:
{{
environment.scheduler_enabled
? `${environment.scheduler_mode} on ${
environment.services?.find(
(service) =>
service.id === environment.scheduler_target_service_id,
)?.name ?? "selected service"
}`
: "disabled"
}}
</p>
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<Button <Button
:as="Link"
variant="secondary" variant="secondary"
:as="Link"
:href=" :href="
route('environments.edit', { route('environments.edit', {
organisation: $page.props.organisation.id, organisation: $page.props.organisation.id,
@@ -141,8 +165,8 @@ const deployEnvironment = (): void => {
Migrate Migrate
</Button> </Button>
<Button <Button
:as="Link"
variant="secondary" variant="secondary"
:as="Link"
:href=" :href="
route('environment-attachments.create', { route('environment-attachments.create', {
organisation: $page.props.organisation.id, organisation: $page.props.organisation.id,
@@ -154,48 +178,27 @@ const deployEnvironment = (): void => {
<PlusIcon class="size-4" /> <PlusIcon class="size-4" />
Attach Attach
</Button> </Button>
<Button
:disabled="deploymentRequirements.registryRequired || deployForm.processing"
:title="
deploymentRequirements.registryRequired
? 'Configure a registry before deploying to multiple servers.'
: undefined
"
@click="deployEnvironment"
>
<RocketIcon class="size-4" />
Deploy
</Button>
</div> </div>
</div> </div>
<Card> <Card
v-if="deploymentRequirements.registryRequired"
class="border-amber-300 bg-amber-50 dark:bg-amber-950/30"
>
<CardHeader> <CardHeader>
<CardTitle>Deploy Target</CardTitle> <CardTitle>Registry required</CardTitle>
<CardDescription>
Deploy the current {{ environment.branch }} branch head, or pin this
deployment to a specific commit SHA.
</CardDescription>
</CardHeader>
<CardContent>
<form class="flex flex-col gap-3 md:flex-row md:items-end" @submit.prevent="deployEnvironment">
<div class="grid flex-1 gap-2">
<Label for="target_commit">Commit SHA</Label>
<Input
id="target_commit"
v-model="deployForm.target_commit"
placeholder="Leave blank to resolve the branch head"
maxlength="40"
/>
<InputError :message="deployForm.errors.target_commit" />
</div>
<Button
type="submit"
:disabled="deploymentRequirements.registryRequired || deployForm.processing"
:title="
deploymentRequirements.registryRequired
? 'Configure a registry before deploying to multiple servers.'
: undefined
"
>
<RocketIcon class="size-4" />
Deploy
</Button>
</form>
</CardContent>
</Card>
<Card v-if="deploymentRequirements.registryRequired" class="border-amber-200 bg-amber-50">
<CardHeader>
<CardTitle>Registry Required</CardTitle>
<CardDescription> <CardDescription>
This environment spans {{ deploymentRequirements.serverCount }} servers. This environment spans {{ deploymentRequirements.serverCount }} servers.
Configure a registry before deploying so every server can pull the same Configure a registry before deploying so every server can pull the same
@@ -217,275 +220,179 @@ const deployEnvironment = (): void => {
</CardContent> </CardContent>
</Card> </Card>
<div class="grid gap-4 lg:grid-cols-[2fr_1fr]"> <!-- Topology -->
<div class="space-y-4"> <div class="grid gap-4 lg:grid-cols-3">
<Card> <!-- Network -->
<CardHeader> <div class="space-y-3">
<CardTitle>Services</CardTitle> <h3 class="px-1 text-sm font-medium text-muted-foreground">Network</h3>
<CardDescription
>{{ environment.services?.length ?? 0 }} runtime and managed
services</CardDescription
>
</CardHeader>
<CardContent class="grid gap-3">
<div
v-for="service in environment.services"
:key="service.id"
class="rounded-md border p-3"
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<div class="flex items-center gap-2">
<ServerIcon class="size-4" />
<h3 class="font-semibold">{{ service.name }}</h3>
<Badge variant="outline">{{ service.type }}</Badge>
</div>
<p class="mt-1 text-sm text-muted-foreground">
{{ service.replicas?.length ?? 0 }} replicas ·
{{ service.slices?.length ?? 0 }} slices ·
{{ service.status?.replace("-", " ") }}
</p>
</div>
<Button
:as="Link"
size="sm"
variant="secondary"
:href="
route('environment-services.show', {
organisation: $page.props.organisation.id,
application: environment.application_id,
environment: environment.id,
service: service.id,
})
"
>
<SettingsIcon class="size-4" />
Open
</Button>
</div>
</div>
</CardContent>
</Card>
<Card> <TopologyCard
<CardHeader> v-for="attachment in gatewayAttachments"
<CardTitle>Operations</CardTitle> :key="attachment.id"
</CardHeader> :icon="GlobeIcon"
<CardContent> title="Gateway"
<OperationTimeline :operations="environment.operations" /> :subtitle="attachment.service_slice?.config?.domain ?? 'Unassigned domain'"
</CardContent> :status="attachment.service_slice?.config?.certificate_status ?? 'pending'"
</Card> >
<SpecRow
<Card> :icon="GlobeIcon"
<CardHeader> label="Domain"
<div class="flex items-center justify-between gap-3"> :value="attachment.service_slice?.config?.domain ?? 'not set'"
<div> />
<CardTitle>Builds</CardTitle> <SpecRow
<CardDescription> :icon="ServerIcon"
Recent artifacts planned or built for this environment. label="Path"
</CardDescription> :value="attachment.service_slice?.config?.path_prefix ?? '/'"
</div> />
<Button <SpecRow :icon="ShieldCheckIcon" label="TLS">
:as="Link" <StatusIndicator
size="sm" :status="
variant="secondary" attachment.service_slice?.config?.tls_enabled === false
:href=" ? 'disabled'
route('build-artifacts.index', { : 'enabled'
organisation: $page.props.organisation.id, "
application: application.id, />
environment: environment.id, </SpecRow>
}) <Collapsible>
" <CollapsibleTrigger
> class="flex w-full items-center justify-between pt-1 text-xs text-muted-foreground hover:text-foreground"
View all
</Button>
</div>
</CardHeader>
<CardContent class="grid gap-2">
<div
v-for="artifact in environment.build_artifacts"
:key="artifact.id"
class="rounded-md border p-3 text-sm"
> >
<div class="flex flex-wrap items-center gap-2"> View Caddyfile
<Badge variant="outline">{{ artifact.status }}</Badge> <ChevronDownIcon class="size-3.5" />
<span class="font-medium">{{ artifact.commit_sha }}</span> </CollapsibleTrigger>
<span class="text-muted-foreground">{{ artifact.image_tag }}</span> <CollapsibleContent>
</div>
<p class="mt-1 text-muted-foreground">
{{ artifact.registry_ref ?? "No registry ref" }}
<span v-if="artifact.image_digest">
· {{ artifact.image_digest }}
</span>
</p>
</div>
<div
v-if="environment.build_artifacts.length === 0"
class="rounded-md border border-dashed p-4 text-sm text-muted-foreground"
>
No builds recorded for this environment.
</div>
</CardContent>
</Card>
</div>
<div class="space-y-4">
<Card>
<CardHeader>
<CardTitle>Attachments</CardTitle>
</CardHeader>
<CardContent class="grid gap-2">
<div
v-for="attachment in environment.attachments"
:key="attachment.id"
class="rounded-md border p-3 text-sm"
>
<div class="flex items-center gap-2 font-medium">
<DatabaseIcon class="size-4" />
<Link
:href="
route('environment-attachments.edit', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
attachment: attachment.id,
})
"
class="hover:underline"
>
{{ attachment.role.replace("_", " ") }}
</Link>
</div>
<p class="mt-1 text-muted-foreground">
{{ attachment.service?.name }} ·
{{ attachment.service_slice?.name ?? "service level" }}
</p>
<div
v-if="attachment.role === 'gateway'"
class="mt-2 grid gap-1 text-xs text-muted-foreground"
>
<div>
Domain:
{{ attachment.service_slice?.config?.domain ?? "not set" }}
</div>
<div>
Path:
{{ attachment.service_slice?.config?.path_prefix ?? "/" }}
· TLS
{{
attachment.service_slice?.config?.tls_enabled === false
? "disabled"
: "enabled"
}}
</div>
<div>
Certificate:
{{
attachment.service_slice?.config?.certificate_status ??
"pending"
}}
</div>
</div>
</div>
</CardContent>
</Card>
<Card v-if="gatewayAttachments.length > 0">
<CardHeader>
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle>Gateway Cutover</CardTitle>
<CardDescription>
Route validation, reload, upstream health, and drain sequence.
</CardDescription>
</div>
<Button
:as="Link"
size="xs"
variant="secondary"
:href="
route('gateway.routes.index', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
})
"
>
Manage routes
</Button>
</div>
</CardHeader>
<CardContent class="grid gap-3">
<div
v-for="attachment in gatewayAttachments"
:key="attachment.id"
class="rounded-md border p-3 text-sm"
>
<div class="font-medium">
{{ attachment.service_slice?.config?.domain ?? "Unassigned domain" }}
</div>
<div class="text-muted-foreground">
Caddyfile: /home/keystone/gateway/Caddyfile
</div>
<pre class="mt-2 overflow-x-auto rounded-md bg-muted p-3 text-xs">{{ <pre class="mt-2 overflow-x-auto rounded-md bg-muted p-3 text-xs">{{
caddyfilePreviewFor(attachment.id) caddyfilePreviewFor(attachment.id)
}}</pre> }}</pre>
<div class="mt-2 flex flex-wrap gap-2"> </CollapsibleContent>
<Badge variant="outline">Render route</Badge> </Collapsible>
<Badge variant="outline">Health check</Badge> </TopologyCard>
<Badge variant="outline">Reload gateway</Badge>
<Badge variant="outline">Drain old upstream</Badge>
</div>
</div>
<OperationTimeline :operations="gatewayCutovers" />
</CardContent>
</Card>
<Card v-else>
<CardHeader>
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle>Gateway Routes</CardTitle>
<CardDescription>
No gateway routes are configured for this environment.
</CardDescription>
</div>
<Button
:as="Link"
size="xs"
variant="secondary"
:href="
route('gateway.routes.index', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
})
"
>
Manage routes
</Button>
</div>
</CardHeader>
</Card>
<Card> <Card v-if="gatewayAttachments.length === 0" class="border-dashed">
<CardHeader> <CardContent class="space-y-3 p-5 text-center">
<CardTitle>Variables</CardTitle> <p class="text-sm text-muted-foreground">
</CardHeader> No gateway routes configured.
<CardContent class="flex flex-wrap gap-2"> </p>
<Badge
v-for="variable in environment.variables"
:key="variable.id"
:variant="variable.source === 'user' ? 'secondary' : 'outline'"
>
{{ variable.key }} · {{ variable.source.replace("_", " ") }}
<span v-if="!variable.overridable"> · locked</span>
</Badge>
<Button <Button
:as="Link" :as="Link"
size="sm" size="sm"
variant="secondary" variant="secondary"
:href=" :href="
route('environment-variables.index', { route('gateway.routes.index', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
})
"
>
Manage routes
</Button>
</CardContent>
</Card>
</div>
<!-- Compute -->
<div class="space-y-3">
<h3 class="px-1 text-sm font-medium text-muted-foreground">Compute</h3>
<TopologyCard
v-for="service in environment.services"
:key="service.id"
:icon="ServerIcon"
:title="service.name"
:subtitle="service.type"
:status="service.status"
>
<SpecRow
:icon="LayersIcon"
label="Replicas"
:value="service.replicas?.length ?? 0"
/>
<SpecRow
:icon="LayersIcon"
label="Slices"
:value="service.slices?.length ?? 0"
/>
<SpecRow
:icon="ServerIcon"
label="Deploy policy"
:value="service.deploy_policy ?? 'default'"
/>
<Button
:as="Link"
size="sm"
variant="secondary"
class="mt-1 w-full"
:href="
route('environment-services.show', {
organisation: $page.props.organisation.id,
application: environment.application_id,
environment: environment.id,
service: service.id,
})
"
>
<SettingsIcon class="size-4" />
Open service
</Button>
</TopologyCard>
<Card v-if="(environment.services?.length ?? 0) === 0" class="border-dashed">
<CardContent class="p-5 text-center text-sm text-muted-foreground">
No services in this environment.
</CardContent>
</Card>
</div>
<!-- Resources -->
<div class="space-y-3">
<h3 class="px-1 text-sm font-medium text-muted-foreground">Resources</h3>
<TopologyCard
v-for="attachment in resourceAttachments"
:key="attachment.id"
:icon="attachmentIcon(attachment.role)"
:title="attachment.role.replaceAll('_', ' ')"
:subtitle="attachment.service?.name"
>
<SpecRow
:icon="ServerIcon"
label="Service"
:value="attachment.service?.name ?? '—'"
/>
<SpecRow
:icon="LayersIcon"
label="Slice"
:value="attachment.service_slice?.name ?? 'service level'"
/>
<Button
:as="Link"
size="sm"
variant="secondary"
class="mt-1 w-full"
:href="
route('environment-attachments.edit', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
attachment: attachment.id,
})
"
>
<SettingsIcon class="size-4" />
Edit attachment
</Button>
</TopologyCard>
<Card v-if="resourceAttachments.length === 0" class="border-dashed">
<CardContent class="space-y-3 p-5 text-center">
<p class="text-sm text-muted-foreground">No resources attached.</p>
<Button
:as="Link"
size="sm"
variant="secondary"
:href="
route('environment-attachments.create', {
organisation: $page.props.organisation.id, organisation: $page.props.organisation.id,
application: application.id, application: application.id,
environment: environment.id, environment: environment.id,
@@ -493,43 +400,152 @@ const deployEnvironment = (): void => {
" "
> >
<PlusIcon class="size-4" /> <PlusIcon class="size-4" />
Manage Attach resource
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
<!-- Variables -->
<Card> <Card>
<CardHeader> <CardHeader class="pb-3">
<CardTitle>Service policy</CardTitle> <div class="flex items-center justify-between">
<CardDescription> <CardTitle class="text-base">Variables</CardTitle>
Migration and scheduler-related defaults exposed by current <Button
services. :as="Link"
</CardDescription> size="xs"
</CardHeader> variant="secondary"
<CardContent class="grid gap-2 text-sm"> :href="
<div route('environment-variables.index', {
v-for="service in environment.services" organisation: $page.props.organisation.id,
:key="service.id" application: application.id,
class="rounded-md border p-3" environment: environment.id,
> })
<div class="font-medium">{{ service.name }}</div> "
<div class="text-muted-foreground"> >
Deploy policy: {{ service.deploy_policy ?? "default" }} · Manage
Roles: {{ service.process_roles?.join(", ") || "none" }} </Button>
</div>
<div class="text-muted-foreground">
Migration:
{{
service.config?.migration_mode ??
service.config?.migration_timing ??
"not configured"
}}
</div>
</div> </div>
</CardHeader>
<CardContent class="flex flex-wrap gap-2">
<Badge
v-for="variable in environment.variables"
:key="variable.id"
:variant="variable.source === 'user' ? 'secondary' : 'outline'"
>
{{ variable.key }}
<span v-if="!variable.overridable"> · locked</span>
</Badge>
<span
v-if="(environment.variables?.length ?? 0) === 0"
class="text-sm text-muted-foreground"
>
No variables set.
</span>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</div> </div>
<!-- Deploy a specific commit -->
<Collapsible>
<Card>
<CollapsibleTrigger class="w-full">
<CardHeader class="flex-row items-center justify-between">
<div class="text-left">
<CardTitle class="text-base">Deploy a specific commit</CardTitle>
<CardDescription>
Pin this deployment to a commit SHA instead of the branch head.
</CardDescription>
</div>
<ChevronDownIcon class="size-4 text-muted-foreground" />
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent>
<form
class="flex flex-col gap-3 md:flex-row md:items-end"
@submit.prevent="deployEnvironment"
>
<div class="grid flex-1 gap-2">
<Label for="target_commit">Commit SHA</Label>
<Input
id="target_commit"
v-model="deployForm.target_commit"
placeholder="Leave blank to resolve the branch head"
maxlength="40"
/>
<InputError :message="deployForm.errors.target_commit" />
</div>
<Button
type="submit"
:disabled="
deploymentRequirements.registryRequired ||
deployForm.processing
"
>
<RocketIcon class="size-4" />
Deploy commit
</Button>
</form>
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
<!-- Activity -->
<div class="grid gap-4 lg:grid-cols-[2fr_1fr]">
<Card>
<CardHeader>
<CardTitle class="text-base">Operations</CardTitle>
</CardHeader>
<CardContent>
<OperationTimeline :operations="environment.operations" />
</CardContent>
</Card>
<Card>
<CardHeader>
<div class="flex items-center justify-between">
<CardTitle class="text-base">Builds</CardTitle>
<Button
:as="Link"
size="xs"
variant="secondary"
:href="
route('build-artifacts.index', {
organisation: $page.props.organisation.id,
application: application.id,
environment: environment.id,
})
"
>
View all
</Button>
</div>
</CardHeader>
<CardContent class="grid gap-2">
<div
v-for="artifact in environment.build_artifacts"
:key="artifact.id"
class="rounded-md border p-3 text-sm"
>
<div class="flex flex-wrap items-center gap-2">
<StatusIndicator :status="artifact.status" />
<span class="font-mono text-xs">{{ artifact.commit_sha }}</span>
</div>
<p class="mt-1 truncate text-xs text-muted-foreground">
{{ artifact.registry_ref ?? "No registry ref" }}
</p>
</div>
<div
v-if="(environment.build_artifacts?.length ?? 0) === 0"
class="rounded-md border border-dashed p-4 text-sm text-muted-foreground"
>
No builds recorded.
</div>
</CardContent>
</Card>
</div>
</div> </div>
</AppLayout> </AppLayout>
</template> </template>
+28 -12
View File
@@ -3,6 +3,13 @@ import InputError from "@/components/InputError.vue";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import AppLayout from "@/layouts/AppLayout.vue"; import AppLayout from "@/layouts/AppLayout.vue";
import { Head, useForm } from "@inertiajs/vue3"; import { Head, useForm } from "@inertiajs/vue3";
@@ -13,7 +20,7 @@ const props = defineProps<{
}>(); }>();
const form = useForm({ const form = useForm({
service_id: props.services[0]?.id ?? null, service_id: String(props.services[0]?.id ?? ""),
name: "", name: "",
domain: "", domain: "",
path_prefix: "/", path_prefix: "/",
@@ -77,16 +84,20 @@ const form = useForm({
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="service_id">Gateway service</Label> <Label for="service_id">Gateway service</Label>
<select <Select v-model="form.service_id" required>
id="service_id" <SelectTrigger id="service_id">
v-model="form.service_id" <SelectValue placeholder="Select gateway service" />
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm" </SelectTrigger>
required <SelectContent>
> <SelectItem
<option v-for="service in services" :key="service.id" :value="service.id"> v-for="service in services"
{{ service.name }} :key="service.id"
</option> :value="String(service.id)"
</select> >
{{ service.name }}
</SelectItem>
</SelectContent>
</Select>
<InputError :message="form.errors.service_id" /> <InputError :message="form.errors.service_id" />
</div> </div>
@@ -99,7 +110,12 @@ const form = useForm({
<div class="grid gap-4 md:grid-cols-2"> <div class="grid gap-4 md:grid-cols-2">
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="domain">Domain</Label> <Label for="domain">Domain</Label>
<Input id="domain" v-model="form.domain" placeholder="app.example.com" required /> <Input
id="domain"
v-model="form.domain"
placeholder="app.example.com"
required
/>
<InputError :message="form.errors.domain" /> <InputError :message="form.errors.domain" />
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
+11 -2
View File
@@ -94,7 +94,12 @@ const destroyRoute = (): void => {
<div class="grid gap-4 md:grid-cols-2"> <div class="grid gap-4 md:grid-cols-2">
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="domain">Domain</Label> <Label for="domain">Domain</Label>
<Input id="domain" v-model="form.domain" placeholder="app.example.com" required /> <Input
id="domain"
v-model="form.domain"
placeholder="app.example.com"
required
/>
<InputError :message="form.errors.domain" /> <InputError :message="form.errors.domain" />
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
@@ -111,7 +116,11 @@ const destroyRoute = (): void => {
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="certificate_status">Certificate status</Label> <Label for="certificate_status">Certificate status</Label>
<Input id="certificate_status" v-model="form.certificate_status" placeholder="pending" /> <Input
id="certificate_status"
v-model="form.certificate_status"
placeholder="pending"
/>
<InputError :message="form.errors.certificate_status" /> <InputError :message="form.errors.certificate_status" />
</div> </div>
+6 -2
View File
@@ -13,7 +13,8 @@ const props = defineProps<{
}>(); }>();
const destroyRoute = (routeAttachment: Record<string, any>): void => { const destroyRoute = (routeAttachment: Record<string, any>): void => {
const domain = routeAttachment.service_slice?.config?.domain ?? routeAttachment.service_slice?.name; const domain =
routeAttachment.service_slice?.config?.domain ?? routeAttachment.service_slice?.name;
if (!window.confirm(`Remove gateway route ${domain}?`)) { if (!window.confirm(`Remove gateway route ${domain}?`)) {
return; return;
@@ -87,7 +88,10 @@ const destroyRoute = (routeAttachment: Record<string, any>): void => {
<div class="flex flex-wrap items-start justify-between gap-3"> <div class="flex flex-wrap items-start justify-between gap-3">
<div> <div>
<CardTitle> <CardTitle>
{{ routeAttachment.service_slice?.config?.domain ?? "Unassigned domain" }} {{
routeAttachment.service_slice?.config?.domain ??
"Unassigned domain"
}}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
{{ routeAttachment.service?.name }} · {{ routeAttachment.service?.name }} ·
+2 -2
View File
@@ -65,7 +65,7 @@ const setFilter = (key: string, value: string | null): void => {
:variant="filters.kind === kind ? 'default' : 'secondary'" :variant="filters.kind === kind ? 'default' : 'secondary'"
@click="setFilter('kind', kind)" @click="setFilter('kind', kind)"
> >
{{ kind.replace('_', ' ') }} {{ kind.replace("_", " ") }}
</Button> </Button>
<Button <Button
v-for="status in operationStatuses" v-for="status in operationStatuses"
@@ -74,7 +74,7 @@ const setFilter = (key: string, value: string | null): void => {
:variant="filters.status === status ? 'default' : 'outline'" :variant="filters.status === status ? 'default' : 'outline'"
@click="setFilter('status', filters.status === status ? null : status)" @click="setFilter('status', filters.status === status ? null : status)"
> >
{{ status.replace('-', ' ') }} {{ status.replace("-", " ") }}
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
+8 -3
View File
@@ -10,7 +10,8 @@ defineProps<{
operation: Record<string, any>; operation: Record<string, any>;
}>(); }>();
const label = (value?: string | null): string => value?.replaceAll("_", " ").replaceAll("-", " ") ?? ""; const label = (value?: string | null): string =>
value?.replaceAll("_", " ").replaceAll("-", " ") ?? "";
usePoll(5000, {}, { keepAlive: true }); usePoll(5000, {}, { keepAlive: true });
@@ -49,9 +50,13 @@ const cancelOperation = (operation: Record<string, any>): void => {
<div class="flex flex-wrap items-center justify-between gap-3"> <div class="flex flex-wrap items-center justify-between gap-3">
<div> <div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<h2 class="text-3xl font-bold tracking-tight">{{ label(operation.kind) }}</h2> <h2 class="text-3xl font-bold tracking-tight">
{{ label(operation.kind) }}
</h2>
<Badge variant="outline">{{ operation.hash }}</Badge> <Badge variant="outline">{{ operation.hash }}</Badge>
<Badge :variant="operation.status === 'completed' ? 'success' : 'secondary'"> <Badge
:variant="operation.status === 'completed' ? 'success' : 'secondary'"
>
{{ label(operation.status) }} {{ label(operation.status) }}
</Badge> </Badge>
</div> </div>
@@ -4,6 +4,13 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import AppLayout from "@/layouts/AppLayout.vue"; import AppLayout from "@/layouts/AppLayout.vue";
import { Head, router, useForm } from "@inertiajs/vue3"; import { Head, router, useForm } from "@inertiajs/vue3";
import { Trash2Icon } from "lucide-vue-next"; import { Trash2Icon } from "lucide-vue-next";
@@ -93,7 +100,8 @@ const cancelInvitation = (invitation: Record<string, any>): void => {
<CardHeader> <CardHeader>
<CardTitle>Invite Member</CardTitle> <CardTitle>Invite Member</CardTitle>
<CardDescription> <CardDescription>
Existing users are added immediately. New emails remain pending until accepted. Existing users are added immediately. New emails remain pending until
accepted.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -104,7 +112,10 @@ const cancelInvitation = (invitation: Record<string, any>): void => {
route('organisation-members.store', { route('organisation-members.store', {
organisation: organisation.id, organisation: organisation.id,
}), }),
{ preserveScroll: true, onSuccess: () => inviteForm.reset('email') }, {
preserveScroll: true,
onSuccess: () => inviteForm.reset('email'),
},
) )
" "
> >
@@ -115,15 +126,16 @@ const cancelInvitation = (invitation: Record<string, any>): void => {
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="role">Role</Label> <Label for="role">Role</Label>
<select <Select v-model="inviteForm.role">
id="role" <SelectTrigger id="role">
v-model="inviteForm.role" <SelectValue placeholder="Select a role" />
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm" </SelectTrigger>
> <SelectContent>
<option v-for="role in roles" :key="role" :value="role"> <SelectItem v-for="role in roles" :key="role" :value="role">
{{ role }} {{ role }}
</option> </SelectItem>
</select> </SelectContent>
</Select>
<InputError :message="inviteForm.errors.role" /> <InputError :message="inviteForm.errors.role" />
</div> </div>
<div class="flex items-end"> <div class="flex items-end">
@@ -151,23 +163,24 @@ const cancelInvitation = (invitation: Record<string, any>): void => {
<div class="text-muted-foreground"> <div class="text-muted-foreground">
Invited by Invited by
{{ invitation.invited_by?.name ?? "Keystone" }} {{ invitation.invited_by?.name ?? "Keystone" }}
<span v-if="invitation.expires_at"> · expires {{ invitation.expires_at }}</span> <span v-if="invitation.expires_at">
· expires {{ invitation.expires_at }}</span
>
</div> </div>
</div> </div>
<select <Select
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm" :model-value="invitation.role ?? 'member'"
:value="invitation.role ?? 'member'" @update:model-value="updateInvitationRole(invitation, $event as string)"
@change="
updateInvitationRole(
invitation,
($event.target as HTMLSelectElement).value,
)
"
> >
<option v-for="role in roles" :key="role" :value="role"> <SelectTrigger>
{{ role }} <SelectValue placeholder="Select a role" />
</option> </SelectTrigger>
</select> <SelectContent>
<SelectItem v-for="role in roles" :key="role" :value="role">
{{ role }}
</SelectItem>
</SelectContent>
</Select>
<Button <Button
size="iconxs" size="iconxs"
variant="ghost" variant="ghost"
@@ -201,15 +214,19 @@ const cancelInvitation = (invitation: Record<string, any>): void => {
<div class="font-medium">{{ member.name }}</div> <div class="font-medium">{{ member.name }}</div>
<div class="text-muted-foreground">{{ member.email }}</div> <div class="text-muted-foreground">{{ member.email }}</div>
</div> </div>
<select <Select
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm" :model-value="member.membership?.role ?? 'member'"
:value="member.membership?.role ?? 'member'" @update:model-value="updateRole(member, $event as string)"
@change="updateRole(member, ($event.target as HTMLSelectElement).value)"
> >
<option v-for="role in roles" :key="role" :value="role"> <SelectTrigger>
{{ role }} <SelectValue placeholder="Select a role" />
</option> </SelectTrigger>
</select> <SelectContent>
<SelectItem v-for="role in roles" :key="role" :value="role">
{{ role }}
</SelectItem>
</SelectContent>
</Select>
<Button <Button
size="iconxs" size="iconxs"
variant="ghost" variant="ghost"
+15 -5
View File
@@ -153,20 +153,30 @@ const destroyResource = (url: string, label: string): void => {
<Card class="mt-4"> <Card class="mt-4">
<CardHeader> <CardHeader>
<CardTitle>Health</CardTitle> <CardTitle>Health</CardTitle>
<CardDescription>Aggregate signals across this organisation.</CardDescription> <CardDescription
>Aggregate signals across this organisation.</CardDescription
>
</CardHeader> </CardHeader>
<CardContent class="grid gap-3 md:grid-cols-3"> <CardContent class="grid gap-3 md:grid-cols-3">
<div class="rounded-md border p-3"> <div class="rounded-md border p-3">
<div class="text-2xl font-semibold">{{ health.unhealthy_services }}</div> <div class="text-2xl font-semibold">
{{ health.unhealthy_services }}
</div>
<div class="text-sm text-muted-foreground">Unhealthy services</div> <div class="text-sm text-muted-foreground">Unhealthy services</div>
</div> </div>
<div class="rounded-md border p-3"> <div class="rounded-md border p-3">
<div class="text-2xl font-semibold">{{ health.failed_operations }}</div> <div class="text-2xl font-semibold">
{{ health.failed_operations }}
</div>
<div class="text-sm text-muted-foreground">Failed operations</div> <div class="text-sm text-muted-foreground">Failed operations</div>
</div> </div>
<div class="rounded-md border p-3"> <div class="rounded-md border p-3">
<div class="text-2xl font-semibold">{{ health.locked_variables }}</div> <div class="text-2xl font-semibold">
<div class="text-sm text-muted-foreground">Environments with locked variables</div> {{ health.locked_variables }}
</div>
<div class="text-sm text-muted-foreground">
Environments with locked variables
</div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
+17 -9
View File
@@ -3,6 +3,13 @@ import InputError from "@/components/InputError.vue";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import AppLayout from "@/layouts/AppLayout.vue"; import AppLayout from "@/layouts/AppLayout.vue";
import { Head, useForm } from "@inertiajs/vue3"; import { Head, useForm } from "@inertiajs/vue3";
@@ -51,15 +58,16 @@ const form = useForm({
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="type">Type</Label> <Label for="type">Type</Label>
<select <Select v-model="form.type">
id="type" <SelectTrigger id="type">
v-model="form.type" <SelectValue placeholder="Select type" />
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm" </SelectTrigger>
> <SelectContent>
<option v-for="type in providerTypes" :key="type" :value="type"> <SelectItem v-for="type in providerTypes" :key="type" :value="type">
{{ type.replace("-", " ") }} {{ type.replace("-", " ") }}
</option> </SelectItem>
</select> </SelectContent>
</Select>
<InputError :message="form.errors.type" /> <InputError :message="form.errors.type" />
</div> </div>
+21 -13
View File
@@ -3,6 +3,13 @@ import InputError from "@/components/InputError.vue";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import AppLayout from "@/layouts/AppLayout.vue"; import AppLayout from "@/layouts/AppLayout.vue";
import { Head, useForm } from "@inertiajs/vue3"; import { Head, useForm } from "@inertiajs/vue3";
@@ -53,19 +60,20 @@ const form = useForm({
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="type">Type</Label> <Label for="type">Type</Label>
<select <Select v-model="form.type">
id="type" <SelectTrigger id="type">
v-model="form.type" <SelectValue placeholder="Select type" />
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm" </SelectTrigger>
> <SelectContent>
<option <SelectItem
v-for="registryType in registryTypes" v-for="registryType in registryTypes"
:key="registryType" :key="registryType"
:value="registryType" :value="registryType"
> >
{{ registryType.replace("_", " ") }} {{ registryType.replace("_", " ") }}
</option> </SelectItem>
</select> </SelectContent>
</Select>
<InputError :message="form.errors.type" /> <InputError :message="form.errors.type" />
</div> </div>
+21 -9
View File
@@ -3,6 +3,13 @@ import InputError from "@/components/InputError.vue";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import AppLayout from "@/layouts/AppLayout.vue"; import AppLayout from "@/layouts/AppLayout.vue";
import { Head, router, useForm } from "@inertiajs/vue3"; import { Head, router, useForm } from "@inertiajs/vue3";
@@ -71,15 +78,20 @@ const destroyRegistry = (): void => {
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="type">Type</Label> <Label for="type">Type</Label>
<select <Select v-model="form.type">
id="type" <SelectTrigger id="type">
v-model="form.type" <SelectValue placeholder="Select type" />
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm" </SelectTrigger>
> <SelectContent>
<option v-for="registryType in registryTypes" :key="registryType" :value="registryType"> <SelectItem
{{ registryType.replace("_", " ") }} v-for="registryType in registryTypes"
</option> :key="registryType"
</select> :value="registryType"
>
{{ registryType.replace("_", " ") }}
</SelectItem>
</SelectContent>
</Select>
<InputError :message="form.errors.type" /> <InputError :message="form.errors.type" />
</div> </div>
+11 -3
View File
@@ -46,7 +46,9 @@ const destroyRegistry = (registry: Record<string, any>): void => {
</div> </div>
<Button <Button
:as="Link" :as="Link"
:href="route('registries.create', { organisation: $page.props.organisation.id })" :href="
route('registries.create', { organisation: $page.props.organisation.id })
"
> >
<PlusIcon class="size-4" /> <PlusIcon class="size-4" />
Add registry Add registry
@@ -67,7 +69,9 @@ const destroyRegistry = (registry: Record<string, any>): void => {
<div> <div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<span class="font-medium">{{ registry.name }}</span> <span class="font-medium">{{ registry.name }}</span>
<Badge variant="outline">{{ registry.type?.replace("_", " ") }}</Badge> <Badge variant="outline">{{
registry.type?.replace("_", " ")
}}</Badge>
</div> </div>
<div class="mt-1 text-muted-foreground"> <div class="mt-1 text-muted-foreground">
{{ registry.url ?? "No registry URL configured" }} {{ registry.url ?? "No registry URL configured" }}
@@ -100,7 +104,11 @@ const destroyRegistry = (registry: Record<string, any>): void => {
> >
<PencilIcon class="size-3" /> <PencilIcon class="size-3" />
</Button> </Button>
<Button size="iconxs" variant="ghost" @click="destroyRegistry(registry)"> <Button
size="iconxs"
variant="ghost"
@click="destroyRegistry(registry)"
>
<Trash2Icon class="size-3" /> <Trash2Icon class="size-3" />
</Button> </Button>
</div> </div>
+3 -6
View File
@@ -47,11 +47,7 @@ defineProps<{
</div> </div>
</div> </div>
<div class="grid gap-4 rounded-xl p-4 md:grid-cols-2 lg:grid-cols-3"> <div class="grid gap-4 rounded-xl p-4 md:grid-cols-2 lg:grid-cols-3">
<Card <Card v-for="server in servers.data" :key="server.id" class="relative w-full">
v-for="server in servers.data"
:key="server.id"
class="relative w-full"
>
<CardHeader> <CardHeader>
<CardTitle>{{ server.name }}</CardTitle> <CardTitle>{{ server.name }}</CardTitle>
<CardDescription> <CardDescription>
@@ -151,7 +147,8 @@ defineProps<{
<CardHeader> <CardHeader>
<CardTitle>No private networks</CardTitle> <CardTitle>No private networks</CardTitle>
<CardDescription> <CardDescription>
Networks are created when the first server is provisioned for a provider zone. Networks are created when the first server is provisioned for a provider
zone.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
</Card> </Card>
+43 -17
View File
@@ -6,6 +6,13 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import AppLayout from "@/layouts/AppLayout.vue"; import AppLayout from "@/layouts/AppLayout.vue";
import { Head, Link, router, useForm } from "@inertiajs/vue3"; import { Head, Link, router, useForm } from "@inertiajs/vue3";
import { useCycleList, useInterval } from "@vueuse/core"; import { useCycleList, useInterval } from "@vueuse/core";
@@ -204,7 +211,10 @@ const healServer = (): void => {
<h3 class="mb-3 text-2xl font-semibold tracking-tight">Operations</h3> <h3 class="mb-3 text-2xl font-semibold tracking-tight">Operations</h3>
<Card> <Card>
<CardContent class="py-4"> <CardContent class="py-4">
<OperationTimeline :operations="server.service_operations" show-target /> <OperationTimeline
:operations="server.service_operations"
show-target
/>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -212,22 +222,29 @@ const healServer = (): void => {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Firewall</CardTitle> <CardTitle>Firewall</CardTitle>
<CardDescription>Rules Keystone knows about for this server.</CardDescription> <CardDescription
>Rules Keystone knows about for this server.</CardDescription
>
</CardHeader> </CardHeader>
<CardContent class="grid gap-4"> <CardContent class="grid gap-4">
<form class="grid gap-3 rounded-md border p-3" @submit.prevent="addFirewallRule"> <form
<div class="grid gap-3 md:grid-cols-[120px_1fr_1fr_auto] md:items-end"> class="grid gap-3 rounded-md border p-3"
@submit.prevent="addFirewallRule"
>
<div
class="grid gap-3 md:grid-cols-[120px_1fr_1fr_auto] md:items-end"
>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="firewall_type">Action</Label> <Label for="firewall_type">Action</Label>
<select <Select v-model="firewallForm.type" required>
id="firewall_type" <SelectTrigger id="firewall_type">
v-model="firewallForm.type" <SelectValue placeholder="Select an action" />
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm" </SelectTrigger>
required <SelectContent>
> <SelectItem value="allow">allow</SelectItem>
<option value="allow">allow</option> <SelectItem value="deny">deny</SelectItem>
<option value="deny">deny</option> </SelectContent>
</select> </Select>
<InputError :message="firewallForm.errors.type" /> <InputError :message="firewallForm.errors.type" />
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
@@ -261,7 +278,9 @@ const healServer = (): void => {
class="flex flex-wrap items-center justify-between gap-3 rounded-md border p-3 text-sm" class="flex flex-wrap items-center justify-between gap-3 rounded-md border p-3 text-sm"
> >
<div> <div>
<div class="font-medium">{{ rule.type }} · {{ rule.ports }}</div> <div class="font-medium">
{{ rule.type }} · {{ rule.ports }}
</div>
<div class="text-muted-foreground"> <div class="text-muted-foreground">
{{ rule.from ? `from ${rule.from}` : "any source" }} · {{ rule.from ? `from ${rule.from}` : "any source" }} ·
{{ rule.status }} {{ rule.status }}
@@ -293,10 +312,13 @@ const healServer = (): void => {
<div v-if="server.network"> <div v-if="server.network">
<div class="font-medium">{{ server.network.name }}</div> <div class="font-medium">{{ server.network.name }}</div>
<div class="text-muted-foreground"> <div class="text-muted-foreground">
{{ server.network.ip_range }} · {{ server.network.network_zone }} {{ server.network.ip_range }} ·
{{ server.network.network_zone }}
</div> </div>
</div> </div>
<div v-else class="text-muted-foreground">No private network attached.</div> <div v-else class="text-muted-foreground">
No private network attached.
</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -325,7 +347,11 @@ const healServer = (): void => {
</div> </div>
</div> </div>
<div> <div>
<Button size="xs" disabled title="Services can be added after provisioning completes."> <Button
size="xs"
disabled
title="Services can be added after provisioning completes."
>
<PlusIcon class="size-4" /> <PlusIcon class="size-4" />
Add service Add service
</Button> </Button>
+3 -1
View File
@@ -82,7 +82,9 @@ const stopReplica = (): void => {
{{ replica.container_name }} {{ replica.container_name }}
</h2> </h2>
<Badge variant="outline">{{ replica.status }}</Badge> <Badge variant="outline">{{ replica.status }}</Badge>
<Badge :variant="replica.health_status === 'healthy' ? 'success' : 'secondary'"> <Badge
:variant="replica.health_status === 'healthy' ? 'success' : 'secondary'"
>
{{ replica.health_status }} {{ replica.health_status }}
</Badge> </Badge>
</div> </div>
+21 -10
View File
@@ -3,6 +3,13 @@ import InputError from "@/components/InputError.vue";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import AppLayout from "@/layouts/AppLayout.vue"; import AppLayout from "@/layouts/AppLayout.vue";
import { Head, useForm } from "@inertiajs/vue3"; import { Head, useForm } from "@inertiajs/vue3";
@@ -77,16 +84,20 @@ const form = useForm({
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="environment_id">Environment</Label> <Label for="environment_id">Environment</Label>
<select <Select v-model="form.environment_id">
id="environment_id" <SelectTrigger id="environment_id">
v-model="form.environment_id" <SelectValue placeholder="Service-level" />
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm" </SelectTrigger>
> <SelectContent>
<option value="">Service-level</option> <SelectItem
<option v-for="environment in environments" :key="environment.id" :value="environment.id"> v-for="environment in environments"
{{ environment.name }} :key="environment.id"
</option> :value="String(environment.id)"
</select> >
{{ environment.name }}
</SelectItem>
</SelectContent>
</Select>
<InputError :message="form.errors.environment_id" /> <InputError :message="form.errors.environment_id" />
</div> </div>
+19 -6
View File
@@ -86,20 +86,30 @@ defineProps<{
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
{{ slice.environment?.application?.name ?? "Service" }} {{ slice.environment?.application?.name ?? "Service" }}
<span v-if="slice.environment">/ {{ slice.environment.name }}</span> <span v-if="slice.environment"
>/ {{ slice.environment.name }}</span
>
</CardDescription> </CardDescription>
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<Badge variant="outline">{{ slice.type }}</Badge> <Badge variant="outline">{{ slice.type }}</Badge>
<Badge :variant="slice.status === 'active' ? 'success' : 'secondary'"> <Badge
:variant="slice.status === 'active' ? 'success' : 'secondary'"
>
{{ slice.status }} {{ slice.status }}
</Badge> </Badge>
<Badge variant="outline">{{ slice.attachments.length }} attachments</Badge> <Badge variant="outline"
>{{ slice.attachments.length }} attachments</Badge
>
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(18rem,24rem)]"> <CardContent
<pre class="overflow-x-auto rounded-md bg-muted p-3 text-xs">{{ JSON.stringify(slice.config ?? {}, null, 2) }}</pre> class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(18rem,24rem)]"
>
<pre class="overflow-x-auto rounded-md bg-muted p-3 text-xs">{{
JSON.stringify(slice.config ?? {}, null, 2)
}}</pre>
<div> <div>
<div class="mb-2 text-sm font-medium">Recent operations</div> <div class="mb-2 text-sm font-medium">Recent operations</div>
<OperationTimeline :operations="slice.operations" /> <OperationTimeline :operations="slice.operations" />
@@ -110,7 +120,10 @@ defineProps<{
<Card v-if="slices.length === 0" class="border-dashed"> <Card v-if="slices.length === 0" class="border-dashed">
<CardHeader> <CardHeader>
<CardTitle>No slices</CardTitle> <CardTitle>No slices</CardTitle>
<CardDescription>Create a slice for a service-level capability or managed attachment.</CardDescription> <CardDescription
>Create a slice for a service-level capability or managed
attachment.</CardDescription
>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Button <Button
+8 -3
View File
@@ -46,10 +46,15 @@ defineProps<{
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Configuration</CardTitle> <CardTitle>Configuration</CardTitle>
<CardDescription>Credentials are stored encrypted and not revealed here.</CardDescription> <CardDescription
>Credentials are stored encrypted and not revealed
here.</CardDescription
>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<pre class="overflow-x-auto rounded-md bg-muted p-3 text-xs">{{ JSON.stringify(slice.config ?? {}, null, 2) }}</pre> <pre class="overflow-x-auto rounded-md bg-muted p-3 text-xs">{{
JSON.stringify(slice.config ?? {}, null, 2)
}}</pre>
</CardContent> </CardContent>
</Card> </Card>
@@ -71,7 +76,7 @@ defineProps<{
" "
class="rounded-md border p-3 text-sm hover:bg-muted/50" class="rounded-md border p-3 text-sm hover:bg-muted/50"
> >
<div class="font-medium">{{ attachment.role.replace('_', ' ') }}</div> <div class="font-medium">{{ attachment.role.replace("_", " ") }}</div>
<div class="text-muted-foreground"> <div class="text-muted-foreground">
{{ attachment.environment.name }} · {{ attachment.environment.name }} ·
{{ attachment.env_prefix ?? "default prefix" }} {{ attachment.env_prefix ?? "default prefix" }}
+29 -11
View File
@@ -4,6 +4,13 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import AppLayout from "@/layouts/AppLayout.vue"; import AppLayout from "@/layouts/AppLayout.vue";
import { Head, router, useForm } from "@inertiajs/vue3"; import { Head, router, useForm } from "@inertiajs/vue3";
@@ -146,20 +153,27 @@ const destroyService = (): void => {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Deployment</CardTitle> <CardTitle>Deployment</CardTitle>
<CardDescription>Stateful services can track available image updates.</CardDescription> <CardDescription
>Stateful services can track available image updates.</CardDescription
>
</CardHeader> </CardHeader>
<CardContent class="grid gap-4 md:grid-cols-2"> <CardContent class="grid gap-4 md:grid-cols-2">
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="deploy_policy">Deploy policy</Label> <Label for="deploy_policy">Deploy policy</Label>
<select <Select v-model="form.deploy_policy">
id="deploy_policy" <SelectTrigger id="deploy_policy">
v-model="form.deploy_policy" <SelectValue placeholder="Select deploy policy" />
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm" </SelectTrigger>
> <SelectContent>
<option v-for="policy in deployPolicies" :key="policy" :value="policy"> <SelectItem
{{ policy.replace("_", " ") }} v-for="policy in deployPolicies"
</option> :key="policy"
</select> :value="policy"
>
{{ policy.replace("_", " ") }}
</SelectItem>
</SelectContent>
</Select>
<InputError :message="form.errors.deploy_policy" /> <InputError :message="form.errors.deploy_policy" />
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
@@ -182,7 +196,11 @@ const destroyService = (): void => {
<CardContent class="grid gap-4 md:grid-cols-2"> <CardContent class="grid gap-4 md:grid-cols-2">
<div class="grid gap-2 md:col-span-2"> <div class="grid gap-2 md:col-span-2">
<Label for="process_roles">Process roles</Label> <Label for="process_roles">Process roles</Label>
<Input id="process_roles" v-model="form.process_roles" placeholder="web, scheduler" /> <Input
id="process_roles"
v-model="form.process_roles"
placeholder="web, scheduler"
/>
<InputError :message="form.errors.process_roles" /> <InputError :message="form.errors.process_roles" />
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
+21 -7
View File
@@ -24,7 +24,9 @@ defineProps<{
? [ ? [
{ {
title: 'Applications', title: 'Applications',
href: route('applications.index', { organisation: $page.props.organisation.id }), href: route('applications.index', {
organisation: $page.props.organisation.id,
}),
}, },
{ {
title: application.name, title: application.name,
@@ -45,7 +47,9 @@ defineProps<{
: [ : [
{ {
title: 'Servers', title: 'Servers',
href: route('servers.index', { organisation: $page.props.organisation.id }), href: route('servers.index', {
organisation: $page.props.organisation.id,
}),
}, },
{ {
title: server?.name ?? 'Server', title: server?.name ?? 'Server',
@@ -197,7 +201,9 @@ defineProps<{
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Endpoints</CardTitle> <CardTitle>Endpoints</CardTitle>
<CardDescription>Network endpoints registered for this service.</CardDescription> <CardDescription
>Network endpoints registered for this service.</CardDescription
>
</CardHeader> </CardHeader>
<CardContent class="grid gap-2"> <CardContent class="grid gap-2">
<div <div
@@ -225,20 +231,28 @@ defineProps<{
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Compose</CardTitle> <CardTitle>Compose</CardTitle>
<CardDescription>Generated artifact location on the target server.</CardDescription> <CardDescription
>Generated artifact location on the target server.</CardDescription
>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<pre class="overflow-x-auto rounded-md bg-muted p-3 text-xs">/home/keystone/services/{{ service.id }}/compose.yml</pre> <pre class="overflow-x-auto rounded-md bg-muted p-3 text-xs">
/home/keystone/services/{{ service.id }}/compose.yml</pre
>
</CardContent> </CardContent>
</Card> </Card>
<Card v-if="service.type === 'caddy'"> <Card v-if="service.type === 'caddy'">
<CardHeader> <CardHeader>
<CardTitle>Caddyfile</CardTitle> <CardTitle>Caddyfile</CardTitle>
<CardDescription>Gateway route configuration generated on the server.</CardDescription> <CardDescription
>Gateway route configuration generated on the server.</CardDescription
>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<pre class="overflow-x-auto rounded-md bg-muted p-3 text-xs">/home/keystone/gateway/Caddyfile</pre> <pre class="overflow-x-auto rounded-md bg-muted p-3 text-xs">
/home/keystone/gateway/Caddyfile</pre
>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -112,7 +112,9 @@ const resolveLatestDigest = (): void => {
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="confirmation">Type {{ service.name }} to confirm downtime</Label> <Label for="confirmation"
>Type {{ service.name }} to confirm downtime</Label
>
<Input id="confirmation" v-model="form.confirmation" /> <Input id="confirmation" v-model="form.confirmation" />
<InputError :message="form.errors.confirmation" /> <InputError :message="form.errors.confirmation" />
</div> </div>
+21 -13
View File
@@ -3,6 +3,13 @@ import InputError from "@/components/InputError.vue";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import AppLayout from "@/layouts/AppLayout.vue"; import AppLayout from "@/layouts/AppLayout.vue";
import { Head, useForm } from "@inertiajs/vue3"; import { Head, useForm } from "@inertiajs/vue3";
@@ -53,19 +60,20 @@ const form = useForm({
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="type">Type</Label> <Label for="type">Type</Label>
<select <Select v-model="form.type">
id="type" <SelectTrigger id="type">
v-model="form.type" <SelectValue placeholder="Select type" />
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm" </SelectTrigger>
> <SelectContent>
<option <SelectItem
v-for="sourceProviderType in sourceProviderTypes" v-for="sourceProviderType in sourceProviderTypes"
:key="sourceProviderType" :key="sourceProviderType"
:value="sourceProviderType" :value="sourceProviderType"
> >
{{ sourceProviderType.replace("_", " ") }} {{ sourceProviderType.replace("_", " ") }}
</option> </SelectItem>
</select> </SelectContent>
</Select>
<InputError :message="form.errors.type" /> <InputError :message="form.errors.type" />
</div> </div>
+21 -13
View File
@@ -3,6 +3,13 @@ import InputError from "@/components/InputError.vue";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import AppLayout from "@/layouts/AppLayout.vue"; import AppLayout from "@/layouts/AppLayout.vue";
import { Head, router, useForm } from "@inertiajs/vue3"; import { Head, router, useForm } from "@inertiajs/vue3";
@@ -66,19 +73,20 @@ const destroySourceProvider = (): void => {
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="type">Type</Label> <Label for="type">Type</Label>
<select <Select v-model="form.type">
id="type" <SelectTrigger id="type">
v-model="form.type" <SelectValue placeholder="Select type" />
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm" </SelectTrigger>
> <SelectContent>
<option <SelectItem
v-for="sourceProviderType in sourceProviderTypes" v-for="sourceProviderType in sourceProviderTypes"
:key="sourceProviderType" :key="sourceProviderType"
:value="sourceProviderType" :value="sourceProviderType"
> >
{{ sourceProviderType.replace("_", " ") }} {{ sourceProviderType.replace("_", " ") }}
</option> </SelectItem>
</select> </SelectContent>
</Select>
<InputError :message="form.errors.type" /> <InputError :message="form.errors.type" />
</div> </div>
+5
View File
@@ -2,7 +2,12 @@
use Illuminate\Foundation\Inspiring; use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
Artisan::command('inspire', function () { Artisan::command('inspire', function () {
$this->comment(Inspiring::quote()); $this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote'); })->purpose('Display an inspiring quote');
Schedule::command('keystone:managed-registry:prune --dispatch')
->daily()
->withoutOverlapping();
+13 -4
View File
@@ -26,6 +26,7 @@ use App\Http\Controllers\ServiceReplicaController;
use App\Http\Controllers\ServiceSliceController; use App\Http\Controllers\ServiceSliceController;
use App\Http\Controllers\ServiceUpdateController; use App\Http\Controllers\ServiceUpdateController;
use App\Http\Controllers\SourceProviderController; use App\Http\Controllers\SourceProviderController;
use App\Models\Application;
use App\Models\Operation; use App\Models\Operation;
use App\Models\Service; use App\Models\Service;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@@ -41,13 +42,21 @@ Route::middleware(['auth', 'verified'])->group(function () {
return inertia('Dashboard', [ return inertia('Dashboard', [
'organisations' => $organisations, 'organisations' => $organisations,
'recentOperations' => Operation::query() 'recentApplications' => Application::query()
->with('target') ->whereIn('organisation_id', $organisationIds)
->with(['sourceProvider', 'environments'])
->latest('updated_at')
->limit(6)
->get(),
'deployments' => Operation::query()
->with(['target' => fn ($morphTo) => $morphTo->morphWith([
Service::class => ['environment.application'],
])])
->whereHasMorph('target', [Service::class], fn ($query) => $query->whereIn('organisation_id', $organisationIds)) ->whereHasMorph('target', [Service::class], fn ($query) => $query->whereIn('organisation_id', $organisationIds))
->latest() ->latest()
->limit(5) ->limit(15)
->get(), ->get(),
'unhealthyServices' => \App\Models\Service::query() 'unhealthyServices' => Service::query()
->whereIn('organisation_id', $organisationIds) ->whereIn('organisation_id', $organisationIds)
->whereNot('status', ServiceStatus::RUNNING) ->whereNot('status', ServiceStatus::RUNNING)
->latest() ->latest()

Some files were not shown because too many files have changed in this diff Show More