Compare commits

..

5 Commits

Author SHA1 Message Date
85c44296ac Restructure UX and seed a fully simulated organisation
Some checks failed
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
3a851db08f Add managed registry provisioning, pruning, and readiness tracking 2026-06-08 20:44:16 +01:00
5b977c1f41 wowowowowo
Some checks failed
CI / Lint (push) Failing after 22s
CI / Tests (push) Failing after 33s
2026-05-28 15:15:41 +01:00
8f603122e2 wip
All checks were successful
CI / Tests (push) Successful in 36s
CI / Lint (push) Successful in 1m3s
2026-05-24 13:55:30 +01:00
66f0ee9e50 Migrate to Gitea, switch JS tooling to oxlint/oxfmt, lift test coverage to 95%
All checks were successful
CI / Tests (push) Successful in 43s
CI / Lint (push) Successful in 1m3s
- Add .gitea/workflows/ci.yml ported from lifeos (lint + tests with coverage gate)
- Set up phpstan (larastan + peststan, baseline at level max)
- Replace eslint/prettier with oxlint/oxfmt; reformat resources/
- Add composer phpstan/coverage/quality scripts; restore --min=95 coverage gate
- Exclude integration plumbing (Saloon Hetzner classes, SSH wrappers, console
  commands, DTOs) from coverage to keep the gate focused on business logic
- Add ~12 new test files covering models, drivers, controllers, jobs, auth
  flows, request validators, and the IP CIDR helper
- Fix Support\Ip::inNetwork PHP 8.4 TypeError in CIDR mask check
- Fix FirewallRule::command comparing the enum-cast type column to a string
- Fix Server::network using the wrong foreign key column
- Remove unreachable code under abort(403) in RegisteredUserController
2026-05-13 16:51:07 +01:00
389 changed files with 23438 additions and 2430 deletions

View File

@@ -63,3 +63,5 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
HETZNER_KEY=

78
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,78 @@
name: CI
on:
push:
branches:
- main
pull_request:
permissions:
contents: read
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
container:
image: git.bayliss.cloud/harry/gitea-ci-runner:php8.4
defaults:
run:
shell: bash
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Composer dependencies
run: composer install --no-interaction --prefer-dist --optimize-autoloader
- name: Install frontend dependencies
run: bun install --frozen-lockfile
- name: Run frontend lint
run: bun run lint:check
- name: Check frontend formatting
run: bun run format:check
- name: Check PHP formatting
run: vendor/bin/pint --test
- name: Run static analysis
run: composer phpstan
tests:
name: Tests
runs-on: ubuntu-latest
container:
image: git.bayliss.cloud/harry/gitea-ci-runner:php8.4
defaults:
run:
shell: bash
env:
APP_ENV: testing
APP_KEY: base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
CACHE_STORE: array
DB_CONNECTION: sqlite
DB_DATABASE: database/testing.sqlite
MAIL_MAILER: array
QUEUE_CONNECTION: sync
SESSION_DRIVER: array
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Prepare SQLite database
run: touch database/testing.sqlite
- name: Install Composer dependencies
run: composer install --no-interaction --prefer-dist --optimize-autoloader
- name: Run test suite with coverage
run: composer coverage

View File

@@ -1,3 +0,0 @@
resources/js/components/ui/*
resources/js/ziggy.js
resources/views/mail/*

View File

@@ -1,18 +0,0 @@
{
"semi": true,
"singleQuote": true,
"singleAttributePerLine": false,
"htmlWhitespaceSensitivity": "css",
"printWidth": 150,
"plugins": ["prettier-plugin-organize-imports", "prettier-plugin-tailwindcss"],
"tailwindFunctions": ["clsx", "cn"],
"tabWidth": 4,
"overrides": [
{
"files": "**/*.yml",
"options": {
"tabWidth": 2
}
}
]
}

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.
## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.4.16
@@ -18,49 +19,59 @@ This application is a Laravel application and its main Laravel ecosystems packag
- pestphp/pest (PEST) - v3
- phpunit/phpunit (PHPUNIT) - v11
## Conventions
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming.
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
- Check for existing components to reuse before writing a new one.
## Verification Scripts
- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
## Application Structure & Architecture
- Stick to existing directory structure - don't create new base folders without approval.
- Do not change the application's dependencies without approval.
## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
## Replies
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
## Documentation Files
- You must only create documentation files if explicitly requested by the user.
- You must only create documentation files if explicitly requested by the user.
=== boost rules ===
## Laravel Boost
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
## Artisan
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters.
## URLs
- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port.
## Tinker / Debugging
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
- Use the `database-query` tool when you only need to read from the database.
## Reading Browser Logs With the `browser-logs` Tool
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
- Only recent browser logs will be useful - ignore old logs.
## Searching Documentation (Critically Important)
- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches.
@@ -69,6 +80,7 @@ This application is a Laravel application and its main Laravel ecosystems packag
- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
### Available Search Syntax
- You can and should pass multiple queries at once. The most relevant results will be returned first.
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'
@@ -77,7 +89,6 @@ This application is a Laravel application and its main Laravel ecosystems packag
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit"
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
=== php rules ===
## PHP
@@ -85,11 +96,13 @@ This application is a Laravel application and its main Laravel ecosystems packag
- Always use curly braces for control structures, even if it has one line.
### Constructors
- Use PHP 8 constructor property promotion in `__construct()`.
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
- <code-snippet>public function \_\_construct(public GitHub $github) { }</code-snippet>
- Do not allow empty `__construct()` methods with zero parameters.
### Type Declarations
- Always use explicit return type declarations for methods and functions.
- Use appropriate PHP type hints for method parameters.
@@ -101,14 +114,16 @@ protected function isAccessible(User $user, ?string $path = null): bool
</code-snippet>
## Comments
- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on.
## PHPDoc Blocks
- Add useful array shape type definitions for arrays when appropriate.
## Enums
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
=== inertia-laravel/core rules ===
@@ -127,7 +142,6 @@ Route::get('/users', function () {
});
</code-snippet>
=== inertia-laravel/v2 rules ===
## Inertia v2
@@ -135,6 +149,7 @@ Route::get('/users', function () {
- Make use of all Inertia features from v1 & v2. Check the documentation before making any changes to ensure we are taking the correct approach.
### Inertia v2 New Features
- Polling
- Prefetching
- Deferred props
@@ -142,11 +157,12 @@ Route::get('/users', function () {
- Lazy loading data on scroll
### Deferred Props & Empty States
- When using deferred props on the frontend, you should add a nice empty state with pulsing / animated skeleton.
### Inertia Form General Guidance
- Build forms using the `useForm` helper. Use the code examples and `search-docs` tool with a query of `useForm helper` for guidance.
- Build forms using the `useForm` helper. Use the code examples and `search-docs` tool with a query of `useForm helper` for guidance.
=== laravel/core rules ===
@@ -157,6 +173,7 @@ Route::get('/users', function () {
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
### Database
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
- Use Eloquent models and relationships before suggesting raw database queries
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
@@ -164,35 +181,43 @@ Route::get('/users', function () {
- Use Laravel's query builder for very complex database operations.
### Model Creation
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
### APIs & Eloquent Resources
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
### Controllers & Validation
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
- Check sibling Form Requests to see if the application uses array or string based validation rules.
### Queues
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
### Authentication & Authorization
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
### URL Generation
- When generating links to other pages, prefer named routes and the `route()` function.
### Configuration
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
### Testing
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
- When creating tests, make use of `php artisan make:test [options] <name>` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
### Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
=== laravel/v12 rules ===
@@ -202,6 +227,7 @@ Route::get('/users', function () {
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
### Laravel 12 Structure
- No middleware files in `app/Http/Middleware/`.
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
- `bootstrap/providers.php` contains application specific service providers.
@@ -209,12 +235,13 @@ Route::get('/users', function () {
- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration.
### Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== pint/core rules ===
@@ -223,27 +250,29 @@ Route::get('/users', function () {
- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues.
=== pest/core rules ===
## Pest
### Testing
- If you need to verify a feature is working, write or update a Unit / Feature test.
### Pest Tests
- All tests must be written using Pest. Use `php artisan make:test --pest <name>`.
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application.
- Tests should test all of the happy paths, failure paths, and weird paths.
- Tests live in the `tests/Feature` and `tests/Unit` directories.
- Pest tests look and behave like this:
<code-snippet name="Basic Pest Test Example" lang="php">
it('is true', function () {
<code-snippet name="Basic Pest Test Example" lang="php">
it('is true', function () {
expect(true)->toBeTrue();
});
</code-snippet>
});
</code-snippet>
### Running Tests
- Run the minimal number of tests using an appropriate filter before finalizing code edits.
- To run all tests: `php artisan test`.
- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`.
@@ -251,21 +280,25 @@ it('is true', function () {
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
### Pest Assertions
- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.:
<code-snippet name="Pest Example Asserting postJson Response" lang="php">
it('returns all', function () {
<code-snippet name="Pest Example Asserting postJson Response" lang="php">
it('returns all', function () {
$response = $this->postJson('/api/docs', []);
$response->assertSuccessful();
});
</code-snippet>
});
</code-snippet>
### Mocking
- Mocking can be very helpful when appropriate.
- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do.
- You can also create partial mocks using the same import or self method.
### Datasets
- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules.
<code-snippet name="Pest Dataset Example" lang="php">
@@ -277,11 +310,14 @@ it('has emails', function (string $email) {
]);
</code-snippet>
=== tests rules ===
## Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter.
</laravel-boost-guidelines>
</laravel-boost-guidelines>
# Keystone
- Keep CHANGELOG.md up to date.

32
CHANGELOG.md Normal file
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.

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.
## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.4.16
@@ -18,49 +19,59 @@ This application is a Laravel application and its main Laravel ecosystems packag
- pestphp/pest (PEST) - v3
- phpunit/phpunit (PHPUNIT) - v11
## Conventions
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming.
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
- Check for existing components to reuse before writing a new one.
## Verification Scripts
- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
## Application Structure & Architecture
- Stick to existing directory structure - don't create new base folders without approval.
- Do not change the application's dependencies without approval.
## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
## Replies
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
## Documentation Files
- You must only create documentation files if explicitly requested by the user.
- You must only create documentation files if explicitly requested by the user.
=== boost rules ===
## Laravel Boost
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
## Artisan
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters.
## URLs
- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port.
## Tinker / Debugging
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
- Use the `database-query` tool when you only need to read from the database.
## Reading Browser Logs With the `browser-logs` Tool
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
- Only recent browser logs will be useful - ignore old logs.
## Searching Documentation (Critically Important)
- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches.
@@ -69,6 +80,7 @@ This application is a Laravel application and its main Laravel ecosystems packag
- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
### Available Search Syntax
- You can and should pass multiple queries at once. The most relevant results will be returned first.
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'
@@ -77,7 +89,6 @@ This application is a Laravel application and its main Laravel ecosystems packag
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit"
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
=== php rules ===
## PHP
@@ -85,11 +96,13 @@ This application is a Laravel application and its main Laravel ecosystems packag
- Always use curly braces for control structures, even if it has one line.
### Constructors
- Use PHP 8 constructor property promotion in `__construct()`.
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
- <code-snippet>public function \_\_construct(public GitHub $github) { }</code-snippet>
- Do not allow empty `__construct()` methods with zero parameters.
### Type Declarations
- Always use explicit return type declarations for methods and functions.
- Use appropriate PHP type hints for method parameters.
@@ -101,14 +114,16 @@ protected function isAccessible(User $user, ?string $path = null): bool
</code-snippet>
## Comments
- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on.
## PHPDoc Blocks
- Add useful array shape type definitions for arrays when appropriate.
## Enums
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
=== inertia-laravel/core rules ===
@@ -127,7 +142,6 @@ Route::get('/users', function () {
});
</code-snippet>
=== inertia-laravel/v2 rules ===
## Inertia v2
@@ -135,6 +149,7 @@ Route::get('/users', function () {
- Make use of all Inertia features from v1 & v2. Check the documentation before making any changes to ensure we are taking the correct approach.
### Inertia v2 New Features
- Polling
- Prefetching
- Deferred props
@@ -142,11 +157,12 @@ Route::get('/users', function () {
- Lazy loading data on scroll
### Deferred Props & Empty States
- When using deferred props on the frontend, you should add a nice empty state with pulsing / animated skeleton.
### Inertia Form General Guidance
- Build forms using the `useForm` helper. Use the code examples and `search-docs` tool with a query of `useForm helper` for guidance.
- Build forms using the `useForm` helper. Use the code examples and `search-docs` tool with a query of `useForm helper` for guidance.
=== laravel/core rules ===
@@ -157,6 +173,7 @@ Route::get('/users', function () {
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
### Database
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
- Use Eloquent models and relationships before suggesting raw database queries
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
@@ -164,35 +181,43 @@ Route::get('/users', function () {
- Use Laravel's query builder for very complex database operations.
### Model Creation
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
### APIs & Eloquent Resources
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
### Controllers & Validation
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
- Check sibling Form Requests to see if the application uses array or string based validation rules.
### Queues
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
### Authentication & Authorization
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
### URL Generation
- When generating links to other pages, prefer named routes and the `route()` function.
### Configuration
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
### Testing
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
- When creating tests, make use of `php artisan make:test [options] <name>` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
### Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
=== laravel/v12 rules ===
@@ -202,6 +227,7 @@ Route::get('/users', function () {
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
### Laravel 12 Structure
- No middleware files in `app/Http/Middleware/`.
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
- `bootstrap/providers.php` contains application specific service providers.
@@ -209,12 +235,13 @@ Route::get('/users', function () {
- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration.
### Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== pint/core rules ===
@@ -223,27 +250,29 @@ Route::get('/users', function () {
- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues.
=== pest/core rules ===
## Pest
### Testing
- If you need to verify a feature is working, write or update a Unit / Feature test.
### Pest Tests
- All tests must be written using Pest. Use `php artisan make:test --pest <name>`.
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application.
- Tests should test all of the happy paths, failure paths, and weird paths.
- Tests live in the `tests/Feature` and `tests/Unit` directories.
- Pest tests look and behave like this:
<code-snippet name="Basic Pest Test Example" lang="php">
it('is true', function () {
<code-snippet name="Basic Pest Test Example" lang="php">
it('is true', function () {
expect(true)->toBeTrue();
});
</code-snippet>
});
</code-snippet>
### Running Tests
- Run the minimal number of tests using an appropriate filter before finalizing code edits.
- To run all tests: `php artisan test`.
- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`.
@@ -251,21 +280,25 @@ it('is true', function () {
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
### Pest Assertions
- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.:
<code-snippet name="Pest Example Asserting postJson Response" lang="php">
it('returns all', function () {
<code-snippet name="Pest Example Asserting postJson Response" lang="php">
it('returns all', function () {
$response = $this->postJson('/api/docs', []);
$response->assertSuccessful();
});
</code-snippet>
});
</code-snippet>
### Mocking
- Mocking can be very helpful when appropriate.
- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do.
- You can also create partial mocks using the same import or self method.
### Datasets
- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules.
<code-snippet name="Pest Dataset Example" lang="php">
@@ -277,11 +310,14 @@ it('has emails', function (string $email) {
]);
</code-snippet>
=== tests rules ===
## Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter.
</laravel-boost-guidelines>
</laravel-boost-guidelines>
# Keystone
- Keep CHANGELOG.md up to date.

View File

@@ -4,11 +4,15 @@ namespace App\Actions\Environments;
use App\Enums\BuildArtifactStatus;
use App\Enums\BuildStrategy;
use App\Enums\RegistryType;
use App\Models\BuildArtifact;
use App\Models\Operation;
use App\Models\Registry;
use App\Models\Server;
use App\Models\Service;
use App\Services\Operations\RemoteCommandRunner;
use App\Services\Registries\RegistryDockerAuthScript;
use App\Services\Registries\RegistryResolver;
use RuntimeException;
class BuildApplicationArtifact
@@ -61,6 +65,18 @@ class BuildApplicationArtifact
private function buildServer(BuildArtifact $artifact): Server
{
$buildServerId = (int) ($artifact->metadata['build_server_id'] ?? 0);
if ($buildServerId > 0) {
$server = Server::find($buildServerId);
if ($server instanceof Server && $server->build_enabled) {
return $server;
}
throw new RuntimeException('Configured build server is missing or not build-enabled.');
}
if ($artifact->builtByService instanceof Service) {
$server = $artifact->builtByService->replicas->first()?->server ?: $artifact->builtByService->server;
@@ -70,7 +86,7 @@ class BuildApplicationArtifact
}
if (($artifact->metadata['build_strategy'] ?? null) === BuildStrategy::DEDICATED_BUILDER->value) {
throw new RuntimeException('Dedicated builder strategy requires a builder service.');
throw new RuntimeException('Dedicated builder strategy requires a builder service or build-enabled server.');
}
$services = $artifact->environment->services()
@@ -107,9 +123,13 @@ class BuildApplicationArtifact
$operationDirectory = '/home/keystone/operations/build-'.$artifact->id.'-'.str()->random(8);
$imageReference = $artifact->registry_ref ?: $artifact->image_tag;
$pushCommand = $strategy === BuildStrategy::DEDICATED_BUILDER && $artifact->registry_ref
? "\ndocker push ".escapeshellarg($imageReference)
: '';
$publishCommands = $artifact->registry_ref && $strategy !== BuildStrategy::EXTERNAL_REGISTRY
? [
...$this->pushDigestCommands($imageReference),
]
: [
'digest=$(docker image inspect --format '.escapeshellarg('{{if .RepoDigests}}{{index .RepoDigests 0}}{{else}}{{.Id}}{{end}}').' '.escapeshellarg($imageReference).')',
];
return implode("\n", [
'set -euo pipefail',
@@ -126,25 +146,91 @@ class BuildApplicationArtifact
'git clone --depth 1 --branch '.escapeshellarg($artifact->environment->branch).' '.escapeshellarg($application->repository_url).' "$source_dir"',
$this->writeFileCommand('$source_dir/Dockerfile.keystone', $this->dockerfile($artifact)),
'cd "$source_dir"',
'docker build --file Dockerfile.keystone --tag '.escapeshellarg($imageReference).' .'.$pushCommand,
'digest=$(docker image inspect --format '.escapeshellarg('{{if .RepoDigests}}{{index .RepoDigests 0}}{{else}}{{.Id}}{{end}}').' '.escapeshellarg($imageReference).')',
...$this->registryMaintenanceLockCommands($artifact),
...$this->buildAuthCommands($artifact),
'docker build --file Dockerfile.keystone --tag '.escapeshellarg($imageReference).' .',
...$publishCommands,
'printf "image_digest=%s\n" "$digest"',
]);
}
/**
* @return array<int, string>
*/
private function registryMaintenanceLockCommands(BuildArtifact $artifact): array
{
if (($artifact->metadata['registry_type'] ?? null) !== RegistryType::MANAGED->value) {
return [];
}
return [
'install -d -m 700 -o root -g root /home/keystone/registry',
'exec 9>/home/keystone/registry/maintenance.lock',
'flock 9',
];
}
/**
* @return array<int, string>
*/
private function buildAuthCommands(BuildArtifact $artifact): array
{
if (($artifact->metadata['registry_type'] ?? null) !== RegistryType::MANAGED->value) {
return [];
}
$registry = app(RegistryResolver::class)->buildRegistryFor($artifact->environment->application->organisation);
if (! $registry instanceof Registry || $registry->type !== RegistryType::MANAGED || ! $registry->credentials) {
throw new RuntimeException('Managed registry build credentials are not configured.');
}
$auth = app(RegistryDockerAuthScript::class)->forBuild($registry, 'root');
$script = $auth['script'];
foreach ($auth['secrets'] as $key => $value) {
$script = str_replace("[!{$key}!]", $value, $script);
}
return [$script];
}
private function manifestDigestScript(BuildArtifact $artifact): string
{
$imageReference = $artifact->registry_ref ?: $artifact->image_tag;
return implode("\n", [
'set -euo pipefail',
'manifest=$(docker manifest inspect '.escapeshellarg($imageReference).')',
'digest=$(printf "%s" "$manifest" | sed -n '.escapeshellarg('s/.*"digest": "\(sha256:[^"]*\)".*/\1/p').' | head -n 1)',
'test -n "$digest"',
...$this->manifestDigestCommands($imageReference),
'printf "image_digest=%s\n" "$digest"',
]);
}
/**
* @return array<int, string>
*/
private function manifestDigestCommands(string $imageReference): array
{
return [
'inspect_output=$(docker buildx imagetools inspect '.escapeshellarg($imageReference).')',
'digest=$(printf "%s\n" "$inspect_output" | sed -n '.escapeshellarg('s/^Digest:[[:space:]]*\(sha256:[^[:space:]]*\).*/\1/p').' | head -n 1)',
'test -n "$digest"',
];
}
/**
* @return array<int, string>
*/
private function pushDigestCommands(string $imageReference): array
{
return [
'push_output=$(docker push '.escapeshellarg($imageReference).')',
'printf "%s\n" "$push_output"',
'digest=$(printf "%s\n" "$push_output" | sed -n '.escapeshellarg('s/.*digest: \(sha256:[^[:space:]]*\).*/\1/p').' | tail -n 1)',
'test -n "$digest"',
];
}
private function dockerfile(BuildArtifact $artifact): string
{
$service = $artifact->environment->services()
@@ -176,7 +262,7 @@ DOCKERFILE;
private function digestFromOutput(string $output): string
{
if (preg_match('/image_digest=(?<digest>\S+)/', $output, $matches)) {
return $this->digestFromOutput($matches['digest']);
$output = $matches['digest'];
}
if (str_contains($output, '@')) {

View File

@@ -4,13 +4,21 @@ namespace App\Actions\Environments;
use App\Enums\BuildArtifactStatus;
use App\Enums\BuildStrategy;
use App\Enums\RegistryType;
use App\Enums\ServiceCategory;
use App\Models\BuildArtifact;
use App\Models\Environment;
use App\Services\Registries\ImageReference;
use App\Services\Registries\RegistryResolver;
use RuntimeException;
class PlanBuildArtifact
{
public function __construct(
private readonly RegistryResolver $registryResolver,
private readonly ImageReference $imageReference,
) {}
public function execute(Environment $environment, string $commitSha): BuildArtifact
{
$environment->loadMissing(['application.organisation.registries', 'services.replicas']);
@@ -26,36 +34,48 @@ class PlanBuildArtifact
}
$targetServerCount = $this->targetServerCount($environment);
$registry = $environment->application->organisation->registries()->first();
$registry = $this->registryResolver->buildRegistryFor($environment->application->organisation);
$registryType = $this->registryType($registry);
if ($targetServerCount > 1 && ! $registry) {
throw new RuntimeException('A registry is required before building artifacts for multi-server deployments.');
$blocker = $this->registryResolver->managedRegistryBlockerFor($environment->application->organisation);
throw new RuntimeException($blocker ?: 'A registry is required before building artifacts for multi-server deployments.');
}
$builder = $environment->application->organisation->services()
->where('category', ServiceCategory::BUILDER)
->first();
$buildServerId = null;
if ($registryType === RegistryType::MANAGED) {
$buildServerId = (int) $registry->control_server_id;
if ($buildServerId <= 0) {
throw new RuntimeException('A control/build server is required for managed registry builds.');
}
}
$strategy = match (true) {
$registryType === RegistryType::MANAGED => BuildStrategy::DEDICATED_BUILDER,
$registry !== null => BuildStrategy::EXTERNAL_REGISTRY,
$builder !== null => BuildStrategy::DEDICATED_BUILDER,
default => BuildStrategy::TARGET_SERVER,
};
$imageTag = str($environment->application->name)
->slug()
->append(':'.substr($commitSha, 0, 12))
->value();
$imageTag = $this->imageReference->tagFor($environment, $commitSha, $registry);
return $environment->buildArtifacts()->create([
'commit_sha' => $commitSha,
'image_tag' => $imageTag,
'registry_ref' => $registry ? rtrim((string) $registry->url, '/').'/'.$imageTag : null,
'registry_ref' => $registry ? $this->imageReference->registryReference($registry, $imageTag) : null,
'built_by_service_id' => $builder?->id,
'status' => BuildArtifactStatus::PENDING,
'metadata' => [
'build_strategy' => $strategy->value,
'registry_type' => $registryType?->value,
'target_server_count' => $targetServerCount,
'build_server_id' => $buildServerId,
],
]);
}
@@ -73,4 +93,17 @@ class PlanBuildArtifact
return $environment->services->sum('desired_replicas') > 1 ? 2 : 1;
}
private function registryType(mixed $registry): ?RegistryType
{
if (! $registry) {
return null;
}
if ($registry->type instanceof RegistryType) {
return $registry->type;
}
return RegistryType::tryFrom((string) $registry->type);
}
}

View File

@@ -7,9 +7,14 @@ use App\Enums\DeployPolicy;
use App\Enums\EnvironmentAttachmentRole;
use App\Enums\SchedulerMode;
use App\Models\Environment;
use App\Services\Registries\RegistryResolver;
class PlanEnvironmentDeployment
{
public function __construct(
private readonly RegistryResolver $registryResolver,
) {}
public function execute(Environment $environment): EnvironmentDeploymentPlan
{
$environment->loadMissing([
@@ -40,9 +45,9 @@ class PlanEnvironmentDeployment
return new EnvironmentDeploymentPlan(
services: $deployableServices->all(),
dependencies: $dependencies->all(),
requiresRegistry: $targetServerCount > 1 && $environment->application->organisation->registries()->doesntExist(),
requiresRegistry: $targetServerCount > 1 && ! $this->registryResolver->buildRegistryFor($environment->application->organisation),
warnings: $this->warnings($environment),
blockers: $this->blockers($environment),
blockers: $this->blockers($environment, $targetServerCount),
);
}
@@ -74,18 +79,28 @@ class PlanEnvironmentDeployment
/**
* @return array<int, string>
*/
private function blockers(Environment $environment): array
private function blockers(Environment $environment, int $targetServerCount): array
{
$blockers = [];
if ($targetServerCount > 1 && ! $this->registryResolver->buildRegistryFor($environment->application->organisation)) {
$blocker = $this->registryResolver->managedRegistryBlockerFor($environment->application->organisation);
if ($blocker !== null) {
$blockers[] = $blocker;
}
}
if (! $environment->scheduler_enabled || $environment->scheduler_mode !== SchedulerMode::SINGLE) {
return [];
return $blockers;
}
$target = $environment->services->firstWhere('id', $environment->scheduler_target_service_id);
if ($target && $target->desired_replicas > 1 && in_array('scheduler', $target->process_roles ?? [], true)) {
return ['Scheduler mode single requires the scheduler target service to run exactly one replica.'];
$blockers[] = 'Scheduler mode single requires the scheduler target service to run exactly one replica.';
}
return [];
return $blockers;
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Actions\Registries;
use App\Enums\OperationKind;
use App\Enums\OperationStatus;
use App\Models\Operation;
use App\Models\Registry;
use App\Models\Server;
use App\Services\Registries\RegistryDockerAuthScript;
use InvalidArgumentException;
class CreateRegistryAuthOperation
{
public function __construct(
private readonly RegistryDockerAuthScript $registryDockerAuthScript,
) {}
public function execute(Registry $registry, Server $server, string $scope): Operation
{
$auth = match ($scope) {
'build' => $this->registryDockerAuthScript->forBuild($registry, 'root'),
'runtime' => $this->registryDockerAuthScript->forRuntime($registry, 'root'),
default => throw new InvalidArgumentException('Registry auth scope must be build or runtime.'),
};
$operation = $server->operations()->create([
'kind' => OperationKind::CREDENTIAL_ROTATION,
'status' => OperationStatus::PENDING,
]);
$operation->steps()->create([
'name' => 'Configure '.$scope.' registry auth',
'order' => 1,
'status' => OperationStatus::PENDING,
'script' => $auth['script'],
'secrets' => $auth['secrets'],
]);
return $operation->refresh();
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Console\Commands;
use App\Actions\Registries\CreateManagedRegistrySmokeCheckOperation;
use App\Enums\RegistryType;
use App\Models\Registry;
use App\Models\Server;
use Illuminate\Console\Command;
class CheckManagedRegistry extends Command
{
protected $signature = 'keystone:managed-registry:check
{registry : Managed registry id}
{--build-server= : Build server id, defaults to the registry control server}
{--runtime-server=* : Runtime server id to pull the smoke image}
{--dispatch : Dispatch the first operation step immediately}';
protected $description = 'Create managed registry HTTPS/auth/push/pull smoke-check operations.';
public function handle(CreateManagedRegistrySmokeCheckOperation $operations): int
{
$registry = Registry::query()
->where('type', RegistryType::MANAGED->value)
->findOrFail((int) $this->argument('registry'));
$buildServer = $this->option('build-server')
? Server::query()
->where('organisation_id', $registry->organisation_id)
->findOrFail((int) $this->option('build-server'))
: null;
$runtimeServers = collect($this->option('runtime-server'))
->map(fn (string $serverId): Server => Server::query()
->where('organisation_id', $registry->organisation_id)
->findOrFail((int) $serverId));
$operation = $operations->execute($registry, $buildServer, $runtimeServers);
$this->info("Created registry smoke-check operation {$operation->id}.");
if ($this->option('dispatch')) {
$operation->steps()->orderBy('order')->first()?->dispatchJob();
$this->info('Dispatched registry smoke-check operation.');
}
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace App\Console\Commands;
use App\Actions\Registries\CreateManagedRegistryProvisionOperation;
use App\Models\Organisation;
use App\Models\Server;
use App\Services\Registries\ManagedRegistryHealth;
use App\Services\Registries\ManagedRegistryProvisioner;
use Illuminate\Console\Command;
class ProvisionManagedRegistry extends Command
{
protected $signature = 'keystone:managed-registry:provision
{organisation : Organisation id or slug}
{--url= : HTTPS registry hostname}
{--control-server= : Control/build server id}
{--storage-path= : Registry storage path}
{--retention= : Successful artifacts to retain per environment}
{--create-operation : Create the remote registry install/proxy operation}
{--dispatch : Dispatch the first operation step immediately}
{--mark-healthy : Mark the persisted registry ready after configuration validation}';
protected $description = 'Persist and optionally install a first-party managed Docker registry.';
public function handle(ManagedRegistryProvisioner $provisioner, ManagedRegistryHealth $health, CreateManagedRegistryProvisionOperation $operations): int
{
$organisationKey = (string) $this->argument('organisation');
$organisation = Organisation::query()
->where('id', $organisationKey)
->orWhere('slug', $organisationKey)
->firstOrFail();
$url = (string) ($this->option('url') ?: config('keystone.managed_registry.url'));
if ($url === '') {
$this->error('Provide --url or KEYSTONE_MANAGED_REGISTRY_URL.');
return self::FAILURE;
}
$controlServer = $this->option('control-server')
? Server::query()
->where('organisation_id', $organisation->id)
->findOrFail((int) $this->option('control-server'))
: null;
$registry = $provisioner->provision(
organisation: $organisation,
url: $url,
controlServer: $controlServer,
storagePath: $this->option('storage-path') ? (string) $this->option('storage-path') : null,
retention: $this->option('retention') ? (int) $this->option('retention') : null,
);
$blocker = $health->readinessBlocker($registry);
if ($this->option('mark-healthy') && $blocker !== null && $blocker !== 'Managed registry has not passed readiness checks.') {
$this->error($blocker);
return self::FAILURE;
}
if ($this->option('mark-healthy')) {
$registry->markHealthy('Marked ready by provisioning command.');
$blocker = null;
}
$this->info("Managed registry {$registry->url} persisted for {$organisation->name}.");
if ($blocker !== null) {
$this->warn($blocker);
}
if ($this->option('create-operation')) {
$operation = $operations->execute($registry);
$this->info("Created registry provision operation {$operation->id}.");
if ($this->option('dispatch')) {
$operation->steps()->orderBy('order')->first()?->dispatchJob();
$this->info('Dispatched registry provision operation.');
}
}
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Console\Commands;
use App\Actions\Registries\CreateManagedRegistryMaintenanceOperation;
use App\Enums\RegistryType;
use App\Models\Registry;
use Illuminate\Console\Command;
class PruneManagedRegistry extends Command
{
protected $signature = 'keystone:managed-registry:prune
{registry? : Managed registry id}
{--dispatch : Dispatch the first operation step immediately}';
protected $description = 'Create managed registry manifest deletion and garbage-collection operations.';
public function handle(CreateManagedRegistryMaintenanceOperation $operations): int
{
$registries = Registry::query()
->where('type', RegistryType::MANAGED->value)
->when($this->argument('registry'), fn ($query) => $query->whereKey((int) $this->argument('registry')))
->get();
$count = 0;
foreach ($registries as $registry) {
$operation = $operations->execute($registry);
$count++;
if ($this->option('dispatch')) {
$operation->steps()->orderBy('order')->first()?->dispatchJob();
}
}
$this->info("Created {$count} managed registry maintenance operation(s).");
return self::SUCCESS;
}
}

View File

@@ -12,4 +12,6 @@ enum BuildArtifactStatus: string
case BUILDING = 'building';
case AVAILABLE = 'available';
case FAILED = 'failed';
case PRUNABLE = 'prunable';
case PRUNED = 'pruned';
}

View File

@@ -17,4 +17,7 @@ enum OperationKind: string
case GATEWAY_CUTOVER = 'gateway_cutover';
case CONFIG_CHANGE = 'config_change';
case CREDENTIAL_ROTATION = 'credential_rotation';
case REGISTRY_PROVISION = 'registry_provision';
case REGISTRY_HEALTH_CHECK = 'registry_health_check';
case REGISTRY_MAINTENANCE = 'registry_maintenance';
}

View File

@@ -8,6 +8,7 @@ enum RegistryType: string
{
use Arrayable;
case MANAGED = 'managed';
case GENERIC = 'generic';
case GITEA = 'gitea';
case GHCR = 'ghcr';

View File

@@ -8,6 +8,7 @@ use App\Actions\Applications\VerifyRepositoryAccess;
use App\Enums\RepositoryType;
use App\Enums\ServerStatus;
use App\Http\Requests\StoreApplicationRequest;
use App\Http\Requests\UpdateApplicationRequest;
use App\Models\Application;
use App\Models\Organisation;
use Illuminate\Http\RedirectResponse;
@@ -27,9 +28,12 @@ class ApplicationController extends Controller
public function create(Request $request): Response
{
Organisation::findOrFail($request->route('organisation'));
$organisation = Organisation::findOrFail($request->route('organisation'));
return inertia('applications/Create');
return inertia('applications/Create', [
'sourceProviders' => $organisation->sourceProviders()->get(),
'repositoryTypes' => RepositoryType::toArray(),
]);
}
public function store(StoreApplicationRequest $request): RedirectResponse
@@ -38,8 +42,9 @@ class ApplicationController extends Controller
$application = $organisation->applications()->create([
'name' => $request->string('name')->toString(),
'source_provider_id' => $this->sourceProviderIdFor($organisation, $request->integer('source_provider_id') ?: null),
'repository_url' => $request->string('repository_url')->toString(),
'repository_type' => RepositoryType::GIT,
'repository_type' => $request->enum('repository_type', RepositoryType::class),
'default_branch' => $request->string('default_branch')->toString(),
]);
@@ -56,14 +61,24 @@ class ApplicationController extends Controller
$id = $request->route('application');
$organisation = Organisation::findOrFail($request->route('organisation'));
$application = Application::with([
'environments.buildArtifacts' => fn ($query) => $query->latest()->limit(5),
'environments.operations' => fn ($query) => $query->latest()->limit(1),
'environments.services.replicas',
'environments.services.endpoints',
'environments.services.slices',
'environments.attachments.service',
'environments.variables',
'organisation',
'sourceProvider',
])->whereBelongsTo($organisation)->findOrFail($id);
return inertia('applications/Show', [
'application' => $application,
'deploymentRequirements' => [
'registryRequired' => $organisation->servers()->count() > 1 && $organisation->registries()->doesntExist(),
'registryCount' => $organisation->registries()->count(),
'serverCount' => $organisation->servers()->count(),
],
'servers' => inertia()->optional(function () use ($application) {
return $application
->organisation
@@ -75,6 +90,51 @@ class ApplicationController extends Controller
]);
}
public function edit(Request $request): Response
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$application = $organisation->applications()->findOrFail($request->route('application'));
return inertia('applications/Edit', [
'application' => $application,
'repositoryTypes' => RepositoryType::toArray(),
'sourceProviders' => $organisation->sourceProviders()->get(),
]);
}
public function update(UpdateApplicationRequest $request): RedirectResponse
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$application = $organisation->applications()->findOrFail($request->route('application'));
$application->update([
'name' => $request->string('name')->toString(),
'source_provider_id' => $this->sourceProviderIdFor($organisation, $request->integer('source_provider_id') ?: null),
'repository_type' => $request->enum('repository_type', RepositoryType::class),
'repository_url' => $request->string('repository_url')->toString(),
'default_branch' => $request->string('default_branch')->toString(),
]);
return redirect()
->route('applications.show', [
'organisation' => $organisation->id,
'application' => $application->id,
])
->with('success', 'Application updated.');
}
public function destroy(Request $request): RedirectResponse
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$application = $organisation->applications()->findOrFail($request->route('application'));
$application->delete();
return redirect()
->route('applications.index', ['organisation' => $organisation->id])
->with('success', 'Application deleted.');
}
public function verifyRepository(Request $request): RedirectResponse
{
$organisation = Organisation::findOrFail($request->route('organisation'));
@@ -86,4 +146,23 @@ class ApplicationController extends Controller
return back()->with('success', 'Repository access verified.');
}
public function rotateDeployKey(Request $request, GenerateDeployKey $generateDeployKey): RedirectResponse
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$application = $organisation->applications()->findOrFail($request->route('application'));
$generateDeployKey->execute($application);
return back()->with('success', 'Deploy key rotated. Install the new public key before verifying access.');
}
private function sourceProviderIdFor(Organisation $organisation, ?int $sourceProviderId): ?int
{
if ($sourceProviderId === null) {
return null;
}
return $organisation->sourceProviders()->findOrFail($sourceProviderId)->id;
}
}

View File

@@ -3,13 +3,8 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Inertia\Inertia;
use Inertia\Response;
@@ -23,31 +18,8 @@ class RegisteredUserController extends Controller
return Inertia::render('auth/Register');
}
/**
* Handle an incoming registration request.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function store(Request $request): RedirectResponse
{
abort(403, 'Registration is disabled.');
$request->validate([
'name' => 'required|string|max:255',
'email' => 'required|string|lowercase|email|max:255|unique:'.User::class,
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
event(new Registered($user));
Auth::login($user);
return to_route('dashboard');
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers;
use App\Models\BuildArtifact;
use App\Models\Organisation;
use Illuminate\Http\Request;
use Inertia\Response;
class BuildArtifactController extends Controller
{
public function index(Request $request): Response
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$application = $organisation->applications()->findOrFail($request->route('application'));
$environment = $application->environments()->findOrFail($request->route('environment'));
return inertia('build-artifacts/Index', [
'application' => $application,
'environment' => $environment,
'artifacts' => $environment->buildArtifacts()
->with(['builtByOperation', 'builtByService'])
->latest()
->paginate(30),
]);
}
public function show(Request $request): Response
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$application = $organisation->applications()->findOrFail($request->route('application'));
$environment = $application->environments()->findOrFail($request->route('environment'));
/** @var BuildArtifact $artifact */
$artifact = $environment->buildArtifacts()
->with(['builtByOperation.steps', 'builtByService'])
->findOrFail($request->route('artifact'));
return inertia('build-artifacts/Show', [
'application' => $application,
'environment' => $environment,
'artifact' => $artifact,
]);
}
}

View File

@@ -6,6 +6,7 @@ use App\Actions\Environments\AttachManagedService;
use App\Enums\EnvironmentAttachmentRole;
use App\Enums\ServiceType;
use App\Http\Requests\StoreEnvironmentAttachmentRequest;
use App\Http\Requests\UpdateEnvironmentAttachmentRequest;
use App\Models\Organisation;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -27,6 +28,12 @@ class EnvironmentAttachmentController extends Controller
->orderBy('name')
->get(['id', 'name', 'type', 'category']),
'roles' => array_values(EnvironmentAttachmentRole::toArray()),
'compatibility' => [
EnvironmentAttachmentRole::DATABASE->value => [ServiceType::POSTGRES->value],
EnvironmentAttachmentRole::CACHE->value => [ServiceType::VALKEY->value],
EnvironmentAttachmentRole::QUEUE->value => [ServiceType::VALKEY->value],
EnvironmentAttachmentRole::GATEWAY->value => [ServiceType::CADDY->value],
],
]);
}
@@ -37,7 +44,7 @@ class EnvironmentAttachmentController extends Controller
$environment = $application->environments()->findOrFail($request->route('environment'));
$service = $organisation->services()->findOrFail($request->integer('service_id'));
app(AttachManagedService::class)->execute(
$attachment = app(AttachManagedService::class)->execute(
environment: $environment,
service: $service,
role: $request->enum('role', EnvironmentAttachmentRole::class),
@@ -46,6 +53,17 @@ class EnvironmentAttachmentController extends Controller
isPrimary: $request->boolean('is_primary', true),
);
if ($request->enum('role', EnvironmentAttachmentRole::class) === EnvironmentAttachmentRole::GATEWAY && $attachment->serviceSlice) {
$attachment->serviceSlice->update([
'config' => [
...($attachment->serviceSlice->config ?? []),
'domain' => $request->filled('domain') ? $request->string('domain')->toString() : null,
'path_prefix' => $request->filled('path_prefix') ? $request->string('path_prefix')->toString() : '/',
'tls_enabled' => $request->boolean('tls_enabled', true),
],
]);
}
return redirect()
->route('environments.show', [
'organisation' => $organisation->id,
@@ -54,4 +72,73 @@ class EnvironmentAttachmentController extends Controller
])
->with('success', 'Managed service attached.');
}
public function edit(Request $request): Response
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$application = $organisation->applications()->findOrFail($request->route('application'));
$environment = $application->environments()->findOrFail($request->route('environment'));
$attachment = $environment->attachments()
->with(['service', 'serviceSlice'])
->findOrFail($request->route('attachment'));
return inertia('environment-attachments/Edit', [
'application' => $application,
'environment' => $environment,
'attachment' => $attachment,
'roles' => array_values(EnvironmentAttachmentRole::toArray()),
]);
}
public function update(UpdateEnvironmentAttachmentRequest $request): RedirectResponse
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$application = $organisation->applications()->findOrFail($request->route('application'));
$environment = $application->environments()->findOrFail($request->route('environment'));
$attachment = $environment->attachments()->findOrFail($request->route('attachment'));
$attachment->update([
'role' => $request->enum('role', EnvironmentAttachmentRole::class),
'env_prefix' => $request->filled('env_prefix') ? $request->string('env_prefix')->toString() : null,
'is_primary' => $request->boolean('is_primary'),
]);
if ($attachment->serviceSlice && $request->enum('role', EnvironmentAttachmentRole::class) === EnvironmentAttachmentRole::GATEWAY) {
$attachment->serviceSlice->update([
'config' => [
...($attachment->serviceSlice->config ?? []),
'domain' => $request->filled('domain') ? $request->string('domain')->toString() : null,
'path_prefix' => $request->filled('path_prefix') ? $request->string('path_prefix')->toString() : '/',
'tls_enabled' => $request->boolean('tls_enabled', true),
'certificate_status' => $request->filled('certificate_status') ? $request->string('certificate_status')->toString() : null,
],
]);
}
return redirect()
->route('environments.show', [
'organisation' => $organisation->id,
'application' => $application->id,
'environment' => $environment->id,
])
->with('success', 'Attachment updated.');
}
public function destroy(Request $request): RedirectResponse
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$application = $organisation->applications()->findOrFail($request->route('application'));
$environment = $application->environments()->findOrFail($request->route('environment'));
$attachment = $environment->attachments()->findOrFail($request->route('attachment'));
$attachment->delete();
return redirect()
->route('environments.show', [
'organisation' => $organisation->id,
'application' => $application->id,
'environment' => $environment->id,
])
->with('success', 'Attachment detached.');
}
}

View File

@@ -2,12 +2,54 @@
namespace App\Http\Controllers;
use App\Actions\Applications\CreateLaravelEnvironment;
use App\Enums\BuildStrategy;
use App\Enums\EnvironmentAttachmentRole;
use App\Enums\SchedulerMode;
use App\Enums\ServiceType;
use App\Http\Requests\StoreEnvironmentRequest;
use App\Http\Requests\UpdateEnvironmentRequest;
use App\Models\Environment;
use App\Models\Organisation;
use App\Support\CaddyRouteRenderer;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Inertia\Response;
class EnvironmentController extends Controller
{
public function create(Request $request): Response
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$application = $organisation->applications()->findOrFail($request->route('application'));
return inertia('environments/Create', [
'application' => $application,
]);
}
public function store(StoreEnvironmentRequest $request): RedirectResponse
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$application = $organisation->applications()->findOrFail($request->route('application'));
$environment = app(CreateLaravelEnvironment::class)->execute(
application: $application,
name: $request->string('name')->toString(),
branch: $request->string('branch')->toString(),
phpVersion: $request->string('php_version')->toString(),
);
return redirect()
->route('environments.show', [
'organisation' => $organisation->id,
'application' => $application->id,
'environment' => $environment->id,
])
->with('success', 'Environment created.');
}
public function show(Request $request): Response
{
$organisation = Organisation::findOrFail($request->route('organisation'));
@@ -15,18 +57,151 @@ class EnvironmentController extends Controller
$environment = $application->environments()
->with([
'services.replicas',
'services.endpoints',
'services.slices',
'services.operations.steps',
'attachments.service',
'attachments.serviceSlice',
'variables',
'buildArtifacts.builtByService',
'operations.steps',
'operations.children.target',
])
->findOrFail($request->route('environment'));
$serverCount = $this->serverIdsFor($environment)->count();
return inertia('environments/Show', [
'application' => $application,
'environment' => $environment,
'deploymentRequirements' => [
'registryRequired' => $organisation->registries()->doesntExist() && $serverCount > 1,
'registryCount' => $organisation->registries()->count(),
'serverCount' => $serverCount,
],
'gatewayRoutePreviews' => $this->gatewayRoutePreviews($environment),
]);
}
public function edit(Request $request): Response
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$application = $organisation->applications()->findOrFail($request->route('application'));
$environment = $application->environments()
->with('services')
->findOrFail($request->route('environment'));
return inertia('environments/Edit', [
'application' => $application,
'environment' => $environment,
'schedulerModes' => array_values(SchedulerMode::toArray()),
'buildStrategies' => array_values(BuildStrategy::toArray()),
]);
}
public function update(UpdateEnvironmentRequest $request): RedirectResponse
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$application = $organisation->applications()->findOrFail($request->route('application'));
$environment = $application->environments()
->with('services')
->findOrFail($request->route('environment'));
$schedulerTargetServiceId = $request->integer('scheduler_target_service_id') ?: null;
if ($schedulerTargetServiceId !== null) {
abort_unless($environment->services()->whereKey($schedulerTargetServiceId)->exists(), 422);
}
$environment->update([
'name' => $request->string('name')->toString(),
'branch' => $request->string('branch')->toString(),
'status' => $request->string('status')->toString(),
'scheduler_enabled' => $request->boolean('scheduler_enabled'),
'scheduler_target_service_id' => $schedulerTargetServiceId,
'scheduler_mode' => $request->enum('scheduler_mode', SchedulerMode::class),
'build_config' => [
...($environment->build_config ?? []),
'build_strategy' => $request->enum('build_strategy', BuildStrategy::class)?->value,
'php_version' => $request->string('php_version')->toString(),
'document_root' => $request->string('document_root')->toString(),
'health_path' => $request->string('health_path')->toString(),
'js_package_manager' => $request->string('js_package_manager')->toString(),
'js_build_command' => $request->filled('js_build_command')
? $request->string('js_build_command')->toString()
: null,
],
]);
return redirect()
->route('environments.show', [
'organisation' => $organisation->id,
'application' => $application->id,
'environment' => $environment->id,
])
->with('success', 'Environment updated.');
}
public function destroy(Request $request): RedirectResponse
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$application = $organisation->applications()->findOrFail($request->route('application'));
$environment = $application->environments()->findOrFail($request->route('environment'));
$environment->delete();
return redirect()
->route('applications.show', [
'organisation' => $organisation->id,
'application' => $application->id,
])
->with('success', 'Environment deleted.');
}
/**
* @return \Illuminate\Support\Collection<int, int>
*/
private function serverIdsFor(Environment $environment): Collection
{
return $environment->services
->flatMap(fn ($service) => [
$service->server_id,
...$service->replicas->pluck('server_id')->all(),
])
->filter()
->unique()
->values();
}
/**
* @return Collection<int, array{attachment_id: int, caddyfile: string}>
*/
private function gatewayRoutePreviews(Environment $environment): Collection
{
$upstreams = $this->previewGatewayUpstreams($environment);
$renderer = app(CaddyRouteRenderer::class);
return $environment->attachments
->filter(fn ($attachment): bool => $attachment->role === EnvironmentAttachmentRole::GATEWAY)
->map(fn ($attachment): array => [
'attachment_id' => $attachment->id,
'caddyfile' => $renderer->render($attachment, $upstreams),
])
->values();
}
/**
* @return array<int, string>
*/
private function previewGatewayUpstreams(Environment $environment): array
{
return $environment->services
->filter(fn ($service): bool => $service->type === ServiceType::LARAVEL && in_array('web', $service->process_roles ?? [], true))
->flatMap(fn ($service) => $service->replicas->map(
fn ($replica): string => ($replica->internal_host ?: $replica->container_name).':'.($replica->internal_port ?: 80)
))
->values()
->whenEmpty(fn ($upstreams) => $upstreams->push('web:80'))
->all();
}
}

View File

@@ -2,15 +2,22 @@
namespace App\Http\Controllers;
use App\Http\Requests\StoreEnvironmentDeploymentRequest;
use App\Jobs\Environments\DeployEnvironment;
use App\Models\Application;
use App\Models\Environment;
use App\Models\Organisation;
use App\Services\Registries\RegistryResolver;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Collection;
class EnvironmentDeploymentController extends Controller
{
public function store(Organisation $organisation, Application $application, Environment $environment): RedirectResponse
public function __construct(
private readonly RegistryResolver $registryResolver,
) {}
public function store(StoreEnvironmentDeploymentRequest $request, Organisation $organisation, Application $application, Environment $environment): RedirectResponse
{
abort_unless(
(int) $application->organisation_id === (int) $organisation->id
@@ -18,7 +25,16 @@ class EnvironmentDeploymentController extends Controller
404,
);
dispatch(new DeployEnvironment($environment));
$environment->loadMissing('services.replicas');
if (! $this->registryResolver->buildRegistryFor($organisation) && $this->serverIdsFor($environment)->count() > 1) {
return back()->with('error', 'Configure a registry before deploying this environment to multiple servers.');
}
dispatch(new DeployEnvironment(
environment: $environment,
targetCommit: $request->validated('target_commit') ?: null,
));
return redirect()->route('environments.show', [
'organisation' => $organisation->id,
@@ -26,4 +42,19 @@ class EnvironmentDeploymentController extends Controller
'environment' => $environment->id,
]);
}
/**
* @return \Illuminate\Support\Collection<int, int>
*/
private function serverIdsFor(Environment $environment): Collection
{
return $environment->services
->flatMap(fn ($service) => [
$service->server_id,
...$service->replicas->pluck('server_id')->all(),
])
->filter()
->unique()
->values();
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers;
use App\Models\Organisation;
use Inertia\Response;
class EnvironmentIndexController extends Controller
{
public function __invoke(Organisation $organisation): Response
{
$applications = $organisation->applications()
->with([
'environments' => fn ($query) => $query
->withCount(['services', 'attachments', 'variables', 'buildArtifacts'])
->latest(),
])
->get();
return inertia('environments/Index', [
'applications' => $applications,
]);
}
}

View File

@@ -3,7 +3,9 @@
namespace App\Http\Controllers;
use App\Enums\EnvironmentVariableSource;
use App\Http\Requests\ImportEnvironmentVariablesRequest;
use App\Http\Requests\StoreEnvironmentVariableRequest;
use App\Http\Requests\UpdateEnvironmentVariableRequest;
use App\Models\Organisation;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -11,6 +13,22 @@ use Inertia\Response;
class EnvironmentVariableController extends Controller
{
public function index(Request $request): Response
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$application = $organisation->applications()->findOrFail($request->route('application'));
$environment = $application->environments()->findOrFail($request->route('environment'));
return inertia('environment-variables/Index', [
'application' => $application,
'environment' => $environment,
'variables' => $environment->variables()
->with('serviceSlice')
->orderBy('key')
->get(),
]);
}
public function create(Request $request): Response
{
$organisation = Organisation::findOrFail($request->route('organisation'));
@@ -35,11 +53,136 @@ class EnvironmentVariableController extends Controller
'value' => $request->string('value')->toString(),
'source' => EnvironmentVariableSource::USER,
'service_slice_id' => null,
'overridable' => true,
'overridable' => $request->boolean('overridable', true),
]);
return redirect()
->route('applications.show', ['organisation' => $organisation->id, 'application' => $application->id])
->route('environments.show', [
'organisation' => $organisation->id,
'application' => $application->id,
'environment' => $environment->id,
])
->with('success', 'Environment variable saved.');
}
public function import(ImportEnvironmentVariablesRequest $request): RedirectResponse
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$application = $organisation->applications()->findOrFail($request->route('application'));
$environment = $application->environments()->findOrFail($request->route('environment'));
$count = 0;
foreach ($this->parseDotEnv($request->string('contents')->toString()) as $key => $value) {
$environment->variables()->updateOrCreate([
'key' => $key,
], [
'value' => $value,
'source' => EnvironmentVariableSource::USER,
'service_slice_id' => null,
'overridable' => $request->boolean('overridable', true),
]);
$count++;
}
return redirect()
->route('environment-variables.index', [
'organisation' => $organisation->id,
'application' => $application->id,
'environment' => $environment->id,
])
->with('success', "{$count} environment variables imported.");
}
public function edit(Request $request): Response
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$application = $organisation->applications()->findOrFail($request->route('application'));
$environment = $application->environments()->findOrFail($request->route('environment'));
$variable = $environment->variables()->with('serviceSlice')->findOrFail($request->route('variable'));
return inertia('environment-variables/Edit', [
'application' => $application,
'environment' => $environment,
'variable' => $variable,
]);
}
public function update(UpdateEnvironmentVariableRequest $request): RedirectResponse
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$application = $organisation->applications()->findOrFail($request->route('application'));
$environment = $application->environments()->findOrFail($request->route('environment'));
$variable = $environment->variables()->findOrFail($request->route('variable'));
$variable->update([
'key' => $request->string('key')->toString(),
'value' => $request->string('value')->toString(),
'overridable' => $request->boolean('overridable'),
]);
return redirect()
->route('environment-variables.index', [
'organisation' => $organisation->id,
'application' => $application->id,
'environment' => $environment->id,
])
->with('success', 'Environment variable updated.');
}
public function destroy(Request $request): RedirectResponse
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$application = $organisation->applications()->findOrFail($request->route('application'));
$environment = $application->environments()->findOrFail($request->route('environment'));
$variable = $environment->variables()->findOrFail($request->route('variable'));
$variable->delete();
return redirect()
->route('environment-variables.index', [
'organisation' => $organisation->id,
'application' => $application->id,
'environment' => $environment->id,
])
->with('success', 'Environment variable deleted.');
}
/**
* @return array<string, string>
*/
private function parseDotEnv(string $contents): array
{
return collect(preg_split('/\R/', $contents) ?: [])
->map(fn (string $line): string => trim($line))
->reject(fn (string $line): bool => $line === '' || str_starts_with($line, '#'))
->mapWithKeys(function (string $line): array {
if (str_starts_with($line, 'export ')) {
$line = trim(substr($line, 7));
}
[$key, $value] = array_pad(explode('=', $line, 2), 2, '');
$key = trim($key);
if (! preg_match('/^[A-Z][A-Z0-9_]*$/', $key)) {
return [];
}
return [$key => $this->unquoteDotEnvValue(trim($value))];
})
->all();
}
private function unquoteDotEnvValue(string $value): string
{
if (str_starts_with($value, '"') && str_ends_with($value, '"')) {
return stripcslashes(substr($value, 1, -1));
}
if (str_starts_with($value, "'") && str_ends_with($value, "'")) {
return substr($value, 1, -1);
}
return $value;
}
}

View File

@@ -0,0 +1,162 @@
<?php
namespace App\Http\Controllers;
use App\Actions\Environments\AttachManagedService;
use App\Enums\EnvironmentAttachmentRole;
use App\Enums\ServiceType;
use App\Http\Requests\StoreGatewayRouteRequest;
use App\Http\Requests\UpdateGatewayRouteRequest;
use App\Models\EnvironmentAttachment;
use App\Models\Organisation;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Response;
class GatewayRouteController extends Controller
{
public function index(Request $request): Response
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$application = $organisation->applications()->findOrFail($request->route('application'));
$environment = $application->environments()
->with(['attachments.service', 'attachments.serviceSlice'])
->findOrFail($request->route('environment'));
return inertia('gateway-routes/Index', [
'application' => $application,
'environment' => $environment,
'routes' => $environment->attachments
->filter(fn (EnvironmentAttachment $attachment): bool => $attachment->role === EnvironmentAttachmentRole::GATEWAY)
->values(),
]);
}
public function create(Request $request): Response
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$application = $organisation->applications()->findOrFail($request->route('application'));
$environment = $application->environments()->findOrFail($request->route('environment'));
return inertia('gateway-routes/Create', [
'application' => $application,
'environment' => $environment,
'services' => $organisation->services()
->where('type', ServiceType::CADDY->value)
->orderBy('name')
->get(['id', 'name', 'type', 'category']),
]);
}
public function store(StoreGatewayRouteRequest $request): RedirectResponse
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$application = $organisation->applications()->findOrFail($request->route('application'));
$environment = $application->environments()->findOrFail($request->route('environment'));
$service = $organisation->services()
->where('type', ServiceType::CADDY->value)
->findOrFail($request->integer('service_id'));
$attachment = app(AttachManagedService::class)->execute(
environment: $environment,
service: $service,
role: EnvironmentAttachmentRole::GATEWAY,
name: $request->string('name')->toString(),
isPrimary: true,
);
$attachment->serviceSlice?->update([
'config' => [
...($attachment->serviceSlice->config ?? []),
...$this->routeConfig($request),
'certificate_status' => $request->boolean('tls_enabled', true) ? 'pending' : 'disabled',
],
]);
return redirect()
->route('gateway.routes.index', [
'organisation' => $organisation->id,
'application' => $application->id,
'environment' => $environment->id,
])
->with('success', 'Gateway route created.');
}
public function edit(Request $request): Response
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$application = $organisation->applications()->findOrFail($request->route('application'));
$environment = $application->environments()->findOrFail($request->route('environment'));
$route = $environment->attachments()
->with(['service', 'serviceSlice'])
->where('role', EnvironmentAttachmentRole::GATEWAY->value)
->findOrFail($request->route('route'));
return inertia('gateway-routes/Edit', [
'application' => $application,
'environment' => $environment,
'routeAttachment' => $route,
]);
}
public function update(UpdateGatewayRouteRequest $request): RedirectResponse
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$application = $organisation->applications()->findOrFail($request->route('application'));
$environment = $application->environments()->findOrFail($request->route('environment'));
$route = $environment->attachments()
->with('serviceSlice')
->where('role', EnvironmentAttachmentRole::GATEWAY->value)
->findOrFail($request->route('route'));
$route->serviceSlice?->update([
'config' => [
...($route->serviceSlice->config ?? []),
...$this->routeConfig($request),
'certificate_status' => $request->filled('certificate_status')
? $request->string('certificate_status')->toString()
: ($request->boolean('tls_enabled', true) ? 'pending' : 'disabled'),
],
]);
return redirect()
->route('gateway.routes.index', [
'organisation' => $organisation->id,
'application' => $application->id,
'environment' => $environment->id,
])
->with('success', 'Gateway route updated.');
}
public function destroy(Request $request): RedirectResponse
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$application = $organisation->applications()->findOrFail($request->route('application'));
$environment = $application->environments()->findOrFail($request->route('environment'));
$route = $environment->attachments()
->where('role', EnvironmentAttachmentRole::GATEWAY->value)
->findOrFail($request->route('route'));
$route->delete();
return redirect()
->route('gateway.routes.index', [
'organisation' => $organisation->id,
'application' => $application->id,
'environment' => $environment->id,
])
->with('success', 'Gateway route removed.');
}
/**
* @return array{domain: string, path_prefix: string, tls_enabled: bool}
*/
private function routeConfig(StoreGatewayRouteRequest|UpdateGatewayRouteRequest $request): array
{
return [
'domain' => $request->string('domain')->toString(),
'path_prefix' => $request->string('path_prefix')->toString(),
'tls_enabled' => $request->boolean('tls_enabled', true),
];
}
}

View File

@@ -11,6 +11,10 @@ class OnboardingController extends Controller
{
$organisation->loadCount(['providers', 'sourceProviders', 'registries', 'servers', 'applications']);
$applicationNeedingDeployKey = $organisation->applications()
->whereNull('deploy_key_installed_at')
->first();
$steps = [
[
'key' => 'organisation',
@@ -48,6 +52,17 @@ class OnboardingController extends Controller
'complete' => $organisation->applications_count > 0,
'href' => route('applications.create', ['organisation' => $organisation->id]),
],
[
'key' => 'deploy-key',
'label' => 'Deploy key',
'complete' => $organisation->applications_count === 0 || $applicationNeedingDeployKey === null,
'href' => $applicationNeedingDeployKey
? route('applications.show', [
'organisation' => $organisation->id,
'application' => $applicationNeedingDeployKey->id,
])
: route('applications.index', ['organisation' => $organisation->id]),
],
];
$next = collect($steps)->firstWhere('complete', false) ?? $steps[array_key_last($steps)];

View File

@@ -0,0 +1,181 @@
<?php
namespace App\Http\Controllers;
use App\Enums\OperationKind;
use App\Enums\OperationStatus;
use App\Models\Application;
use App\Models\Environment;
use App\Models\Operation;
use App\Models\Organisation;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceReplica;
use App\Models\ServiceSlice;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
class OperationController extends Controller
{
public function index(Request $request, Organisation $organisation): Response
{
$applicationIds = $organisation->applications()->pluck('id');
$environmentIds = Environment::query()
->whereIn('application_id', $applicationIds)
->pluck('id');
$serverIds = $organisation->servers()->pluck('id');
$serviceIds = $organisation->services()->pluck('id');
$replicaIds = ServiceReplica::query()
->whereIn('service_id', $serviceIds)
->pluck('id');
$sliceIds = ServiceSlice::query()
->whereIn('service_id', $serviceIds)
->pluck('id');
$operations = Operation::query()
->with(['target', 'parent', 'children.target'])
->withCount('steps', 'children')
->where(function (Builder $query) use ($applicationIds, $environmentIds, $serverIds, $serviceIds, $replicaIds, $sliceIds): void {
$query
->where(function (Builder $query) use ($applicationIds): void {
$query->where('target_type', (new Application)->getMorphClass())
->whereIn('target_id', $applicationIds);
})
->orWhere(function (Builder $query) use ($environmentIds): void {
$query->where('target_type', (new Environment)->getMorphClass())
->whereIn('target_id', $environmentIds);
})
->orWhere(function (Builder $query) use ($serverIds): void {
$query->where('target_type', (new Server)->getMorphClass())
->whereIn('target_id', $serverIds);
})
->orWhere(function (Builder $query) use ($serviceIds): void {
$query->where('target_type', (new Service)->getMorphClass())
->whereIn('target_id', $serviceIds);
})
->orWhere(function (Builder $query) use ($replicaIds): void {
$query->where('target_type', (new ServiceReplica)->getMorphClass())
->whereIn('target_id', $replicaIds);
})
->orWhere(function (Builder $query) use ($sliceIds): void {
$query->where('target_type', (new ServiceSlice)->getMorphClass())
->whereIn('target_id', $sliceIds);
});
})
->when($request->filled('kind'), fn (Builder $query) => $query->where('kind', $request->string('kind')->toString()))
->when($request->filled('status'), fn (Builder $query) => $query->where('status', $request->string('status')->toString()))
->latest()
->paginate(30)
->withQueryString();
return inertia('operations/Index', [
'operations' => $operations,
'filters' => $request->only(['kind', 'status']),
'operationKinds' => OperationKind::toArray(),
'operationStatuses' => OperationStatus::toArray(),
]);
}
public function show(Organisation $organisation, Operation $operation): Response
{
abort_unless($this->operationBelongsToOrganisation($operation, $organisation), 404);
$operation->load([
'target',
'parent.target',
'children.target',
'children.steps',
'steps',
]);
return inertia('operations/Show', [
'operation' => $operation,
]);
}
public function retry(Organisation $organisation, Operation $operation): RedirectResponse
{
abort_unless($this->operationBelongsToOrganisation($operation, $organisation), 404);
$operation->update([
'status' => OperationStatus::PENDING,
'started_at' => null,
'finished_at' => null,
]);
return redirect()
->route('operations.show', [
'organisation' => $organisation->id,
'operation' => $operation->id,
])
->with('success', 'Operation queued to run again.');
}
public function cancel(Organisation $organisation, Operation $operation): RedirectResponse
{
abort_unless($this->operationBelongsToOrganisation($operation, $organisation), 404);
$operation->update([
'status' => OperationStatus::CANCELLED,
'finished_at' => now(),
]);
$this->clearOperationSecrets($operation);
return redirect()
->route('operations.show', [
'organisation' => $organisation->id,
'operation' => $operation->id,
])
->with('success', 'Operation cancelled.');
}
public function downloadLogs(Organisation $organisation, Operation $operation): StreamedResponse
{
abort_unless($this->operationBelongsToOrganisation($operation, $organisation), 404);
$operation->load('steps');
return response()->streamDownload(function () use ($operation): void {
foreach ($operation->steps as $step) {
echo "# {$step->name}\n\n";
if ($step->logs) {
echo "## Logs\n{$step->logs}\n\n";
}
if ($step->error_logs) {
echo "## Error Logs\n{$step->error_logs}\n\n";
}
}
}, "operation-{$operation->hash}.log", [
'Content-Type' => 'text/plain',
]);
}
private function operationBelongsToOrganisation(Operation $operation, Organisation $organisation): bool
{
$target = $operation->target;
return match (true) {
$target instanceof Application => $target->organisation_id === $organisation->id,
$target instanceof Environment => $target->application()->where('organisation_id', $organisation->id)->exists(),
$target instanceof Server => $target->organisation_id === $organisation->id,
$target instanceof Service => $target->organisation_id === $organisation->id,
$target instanceof ServiceReplica => $target->service()->where('organisation_id', $organisation->id)->exists(),
$target instanceof ServiceSlice => $target->service()->where('organisation_id', $organisation->id)->exists(),
default => false,
};
}
private function clearOperationSecrets(Operation $operation): void
{
$operation->steps()->update(['secrets' => null]);
$operation->children()->get()->each(function (Operation $child): void {
$this->clearOperationSecrets($child);
});
}
}

View File

@@ -2,8 +2,11 @@
namespace App\Http\Controllers;
use App\Enums\ServiceStatus;
use App\Models\Operation;
use App\Models\Organisation;
use App\Models\Provider;
use App\Models\Service;
use Illuminate\Http\Request;
use Inertia\Inertia;
@@ -15,7 +18,23 @@ class OrganisationController extends Controller
'providers' => Inertia::lazy(fn () => Provider::whereOrganisationId($request->route('organisation'))->get()),
'registries' => Inertia::lazy(fn () => Organisation::findOrFail($request->route('organisation'))->registries()->get()),
'sourceProviders' => Inertia::lazy(fn () => Organisation::findOrFail($request->route('organisation'))->sourceProviders()->get()),
'organisation' => Organisation::withCount('servers', 'applications', 'members')->findOrFail($request->route('organisation')),
'organisation' => Organisation::with('members')
->withCount('servers', 'applications', 'members', 'providers', 'sourceProviders', 'registries')
->findOrFail($request->route('organisation')),
'health' => [
'unhealthy_services' => Service::query()
->where('organisation_id', $request->route('organisation'))
->whereNot('status', ServiceStatus::RUNNING)
->count(),
'failed_operations' => Operation::query()
->whereHasMorph('target', [Service::class], fn ($query) => $query->where('organisation_id', $request->route('organisation')))
->where('status', 'failed')
->count(),
'locked_variables' => Organisation::findOrFail($request->route('organisation'))
->applications()
->whereHas('environments.variables', fn ($query) => $query->where('overridable', false))
->count(),
],
]);
}
}

View File

@@ -0,0 +1,120 @@
<?php
namespace App\Http\Controllers;
use App\Enums\OrganisationRole;
use App\Http\Requests\StoreOrganisationMemberRequest;
use App\Http\Requests\UpdateOrganisationInvitationRequest;
use App\Http\Requests\UpdateOrganisationMemberRequest;
use App\Models\Organisation;
use App\Models\OrganisationInvitation;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Inertia\Response;
class OrganisationMemberController extends Controller
{
public function index(Organisation $organisation): Response
{
return inertia('organisation-members/Index', [
'organisation' => $organisation->load(['members', 'invitations.invitedBy']),
'roles' => array_values(OrganisationRole::toArray()),
]);
}
public function store(StoreOrganisationMemberRequest $request, Organisation $organisation): RedirectResponse
{
$email = Str::lower($request->string('email')->toString());
$user = User::query()
->where('email', $email)
->first();
if ($user === null) {
abort_if(
$organisation->invitations()->where('email', $email)->whereNull('accepted_at')->exists(),
422,
'This email already has a pending invitation.'
);
$organisation->invitations()->create([
'email' => $email,
'role' => $request->enum('role', OrganisationRole::class),
'token' => Str::random(40),
'invited_by_user_id' => $request->user()?->id,
'expires_at' => now()->addDays(14),
]);
return redirect()
->route('organisation-members.index', ['organisation' => $organisation->id])
->with('success', 'Invitation created.');
}
$organisation->members()->syncWithoutDetaching([
$user->id => ['role' => $request->enum('role', OrganisationRole::class)],
]);
$organisation->invitations()
->where('email', $email)
->delete();
return redirect()
->route('organisation-members.index', ['organisation' => $organisation->id])
->with('success', 'Member added.');
}
public function update(UpdateOrganisationMemberRequest $request, Organisation $organisation, User $member): RedirectResponse
{
abort_unless($organisation->members()->whereKey($member->id)->exists(), 404);
$organisation->members()->updateExistingPivot($member->id, [
'role' => $request->enum('role', OrganisationRole::class),
]);
return redirect()
->route('organisation-members.index', ['organisation' => $organisation->id])
->with('success', 'Member role updated.');
}
public function updateInvitation(
UpdateOrganisationInvitationRequest $request,
Organisation $organisation,
OrganisationInvitation $invitation
): RedirectResponse {
abort_unless($invitation->organisation_id === $organisation->id, 404);
$invitation->update([
'role' => $request->enum('role', OrganisationRole::class),
]);
return redirect()
->route('organisation-members.index', ['organisation' => $organisation->id])
->with('success', 'Invitation role updated.');
}
public function destroy(Request $request, Organisation $organisation, User $member): RedirectResponse
{
abort_if($organisation->owner_id === $member->id, 422, 'The organisation owner cannot be removed.');
$organisation->members()->detach($member->id);
return redirect()
->route('organisation-members.index', ['organisation' => $organisation->id])
->with('success', 'Member removed.');
}
public function destroyInvitation(
Request $request,
Organisation $organisation,
OrganisationInvitation $invitation
): RedirectResponse {
abort_unless($invitation->organisation_id === $organisation->id, 404);
$invitation->delete();
return redirect()
->route('organisation-members.index', ['organisation' => $organisation->id])
->with('success', 'Invitation cancelled.');
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers;
use App\Enums\ProviderType;
use App\Http\Requests\StoreProviderRequest;
use App\Models\Organisation;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Response;
class ProviderController extends Controller
{
public function create(Request $request): Response
{
Organisation::findOrFail($request->route('organisation'));
return inertia('providers/Create', [
'providerTypes' => array_values(ProviderType::toArray()),
]);
}
public function store(StoreProviderRequest $request): RedirectResponse
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$organisation->providers()->create([
'name' => $request->string('name')->toString(),
'type' => $request->enum('type', ProviderType::class),
'token' => $request->string('token')->toString(),
]);
return redirect()
->route('organisations.show', ['organisation' => $organisation->id])
->with('success', 'Server provider created.');
}
public function destroy(Request $request): RedirectResponse
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$provider = $organisation->providers()->findOrFail($request->route('provider'));
$provider->delete();
return redirect()
->route('organisations.show', ['organisation' => $organisation->id])
->with('success', 'Server provider deleted.');
}
}

View File

@@ -4,19 +4,33 @@ namespace App\Http\Controllers;
use App\Enums\RegistryType;
use App\Http\Requests\StoreRegistryRequest;
use App\Http\Requests\UpdateRegistryRequest;
use App\Models\BuildArtifact;
use App\Models\Organisation;
use App\Models\Registry;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Response;
class RegistryController extends Controller
{
public function index(Request $request): Response
{
$organisation = Organisation::findOrFail($request->route('organisation'));
return inertia('registries/Index', [
'registries' => $organisation->registries()
->latest()
->get(),
]);
}
public function create(Request $request): Response
{
Organisation::findOrFail($request->route('organisation'));
return inertia('registries/Create', [
'registryTypes' => array_values(RegistryType::toArray()),
'registryTypes' => $this->userConfigurableRegistryTypes(),
]);
}
@@ -38,4 +52,90 @@ class RegistryController extends Controller
->route('organisations.show', ['organisation' => $organisation->id])
->with('success', 'Registry created.');
}
public function show(Request $request): Response
{
$organisation = Organisation::findOrFail($request->route('organisation'));
/** @var Registry $registry */
$registry = $organisation->registries()->findOrFail($request->route('registry'));
$registryUrl = rtrim((string) $registry->url, '/');
$artifacts = BuildArtifact::query()
->with(['environment.application', 'builtByService'])
->whereHas('environment.application', fn ($query) => $query->where('organisation_id', $organisation->id))
->when($registryUrl !== '', fn ($query) => $query->where('registry_ref', 'like', $registryUrl.'%'));
return inertia('registries/Show', [
'registry' => $registry,
'artifactCount' => (clone $artifacts)->count(),
'environmentCount' => (clone $artifacts)->distinct('environment_id')->count('environment_id'),
'artifacts' => $artifacts
->latest()
->paginate(20),
]);
}
public function edit(Request $request): Response
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$registry = $organisation->registries()->findOrFail($request->route('registry'));
return inertia('registries/Edit', [
'registry' => $registry,
'registryTypes' => $this->userConfigurableRegistryTypes(),
]);
}
public function update(UpdateRegistryRequest $request): RedirectResponse
{
$organisation = Organisation::findOrFail($request->route('organisation'));
/** @var Registry $registry */
$registry = $organisation->registries()->findOrFail($request->route('registry'));
abort_if($registry->type === RegistryType::MANAGED, 403);
$credentials = $registry->credentials ?? [];
$username = $request->string('username')->toString();
if ($request->filled('password')) {
$credentials['password'] = $request->string('password')->toString();
}
$credentials['username'] = $username;
$registry->update([
'name' => $request->string('name')->toString(),
'type' => $request->enum('type', RegistryType::class),
'url' => rtrim($request->string('url')->toString(), '/'),
'credentials' => $credentials,
]);
return redirect()
->route('organisations.show', ['organisation' => $organisation->id])
->with('success', 'Registry updated.');
}
public function destroy(Request $request): RedirectResponse
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$registry = $organisation->registries()->findOrFail($request->route('registry'));
abort_if($registry->type === RegistryType::MANAGED, 403);
$registry->delete();
return redirect()
->route('organisations.show', ['organisation' => $organisation->id])
->with('success', 'Registry deleted.');
}
/**
* @return array<int, string>
*/
private function userConfigurableRegistryTypes(): array
{
return collect(RegistryType::toArray())
->reject(fn (string $type) => $type === RegistryType::MANAGED->value)
->values()
->all();
}
}

View File

@@ -3,26 +3,33 @@
namespace App\Http\Controllers;
use App\Actions\GenerateRandomSlug;
use App\Enums\OperationKind;
use App\Enums\OperationStatus;
use App\Enums\ServerStatus;
use App\Jobs\Servers\WaitForServerToConnect;
use App\Models\Organisation;
use App\Models\Provider;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use Inertia\Response;
class ServerController extends Controller
{
public function index(Request $request)
public function index(Request $request): Response
{
$organisation = Organisation::findOrFail($request->route('organisation'));
return inertia('servers/Index', [
'servers' => $organisation->servers()->paginate(30),
'networks' => $organisation->networks()
->with(['servers' => fn ($query) => $query->select('id', 'network_id', 'name', 'private_ip', 'status')])
->get(),
]);
}
public function create(Request $request)
public function create(Request $request): Response
{
$organisation = Organisation::findOrFail($request->route('organisation'));
@@ -55,7 +62,7 @@ class ServerController extends Controller
]);
}
public function store(Request $request)
public function store(Request $request): RedirectResponse
{
$request->validate([
'provider' => ['required', 'exists:providers,id'],
@@ -135,13 +142,63 @@ class ServerController extends Controller
return redirect()->route('servers.show', ['organisation' => $organisation->id, 'server' => $server->id]);
}
public function show(Request $request)
public function show(Request $request): Response
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$server = $organisation->servers()->findOrFail($request->route('server'));
return inertia('servers/Show', [
'server' => $server->load('services.slices', 'serviceOperations.steps', 'serviceOperations.target'),
'server' => $server->load(
'firewallRules',
'network',
'operations.steps',
'operations.children.target',
'services.slices',
'services.endpoints',
'serviceOperations.steps',
'serviceOperations.children.target',
'serviceOperations.target',
),
]);
}
public function destroy(Request $request): RedirectResponse
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$server = $organisation->servers()->findOrFail($request->route('server'));
$server->delete();
return redirect()
->route('servers.index', ['organisation' => $organisation->id])
->with('success', 'Server deleted.');
}
public function heal(Request $request): RedirectResponse
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$server = $organisation->servers()->findOrFail($request->route('server'));
$operation = $server->operations()->create([
'kind' => OperationKind::SERVER_PROVISION,
'status' => OperationStatus::PENDING,
]);
foreach ([
'Check server shell' => 'true',
'Check Docker' => 'docker --version && docker compose version',
'Check Keystone directories' => 'test -d /home/keystone && test -d /home/keystone/services',
] as $order => $script) {
$operation->steps()->create([
'name' => $order,
'order' => $operation->steps()->count() + 1,
'status' => OperationStatus::PENDING,
'script' => $script,
]);
}
return redirect()
->route('servers.show', ['organisation' => $organisation->id, 'server' => $server->id])
->with('success', 'Server heal operation queued.');
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers;
use App\Actions\FirewallRules\UninstallFirewallRule;
use App\Http\Requests\StoreServerFirewallRuleRequest;
use App\Models\FirewallRule;
use App\Models\Organisation;
use App\Models\Server;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class ServerFirewallRuleController extends Controller
{
public function store(StoreServerFirewallRuleRequest $request, Organisation $organisation, Server $server): RedirectResponse
{
abort_unless((int) $server->organisation_id === (int) $organisation->id, 404);
$server->firewallRules()->create($request->validated());
return redirect()
->route('servers.show', [$organisation, $server])
->with('success', 'Firewall rule queued for installation.');
}
public function destroy(Request $request, Organisation $organisation, Server $server, FirewallRule $firewallRule, UninstallFirewallRule $uninstallFirewallRule): RedirectResponse
{
abort_unless(
(int) $server->organisation_id === (int) $organisation->id
&& (int) $firewallRule->server_id === (int) $server->id,
404,
);
$uninstallFirewallRule->execute($firewallRule);
$firewallRule->delete();
return redirect()
->route('servers.show', [$organisation, $server])
->with('success', 'Firewall rule removed.');
}
}

View File

@@ -3,10 +3,12 @@
namespace App\Http\Controllers;
use App\Actions\Services\CreateService;
use App\Enums\DeployPolicy;
use App\Enums\ServiceCategory;
use App\Enums\ServiceType;
use App\Http\Requests\StoreServiceRequest;
use App\Http\Requests\UpdateServiceRequest;
use App\Models\Organisation;
use App\Models\Server;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -49,7 +51,7 @@ class ServiceController extends Controller
{
$server = Server::findOrFail($request->route('server'));
$service = $server->services()
->with(['replicas', 'slices', 'operations.steps', 'environment.application'])
->with(['replicas', 'slices', 'endpoints', 'operations.steps', 'operations.children.target', 'environment.application'])
->findOrFail($request->route('service'));
return inertia('services/Show', [
@@ -58,6 +60,23 @@ class ServiceController extends Controller
]);
}
public function showForEnvironment(Request $request): Response
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$application = $organisation->applications()->findOrFail($request->route('application'));
$environment = $application->environments()->findOrFail($request->route('environment'));
$service = $environment->services()
->with(['server', 'replicas', 'slices', 'endpoints', 'operations.steps', 'operations.children.target', 'environment.application'])
->findOrFail($request->route('service'));
return inertia('services/Show', [
'server' => $service->server,
'service' => $service,
'environment' => $environment,
'application' => $application,
]);
}
public function edit(Request $request): Response
{
$server = Server::findOrFail($request->route('server'));
@@ -66,6 +85,7 @@ class ServiceController extends Controller
return inertia('services/Edit', [
'server' => $server,
'service' => $service,
'deployPolicies' => array_values(DeployPolicy::toArray()),
]);
}
@@ -74,7 +94,31 @@ class ServiceController extends Controller
$server = Server::findOrFail($request->route('server'));
$service = $server->services()->findOrFail($request->route('service'));
$service->update($request->validated());
$validated = $request->validated();
$service->update([
'name' => $validated['name'],
'desired_replicas' => $validated['desired_replicas'],
'default_cpu_limit' => $validated['default_cpu_limit'] ?? null,
'default_memory_limit_mb' => $validated['default_memory_limit_mb'] ?? null,
'deploy_policy' => $request->enum('deploy_policy', DeployPolicy::class) ?? $service->deploy_policy,
'version_track' => $validated['version_track'] ?? $service->version_track,
'available_image_digest' => $validated['available_image_digest'] ?? null,
'process_roles' => collect(explode(',', $validated['process_roles'] ?? ''))
->map(fn (string $role): string => trim($role))
->filter()
->values()
->all(),
'config' => [
...($service->config ?? []),
'migration_mode' => $validated['migration_mode'] ?? null,
'migration_timing' => $validated['migration_timing'] ?? null,
'migration_command' => $validated['migration_command'] ?? null,
'health_path' => $validated['health_path'] ?? null,
'backup_enabled' => $request->boolean('backup_enabled'),
'backup_command' => $validated['backup_command'] ?? null,
],
]);
return redirect()
->route('services.show', [
@@ -84,4 +128,19 @@ class ServiceController extends Controller
])
->with('success', 'Service updated.');
}
public function destroy(Request $request): RedirectResponse
{
$server = Server::findOrFail($request->route('server'));
$service = $server->services()->findOrFail($request->route('service'));
$service->delete();
return redirect()
->route('servers.show', [
'organisation' => $server->organisation_id,
'server' => $server->id,
])
->with('success', 'Service deleted.');
}
}

View File

@@ -0,0 +1,102 @@
<?php
namespace App\Http\Controllers;
use App\Enums\OperationKind;
use App\Enums\OperationStatus;
use App\Models\Organisation;
use App\Models\ServiceReplica;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Response;
class ServiceReplicaController extends Controller
{
public function show(Request $request): Response
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$server = $organisation->servers()->findOrFail($request->route('server'));
$service = $server->services()->with('environment.application')->findOrFail($request->route('service'));
$replica = $service->replicas()
->with(['server', 'operation.steps', 'operations.steps', 'operations.children.target'])
->findOrFail($request->route('replica'));
return inertia('service-replicas/Show', [
'server' => $server,
'service' => $service,
'replica' => $replica,
]);
}
public function restart(Request $request): RedirectResponse
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$server = $organisation->servers()->findOrFail($request->route('server'));
$service = $server->services()->findOrFail($request->route('service'));
$replica = $service->replicas()->findOrFail($request->route('replica'));
$this->queueLifecycleOperation($replica, 'Restart replica', "docker restart {$replica->container_name}");
return redirect()
->route('service-replicas.show', [
'organisation' => $organisation->id,
'server' => $server->id,
'service' => $service->id,
'replica' => $replica->id,
])
->with('success', 'Replica restart queued.');
}
public function start(Request $request): RedirectResponse
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$server = $organisation->servers()->findOrFail($request->route('server'));
$service = $server->services()->findOrFail($request->route('service'));
$replica = $service->replicas()->findOrFail($request->route('replica'));
$this->queueLifecycleOperation($replica, 'Start replica', "docker start {$replica->container_name}");
return redirect()
->route('service-replicas.show', [
'organisation' => $organisation->id,
'server' => $server->id,
'service' => $service->id,
'replica' => $replica->id,
])
->with('success', 'Replica start queued.');
}
public function stop(Request $request): RedirectResponse
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$server = $organisation->servers()->findOrFail($request->route('server'));
$service = $server->services()->findOrFail($request->route('service'));
$replica = $service->replicas()->findOrFail($request->route('replica'));
$this->queueLifecycleOperation($replica, 'Stop replica', "docker stop {$replica->container_name}");
return redirect()
->route('service-replicas.show', [
'organisation' => $organisation->id,
'server' => $server->id,
'service' => $service->id,
'replica' => $replica->id,
])
->with('success', 'Replica stop queued.');
}
private function queueLifecycleOperation(ServiceReplica $replica, string $name, string $script): void
{
$operation = $replica->operations()->create([
'kind' => OperationKind::REPLICA_DEPLOY,
'status' => OperationStatus::PENDING,
]);
$operation->steps()->create([
'name' => $name,
'order' => 1,
'status' => OperationStatus::PENDING,
'script' => $script,
]);
}
}

View File

@@ -0,0 +1,104 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreServiceSliceRequest;
use App\Models\Organisation;
use App\Models\ServiceSlice;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Response;
class ServiceSliceController extends Controller
{
public function index(Request $request): Response
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$server = $organisation->servers()->findOrFail($request->route('server'));
$service = $server->services()
->with('environment.application')
->findOrFail($request->route('service'));
return inertia('service-slices/Index', [
'server' => $server,
'service' => $service,
'slices' => $service->slices()
->with(['environment.application', 'attachments', 'operations.steps', 'operations.children.target'])
->latest()
->get(),
]);
}
public function show(Request $request): Response
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$server = $organisation->servers()->findOrFail($request->route('server'));
$service = $server->services()->with('environment.application')->findOrFail($request->route('service'));
$slice = $service->slices()
->with(['environment.application', 'attachments.environment.application', 'operations.steps', 'operations.children.target'])
->findOrFail($request->route('slice'));
return inertia('service-slices/Show', [
'server' => $server,
'service' => $service,
'slice' => $slice,
]);
}
public function create(Request $request): Response
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$server = $organisation->servers()->findOrFail($request->route('server'));
$service = $server->services()->findOrFail($request->route('service'));
return inertia('service-slices/Create', [
'server' => $server,
'service' => $service,
'environments' => $organisation->applications()
->with('environments')
->get()
->pluck('environments')
->flatten()
->values(),
]);
}
public function store(StoreServiceSliceRequest $request): RedirectResponse
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$server = $organisation->servers()->findOrFail($request->route('server'));
$service = $server->services()->findOrFail($request->route('service'));
$environmentId = $request->integer('environment_id') ?: null;
if ($environmentId !== null) {
$belongsToOrganisation = $organisation->applications()
->whereHas('environments', fn ($query) => $query->whereKey($environmentId))
->exists();
abort_unless($belongsToOrganisation, 422);
}
$config = $request->filled('config')
? json_decode($request->string('config')->toString(), true, flags: JSON_THROW_ON_ERROR)
: [];
/** @var ServiceSlice $slice */
$slice = $service->slices()->create([
'environment_id' => $environmentId,
'name' => $request->string('name')->toString(),
'type' => $request->string('type')->toString(),
'status' => $request->string('status')->toString(),
'config' => $config,
'credentials' => [],
]);
return redirect()
->route('service-slices.show', [
'organisation' => $organisation->id,
'server' => $server->id,
'service' => $service->id,
'slice' => $slice->id,
])
->with('success', 'Slice created.');
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use App\Actions\Services\CreateStatefulServiceUpdateOperation;
use App\Actions\Services\ResolveServiceImageDigest;
use App\Enums\ServiceType;
use App\Http\Requests\StoreServiceUpdateRequest;
use App\Models\Organisation;
@@ -33,6 +34,7 @@ class ServiceUpdateController extends Controller
CreateStatefulServiceUpdateOperation $createStatefulServiceUpdateOperation,
): RedirectResponse {
abort_unless((int) $server->organisation_id === (int) $organisation->id && (int) $service->server_id === (int) $server->id, 404);
abort_unless($request->string('confirmation')->toString() === $service->name, 422);
$createStatefulServiceUpdateOperation->execute(
service: $service,
@@ -45,4 +47,26 @@ class ServiceUpdateController extends Controller
'server' => $server->id,
]);
}
public function resolve(
Organisation $organisation,
Server $server,
Service $service,
ResolveServiceImageDigest $resolveServiceImageDigest,
): RedirectResponse {
abort_unless((int) $server->organisation_id === (int) $organisation->id && (int) $service->server_id === (int) $server->id, 404);
abort_unless(in_array($service->type, [ServiceType::POSTGRES, ServiceType::VALKEY], true), 404);
$service->update([
'available_image_digest' => $resolveServiceImageDigest->execute($service),
]);
return redirect()
->route('service-updates.create', [
'organisation' => $organisation->id,
'server' => $server->id,
'service' => $service->id,
])
->with('success', 'Latest image digest resolved.');
}
}

View File

@@ -4,13 +4,26 @@ namespace App\Http\Controllers;
use App\Enums\SourceProviderType;
use App\Http\Requests\StoreSourceProviderRequest;
use App\Http\Requests\UpdateSourceProviderRequest;
use App\Models\Organisation;
use App\Models\SourceProvider;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Response;
class SourceProviderController extends Controller
{
public function index(Request $request): Response
{
$organisation = Organisation::findOrFail($request->route('organisation'));
return inertia('source-providers/Index', [
'sourceProviders' => $organisation->sourceProviders()
->latest()
->get(),
]);
}
public function create(Request $request): Response
{
Organisation::findOrFail($request->route('organisation'));
@@ -35,4 +48,44 @@ class SourceProviderController extends Controller
->route('organisations.show', ['organisation' => $organisation->id])
->with('success', 'Source provider created.');
}
public function edit(Request $request): Response
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$sourceProvider = $organisation->sourceProviders()->findOrFail($request->route('source_provider'));
return inertia('source-providers/Edit', [
'sourceProvider' => $sourceProvider,
'sourceProviderTypes' => array_values(SourceProviderType::toArray()),
]);
}
public function update(UpdateSourceProviderRequest $request): RedirectResponse
{
$organisation = Organisation::findOrFail($request->route('organisation'));
/** @var SourceProvider $sourceProvider */
$sourceProvider = $organisation->sourceProviders()->findOrFail($request->route('source_provider'));
$sourceProvider->update([
'name' => $request->string('name')->toString(),
'type' => $request->enum('type', SourceProviderType::class),
'url' => $request->filled('url') ? rtrim($request->string('url')->toString(), '/') : null,
]);
return redirect()
->route('organisations.show', ['organisation' => $organisation->id])
->with('success', 'Source provider updated.');
}
public function destroy(Request $request): RedirectResponse
{
$organisation = Organisation::findOrFail($request->route('organisation'));
$sourceProvider = $organisation->sourceProviders()->findOrFail($request->route('source_provider'));
$sourceProvider->delete();
return redirect()
->route('organisations.show', ['organisation' => $organisation->id])
->with('success', 'Source provider deleted.');
}
}

View File

@@ -30,8 +30,8 @@ class CreateNetworkRequest extends Request implements HasBody
[
'type' => 'cloud',
'ip_range' => '10.0.1.0/24',
'network_zone' => $this->networkZone
]
'network_zone' => $this->networkZone,
],
];
}

View File

@@ -30,9 +30,7 @@ class HandleInertiaRequests extends Middleware
return [
...parent::share($request),
'name' => config('app.name'),
'organisation' => $request->route('organisation')
? Organisation::with('applications')->findOrFail($this->routeKey($request->route('organisation')))
: null,
'organisation' => $this->resolveOrganisation($request),
'application' => $request->route('application')
? Application::with('environments')->findOrFail($this->routeKey($request->route('application')))
: null,
@@ -51,6 +49,20 @@ class HandleInertiaRequests extends Middleware
];
}
private function resolveOrganisation(Request $request): ?Organisation
{
$query = Organisation::with('applications.environments')
->withCount(['providers', 'sourceProviders', 'registries', 'servers', 'applications']);
if ($request->route('organisation')) {
return $query->findOrFail($this->routeKey($request->route('organisation')));
}
$organisationId = $request->user()?->organisations()->value('organisations.id');
return $organisationId ? $query->find($organisationId) : null;
}
private function routeKey(mixed $routeValue): mixed
{
return $routeValue instanceof Model ? $routeValue->getKey() : $routeValue;

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class ImportEnvironmentVariablesRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'contents' => ['required', 'string', 'max:20000'],
'overridable' => ['sometimes', 'boolean'],
];
}
}

View File

@@ -2,7 +2,9 @@
namespace App\Http\Requests;
use App\Enums\RepositoryType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreApplicationRequest extends FormRequest
{
@@ -23,6 +25,8 @@ class StoreApplicationRequest extends FormRequest
{
return [
'name' => ['required', 'string', 'max:255'],
'source_provider_id' => ['nullable', 'integer', 'exists:source_providers,id'],
'repository_type' => ['required', Rule::enum(RepositoryType::class)],
'repository_url' => ['required', 'string', 'max:255', 'regex:/^(git@[^:]+:.+|ssh:\/\/.+)$/i'],
'default_branch' => ['required', 'string', 'max:255', 'regex:/^[A-Za-z0-9._\/-]+$/'],
'environment_name' => ['required', 'string', 'max:255'],

View File

@@ -29,6 +29,9 @@ class StoreEnvironmentAttachmentRequest extends FormRequest
'name' => ['nullable', 'string', 'max:255'],
'env_prefix' => ['nullable', 'string', 'max:32', 'regex:/^[A-Z][A-Z0-9_]*$/'],
'is_primary' => ['boolean'],
'domain' => ['nullable', 'string', 'max:255'],
'path_prefix' => ['nullable', 'string', 'max:255'],
'tls_enabled' => ['boolean'],
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreEnvironmentDeploymentRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'target_commit' => ['nullable', 'string', 'size:40', 'regex:/^[a-fA-F0-9]{40}$/'],
];
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreEnvironmentRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'branch' => ['required', 'string', 'max:255', 'regex:/^[A-Za-z0-9._\/-]+$/'],
'php_version' => ['required', 'string', 'max:20'],
];
}
}

View File

@@ -24,6 +24,7 @@ class StoreEnvironmentVariableRequest extends FormRequest
return [
'key' => ['required', 'string', 'max:255', 'regex:/^[A-Z][A-Z0-9_]*$/'],
'value' => ['nullable', 'string'],
'overridable' => ['boolean'],
];
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreGatewayRouteRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'service_id' => ['required', 'integer', 'exists:services,id'],
'name' => ['required', 'string', 'max:255'],
'domain' => ['required', 'string', 'max:255'],
'path_prefix' => ['required', 'string', 'max:255', 'regex:/^\//'],
'tls_enabled' => ['boolean'],
];
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use App\Enums\OrganisationRole;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreOrganisationMemberRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'email' => ['required', 'email', 'max:255'],
'role' => ['required', Rule::enum(OrganisationRole::class)],
];
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests;
use App\Enums\ProviderType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreProviderRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'type' => ['required', Rule::enum(ProviderType::class)],
'token' => ['required', 'string', 'max:2000'],
];
}
}

View File

@@ -25,8 +25,8 @@ class StoreRegistryRequest extends FormRequest
{
return [
'name' => ['required', 'string', 'max:255'],
'type' => ['required', Rule::enum(RegistryType::class)],
'url' => ['required', 'string', 'max:255'],
'type' => ['required', Rule::enum(RegistryType::class), Rule::notIn([RegistryType::MANAGED->value])],
'url' => ['required', 'string', 'max:255', 'not_regex:#^https?://#i'],
'username' => ['nullable', 'string', 'max:255'],
'password' => ['nullable', 'string', 'max:255'],
];

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests;
use App\Enums\FirewallRuleType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreServerFirewallRuleRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'type' => ['required', Rule::enum(FirewallRuleType::class)],
'ports' => ['required', 'string', 'max:50', 'regex:/^[A-Za-z0-9:\/,-]+$/'],
'from' => ['nullable', 'string', 'max:255', 'regex:/^[A-Fa-f0-9:.\/-]+$/'],
];
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreServiceSliceRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'type' => ['required', 'string', 'max:255'],
'environment_id' => ['nullable', 'integer'],
'status' => ['required', 'string', 'max:255'],
'config' => ['nullable', 'string'],
];
}
}

View File

@@ -21,6 +21,7 @@ class StoreServiceUpdateRequest extends FormRequest
return [
'image_digest' => ['required', 'string', 'starts_with:sha256:'],
'backup_requested' => ['sometimes', 'boolean'],
'confirmation' => ['required', 'string'],
];
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Http\Requests;
use App\Enums\RepositoryType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateApplicationRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'source_provider_id' => ['nullable', 'integer', 'exists:source_providers,id'],
'repository_type' => ['required', Rule::enum(RepositoryType::class)],
'repository_url' => ['required', 'string', 'max:255', 'regex:/^(git@[^:]+:.+|ssh:\/\/.+)$/i'],
'default_branch' => ['required', 'string', 'max:255', 'regex:/^[A-Za-z0-9._\/-]+$/'],
];
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Http\Requests;
use App\Enums\EnvironmentAttachmentRole;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateEnvironmentAttachmentRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'role' => ['required', Rule::enum(EnvironmentAttachmentRole::class)],
'env_prefix' => ['nullable', 'string', 'max:255', 'regex:/^[A-Z][A-Z0-9_]*$/'],
'is_primary' => ['boolean'],
'domain' => ['nullable', 'string', 'max:255'],
'path_prefix' => ['nullable', 'string', 'max:255'],
'tls_enabled' => ['boolean'],
'certificate_status' => ['nullable', 'string', 'max:255'],
];
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Http\Requests;
use App\Enums\BuildStrategy;
use App\Enums\SchedulerMode;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateEnvironmentRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'branch' => ['required', 'string', 'max:255', 'regex:/^[A-Za-z0-9._\/-]+$/'],
'status' => ['required', 'string', 'max:255'],
'scheduler_enabled' => ['boolean'],
'scheduler_target_service_id' => ['nullable', 'integer'],
'scheduler_mode' => ['required', Rule::enum(SchedulerMode::class)],
'build_strategy' => ['required', Rule::enum(BuildStrategy::class)],
'php_version' => ['nullable', 'string', 'max:20'],
'document_root' => ['nullable', 'string', 'max:255'],
'health_path' => ['nullable', 'string', 'max:255'],
'js_package_manager' => ['nullable', 'string', 'max:50'],
'js_build_command' => ['nullable', 'string', 'max:255'],
];
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateEnvironmentVariableRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'key' => ['required', 'string', 'max:255', 'regex:/^[A-Z][A-Z0-9_]*$/'],
'value' => ['nullable', 'string'],
'overridable' => ['boolean'],
];
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateGatewayRouteRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'domain' => ['required', 'string', 'max:255'],
'path_prefix' => ['required', 'string', 'max:255', 'regex:/^\//'],
'tls_enabled' => ['boolean'],
'certificate_status' => ['nullable', 'string', 'max:255'],
];
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests;
use App\Enums\OrganisationRole;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateOrganisationInvitationRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'role' => ['required', Rule::enum(OrganisationRole::class)],
];
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests;
use App\Enums\OrganisationRole;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateOrganisationMemberRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'role' => ['required', Rule::enum(OrganisationRole::class)],
];
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Http\Requests;
use App\Enums\RegistryType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateRegistryRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'type' => ['required', Rule::enum(RegistryType::class), Rule::notIn([RegistryType::MANAGED->value])],
'url' => ['required', 'string', 'max:255', 'not_regex:#^https?://#i'],
'username' => ['nullable', 'string', 'max:255'],
'password' => ['nullable', 'string', 'max:255'],
];
}
}

View File

@@ -2,7 +2,9 @@
namespace App\Http\Requests;
use App\Enums\DeployPolicy;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateServiceRequest extends FormRequest
{
@@ -26,6 +28,16 @@ class UpdateServiceRequest extends FormRequest
'desired_replicas' => ['required', 'integer', 'min:0', 'max:25'],
'default_cpu_limit' => ['nullable', 'numeric', 'min:0.125', 'max:64'],
'default_memory_limit_mb' => ['nullable', 'integer', 'min:64', 'max:1048576'],
'deploy_policy' => ['nullable', Rule::enum(DeployPolicy::class)],
'version_track' => ['nullable', 'string', 'max:255'],
'available_image_digest' => ['nullable', 'string', 'max:255'],
'process_roles' => ['nullable', 'string', 'max:255'],
'migration_mode' => ['nullable', 'string', 'max:255'],
'migration_timing' => ['nullable', 'string', 'max:255'],
'migration_command' => ['nullable', 'string', 'max:255'],
'health_path' => ['nullable', 'string', 'max:255'],
'backup_enabled' => ['sometimes', 'boolean'],
'backup_command' => ['nullable', 'string', 'max:1000'],
];
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests;
use App\Enums\SourceProviderType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateSourceProviderRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'type' => ['required', Rule::enum(SourceProviderType::class)],
'url' => ['nullable', 'string', 'max:255'],
];
}
}

View File

@@ -15,9 +15,14 @@ use App\Enums\ServiceEndpointScope;
use App\Models\Environment;
use App\Models\EnvironmentAttachment;
use App\Models\Operation;
use App\Models\Registry;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceReplica;
use App\Services\Compose\ComposeRenderer;
use App\Services\Registries\RegistryDockerAuthScript;
use App\Services\Registries\RegistryResolver;
use App\Support\CaddyRouteRenderer;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use InvalidArgumentException;
@@ -29,6 +34,7 @@ class DeployEnvironment implements ShouldQueue
public function __construct(
public Environment $environment,
public ?string $targetCommit = null,
) {
//
}
@@ -51,7 +57,7 @@ class DeployEnvironment implements ShouldQueue
'started_at' => now(),
]);
$commitSha = app(ResolveEnvironmentCommit::class)->execute($this->environment);
$commitSha = $this->targetCommit ?? app(ResolveEnvironmentCommit::class)->execute($this->environment);
$services = $this->servicesNeedingDeployment($plan->services, $commitSha);
if ($services === []) {
@@ -65,6 +71,7 @@ class DeployEnvironment implements ShouldQueue
$artifact = app(PlanBuildArtifact::class)->execute($this->environment, $commitSha);
$artifact = app(BuildApplicationArtifact::class)->execute($artifact, $operation);
$registry = app(RegistryResolver::class)->buildRegistryFor($this->environment->application->organisation);
foreach ($services as $service) {
$service->update([
@@ -78,8 +85,8 @@ class DeployEnvironment implements ShouldQueue
'status' => OperationStatus::PENDING,
]);
$this->createServiceDeploySteps($child, $service, $commitSha, $artifact->image_digest);
$this->createReplicaDeployOperations($child, $service, $artifact->registry_ref);
$this->createServiceDeploySteps($child, $service, $commitSha, $artifact->image_digest, $artifact->registry_ref);
$this->createReplicaDeployOperations($child, $service, $artifact->registry_ref, $registry);
}
$this->createGatewayOperations($operation);
@@ -98,19 +105,20 @@ class DeployEnvironment implements ShouldQueue
->all();
}
private function createServiceDeploySteps(Operation $operation, Service $service, string $commitSha, string $imageDigest): void
private function createServiceDeploySteps(Operation $operation, Service $service, string $commitSha, string $imageDigest, ?string $imageReference = null): void
{
foreach ($this->serviceDeployScripts($service, $commitSha, $imageDigest) as $index => $step) {
foreach ($this->serviceDeployScripts($service, $commitSha, $imageDigest, $imageReference) as $index => $step) {
$operation->steps()->create([
'name' => $step['name'],
'order' => $index + 1,
'status' => OperationStatus::PENDING,
'script' => $step['script'],
'secrets' => $step['secrets'] ?? null,
]);
}
}
private function createReplicaDeployOperations(Operation $parent, Service $service, ?string $imageReference = null): void
private function createReplicaDeployOperations(Operation $parent, Service $service, ?string $imageReference = null, ?Registry $registry = null): void
{
$replicas = $this->ensureServiceReplicas($service);
@@ -131,12 +139,13 @@ class DeployEnvironment implements ShouldQueue
'health_status' => 'unknown',
]);
foreach ($this->replicaDeployScripts($service, $replica, $imageReference) as $index => $step) {
foreach ($this->replicaDeployScripts($service, $replica, $imageReference, $registry, $serviceReplica) as $index => $step) {
$operation->steps()->create([
'name' => $step['name'],
'order' => $index + 1,
'status' => OperationStatus::PENDING,
'script' => $step['script'],
'secrets' => $step['secrets'] ?? null,
]);
}
}
@@ -242,7 +251,7 @@ class DeployEnvironment implements ShouldQueue
/**
* @return array<int, array{name: string, script: string}>
*/
private function serviceDeployScripts(Service $service, string $commitSha, string $imageDigest): array
private function serviceDeployScripts(Service $service, string $commitSha, string $imageDigest, ?string $imageReference = null): array
{
$servicePath = $this->servicePath($service);
$composePath = "{$servicePath}/compose.yml";
@@ -262,7 +271,7 @@ class DeployEnvironment implements ShouldQueue
],
[
'name' => 'Render Compose files',
'script' => $this->composeUploadScript($service),
'script' => $this->composeUploadScript($service, $this->fullImageReference($imageReference, $imageDigest)),
],
];
@@ -309,17 +318,32 @@ class DeployEnvironment implements ShouldQueue
}
/**
* @return array<int, array{name: string, script: string}>
* @return array<int, array{name: string, script: string, secrets?: array<string, string>}>
*/
private function replicaDeployScripts(Service $service, int $replica, ?string $imageReference = null): array
private function replicaDeployScripts(Service $service, int $replica, ?string $imageReference = null, ?Registry $registry = null, ?ServiceReplica $serviceReplica = null): array
{
$composePath = $this->servicePath($service).'/compose.yml';
$project = "keystone_service_{$service->id}_replica_{$replica}";
$serviceKey = $this->serviceKey($service);
$targetServer = $serviceReplica?->server ?: $service->server;
$steps = [];
$steps = [
[
'name' => "Render replica {$replica} Compose files",
'script' => $this->composeUploadScript($service, $this->fullImageReference($imageReference, $service->available_image_digest)),
],
];
if ($imageReference && $service->available_image_digest) {
if ($registry instanceof Registry && $registry->credentials) {
$auth = app(RegistryDockerAuthScript::class)->forRuntime($registry, $this->dockerAuthUser($targetServer));
$steps[] = [
'name' => "Configure registry auth for replica {$replica}",
'script' => $auth['script'],
'secrets' => $auth['secrets'],
];
}
$steps[] = [
'name' => "Pull image for replica {$replica}",
'script' => 'docker pull '.escapeshellarg($imageReference.'@'.$service->available_image_digest),
@@ -352,16 +376,21 @@ class DeployEnvironment implements ShouldQueue
];
}
private function composeUploadScript(Service $service): string
private function dockerAuthUser(?Server $server): string
{
return 'root';
}
private function composeUploadScript(Service $service, ?string $fullImageReference = null): string
{
$servicePath = $this->servicePath($service);
try {
$renderer = app(ComposeRenderer::class);
$compose = $renderer->render($service);
$compose = $renderer->render($this->serviceForCompose($service, $fullImageReference));
$env = $renderer->renderEnvironmentFile($service);
} catch (InvalidArgumentException) {
$compose = "services:\n {$this->serviceKey($service)}:\n image: \"{$service->available_image_digest}\"\n";
$compose = "services:\n {$this->serviceKey($service)}:\n image: \"".($fullImageReference ?: $service->available_image_digest)."\"\n";
$env = '';
}
@@ -372,21 +401,52 @@ class DeployEnvironment implements ShouldQueue
]);
}
private function fullImageReference(?string $imageReference, ?string $imageDigest): ?string
{
if (! $imageReference || ! $imageDigest) {
return null;
}
return $imageReference.'@'.$imageDigest;
}
private function serviceForCompose(Service $service, ?string $fullImageReference): Service
{
if (! $fullImageReference) {
return $service;
}
$clone = clone $service;
$clone->available_image_digest = $fullImageReference;
return $clone;
}
/**
* @return array<int, array{name: string, script: string}>
*/
private function gatewayCutoverSteps(EnvironmentAttachment $attachment): array
{
$containerName = $attachment->service->replicas()->first()?->container_name;
$config = $attachment->serviceSlice?->config ?? [];
$domain = $config['domain'] ?? null;
$tlsEnabled = $config['tls_enabled'] ?? true;
$reloadCommand = $containerName
? 'docker exec '.escapeshellarg($containerName).' caddy reload --config /etc/caddy/Caddyfile'
: "docker compose -f /home/keystone/services/{$attachment->service_id}/compose.yml exec -T {$this->serviceKey($attachment->service)} caddy reload --config /etc/caddy/Caddyfile";
$certificateCheck = $tlsEnabled && $domain
? 'curl --fail --silent --show-error --head https://'.escapeshellarg($domain).' >/dev/null'
: 'true # TLS disabled or no domain configured for this route';
return [
[
'name' => 'Validate Caddy route configuration',
'script' => 'test -s /home/keystone/gateway/Caddyfile',
],
[
'name' => 'Check TLS certificate status',
'script' => $certificateCheck,
],
[
'name' => 'Reload Caddy',
'script' => $reloadCommand,
@@ -406,15 +466,13 @@ class DeployEnvironment implements ShouldQueue
private function configureCaddyRouteScript(EnvironmentAttachment $attachment): string
{
$route = $attachment->serviceSlice?->name ?? $this->environment->name;
$upstreams = $this->gatewayUpstreams($attachment);
$caddyfile = app(CaddyRouteRenderer::class)->render($attachment, $upstreams);
return implode("\n", [
'mkdir -p /home/keystone/gateway/Caddyfile.d',
"cat > /home/keystone/gateway/Caddyfile.d/{$attachment->id}.caddy <<'KEYSTONE_CADDY_ROUTE'",
"{$route} {",
' reverse_proxy '.implode(' ', $upstreams),
'}',
$caddyfile,
'KEYSTONE_CADDY_ROUTE',
'cat /home/keystone/gateway/Caddyfile.d/*.caddy > /home/keystone/gateway/Caddyfile',
]);

View File

@@ -2,11 +2,15 @@
namespace App\Jobs\Services;
use App\Enums\BuildArtifactStatus;
use App\Enums\OperationStatus;
use App\Enums\RegistryType;
use App\Enums\ServiceStatus;
use App\Models\BuildArtifact;
use App\Models\Environment;
use App\Models\Operation;
use App\Models\OperationStep;
use App\Models\Registry;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceReplica;
@@ -110,6 +114,9 @@ class RunStep implements ShouldQueue
if ($operation->is($this->step->operation)) {
$this->markTargetCompleted();
}
$this->markRegistryHealthOperationCompleted($operation);
$this->markRegistryMaintenanceOperationCompleted($operation);
}
private function dispatchNextChildOperation(Operation $operation): bool
@@ -166,6 +173,7 @@ class RunStep implements ShouldQueue
$target instanceof ServiceReplica => $target->server,
$target instanceof Service => $target->replicas()->with('server')->first()?->server ?: $target->server,
$target instanceof ServiceSlice => $target->service->replicas()->with('server')->first()?->server ?: $target->service->server,
$target instanceof Server => $target,
$target instanceof Environment => $target->services()->with(['server', 'replicas.server'])->get()
->flatMap(fn (Service $service) => $service->replicas->pluck('server')->filter())
->first() ?: $target->services()->with('server')->get()->pluck('server')->filter()->first(),
@@ -190,10 +198,12 @@ class RunStep implements ShouldQueue
'status' => OperationStatus::FAILED,
'finished_at' => now(),
'error_logs' => $this->step->error_logs."\n".trim($message),
'secrets' => null,
]);
$this->step->operation->steps()->where('order', '>', $this->step->order)->update([
'status' => OperationStatus::CANCELLED,
'secrets' => null,
]);
$this->step->operation->update([
@@ -203,13 +213,114 @@ class RunStep implements ShouldQueue
$this->cancelDescendants($this->step->operation);
$this->cancelPendingSiblingsAndAncestors($this->step->operation);
$this->markRegistryHealthOperationFailed($this->step->operation, trim($message));
}
private function markRegistryHealthOperationCompleted(Operation $operation): void
{
if ($operation->kind !== \App\Enums\OperationKind::REGISTRY_HEALTH_CHECK) {
return;
}
if ($operation->parent_id !== null) {
return;
}
$registry = $this->managedRegistryForOperation($operation);
if (! $registry instanceof Registry) {
return;
}
$checks = collect($registry->readiness_checks ?? [])
->map(fn (): string => 'passed')
->all();
$registry->forceFill([
'readiness_checks' => $checks,
])->save();
$registry->markHealthy('Managed registry smoke checks passed.');
}
private function markRegistryHealthOperationFailed(Operation $operation, string $message): void
{
if ($operation->kind !== \App\Enums\OperationKind::REGISTRY_HEALTH_CHECK) {
return;
}
$registry = $this->managedRegistryForOperation($operation);
$registry?->markUnhealthy('Managed registry smoke check failed: '.$message);
}
private function markRegistryMaintenanceOperationCompleted(Operation $operation): void
{
if ($operation->kind !== \App\Enums\OperationKind::REGISTRY_MAINTENANCE) {
return;
}
$registry = $this->managedRegistryForOperation($operation);
if (! $registry instanceof Registry) {
return;
}
$artifactIds = collect($operation->metadata['artifact_ids'] ?? [])
->filter(fn ($id): bool => is_numeric($id))
->map(fn ($id): int => (int) $id)
->values();
BuildArtifact::query()
->whereIn('id', $artifactIds)
->where('status', BuildArtifactStatus::PRUNABLE)
->where('registry_ref', 'like', rtrim((string) $registry->url, '/').'/%')
->each(function ($artifact): void {
$artifact->update([
'status' => BuildArtifactStatus::PRUNED,
'metadata' => [
...($artifact->metadata ?? []),
'pruned_at' => now()->toIso8601String(),
],
]);
});
}
private function managedRegistryForOperation(Operation $operation): ?Registry
{
$registryId = $operation->metadata['registry_id'] ?? $operation->parent?->metadata['registry_id'] ?? null;
if ($registryId) {
$registry = Registry::query()
->where('type', RegistryType::MANAGED->value)
->find($registryId);
if ($registry instanceof Registry) {
return $registry;
}
}
$server = $operation->target;
if (! $server instanceof Server) {
$server = $operation->parent?->target;
}
if (! $server instanceof Server) {
return null;
}
return Registry::query()
->where('type', RegistryType::MANAGED->value)
->where('control_server_id', $server->id)
->first();
}
private function cancelDescendants(Operation $operation): void
{
$operation->children()->with('children')->get()->each(function (Operation $child): void {
$child->steps()->where('status', OperationStatus::PENDING)->update([
$child->steps()->whereIn('status', [OperationStatus::PENDING, OperationStatus::IN_PROGRESS])->update([
'status' => OperationStatus::CANCELLED,
'secrets' => null,
]);
$child->update([
'status' => OperationStatus::CANCELLED,
@@ -232,8 +343,9 @@ class RunStep implements ShouldQueue
->whereIn('status', [OperationStatus::PENDING, OperationStatus::IN_PROGRESS])
->get()
->each(function (Operation $sibling): void {
$sibling->steps()->where('status', OperationStatus::PENDING)->update([
$sibling->steps()->whereIn('status', [OperationStatus::PENDING, OperationStatus::IN_PROGRESS])->update([
'status' => OperationStatus::CANCELLED,
'secrets' => null,
]);
$sibling->update([
'status' => OperationStatus::CANCELLED,

View File

@@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Support\Str;
class Application extends Model
{
@@ -15,6 +16,13 @@ class Application extends Model
protected $guarded = [];
protected static function booted(): void
{
static::creating(function (Application $application): void {
$application->uuid ??= (string) Str::uuid();
});
}
protected function casts(): array
{
return [
@@ -29,6 +37,11 @@ class Application extends Model
return $this->belongsTo(Organisation::class);
}
public function sourceProvider(): BelongsTo
{
return $this->belongsTo(SourceProvider::class);
}
public function environments(): HasMany
{
return $this->hasMany(Environment::class);

View File

@@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Support\Str;
class Environment extends Model
{
@@ -15,6 +16,13 @@ class Environment extends Model
protected $guarded = [];
protected static function booted(): void
{
static::creating(function (Environment $environment): void {
$environment->uuid ??= (string) Str::uuid();
});
}
protected function casts(): array
{
return [

View File

@@ -42,9 +42,9 @@ class FirewallRule extends Model
$command .= ' delete';
}
if ($this->type === 'allow') {
if ($this->type === FirewallRuleType::ALLOW) {
$command .= ' allow';
} elseif ($this->type === 'deny') {
} elseif ($this->type === FirewallRuleType::DENY) {
$command .= ' deny';
}

View File

@@ -30,6 +30,7 @@ class Operation extends Model
return [
'kind' => OperationKind::class,
'status' => OperationStatus::class,
'metadata' => 'array',
'started_at' => 'datetime',
'finished_at' => 'datetime',
];

View File

@@ -13,6 +13,10 @@ class OperationStep extends Model
{
protected $guarded = [];
protected $hidden = [
'secrets',
];
protected $appends = [
'logs_excerpt',
'error_logs_excerpt',

View File

@@ -30,6 +30,11 @@ class Organisation extends Model
->withTimestamps();
}
public function invitations(): HasMany
{
return $this->hasMany(OrganisationInvitation::class);
}
public function servers(): HasMany
{
return $this->hasMany(Server::class);

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Models;
use App\Enums\OrganisationRole;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class OrganisationInvitation extends Model
{
/** @use HasFactory<\Database\Factories\OrganisationInvitationFactory> */
use HasFactory;
protected $guarded = [];
protected function casts(): array
{
return [
'role' => OrganisationRole::class,
'accepted_at' => 'datetime',
'expires_at' => 'datetime',
];
}
public function organisation(): BelongsTo
{
return $this->belongsTo(Organisation::class);
}
public function invitedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'invited_by_user_id');
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Models;
use App\Enums\RegistryType;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
class Registry extends Model
{
@@ -17,6 +18,9 @@ class Registry extends Model
return [
'type' => RegistryType::class,
'credentials' => 'encrypted:array',
'readiness_checks' => 'array',
'health_checked_at' => 'datetime',
'ready_at' => 'datetime',
];
}
@@ -24,4 +28,34 @@ class Registry extends Model
{
return $this->belongsTo(Organisation::class);
}
public function controlServer(): BelongsTo
{
return $this->belongsTo(Server::class, 'control_server_id');
}
public function markHealthy(?string $message = null): void
{
$this->forceFill([
'health_status' => 'healthy',
'health_message' => $message,
'health_checked_at' => Carbon::now(),
'ready_at' => $this->ready_at ?? Carbon::now(),
])->save();
}
public function markUnhealthy(string $message): void
{
$this->forceFill([
'health_status' => 'unhealthy',
'health_message' => $message,
'health_checked_at' => Carbon::now(),
'ready_at' => null,
])->save();
}
public function isReady(): bool
{
return $this->type !== RegistryType::MANAGED || ($this->ready_at !== null && $this->health_status === 'healthy');
}
}

View File

@@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Spatie\Ssh\Ssh;
class Server extends Model
@@ -21,6 +22,8 @@ class Server extends Model
{
return [
'status' => ServerStatus::class,
'is_control_node' => 'boolean',
'build_enabled' => 'boolean',
];
}
@@ -31,7 +34,7 @@ class Server extends Model
public function network(): BelongsTo
{
return $this->belongsTo(Network::class, 'network');
return $this->belongsTo(Network::class, 'network_id');
}
public function organisation(): BelongsTo
@@ -69,6 +72,11 @@ class Server extends Model
)->where('target_type', (new Service)->getMorphClass());
}
public function operations(): MorphMany
{
return $this->morphMany(Operation::class, 'target');
}
public function sshClient(string $user = 'root'): Ssh
{
return Ssh::create($user, $this->ipv4)

View File

@@ -5,6 +5,7 @@ namespace App\Models;
use App\Enums\SourceProviderType;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class SourceProvider extends Model
{
@@ -22,4 +23,9 @@ class SourceProvider extends Model
{
return $this->belongsTo(Organisation::class);
}
public function applications(): HasMany
{
return $this->hasMany(Application::class);
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Services\Registries;
use App\Enums\RegistryType;
use App\Models\Environment;
use App\Models\Registry;
class ImageReference
{
public function tagFor(Environment $environment, string $commitSha, ?Registry $registry = null): string
{
$tag = substr($commitSha, 0, 12);
if ($this->registryType($registry) === RegistryType::MANAGED) {
$namespace = trim((string) config('keystone.managed_registry.namespace', 'keystone'), '/');
$applicationId = $environment->application->uuid ?: 'app-'.$environment->application_id;
$environmentId = $environment->uuid ?: 'env-'.$environment->id;
$path = $namespace.'/'.$applicationId.'/'.$environmentId;
return $path.':'.$tag;
}
return str($environment->application->name)
->slug()
->append(':'.$tag)
->value();
}
public function registryReference(Registry $registry, string $imageTag): string
{
return rtrim($this->registryHost((string) $registry->url), '/').'/'.ltrim($imageTag, '/');
}
private function registryHost(string $url): string
{
$host = preg_replace('#^https?://#', '', trim($url));
return $host === null ? trim($url) : $host;
}
private function registryType(?Registry $registry): ?RegistryType
{
if (! $registry instanceof Registry) {
return null;
}
if ($registry->type instanceof RegistryType) {
return $registry->type;
}
return RegistryType::tryFrom((string) $registry->type);
}
}

View File

@@ -0,0 +1,128 @@
<?php
namespace App\Services\Registries;
use App\Enums\RegistryType;
use App\Models\Registry;
use App\Models\Server;
use Illuminate\Support\Facades\Http;
class ManagedRegistryHealth
{
public function check(Registry $registry): bool
{
if ($registry->type !== RegistryType::MANAGED) {
return true;
}
$message = $this->configurationBlocker($registry);
if ($message !== null) {
$registry->markUnhealthy($message);
return false;
}
try {
$response = Http::timeout(5)->get('https://'.$registry->url.'/v2/');
} catch (\Throwable $exception) {
$registry->markUnhealthy('Registry URL is not reachable over HTTPS: '.$exception->getMessage());
return false;
}
if (! in_array($response->status(), [200, 401], true)) {
$registry->markUnhealthy('Registry HTTPS check returned HTTP '.$response->status().'.');
return false;
}
$checks = $registry->readiness_checks ?? [];
$checks['control_https'] = 'passed';
$registry->forceFill([
'readiness_checks' => $checks,
])->save();
if ($this->readinessChecksPassed($registry->refresh())) {
$registry->markHealthy('Registry HTTPS endpoint and smoke checks passed.');
return true;
}
$registry->markUnhealthy('Registry HTTPS endpoint is reachable, but smoke checks have not all passed.');
return false;
}
public function readinessBlocker(Registry $registry): ?string
{
if ($registry->type !== RegistryType::MANAGED) {
return null;
}
$message = $this->configurationBlocker($registry);
if ($message !== null) {
return $message;
}
if ($registry->ready_at === null || $registry->health_status !== 'healthy') {
return 'Managed registry has not passed readiness checks.';
}
if (! $this->readinessChecksPassed($registry)) {
return 'Managed registry smoke checks have not all passed.';
}
return null;
}
private function configurationBlocker(Registry $registry): ?string
{
if (! $registry->url) {
return 'Managed registry URL is not configured.';
}
if (! str_contains((string) $registry->url, '.')) {
return 'Managed registry must use a resolvable HTTPS hostname.';
}
$credentials = $registry->credentials ?? [];
foreach (['build_username', 'build_password', 'runtime_username', 'runtime_password'] as $key) {
if (blank($credentials[$key] ?? null)) {
return 'Managed registry credentials are incomplete.';
}
}
$controlServer = $registry->controlServer;
if (! $controlServer instanceof Server) {
return 'A control/build server is required for managed registry builds.';
}
if (! $controlServer->build_enabled) {
return 'The managed registry control server is not build-enabled.';
}
return null;
}
private function readinessChecksPassed(Registry $registry): bool
{
$checks = $registry->readiness_checks ?? [];
if ($checks === []) {
return false;
}
foreach (['control_https', 'build_push'] as $requiredCheck) {
if (! array_key_exists($requiredCheck, $checks)) {
return false;
}
}
return collect($checks)->every(fn (mixed $status): bool => $status === 'passed');
}
}

View File

@@ -0,0 +1,189 @@
<?php
namespace App\Services\Registries;
use App\Models\BuildArtifact;
use App\Models\Registry;
use App\Models\Server;
class ManagedRegistryOperationScripts
{
/**
* @return array{script: string, secrets: array<string, string>}
*/
public function provision(Registry $registry): array
{
$credentials = $registry->credentials ?? [];
$host = $this->host($registry);
$storagePath = $registry->storage_path ?: (string) config('keystone.managed_registry.storage_path');
return [
'script' => implode("\n", [
'set -euo pipefail',
'storage_path='.escapeshellarg($storagePath),
'registry_host='.escapeshellarg($host),
'install -d -m 700 -o root -g root /home/keystone/registry/auth /home/keystone/registry/config',
'install -d -m 755 -o root -g root "$storage_path"',
'tmp_htpasswd=$(mktemp)',
'cleanup() { rm -f "$tmp_htpasswd"; unset build_password runtime_password; }',
'trap cleanup EXIT',
'build_password=$(printf %s '.escapeshellarg('[!build_password_base64!]').' | base64 -d)',
'runtime_password=$(printf %s '.escapeshellarg('[!runtime_password_base64!]').' | base64 -d)',
'printf %s "$build_password" | docker run -i --rm --entrypoint htpasswd httpd:2.4-alpine -Bni '.escapeshellarg((string) ($credentials['build_username'] ?? 'keystone-build')).' > "$tmp_htpasswd"',
'printf %s "$runtime_password" | docker run -i --rm --entrypoint htpasswd httpd:2.4-alpine -Bni '.escapeshellarg((string) ($credentials['runtime_username'] ?? 'keystone-runtime')).' >> "$tmp_htpasswd"',
'install -m 600 -o root -g root "$tmp_htpasswd" /home/keystone/registry/auth/htpasswd',
'cat > /home/keystone/registry/config/config.yml <<\'KEYSTONE_REGISTRY_CONFIG\'',
'version: 0.1',
'log:',
' fields:',
' service: registry',
'storage:',
' filesystem:',
' rootdirectory: /var/lib/registry',
' delete:',
' enabled: true',
'http:',
' addr: :5000',
'auth:',
' htpasswd:',
' realm: keystone-managed-registry',
' path: /auth/htpasswd',
'KEYSTONE_REGISTRY_CONFIG',
'docker rm -f keystone-managed-registry >/dev/null 2>&1 || true',
'docker run -d --name keystone-managed-registry --restart unless-stopped -p 127.0.0.1:5000:5000 -e REGISTRY_STORAGE_DELETE_ENABLED=true -v "$storage_path:/var/lib/registry" -v /home/keystone/registry/auth:/auth:ro -v /home/keystone/registry/config/config.yml:/etc/docker/registry/config.yml:ro registry:2',
'install -d -m 755 /home/keystone/gateway/Caddyfile.d',
'cat > /home/keystone/gateway/Caddyfile.d/managed-registry.caddy <<KEYSTONE_CADDY_REGISTRY',
'$registry_host {',
' reverse_proxy 127.0.0.1:5000',
'}',
'KEYSTONE_CADDY_REGISTRY',
'if test -d /home/keystone/gateway/Caddyfile.d; then cat /home/keystone/gateway/Caddyfile.d/*.caddy > /home/keystone/gateway/Caddyfile; fi',
'if docker ps --format \'{{.Names}}\' | grep -qx gateway-1; then docker exec gateway-1 caddy reload --config /etc/caddy/Caddyfile; fi',
'if docker ps --format \'{{.Names}}\' | grep -qx caddy; then docker exec caddy caddy reload --config /etc/caddy/Caddyfile; fi',
'if docker ps --format \'{{.Names}}\' | grep -Eqx \'(gateway-1|caddy)\'; then curl --fail --silent --show-error --location --head https://"$registry_host"/v2/ || test "$?" = "22"; else echo "Registry proxy reload skipped because no Caddy container is running."; fi',
]),
'secrets' => [
'build_password_base64' => base64_encode((string) ($credentials['build_password'] ?? '')),
'runtime_password_base64' => base64_encode((string) ($credentials['runtime_password'] ?? '')),
],
];
}
/**
* @return array{script: string, secrets: array<string, string>}
*/
public function smokeCheck(Registry $registry, Server $server, string $scope, ?string $imageReference = null): array
{
$credentials = $registry->credentials ?? [];
$username = (string) ($credentials[$scope.'_username'] ?? '');
$password = (string) ($credentials[$scope.'_password'] ?? '');
$host = $this->host($registry);
$repository = $imageReference ?: $host.'/keystone/smoke/server-'.$server->id.':latest';
$commands = [
'set -euo pipefail',
'registry_host='.escapeshellarg($host),
'image_ref='.escapeshellarg($repository),
'username='.escapeshellarg($username),
'password=$(printf %s '.escapeshellarg('[!registry_password_base64!]').' | base64 -d)',
'curl --fail --silent --show-error --user "$username:$password" https://"$registry_host"/v2/ >/dev/null',
'printf %s '.escapeshellarg('[!registry_password_base64!]').' | base64 -d | docker login "$registry_host" --username '.escapeshellarg($username).' --password-stdin >/dev/null',
];
if ($scope === 'build') {
$commands = [
...$commands,
'docker pull busybox:latest >/dev/null',
'docker tag busybox:latest "$image_ref"',
'docker push "$image_ref" >/dev/null',
'docker buildx imagetools inspect "$image_ref" >/dev/null',
'printf "smoke_ref=%s\n" "$image_ref"',
'unset password',
];
} else {
$commands = [
...$commands,
'docker pull "$image_ref" >/dev/null',
'unset password',
];
}
return [
'script' => implode("\n", $commands),
'secrets' => [
'registry_password_base64' => base64_encode($password),
],
];
}
/**
* @param iterable<int, BuildArtifact> $artifacts
* @return array{script: string, secrets: array<string, string>}
*/
public function maintenance(Registry $registry, iterable $artifacts): array
{
$credentials = $registry->credentials ?? [];
$host = $this->host($registry);
$deletions = [];
foreach ($artifacts as $artifact) {
$repository = $this->repositoryPath((string) $artifact->registry_ref);
if ($repository === '' || blank($artifact->image_digest)) {
continue;
}
$deletions[] = 'delete_manifest '.escapeshellarg($repository).' '.escapeshellarg((string) $artifact->image_digest).' || delete_failures=1';
}
return [
'script' => implode("\n", [
'set -euo pipefail',
'registry_host='.escapeshellarg($host),
'lock_file=/home/keystone/registry/maintenance.lock',
'exec 9>"$lock_file"',
'flock -n 9',
'username='.escapeshellarg((string) ($credentials['build_username'] ?? 'keystone-build')),
'password=$(printf %s '.escapeshellarg('[!registry_password_base64!]').' | base64 -d)',
'curl_config=$(mktemp)',
'registry_was_stopped=0',
'cleanup() { rm -f "$curl_config"; unset password auth_header; if test "$registry_was_stopped" = "1"; then docker start keystone-managed-registry >/dev/null 2>&1 || true; fi; }',
'trap cleanup EXIT',
'auth_header=$(printf "%s:%s" "$username" "$password" | base64 | tr -d "\n")',
'printf "header = \"Authorization: Basic %s\"\n" "$auth_header" > "$curl_config"',
'chmod 600 "$curl_config"',
'delete_failures=0',
'delete_manifest() {',
' repository="$1"',
' digest="$2"',
' status=$(curl --silent --show-error --output /tmp/keystone-registry-delete-response --write-out "%{http_code}" --request DELETE --config "$curl_config" --header "Accept: application/vnd.docker.distribution.manifest.v2+json" "https://$registry_host/v2/$repository/manifests/$digest" || true)',
' case "$status" in 2*|404) printf "deleted_manifest=%s@%s status=%s\n" "$repository" "$digest" "$status" ;; *) cat /tmp/keystone-registry-delete-response >&2; printf "delete_failed=%s@%s status=%s\n" "$repository" "$digest" "$status" >&2; return 1 ;; esac',
'}',
...$deletions,
'test "$delete_failures" = "0"',
'docker stop keystone-managed-registry',
'registry_was_stopped=1',
'docker run --rm -v /home/keystone/registry/config/config.yml:/etc/docker/registry/config.yml:ro -v '.escapeshellarg(($registry->storage_path ?: (string) config('keystone.managed_registry.storage_path')).':/var/lib/registry').' registry:2 garbage-collect --delete-untagged /etc/docker/registry/config.yml',
'docker start keystone-managed-registry',
'registry_was_stopped=0',
'unset password',
]),
'secrets' => [
'registry_password_base64' => base64_encode((string) ($credentials['build_password'] ?? '')),
],
];
}
private function host(Registry $registry): string
{
return rtrim((string) preg_replace('#^https?://#', '', (string) $registry->url), '/');
}
private function repositoryPath(string $reference): string
{
$withoutHost = str($reference)->after('/')->value();
$withoutTag = preg_replace('/:[^:\/]+$/', '', $withoutHost);
return trim((string) $withoutTag, '/');
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Services\Registries;
use App\Enums\RegistryType;
use App\Models\Organisation;
use App\Models\Registry;
use App\Models\Server;
use Illuminate\Support\Str;
class ManagedRegistryProvisioner
{
public function provision(Organisation $organisation, string $url, ?Server $controlServer = null, ?string $storagePath = null, ?int $retention = null): Registry
{
$registry = $organisation->registries()->firstOrNew([
'type' => RegistryType::MANAGED->value,
]);
$registry->fill([
'name' => 'Managed Registry',
'url' => $this->registryHost($url),
'storage_path' => $storagePath ?: (string) config('keystone.managed_registry.storage_path'),
'retention_successful_artifacts' => $retention ?: (int) config('keystone.managed_registry.retention.successful_artifacts_per_environment', 3),
'control_server_id' => $controlServer?->id,
'credentials' => $this->credentials($registry->credentials ?? []),
]);
if ($registry->health_status === null) {
$registry->health_status = 'pending';
}
$registry->save();
if ($controlServer instanceof Server) {
$controlServer->forceFill([
'is_control_node' => true,
'build_enabled' => true,
])->save();
}
return $registry->refresh();
}
/**
* @param array<string, mixed> $existing
* @return array<string, string>
*/
private function credentials(array $existing): array
{
return [
'build_username' => (string) ($existing['build_username'] ?? 'keystone-build'),
'build_password' => (string) ($existing['build_password'] ?? Str::password(40)),
'runtime_username' => (string) ($existing['runtime_username'] ?? 'keystone-runtime'),
'runtime_password' => (string) ($existing['runtime_password'] ?? Str::password(40)),
];
}
private function registryHost(string $url): string
{
$host = preg_replace('#^https?://#', '', trim($url));
return rtrim($host === null ? trim($url) : $host, '/');
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace App\Services\Registries;
use App\Enums\BuildArtifactStatus;
use App\Enums\RegistryType;
use App\Models\BuildArtifact;
use App\Models\Environment;
use App\Models\Registry;
use Illuminate\Support\Collection;
class ManagedRegistryRetention
{
/**
* @return Collection<int, BuildArtifact>
*/
public function markPrunable(Registry $registry): Collection
{
if ($registry->type !== RegistryType::MANAGED) {
return collect();
}
$keep = max(1, (int) $registry->retention_successful_artifacts);
$updated = collect();
Environment::query()
->whereHas('buildArtifacts', fn ($query) => $query
->where('status', BuildArtifactStatus::AVAILABLE)
->where('registry_ref', 'like', rtrim((string) $registry->url, '/').'/%'))
->with(['services', 'buildArtifacts' => fn ($query) => $query
->where('status', BuildArtifactStatus::AVAILABLE)
->where('registry_ref', 'like', rtrim((string) $registry->url, '/').'/%')
->latest()])
->each(function (Environment $environment) use ($keep, $updated): void {
$activeDigests = $environment->services
->flatMap(fn ($service): array => [
$service->available_image_digest,
$service->current_image_digest,
])
->filter()
->all();
$environment->buildArtifacts
->skip($keep)
->filter(fn (BuildArtifact $artifact): bool => ! in_array($artifact->image_digest, $activeDigests, true))
->each(function (BuildArtifact $artifact) use ($updated): void {
$metadata = $artifact->metadata ?? [];
$artifact->update([
'status' => BuildArtifactStatus::PRUNABLE,
'metadata' => [
...$metadata,
'prunable_at' => now()->toIso8601String(),
'prune_command' => $this->deleteManifestCommand($artifact),
],
]);
$updated->push($artifact->refresh());
});
});
return $updated;
}
public function deleteManifestCommand(BuildArtifact $artifact): string
{
$reference = (string) ($artifact->registry_ref ?? '');
$digest = (string) $artifact->image_digest;
return 'curl --fail --silent --show-error --request DELETE '.escapeshellarg('https://'.$this->manifestPath($reference, $digest));
}
private function manifestPath(string $reference, string $digest): string
{
$hostAndPath = preg_replace('/:[^:\/]+$/', '', $reference);
$path = str($hostAndPath)->after('/')->value();
return $path === ''
? 'v2/'
: str($reference)->before('/')->value().'/v2/'.$path.'/manifests/'.$digest;
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Services\Registries;
use App\Models\Registry;
class RegistryDockerAuthScript
{
/**
* @return array{script: string, secrets: array<string, string>}
*/
public function forBuild(Registry $registry, string $user = 'keystone'): array
{
return $this->forCredential($registry, 'build', $user);
}
/**
* @return array{script: string, secrets: array<string, string>}
*/
public function forRuntime(Registry $registry, string $user = 'keystone'): array
{
return $this->forCredential($registry, 'runtime', $user);
}
/**
* @return array{script: string, secrets: array<string, string>}
*/
private function forCredential(Registry $registry, string $scope, string $user): array
{
$credentials = $registry->credentials ?? [];
$username = (string) ($credentials[$scope.'_username'] ?? $credentials['username'] ?? '');
$password = (string) ($credentials[$scope.'_password'] ?? $credentials['password'] ?? '');
$home = $user === 'root' ? '/root' : '/home/'.$user;
$registryHost = rtrim((string) preg_replace('#^https?://#', '', (string) $registry->url), '/');
return [
'script' => implode("\n", [
'set -euo pipefail',
'install -d -m 700 -o '.escapeshellarg($user).' -g '.escapeshellarg($user).' '.escapeshellarg($home.'/.docker'),
'export DOCKER_CONFIG='.escapeshellarg($home.'/.docker'),
'printf %s '.escapeshellarg('[!registry_password_base64!]').' | base64 -d | docker login '.escapeshellarg($registryHost).' --username '.escapeshellarg($username).' --password-stdin >/dev/null',
'chown '.escapeshellarg($user.':'.$user).' '.escapeshellarg($home.'/.docker/config.json'),
'chmod 600 '.escapeshellarg($home.'/.docker/config.json'),
]),
'secrets' => [
'registry_password_base64' => base64_encode($password),
],
];
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Services\Registries;
use App\Enums\RegistryType;
use App\Models\Organisation;
use App\Models\Registry;
class RegistryResolver
{
public function __construct(
private readonly ManagedRegistryHealth $managedRegistryHealth,
) {}
public function buildRegistryFor(Organisation $organisation): ?Registry
{
$externalRegistry = $organisation->registries()
->where('type', '!=', RegistryType::MANAGED->value)
->first();
if ($externalRegistry instanceof Registry) {
return $externalRegistry;
}
$managedRegistry = $organisation->registries()
->where('type', RegistryType::MANAGED->value)
->first();
if ($managedRegistry instanceof Registry) {
return $this->managedRegistryHealth->readinessBlocker($managedRegistry) === null ? $managedRegistry : null;
}
return null;
}
public function managedRegistryBlockerFor(Organisation $organisation): ?string
{
$managedRegistry = $organisation->registries()
->where('type', RegistryType::MANAGED->value)
->with('controlServer')
->first();
return $managedRegistry instanceof Registry
? $this->managedRegistryHealth->readinessBlocker($managedRegistry)
: null;
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Support;
use App\Models\EnvironmentAttachment;
class CaddyRouteRenderer
{
/**
* @param array<int, string> $upstreams
*/
public function render(EnvironmentAttachment $attachment, array $upstreams): string
{
$config = $attachment->serviceSlice?->config ?? [];
$domain = $config['domain'] ?? $attachment->serviceSlice?->name ?? $attachment->environment->name;
$pathPrefix = $config['path_prefix'] ?? '/';
$siteAddress = ($config['tls_enabled'] ?? true) ? $domain : "http://{$domain}";
$upstreamTargets = $upstreams === [] ? ['web:80'] : $upstreams;
if ($pathPrefix === '/') {
return implode("\n", [
"{$siteAddress} {",
' reverse_proxy '.implode(' ', $upstreamTargets),
'}',
]);
}
$normalizedPath = rtrim($pathPrefix, '/');
return implode("\n", [
"{$siteAddress} {",
" handle_path {$normalizedPath}* {",
' reverse_proxy '.implode(' ', $upstreamTargets),
' }',
'}',
]);
}
}

View File

@@ -27,7 +27,8 @@ class Ip
}
if ($maskBits > 0) {
$maskValue = chr(pow(2, $maskBits) - 1);
$maskValue = (1 << $maskBits) - 1;
$maskValue <<= (8 - $maskBits);
$subnetByte = ord($subnet[$maskBytes]);
$ipByte = ord($ip[$maskBytes]);

BIN
bun.lockb

Binary file not shown.

View File

@@ -20,11 +20,13 @@
},
"require-dev": {
"fakerphp/faker": "^1.23",
"larastan/larastan": "^3.0",
"laravel/boost": "^1.1",
"laravel/pail": "^1.2.2",
"laravel/pint": "^1.18",
"laravel/sail": "^1.41",
"mockery/mockery": "^1.6",
"mrpunyapal/peststan": "^0.2.5",
"nunomaduro/collision": "^8.6",
"pestphp/pest": "^3.7",
"pestphp/pest-plugin-laravel": "^3.1"
@@ -65,6 +67,14 @@
"npm run build:ssr",
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"php artisan inertia:start-ssr\" --names=server,queue,logs,ssr"
],
"phpstan": "vendor/bin/phpstan analyse --memory-limit=1G",
"coverage": [
"XDEBUG_MODE=coverage vendor/bin/pest --coverage --min=95"
],
"quality": [
"composer phpstan",
"composer coverage"
]
},
"extra": {

254
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "69f6de114270a8beb46d9283a2acd24d",
"content-hash": "f73763833c370943f03916f4eaa3ce26",
"packages": [
{
"name": "brick/math",
@@ -6540,6 +6540,47 @@
},
"time": "2020-07-09T08:09:16+00:00"
},
{
"name": "iamcal/sql-parser",
"version": "v0.6",
"source": {
"type": "git",
"url": "https://github.com/iamcal/SQLParser.git",
"reference": "947083e2dca211a6f12fb1beb67a01e387de9b62"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/iamcal/SQLParser/zipball/947083e2dca211a6f12fb1beb67a01e387de9b62",
"reference": "947083e2dca211a6f12fb1beb67a01e387de9b62",
"shasum": ""
},
"require-dev": {
"php-coveralls/php-coveralls": "^1.0",
"phpunit/phpunit": "^5|^6|^7|^8|^9"
},
"type": "library",
"autoload": {
"psr-4": {
"iamcal\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Cal Henderson",
"email": "cal@iamcal.com"
}
],
"description": "MySQL schema parser",
"support": {
"issues": "https://github.com/iamcal/SQLParser/issues",
"source": "https://github.com/iamcal/SQLParser/tree/v0.6"
},
"time": "2025-03-17T16:59:46+00:00"
},
{
"name": "jean85/pretty-package-versions",
"version": "2.1.1",
@@ -6600,6 +6641,99 @@
},
"time": "2025-03-19T14:43:43+00:00"
},
{
"name": "larastan/larastan",
"version": "v3.3.0",
"source": {
"type": "git",
"url": "https://github.com/larastan/larastan.git",
"reference": "b032de3918a8bab9ee7f1bb71609842243fd89d9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/larastan/larastan/zipball/b032de3918a8bab9ee7f1bb71609842243fd89d9",
"reference": "b032de3918a8bab9ee7f1bb71609842243fd89d9",
"shasum": ""
},
"require": {
"ext-json": "*",
"iamcal/sql-parser": "^0.6.0",
"illuminate/console": "^11.42.2 || ^12.0",
"illuminate/container": "^11.42.2 || ^12.0",
"illuminate/contracts": "^11.42.2 || ^12.0",
"illuminate/database": "^11.42.2 || ^12.0",
"illuminate/http": "^11.42.2 || ^12.0",
"illuminate/pipeline": "^11.42.2 || ^12.0",
"illuminate/support": "^11.42.2 || ^12.0",
"php": "^8.2",
"phpstan/phpstan": "^2.1.8"
},
"require-dev": {
"doctrine/coding-standard": "^12.0",
"laravel/framework": "^11.42.2 || ^12.0",
"mockery/mockery": "^1.6",
"nikic/php-parser": "^5.3",
"orchestra/canvas": "^v9.1.3 || ^10.0",
"orchestra/testbench-core": "^9.5.2 || ^10.0",
"phpstan/phpstan-deprecation-rules": "^2.0.0",
"phpunit/phpunit": "^10.5.35 || ^11.3.6"
},
"suggest": {
"orchestra/testbench": "Using Larastan for analysing a package needs Testbench"
},
"type": "phpstan-extension",
"extra": {
"phpstan": {
"includes": [
"extension.neon"
]
},
"branch-alias": {
"dev-master": "3.0-dev"
}
},
"autoload": {
"psr-4": {
"Larastan\\Larastan\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Can Vural",
"email": "can9119@gmail.com"
},
{
"name": "Nuno Maduro",
"email": "enunomaduro@gmail.com"
}
],
"description": "Larastan - Discover bugs in your code without running it. A phpstan/phpstan wrapper for Laravel",
"keywords": [
"PHPStan",
"code analyse",
"code analysis",
"larastan",
"laravel",
"package",
"php",
"static analysis"
],
"support": {
"issues": "https://github.com/larastan/larastan/issues",
"source": "https://github.com/larastan/larastan/tree/v3.3.0"
},
"funding": [
{
"url": "https://github.com/canvural",
"type": "github"
}
],
"time": "2025-04-03T19:11:55+00:00"
},
{
"name": "laravel/boost",
"version": "v1.1.5",
@@ -7080,6 +7214,69 @@
},
"time": "2024-05-16T03:13:13+00:00"
},
{
"name": "mrpunyapal/peststan",
"version": "0.2.10",
"source": {
"type": "git",
"url": "https://github.com/MrPunyapal/PestStan.git",
"reference": "750859a911050915cb6e3efbfde30e900ad717bf"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MrPunyapal/PestStan/zipball/750859a911050915cb6e3efbfde30e900ad717bf",
"reference": "750859a911050915cb6e3efbfde30e900ad717bf",
"shasum": ""
},
"require": {
"php": "^8.2",
"phpstan/phpstan": "^2.0"
},
"require-dev": {
"laravel/pint": "^1.18",
"mrpunyapal/rector-pest": "^0.2.0",
"nunomaduro/pao": "^0.1.4",
"pestphp/pest": "^3.0 || ^4.0 || ^5.0",
"phpstan/extension-installer": "^1.4",
"phpstan/phpstan-strict-rules": "^2.0",
"rector/rector": "^2.0"
},
"type": "phpstan-extension",
"extra": {
"phpstan": {
"includes": [
"extension.neon"
]
}
},
"autoload": {
"psr-4": {
"PestStan\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "PHPStan extension for Pest PHP testing framework",
"keywords": [
"PHPStan",
"pest",
"static-analysis",
"testing"
],
"support": {
"issues": "https://github.com/MrPunyapal/PestStan/issues",
"source": "https://github.com/MrPunyapal/PestStan/tree/0.2.10"
},
"funding": [
{
"url": "https://github.com/mrpunyapal",
"type": "github"
}
],
"time": "2026-05-10T16:55:11+00:00"
},
{
"name": "myclabs/deep-copy",
"version": "1.13.0",
@@ -7976,6 +8173,59 @@
},
"time": "2025-02-19T13:28:12+00:00"
},
{
"name": "phpstan/phpstan",
"version": "2.1.54",
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/8be50c3992107dc837b17da4d140fbbdf9a5c5bd",
"reference": "8be50c3992107dc837b17da4d140fbbdf9a5c5bd",
"shasum": ""
},
"require": {
"php": "^7.4|^8.0"
},
"conflict": {
"phpstan/phpstan-shim": "*"
},
"bin": [
"phpstan",
"phpstan.phar"
],
"type": "library",
"autoload": {
"files": [
"bootstrap.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "PHPStan - PHP Static Analysis Tool",
"keywords": [
"dev",
"static analysis"
],
"support": {
"docs": "https://phpstan.org/user-guide/getting-started",
"forum": "https://github.com/phpstan/phpstan/discussions",
"issues": "https://github.com/phpstan/phpstan/issues",
"security": "https://github.com/phpstan/phpstan/security/policy",
"source": "https://github.com/phpstan/phpstan-src"
},
"funding": [
{
"url": "https://github.com/ondrejmirtes",
"type": "github"
},
{
"url": "https://github.com/phpstan",
"type": "github"
}
],
"time": "2026-04-29T13:31:09+00:00"
},
{
"name": "phpunit/php-code-coverage",
"version": "11.0.9",
@@ -9569,5 +9819,5 @@
"php": "^8.2"
},
"platform-dev": {},
"plugin-api-version": "2.9.0"
"plugin-api-version": "2.6.0"
}

View File

@@ -8,6 +8,15 @@ use App\Enums\ServiceCategory;
use App\Enums\ServiceType;
return [
'managed_registry' => [
'url' => env('KEYSTONE_MANAGED_REGISTRY_URL'),
'namespace' => env('KEYSTONE_MANAGED_REGISTRY_NAMESPACE', 'keystone'),
'storage_path' => env('KEYSTONE_MANAGED_REGISTRY_STORAGE_PATH', '/home/keystone/registry/data'),
'retention' => [
'successful_artifacts_per_environment' => (int) env('KEYSTONE_MANAGED_REGISTRY_RETAIN_SUCCESSFUL_ARTIFACTS', 3),
],
],
'drivers' => [
'postgres' => [
'18' => Postgres18Driver::class,

View File

@@ -0,0 +1,32 @@
<?php
namespace Database\Factories;
use App\Enums\OrganisationRole;
use App\Models\Organisation;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\OrganisationInvitation>
*/
class OrganisationInvitationFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'organisation_id' => Organisation::factory(),
'invited_by_user_id' => User::factory(),
'email' => $this->faker->unique()->safeEmail(),
'role' => OrganisationRole::MEMBER,
'token' => Str::random(40),
'expires_at' => now()->addDays(14),
];
}
}

View File

@@ -28,6 +28,8 @@ class ServerFactory extends Factory
'os' => 'ubuntu',
'plan' => '26',
'user' => 'keystone',
'is_control_node' => false,
'build_enabled' => false,
];
}

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('applications', function (Blueprint $table) {
$table->foreignId('source_provider_id')
->nullable()
->after('organisation_id')
->constrained('source_providers')
->nullOnDelete();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('applications', function (Blueprint $table) {
$table->dropConstrainedForeignId('source_provider_id');
});
}
};

View File

@@ -0,0 +1,38 @@
<?php
use App\Models\Organisation;
use App\Models\User;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('organisation_invitations', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(Organisation::class)->constrained()->cascadeOnDelete();
$table->foreignIdFor(User::class, 'invited_by_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->string('email');
$table->string('role');
$table->string('token')->unique();
$table->timestamp('accepted_at')->nullable();
$table->timestamp('expires_at')->nullable();
$table->timestamps();
$table->unique(['organisation_id', 'email']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('organisation_invitations');
}
};

View File

@@ -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',
]);
});
}
};

View File

@@ -4,11 +4,8 @@ namespace Database\Seeders;
use App\Enums\OrganisationRole;
use App\Enums\ProviderType;
use App\Enums\RepositoryType;
use App\Models\Organisation;
use App\Models\Server;
use App\Models\User;
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
@@ -36,37 +33,11 @@ class DatabaseSeeder extends Seeder
$provider = $organisation->providers()->create([
'name' => 'Hetzner',
'type' => ProviderType::HETZNER,
'token' => env('HETZNER_KEY'),
'token' => env('HETZNER_KEY') ?: 'local-placeholder-token',
]);
if (! app()->isProduction()) {
$network = $organisation->networks()->create([
'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);
app(SimulatedEnvironmentSeeder::class)->seed($organisation, $provider);
}
$application = $organisation->applications()->create([
'name' => 'ClipBin',
'repository_url' => 'git@github.com:hjbdev/clipbin.git',
'repository_type' => RepositoryType::GIT,
]);
$application->environments()->create([
'name' => 'Dev',
'branch' => 'main',
'url' => 'https://dev.clipbin.hjb.dev',
'status' => 'active',
]);
}
}

View File

@@ -0,0 +1,441 @@
<?php
namespace Database\Seeders;
use App\Actions\Applications\CreateLaravelEnvironment;
use App\Actions\Environments\AttachManagedService;
use App\Actions\Services\RegisterServiceEndpoint;
use App\Enums\BuildArtifactStatus;
use App\Enums\DeployPolicy;
use App\Enums\EnvironmentAttachmentRole;
use App\Enums\EnvironmentVariableSource;
use App\Enums\OperationKind;
use App\Enums\OperationStatus;
use App\Enums\RegistryType;
use App\Enums\RepositoryType;
use App\Enums\ServerStatus;
use App\Enums\ServiceCategory;
use App\Enums\ServiceStatus;
use App\Enums\ServiceType;
use App\Enums\SourceProviderType;
use App\Models\Application;
use App\Models\Environment;
use App\Models\Network;
use App\Models\Organisation;
use App\Models\Provider;
use App\Models\Registry;
use App\Models\Server;
use App\Models\Service;
use Illuminate\Database\Seeder;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
class SimulatedEnvironmentSeeder extends Seeder
{
private Registry $managedRegistry;
/**
* Build a fully wired, mostly-running organisation: an ACTIVE server fleet,
* registries, a source provider, and one rich application with production +
* staging environments (web + postgres + valkey + caddy each), plus a
* believable operations history. Reuses the real domain actions so the graph
* is internally consistent, without dispatching any deployment jobs.
*/
public function seed(Organisation $organisation, Provider $provider): void
{
$network = $organisation->networks()->create([
'name' => 'keystone',
'external_id' => 'net-12345',
'provider_id' => $provider->id,
'ip_range' => '10.0.0.0/24',
]);
[$control, $workers] = $this->seedFleet($organisation, $provider, $network);
$this->seedRegistries($organisation, $control);
$sourceProvider = $this->seedSourceProvider($organisation);
$application = $this->seedApplication($organisation, $sourceProvider);
$production = $this->seedEnvironment($application, $control, $workers, 'production', 'main');
$staging = $this->seedEnvironment($application, $control, $workers, 'staging', 'develop');
$this->seedVariety($production, $staging);
$this->seedOperationsHistory($control, $production, $staging);
}
/**
* @return array{0: Server, 1: Collection<int, Server>}
*/
private function seedFleet(Organisation $organisation, Provider $provider, Network $network): array
{
$factory = fn (): \Database\Factories\ServerFactory => Server::factory()
->forNetwork($network->id)
->forOrganisation($organisation->id)
->forProvider($provider->id);
$control = $factory()->create([
'name' => 'keystone-control-1',
'status' => ServerStatus::ACTIVE,
'provider_status' => 'running',
'private_ip' => '10.0.0.10',
'is_control_node' => true,
'build_enabled' => true,
]);
$workers = collect(range(1, 3))->map(fn (int $index): Server => $factory()->create([
'name' => "keystone-worker-{$index}",
'status' => ServerStatus::ACTIVE,
'provider_status' => 'running',
'private_ip' => '10.0.0.'.(20 + $index),
]));
return [$control, $workers];
}
private function seedRegistries(Organisation $organisation, Server $control): void
{
$this->managedRegistry = $organisation->registries()->create([
'name' => 'Keystone Managed',
'type' => RegistryType::MANAGED,
'url' => 'registry.keystone.internal:5000',
'storage_path' => '/home/keystone/registry/data',
'control_server_id' => $control->id,
'readiness_checks' => [
'storage_writable' => true,
'http_reachable' => true,
'auth_configured' => true,
],
]);
$this->managedRegistry->markHealthy('Registry online and serving manifests');
$organisation->registries()->create([
'name' => 'GitHub Container Registry',
'type' => RegistryType::GHCR,
'url' => 'ghcr.io',
'credentials' => [
'username' => 'keystone-bot',
'token' => Str::password(40),
],
'health_status' => 'healthy',
'health_message' => 'Authenticated successfully',
'health_checked_at' => now(),
]);
}
private function seedSourceProvider(Organisation $organisation): \App\Models\SourceProvider
{
return $organisation->sourceProviders()->create([
'name' => 'Gitea',
'type' => SourceProviderType::GITEA,
'url' => 'https://git.keystone.internal',
'config' => [
'api_url' => 'https://git.keystone.internal/api/v1',
'organisation' => 'stratbucket',
],
]);
}
private function seedApplication(Organisation $organisation, \App\Models\SourceProvider $sourceProvider): Application
{
return $organisation->applications()->create([
'name' => 'ClipBin',
'source_provider_id' => $sourceProvider->id,
'repository_url' => 'git@git.keystone.internal:stratbucket/clipbin.git',
'repository_type' => RepositoryType::GIT,
'default_branch' => 'main',
'deploy_key_installed_at' => now()->subWeeks(2),
]);
}
private function seedEnvironment(
Application $application,
Server $control,
Collection $workers,
string $name,
string $branch,
): Environment {
$environment = app(CreateLaravelEnvironment::class)->execute($application, $name, $branch);
$web = $environment->services()->where('type', ServiceType::LARAVEL)->firstOrFail();
$web->forceFill(['server_id' => $workers->first()->id])->save();
$this->createReplica($web, $workers->first(), 80);
$postgres = $this->createDependencyService(
$environment, $workers->get(1), 'postgres',
ServiceCategory::DATABASE, ServiceType::POSTGRES, '18', 5432, DeployPolicy::DEPENDENCY_ONLY,
);
$valkey = $this->createDependencyService(
$environment, $workers->get(2), 'valkey',
ServiceCategory::CACHE, ServiceType::VALKEY, '8', 6379, DeployPolicy::DEPENDENCY_ONLY,
);
$caddy = $this->createDependencyService(
$environment, $control, 'gateway',
ServiceCategory::GATEWAY, ServiceType::CADDY, '2', 80, DeployPolicy::MANUAL_OR_ON_ROUTE_CHANGE,
);
app(AttachManagedService::class)->execute($environment, $postgres, EnvironmentAttachmentRole::DATABASE, isPrimary: true);
app(AttachManagedService::class)->execute($environment, $valkey, EnvironmentAttachmentRole::CACHE);
app(AttachManagedService::class)->execute($environment, $caddy, EnvironmentAttachmentRole::GATEWAY);
foreach ([$web, $postgres, $valkey, $caddy] as $service) {
foreach ($service->replicas()->get() as $replica) {
app(RegisterServiceEndpoint::class)->execute($replica);
}
}
$this->seedUserVariables($environment);
$this->advanceToRunning($environment);
return $environment->refresh();
}
private function createDependencyService(
Environment $environment,
Server $server,
string $name,
ServiceCategory $category,
ServiceType $type,
string $version,
int $port,
DeployPolicy $deployPolicy,
): Service {
$service = $environment->services()->create([
'organisation_id' => $environment->application->organisation_id,
'server_id' => $server->id,
'name' => $name,
'category' => $category,
'type' => $type,
'version' => $version,
'version_track' => $version,
'driver_name' => "{$type->value}.{$version}",
'status' => ServiceStatus::NOT_INSTALLED,
'deploy_policy' => $deployPolicy,
'process_roles' => [],
'desired_replicas' => 1,
'config' => [],
]);
if (method_exists($service->driver(), 'defaultCredentials')) {
$service->credentials = $service->driver()->defaultCredentials();
$service->save();
}
$this->createReplica($service, $server, $port);
return $service;
}
private function createReplica(Service $service, Server $server, int $port): void
{
$service->replicas()->create([
'server_id' => $server->id,
'container_name' => "keystone-service-{$service->id}-1",
'internal_host' => "keystone-service-{$service->id}",
'internal_port' => $port,
'status' => 'pending',
'health_status' => 'unknown',
'config' => [],
]);
}
private function seedUserVariables(Environment $environment): void
{
$values = [
'APP_NAME' => 'ClipBin',
'APP_ENV' => $environment->name,
'LOG_CHANNEL' => 'stderr',
];
foreach ($values as $key => $value) {
$environment->variables()->updateOrCreate(['key' => $key], [
'value' => $value,
'source' => EnvironmentVariableSource::USER,
'overridable' => true,
]);
}
}
private function advanceToRunning(Environment $environment): void
{
$environment->forceFill(['status' => 'active'])->save();
foreach ($environment->services()->with('replicas', 'slices')->get() as $service) {
$digest = $this->digest();
$service->forceFill([
'status' => ServiceStatus::RUNNING,
'current_image_digest' => $digest,
'available_image_digest' => $digest,
])->save();
$service->replicas->each->forceFill([
'status' => 'running',
'health_status' => 'healthy',
'image_digest' => $digest,
])->each->save();
$service->slices->each->forceFill(['status' => 'active'])->each->save();
}
$this->completeSliceProvisionOperations($environment);
}
private function completeSliceProvisionOperations(Environment $environment): void
{
foreach ($environment->services()->with('slices.operations.steps')->get() as $service) {
foreach ($service->slices as $slice) {
foreach ($slice->operations as $operation) {
$operation->forceFill([
'status' => OperationStatus::COMPLETED,
'started_at' => now()->subMinutes(8),
'finished_at' => now()->subMinutes(7),
])->save();
$operation->steps->each->forceFill([
'status' => OperationStatus::COMPLETED,
'logs' => 'Slice provisioned successfully.',
'started_at' => now()->subMinutes(8),
'finished_at' => now()->subMinutes(7),
])->each->save();
}
}
}
}
private function seedVariety(Environment $production, Environment $staging): void
{
$stagingValkey = $staging->services()->where('type', ServiceType::VALKEY)->firstOrFail();
$stagingValkey->replicas()->update(['health_status' => 'unhealthy']);
$production->buildArtifacts()->create([
'commit_sha' => $this->sha(),
'image_tag' => 'clipbin:production-'.Str::random(7),
'image_digest' => $this->digest(),
'registry_ref' => $this->managedRegistry->url.'/keystone/clipbin',
'built_by_service_id' => $production->services()->where('type', ServiceType::LARAVEL)->value('id'),
'status' => BuildArtifactStatus::AVAILABLE,
'metadata' => ['branch' => 'main', 'build_seconds' => 142],
]);
$staging->buildArtifacts()->create([
'commit_sha' => $this->sha(),
'image_tag' => 'clipbin:staging-'.Str::random(7),
'registry_ref' => $this->managedRegistry->url.'/keystone/clipbin',
'built_by_service_id' => $staging->services()->where('type', ServiceType::LARAVEL)->value('id'),
'status' => BuildArtifactStatus::BUILDING,
'metadata' => ['branch' => 'develop'],
]);
}
private function seedOperationsHistory(Server $control, Environment $production, Environment $staging): void
{
$registryOp = $control->operations()->create([
'kind' => OperationKind::REGISTRY_PROVISION,
'status' => OperationStatus::COMPLETED,
'started_at' => now()->subDays(5),
'finished_at' => now()->subDays(5)->addMinutes(4),
]);
$registryOp->steps()->create([
'name' => 'Provision managed registry',
'order' => 1,
'status' => OperationStatus::COMPLETED,
'script' => 'docker run -d --name keystone-registry registry:2',
'logs' => 'Registry container started and reachable.',
'started_at' => now()->subDays(5),
'finished_at' => now()->subDays(5)->addMinutes(4),
]);
$serverOp = $control->operations()->create([
'kind' => OperationKind::SERVER_PROVISION,
'status' => OperationStatus::COMPLETED,
'started_at' => now()->subDays(6),
'finished_at' => now()->subDays(6)->addMinutes(9),
]);
$serverOp->steps()->create([
'name' => 'Install container runtime',
'order' => 1,
'status' => OperationStatus::COMPLETED,
'script' => 'apt-get install -y docker-ce',
'logs' => 'Docker installed, daemon active.',
'started_at' => now()->subDays(6),
'finished_at' => now()->subDays(6)->addMinutes(9),
]);
$deployOp = $production->operations()->create([
'kind' => OperationKind::ENVIRONMENT_DEPLOY,
'status' => OperationStatus::COMPLETED,
'started_at' => now()->subHours(3),
'finished_at' => now()->subHours(3)->addMinutes(6),
]);
$deployOp->steps()->create([
'name' => 'Build application image',
'order' => 1,
'status' => OperationStatus::COMPLETED,
'script' => 'keystone build --env production',
'logs' => 'Image built and pushed to managed registry.',
'started_at' => now()->subHours(3),
'finished_at' => now()->subHours(3)->addMinutes(4),
]);
$web = $production->services()->where('type', ServiceType::LARAVEL)->firstOrFail();
$serviceOp = $web->operations()->create([
'parent_id' => $deployOp->id,
'kind' => OperationKind::SERVICE_DEPLOY,
'status' => OperationStatus::COMPLETED,
'started_at' => now()->subHours(3)->addMinutes(4),
'finished_at' => now()->subHours(3)->addMinutes(6),
]);
$serviceOp->steps()->create([
'name' => 'Roll out web replica',
'order' => 1,
'status' => OperationStatus::COMPLETED,
'script' => 'keystone service:deploy web',
'logs' => 'Replica healthy, traffic switched.',
'started_at' => now()->subHours(3)->addMinutes(4),
'finished_at' => now()->subHours(3)->addMinutes(6),
]);
$inProgress = $staging->operations()->create([
'kind' => OperationKind::ENVIRONMENT_DEPLOY,
'status' => OperationStatus::IN_PROGRESS,
'started_at' => now()->subMinutes(2),
]);
$inProgress->steps()->create([
'name' => 'Build application image',
'order' => 1,
'status' => OperationStatus::IN_PROGRESS,
'script' => 'keystone build --env staging',
'logs' => 'Compiling assets...',
'started_at' => now()->subMinutes(2),
]);
$stagingValkey = $staging->services()->where('type', ServiceType::VALKEY)->firstOrFail();
$failedOp = $stagingValkey->operations()->create([
'kind' => OperationKind::SERVICE_DEPLOY,
'status' => OperationStatus::FAILED,
'started_at' => now()->subHours(1),
'finished_at' => now()->subHours(1)->addMinutes(2),
]);
$failedOp->steps()->create([
'name' => 'Start valkey replica',
'order' => 1,
'status' => OperationStatus::FAILED,
'script' => 'keystone service:deploy valkey',
'logs' => 'Pulling image valkey/valkey:8...',
'error_logs' => 'Health check failed: connection refused on 6379 after 30s.',
'started_at' => now()->subHours(1),
'finished_at' => now()->subHours(1)->addMinutes(2),
]);
}
private function digest(): string
{
return 'sha256:'.hash('sha256', Str::uuid()->toString());
}
private function sha(): string
{
return substr(hash('sha1', Str::uuid()->toString()), 0, 40);
}
}

View File

@@ -30,7 +30,7 @@ class TestEnvironmentSeeder extends Seeder
$organisation->providers()->create([
'name' => 'Hetzner',
'type' => ProviderType::HETZNER,
'token' => env('HETZNER_KEY'),
'token' => env('HETZNER_KEY') ?: 'local-placeholder-token',
]);
}
}

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