From aa680b25fd983bd8f74b0b9073c8cf80f82854b5 Mon Sep 17 00:00:00 2001 From: Harry Bayliss Date: Wed, 13 May 2026 16:11:23 +0100 Subject: [PATCH] Implement Keystone environment deployments --- AGENTS.md | 287 +++++++ CLAUDE.md | 287 +++++++ app/Actions/Applications/CreateInstance.php | 24 - .../Applications/CreateLaravelEnvironment.php | 65 ++ .../Applications/GenerateDeployKey.php | 78 ++ .../Applications/VerifyRepositoryAccess.php | 51 ++ .../Environments/AttachManagedService.php | 152 ++++ .../Environments/BuildApplicationArtifact.php | 192 +++++ .../Environments/BuildMigrationScript.php | 20 + .../CreateLaravelWorkerService.php | 40 + .../Environments/CreateMigrationOperation.php | 44 ++ .../Environments/PlanBuildArtifact.php | 76 ++ .../PlanEnvironmentDeployment.php | 91 +++ .../Environments/ResolveEnvironmentCommit.php | 61 ++ app/Actions/Services/CreateService.php | 53 +- .../CreateStatefulServiceUpdateOperation.php | 100 +++ .../Services/RegisterServiceEndpoint.php | 70 ++ .../Services/ResolveServiceImageDigest.php | 85 ++ app/Console/Commands/CreateServiceCommand.php | 6 +- app/Console/Commands/GenerateJSEnums.php | 4 +- .../EnvironmentDeploymentPlan.php | 24 + app/Data/{Deployments => Operations}/Plan.php | 2 +- .../PlannedStep.php | 22 +- app/Drivers/Caddy/Caddy2Driver.php | 152 ++-- app/Drivers/Concerns/RendersCompose.php | 21 + app/Drivers/Concerns/SupportsSlices.php | 21 + app/Drivers/Driver.php | 42 +- app/Drivers/Laravel/LaravelRuntimeDriver.php | 205 +++++ app/Drivers/Postgres/Postgres17Driver.php | 94 --- app/Drivers/Postgres/Postgres18Driver.php | 203 +++++ app/Drivers/Valkey/Valkey8Driver.php | 154 ++++ app/Enums/BuildArtifactStatus.php | 15 + app/Enums/BuildStrategy.php | 14 + app/Enums/DeployPolicy.php | 15 + app/Enums/EnvironmentAttachmentRole.php | 17 + app/Enums/EnvironmentVariableSource.php | 14 + app/Enums/OperationKind.php | 20 + ...ploymentStatus.php => OperationStatus.php} | 2 +- app/Enums/RegistryType.php | 15 + app/Enums/SchedulerMode.php | 13 + app/Enums/ServiceCategory.php | 6 +- app/Enums/ServiceEndpointScope.php | 14 + app/Enums/ServiceType.php | 8 +- app/Enums/SourceProviderType.php | 14 + .../Controllers/ApplicationController.php | 59 +- .../EnvironmentAttachmentController.php | 57 ++ .../Controllers/EnvironmentController.php | 32 + .../EnvironmentDeploymentController.php | 29 + .../EnvironmentMigrationController.php | 24 + .../EnvironmentVariableController.php | 45 ++ .../EnvironmentWorkerController.php | 24 + app/Http/Controllers/InstanceController.php | 36 - app/Http/Controllers/OnboardingController.php | 61 ++ .../Controllers/OrganisationController.php | 2 + app/Http/Controllers/RegistryController.php | 41 + app/Http/Controllers/ServerController.php | 6 +- app/Http/Controllers/ServiceController.php | 60 +- .../Controllers/ServiceUpdateController.php | 48 ++ .../Controllers/SourceProviderController.php | 38 + app/Http/Middleware/HandleInertiaRequests.php | 14 +- app/Http/Requests/StoreApplicationRequest.php | 31 + .../StoreEnvironmentAttachmentRequest.php | 34 + .../StoreEnvironmentVariableRequest.php | 29 + app/Http/Requests/StoreRegistryRequest.php | 34 + app/Http/Requests/StoreServiceRequest.php | 56 ++ .../Requests/StoreServiceUpdateRequest.php | 26 + .../Requests/StoreSourceProviderRequest.php | 32 + app/Http/Requests/UpdateServiceRequest.php | 31 + app/Jobs/Applications/DeployApplication.php | 59 -- app/Jobs/Environments/DeployEnvironment.php | 497 ++++++++++++ app/Jobs/Services/DeployService.php | 73 +- app/Jobs/Services/RunStep.php | 244 ++++-- app/Models/Application.php | 19 +- app/Models/BuildArtifact.php | 35 + app/Models/Deployment.php | 39 - app/Models/Environment.php | 56 ++ app/Models/EnvironmentAttachment.php | 35 + app/Models/EnvironmentVariable.php | 31 + app/Models/Instance.php | 34 - app/Models/Operation.php | 57 ++ app/Models/OperationStep.php | 85 ++ app/Models/Organisation.php | 15 + app/Models/Registry.php | 27 + app/Models/Server.php | 23 +- app/Models/Service.php | 44 +- app/Models/ServiceEndpoint.php | 29 + app/Models/ServiceReplica.php | 43 ++ app/Models/ServiceSlice.php | 44 ++ app/Models/SourceProvider.php | 25 + app/Models/Step.php | 52 -- app/Providers/AppServiceProvider.php | 20 +- app/Services/Compose/ComposeRenderer.php | 149 ++++ .../Operations/RemoteCommandRunner.php | 10 + .../Operations/SshRemoteCommandRunner.php | 20 + bun.lockb | Bin 174635 -> 178235 bytes composer.lock | 12 +- config/keystone.php | 92 +-- database/factories/ApplicationFactory.php | 29 + database/factories/EnvironmentFactory.php | 31 + database/factories/OperationFactory.php | 29 + database/factories/ServiceFactory.php | 37 + database/factories/ServiceReplicaFactory.php | 33 + database/factories/ServiceSliceFactory.php | 33 + ...03_27_114736_create_applications_table.php | 5 + ...03_27_121049_create_environments_table.php | 32 + ...025_03_27_121050_create_services_table.php | 18 +- ...7_121051_create_service_replicas_table.php | 37 + ..._27_121052_create_service_slices_table.php | 31 + ...3_create_environment_attachments_table.php | 28 + ...054_create_environment_variables_table.php | 29 + ...ies_sources_and_build_artifacts_tables.php | 55 ++ ..._121056_create_service_endpoints_table.php | 30 + ...25_03_27_122034_create_instances_table.php | 28 - ..._03_31_140110_create_operations_table.php} | 6 +- ...1_141005_create_operation_steps_table.php} | 8 +- docs/implementation-review.md | 220 ++++++ docs/implementation-spec.md | 726 ++++++++++++++++++ phpunit.xml | 1 + provision.sh | 13 +- resources/js/enums/BuildArtifactStatus.js | 9 + resources/js/enums/BuildStrategy.js | 8 + resources/js/enums/DeployPolicy.js | 9 + .../js/enums/EnvironmentAttachmentRole.js | 11 + .../js/enums/EnvironmentVariableSource.js | 8 + resources/js/enums/OperationKind.js | 14 + ...DeploymentStatus.js => OperationStatus.js} | 0 resources/js/enums/RegistryType.js | 9 + resources/js/enums/SchedulerMode.js | 7 + resources/js/enums/ServiceCategory.js | 8 +- resources/js/enums/ServiceEndpointScope.js | 8 + resources/js/enums/ServiceType.js | 6 +- resources/js/enums/SourceProviderType.js | 8 + resources/js/pages/applications/Create.vue | 72 ++ resources/js/pages/applications/Index.vue | 19 +- resources/js/pages/applications/Show.vue | 168 +++- .../pages/environment-attachments/Create.vue | 164 ++++ .../js/pages/environment-variables/Create.vue | 82 ++ resources/js/pages/environments/Show.vue | 195 +++++ resources/js/pages/onboarding/Show.vue | 53 ++ resources/js/pages/organisations/Show.vue | 63 +- resources/js/pages/registries/Create.vue | 90 +++ resources/js/pages/servers/Index.vue | 1 - resources/js/pages/servers/Show.vue | 27 +- resources/js/pages/services/Edit.vue | 68 ++ resources/js/pages/services/Show.vue | 84 ++ .../js/pages/services/updates/Create.vue | 102 +++ .../js/pages/source-providers/Create.vue | 74 ++ routes/web.php | 51 +- tests/Feature/ApplicationControllerTest.php | 122 +++ .../Feature/BuildApplicationArtifactTest.php | 104 +++ tests/Feature/BuildArtifactPlanningTest.php | 81 ++ tests/Feature/ComposeRendererTest.php | 174 +++++ tests/Feature/DeployEnvironmentJobTest.php | 454 +++++++++++ tests/Feature/DriverContractTest.php | 21 + .../EnvironmentAttachmentControllerTest.php | 76 ++ .../EnvironmentDeploymentControllerTest.php | 95 +++ .../Feature/EnvironmentDeploymentPlanTest.php | 113 +++ .../EnvironmentMigrationControllerTest.php | 56 ++ .../EnvironmentVariableControllerTest.php | 54 ++ .../EnvironmentWorkerControllerTest.php | 55 ++ tests/Feature/KeystoneDomainModelTest.php | 115 +++ .../LaravelEnvironmentDefaultsTest.php | 71 ++ tests/Feature/ManagedAttachmentTest.php | 99 +++ tests/Feature/ProvisionScriptTest.php | 43 ++ tests/Feature/RegistryControllerTest.php | 49 ++ tests/Feature/RepositoryAccessTest.php | 54 ++ tests/Feature/ServerControllerTest.php | 9 +- tests/Feature/ServiceControllerTest.php | 145 ++-- .../ServiceDeploymentOperationTest.php | 151 ++++ tests/Feature/ServiceEndpointTest.php | 61 ++ tests/Feature/ServiceImageDigestTest.php | 85 ++ tests/Feature/ServiceUpdateControllerTest.php | 93 +++ .../Feature/SourceProviderControllerTest.php | 43 ++ tests/Feature/StatefulServiceUpdateTest.php | 80 ++ tests/TestCase.php | 7 +- 175 files changed, 10258 insertions(+), 740 deletions(-) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md delete mode 100644 app/Actions/Applications/CreateInstance.php create mode 100644 app/Actions/Applications/CreateLaravelEnvironment.php create mode 100644 app/Actions/Applications/GenerateDeployKey.php create mode 100644 app/Actions/Applications/VerifyRepositoryAccess.php create mode 100644 app/Actions/Environments/AttachManagedService.php create mode 100644 app/Actions/Environments/BuildApplicationArtifact.php create mode 100644 app/Actions/Environments/BuildMigrationScript.php create mode 100644 app/Actions/Environments/CreateLaravelWorkerService.php create mode 100644 app/Actions/Environments/CreateMigrationOperation.php create mode 100644 app/Actions/Environments/PlanBuildArtifact.php create mode 100644 app/Actions/Environments/PlanEnvironmentDeployment.php create mode 100644 app/Actions/Environments/ResolveEnvironmentCommit.php create mode 100644 app/Actions/Services/CreateStatefulServiceUpdateOperation.php create mode 100644 app/Actions/Services/RegisterServiceEndpoint.php create mode 100644 app/Actions/Services/ResolveServiceImageDigest.php create mode 100644 app/Data/Environments/EnvironmentDeploymentPlan.php rename app/Data/{Deployments => Operations}/Plan.php (84%) rename app/Data/{Deployments => Operations}/PlannedStep.php (59%) create mode 100644 app/Drivers/Concerns/RendersCompose.php create mode 100644 app/Drivers/Concerns/SupportsSlices.php create mode 100644 app/Drivers/Laravel/LaravelRuntimeDriver.php delete mode 100644 app/Drivers/Postgres/Postgres17Driver.php create mode 100644 app/Drivers/Postgres/Postgres18Driver.php create mode 100644 app/Drivers/Valkey/Valkey8Driver.php create mode 100644 app/Enums/BuildArtifactStatus.php create mode 100644 app/Enums/BuildStrategy.php create mode 100644 app/Enums/DeployPolicy.php create mode 100644 app/Enums/EnvironmentAttachmentRole.php create mode 100644 app/Enums/EnvironmentVariableSource.php create mode 100644 app/Enums/OperationKind.php rename app/Enums/{DeploymentStatus.php => OperationStatus.php} (89%) create mode 100644 app/Enums/RegistryType.php create mode 100644 app/Enums/SchedulerMode.php create mode 100644 app/Enums/ServiceEndpointScope.php create mode 100644 app/Enums/SourceProviderType.php create mode 100644 app/Http/Controllers/EnvironmentAttachmentController.php create mode 100644 app/Http/Controllers/EnvironmentController.php create mode 100644 app/Http/Controllers/EnvironmentDeploymentController.php create mode 100644 app/Http/Controllers/EnvironmentMigrationController.php create mode 100644 app/Http/Controllers/EnvironmentVariableController.php create mode 100644 app/Http/Controllers/EnvironmentWorkerController.php delete mode 100644 app/Http/Controllers/InstanceController.php create mode 100644 app/Http/Controllers/OnboardingController.php create mode 100644 app/Http/Controllers/RegistryController.php create mode 100644 app/Http/Controllers/ServiceUpdateController.php create mode 100644 app/Http/Controllers/SourceProviderController.php create mode 100644 app/Http/Requests/StoreApplicationRequest.php create mode 100644 app/Http/Requests/StoreEnvironmentAttachmentRequest.php create mode 100644 app/Http/Requests/StoreEnvironmentVariableRequest.php create mode 100644 app/Http/Requests/StoreRegistryRequest.php create mode 100644 app/Http/Requests/StoreServiceRequest.php create mode 100644 app/Http/Requests/StoreServiceUpdateRequest.php create mode 100644 app/Http/Requests/StoreSourceProviderRequest.php create mode 100644 app/Http/Requests/UpdateServiceRequest.php delete mode 100644 app/Jobs/Applications/DeployApplication.php create mode 100644 app/Jobs/Environments/DeployEnvironment.php create mode 100644 app/Models/BuildArtifact.php delete mode 100644 app/Models/Deployment.php create mode 100644 app/Models/Environment.php create mode 100644 app/Models/EnvironmentAttachment.php create mode 100644 app/Models/EnvironmentVariable.php delete mode 100644 app/Models/Instance.php create mode 100644 app/Models/Operation.php create mode 100644 app/Models/OperationStep.php create mode 100644 app/Models/Registry.php create mode 100644 app/Models/ServiceEndpoint.php create mode 100644 app/Models/ServiceReplica.php create mode 100644 app/Models/ServiceSlice.php create mode 100644 app/Models/SourceProvider.php delete mode 100644 app/Models/Step.php create mode 100644 app/Services/Compose/ComposeRenderer.php create mode 100644 app/Services/Operations/RemoteCommandRunner.php create mode 100644 app/Services/Operations/SshRemoteCommandRunner.php create mode 100644 database/factories/ApplicationFactory.php create mode 100644 database/factories/EnvironmentFactory.php create mode 100644 database/factories/OperationFactory.php create mode 100644 database/factories/ServiceFactory.php create mode 100644 database/factories/ServiceReplicaFactory.php create mode 100644 database/factories/ServiceSliceFactory.php create mode 100644 database/migrations/2025_03_27_121049_create_environments_table.php create mode 100644 database/migrations/2025_03_27_121051_create_service_replicas_table.php create mode 100644 database/migrations/2025_03_27_121052_create_service_slices_table.php create mode 100644 database/migrations/2025_03_27_121053_create_environment_attachments_table.php create mode 100644 database/migrations/2025_03_27_121054_create_environment_variables_table.php create mode 100644 database/migrations/2025_03_27_121055_create_registries_sources_and_build_artifacts_tables.php create mode 100644 database/migrations/2025_03_27_121056_create_service_endpoints_table.php delete mode 100644 database/migrations/2025_03_27_122034_create_instances_table.php rename database/migrations/{2025_03_31_140110_create_deployments_table.php => 2025_03_31_140110_create_operations_table.php} (68%) rename database/migrations/{2025_03_31_141005_create_steps_table.php => 2025_03_31_141005_create_operation_steps_table.php} (79%) create mode 100644 docs/implementation-review.md create mode 100644 docs/implementation-spec.md create mode 100644 resources/js/enums/BuildArtifactStatus.js create mode 100644 resources/js/enums/BuildStrategy.js create mode 100644 resources/js/enums/DeployPolicy.js create mode 100644 resources/js/enums/EnvironmentAttachmentRole.js create mode 100644 resources/js/enums/EnvironmentVariableSource.js create mode 100644 resources/js/enums/OperationKind.js rename resources/js/enums/{DeploymentStatus.js => OperationStatus.js} (100%) create mode 100644 resources/js/enums/RegistryType.js create mode 100644 resources/js/enums/SchedulerMode.js create mode 100644 resources/js/enums/ServiceEndpointScope.js create mode 100644 resources/js/enums/SourceProviderType.js create mode 100644 resources/js/pages/applications/Create.vue create mode 100644 resources/js/pages/environment-attachments/Create.vue create mode 100644 resources/js/pages/environment-variables/Create.vue create mode 100644 resources/js/pages/environments/Show.vue create mode 100644 resources/js/pages/onboarding/Show.vue create mode 100644 resources/js/pages/registries/Create.vue create mode 100644 resources/js/pages/services/Edit.vue create mode 100644 resources/js/pages/services/Show.vue create mode 100644 resources/js/pages/services/updates/Create.vue create mode 100644 resources/js/pages/source-providers/Create.vue create mode 100644 tests/Feature/ApplicationControllerTest.php create mode 100644 tests/Feature/BuildApplicationArtifactTest.php create mode 100644 tests/Feature/BuildArtifactPlanningTest.php create mode 100644 tests/Feature/ComposeRendererTest.php create mode 100644 tests/Feature/DeployEnvironmentJobTest.php create mode 100644 tests/Feature/DriverContractTest.php create mode 100644 tests/Feature/EnvironmentAttachmentControllerTest.php create mode 100644 tests/Feature/EnvironmentDeploymentControllerTest.php create mode 100644 tests/Feature/EnvironmentDeploymentPlanTest.php create mode 100644 tests/Feature/EnvironmentMigrationControllerTest.php create mode 100644 tests/Feature/EnvironmentVariableControllerTest.php create mode 100644 tests/Feature/EnvironmentWorkerControllerTest.php create mode 100644 tests/Feature/KeystoneDomainModelTest.php create mode 100644 tests/Feature/LaravelEnvironmentDefaultsTest.php create mode 100644 tests/Feature/ManagedAttachmentTest.php create mode 100644 tests/Feature/ProvisionScriptTest.php create mode 100644 tests/Feature/RegistryControllerTest.php create mode 100644 tests/Feature/RepositoryAccessTest.php create mode 100644 tests/Feature/ServiceDeploymentOperationTest.php create mode 100644 tests/Feature/ServiceEndpointTest.php create mode 100644 tests/Feature/ServiceImageDigestTest.php create mode 100644 tests/Feature/ServiceUpdateControllerTest.php create mode 100644 tests/Feature/SourceProviderControllerTest.php create mode 100644 tests/Feature/StatefulServiceUpdateTest.php diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..78b4b8b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,287 @@ + +=== foundation rules === + +# Laravel Boost Guidelines + +The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications. + +## Foundational Context +This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. + +- php - 8.4.16 +- inertiajs/inertia-laravel (INERTIA) - v2 +- laravel/framework (LARAVEL) - v12 +- laravel/prompts (PROMPTS) - v0 +- tightenco/ziggy (ZIGGY) - v2 +- laravel/pint (PINT) - v1 +- laravel/sail (SAIL) - v1 +- pestphp/pest (PEST) - v3 +- phpunit/phpunit (PHPUNIT) - v11 + + +## Conventions +- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming. +- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. +- Check for existing components to reuse before writing a new one. + +## Verification Scripts +- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important. + +## Application Structure & Architecture +- Stick to existing directory structure - don't create new base folders without approval. +- Do not change the application's dependencies without approval. + +## Frontend Bundling +- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them. + +## Replies +- Be concise in your explanations - focus on what's important rather than explaining obvious details. + +## Documentation Files +- You must only create documentation files if explicitly requested by the user. + + +=== boost rules === + +## Laravel Boost +- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them. + +## Artisan +- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters. + +## URLs +- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port. + +## Tinker / Debugging +- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly. +- Use the `database-query` tool when you only need to read from the database. + +## Reading Browser Logs With the `browser-logs` Tool +- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost. +- Only recent browser logs will be useful - ignore old logs. + +## Searching Documentation (Critically Important) +- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages. +- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc. +- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches. +- Search the documentation before making code changes to ensure we are taking the correct approach. +- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`. +- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`. + +### Available Search Syntax +- You can and should pass multiple queries at once. The most relevant results will be returned first. + +1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth' +2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit" +3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order +4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit" +5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms + + +=== php rules === + +## PHP + +- Always use curly braces for control structures, even if it has one line. + +### Constructors +- Use PHP 8 constructor property promotion in `__construct()`. + - public function __construct(public GitHub $github) { } +- 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. + + +protected function isAccessible(User $user, ?string $path = null): bool +{ + ... +} + + +## Comments +- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on. + +## PHPDoc Blocks +- Add useful array shape type definitions for arrays when appropriate. + +## Enums +- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. + + +=== inertia-laravel/core rules === + +## Inertia Core + +- Inertia.js components should be placed in the `resources/js/Pages` directory unless specified differently in the JS bundler (vite.config.js). +- Use `Inertia::render()` for server-side routing instead of traditional Blade views. +- Use `search-docs` for accurate guidance on all things Inertia. + + +// routes/web.php example +Route::get('/users', function () { + return Inertia::render('Users/Index', [ + 'users' => User::all() + ]); +}); + + + +=== inertia-laravel/v2 rules === + +## Inertia v2 + +- Make use of all Inertia features from v1 & v2. Check the documentation before making any changes to ensure we are taking the correct approach. + +### Inertia v2 New Features +- Polling +- Prefetching +- Deferred props +- Infinite scrolling using merging props and `WhenVisible` +- Lazy loading data on scroll + +### Deferred Props & Empty States +- When using deferred props on the frontend, you should add a nice empty state with pulsing / animated skeleton. + +### Inertia Form General Guidance +- Build forms using the `useForm` helper. Use the code examples and `search-docs` tool with a query of `useForm helper` for guidance. + + +=== laravel/core rules === + +## Do Things the Laravel Way + +- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool. +- If you're creating a generic PHP class, use `artisan make:class`. +- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. + +### Database +- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins. +- Use Eloquent models and relationships before suggesting raw database queries +- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them. +- Generate code that prevents N+1 query problems by using eager loading. +- Use Laravel's query builder for very complex database operations. + +### Model Creation +- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`. + +### APIs & Eloquent Resources +- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. + +### Controllers & Validation +- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages. +- Check sibling Form Requests to see if the application uses array or string based validation rules. + +### Queues +- Use queued jobs for time-consuming operations with the `ShouldQueue` interface. + +### Authentication & Authorization +- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.). + +### URL Generation +- When generating links to other pages, prefer named routes and the `route()` function. + +### Configuration +- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`. + +### Testing +- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. +- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. +- When creating tests, make use of `php artisan make:test [options] ` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. + +### Vite Error +- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`. + + +=== laravel/v12 rules === + +## Laravel 12 + +- Use the `search-docs` tool to get version specific documentation. +- Since Laravel 11, Laravel has a new streamlined file structure which this project uses. + +### Laravel 12 Structure +- No middleware files in `app/Http/Middleware/`. +- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files. +- `bootstrap/providers.php` contains application specific service providers. +- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration. +- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration. + +### Database +- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. +- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. + +### Models +- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. + + +=== pint/core rules === + +## Laravel Pint Code Formatter + +- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style. +- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues. + + +=== pest/core rules === + +## Pest + +### Testing +- If you need to verify a feature is working, write or update a Unit / Feature test. + +### Pest Tests +- All tests must be written using Pest. Use `php artisan make:test --pest `. +- 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: + +it('is true', function () { + expect(true)->toBeTrue(); +}); + + +### Running Tests +- Run the minimal number of tests using an appropriate filter before finalizing code edits. +- To run all tests: `php artisan test`. +- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`. +- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file). +- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing. + +### Pest Assertions +- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.: + +it('returns all', function () { + $response = $this->postJson('/api/docs', []); + + $response->assertSuccessful(); +}); + + +### 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. + + +it('has emails', function (string $email) { + expect($email)->not->toBeEmpty(); +})->with([ + 'james' => 'james@laravel.com', + 'taylor' => 'taylor@laravel.com', +]); + + + +=== 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. + \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..78b4b8b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,287 @@ + +=== foundation rules === + +# Laravel Boost Guidelines + +The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications. + +## Foundational Context +This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. + +- php - 8.4.16 +- inertiajs/inertia-laravel (INERTIA) - v2 +- laravel/framework (LARAVEL) - v12 +- laravel/prompts (PROMPTS) - v0 +- tightenco/ziggy (ZIGGY) - v2 +- laravel/pint (PINT) - v1 +- laravel/sail (SAIL) - v1 +- pestphp/pest (PEST) - v3 +- phpunit/phpunit (PHPUNIT) - v11 + + +## Conventions +- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming. +- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. +- Check for existing components to reuse before writing a new one. + +## Verification Scripts +- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important. + +## Application Structure & Architecture +- Stick to existing directory structure - don't create new base folders without approval. +- Do not change the application's dependencies without approval. + +## Frontend Bundling +- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them. + +## Replies +- Be concise in your explanations - focus on what's important rather than explaining obvious details. + +## Documentation Files +- You must only create documentation files if explicitly requested by the user. + + +=== boost rules === + +## Laravel Boost +- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them. + +## Artisan +- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters. + +## URLs +- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port. + +## Tinker / Debugging +- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly. +- Use the `database-query` tool when you only need to read from the database. + +## Reading Browser Logs With the `browser-logs` Tool +- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost. +- Only recent browser logs will be useful - ignore old logs. + +## Searching Documentation (Critically Important) +- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages. +- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc. +- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches. +- Search the documentation before making code changes to ensure we are taking the correct approach. +- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`. +- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`. + +### Available Search Syntax +- You can and should pass multiple queries at once. The most relevant results will be returned first. + +1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth' +2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit" +3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order +4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit" +5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms + + +=== php rules === + +## PHP + +- Always use curly braces for control structures, even if it has one line. + +### Constructors +- Use PHP 8 constructor property promotion in `__construct()`. + - public function __construct(public GitHub $github) { } +- 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. + + +protected function isAccessible(User $user, ?string $path = null): bool +{ + ... +} + + +## Comments +- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on. + +## PHPDoc Blocks +- Add useful array shape type definitions for arrays when appropriate. + +## Enums +- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. + + +=== inertia-laravel/core rules === + +## Inertia Core + +- Inertia.js components should be placed in the `resources/js/Pages` directory unless specified differently in the JS bundler (vite.config.js). +- Use `Inertia::render()` for server-side routing instead of traditional Blade views. +- Use `search-docs` for accurate guidance on all things Inertia. + + +// routes/web.php example +Route::get('/users', function () { + return Inertia::render('Users/Index', [ + 'users' => User::all() + ]); +}); + + + +=== inertia-laravel/v2 rules === + +## Inertia v2 + +- Make use of all Inertia features from v1 & v2. Check the documentation before making any changes to ensure we are taking the correct approach. + +### Inertia v2 New Features +- Polling +- Prefetching +- Deferred props +- Infinite scrolling using merging props and `WhenVisible` +- Lazy loading data on scroll + +### Deferred Props & Empty States +- When using deferred props on the frontend, you should add a nice empty state with pulsing / animated skeleton. + +### Inertia Form General Guidance +- Build forms using the `useForm` helper. Use the code examples and `search-docs` tool with a query of `useForm helper` for guidance. + + +=== laravel/core rules === + +## Do Things the Laravel Way + +- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool. +- If you're creating a generic PHP class, use `artisan make:class`. +- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. + +### Database +- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins. +- Use Eloquent models and relationships before suggesting raw database queries +- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them. +- Generate code that prevents N+1 query problems by using eager loading. +- Use Laravel's query builder for very complex database operations. + +### Model Creation +- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`. + +### APIs & Eloquent Resources +- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. + +### Controllers & Validation +- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages. +- Check sibling Form Requests to see if the application uses array or string based validation rules. + +### Queues +- Use queued jobs for time-consuming operations with the `ShouldQueue` interface. + +### Authentication & Authorization +- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.). + +### URL Generation +- When generating links to other pages, prefer named routes and the `route()` function. + +### Configuration +- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`. + +### Testing +- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. +- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. +- When creating tests, make use of `php artisan make:test [options] ` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. + +### Vite Error +- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`. + + +=== laravel/v12 rules === + +## Laravel 12 + +- Use the `search-docs` tool to get version specific documentation. +- Since Laravel 11, Laravel has a new streamlined file structure which this project uses. + +### Laravel 12 Structure +- No middleware files in `app/Http/Middleware/`. +- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files. +- `bootstrap/providers.php` contains application specific service providers. +- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration. +- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration. + +### Database +- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. +- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. + +### Models +- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. + + +=== pint/core rules === + +## Laravel Pint Code Formatter + +- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style. +- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues. + + +=== pest/core rules === + +## Pest + +### Testing +- If you need to verify a feature is working, write or update a Unit / Feature test. + +### Pest Tests +- All tests must be written using Pest. Use `php artisan make:test --pest `. +- 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: + +it('is true', function () { + expect(true)->toBeTrue(); +}); + + +### Running Tests +- Run the minimal number of tests using an appropriate filter before finalizing code edits. +- To run all tests: `php artisan test`. +- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`. +- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file). +- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing. + +### Pest Assertions +- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.: + +it('returns all', function () { + $response = $this->postJson('/api/docs', []); + + $response->assertSuccessful(); +}); + + +### 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. + + +it('has emails', function (string $email) { + expect($email)->not->toBeEmpty(); +})->with([ + 'james' => 'james@laravel.com', + 'taylor' => 'taylor@laravel.com', +]); + + + +=== 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. + \ No newline at end of file diff --git a/app/Actions/Applications/CreateInstance.php b/app/Actions/Applications/CreateInstance.php deleted file mode 100644 index 59df609..0000000 --- a/app/Actions/Applications/CreateInstance.php +++ /dev/null @@ -1,24 +0,0 @@ -instances()->create([ - 'server_id' => $server->id, - 'branch' => $branch, - 'status' => 'pending', - 'config' => $config, - ]); - } -} \ No newline at end of file diff --git a/app/Actions/Applications/CreateLaravelEnvironment.php b/app/Actions/Applications/CreateLaravelEnvironment.php new file mode 100644 index 0000000..063f664 --- /dev/null +++ b/app/Actions/Applications/CreateLaravelEnvironment.php @@ -0,0 +1,65 @@ +environments()->create([ + 'name' => $name, + 'branch' => $branch ?? $application->default_branch, + 'status' => 'pending', + 'scheduler_enabled' => true, + 'scheduler_mode' => SchedulerMode::SINGLE, + 'build_config' => [ + 'php_version' => $phpVersion, + 'document_root' => 'public', + 'health_path' => '/up', + 'js_build_command' => null, + 'js_package_manager' => 'bun', + ], + ]); + + $web = $environment->services()->create([ + 'organisation_id' => $application->organisation_id, + 'name' => 'web', + 'category' => ServiceCategory::APPLICATION, + 'type' => ServiceType::LARAVEL, + 'version' => "php-{$phpVersion}", + 'version_track' => "php-{$phpVersion}", + 'driver_name' => "laravel.php-{$phpVersion}", + 'status' => ServiceStatus::NOT_INSTALLED, + 'desired_replicas' => 1, + 'deploy_policy' => DeployPolicy::WITH_ENVIRONMENT, + 'process_roles' => ['web', 'scheduler'], + 'config' => [ + 'migration_mode' => 'auto', + 'migration_timing' => 'pre_switch', + 'migration_command' => 'php artisan migrate --force', + 'document_root' => 'public', + 'health_path' => '/up', + 'js_build_command' => null, + 'js_package_manager' => 'bun', + ], + ]); + + $environment->forceFill([ + 'scheduler_target_service_id' => $web->id, + ])->save(); + + return $environment->refresh(); + } +} diff --git a/app/Actions/Applications/GenerateDeployKey.php b/app/Actions/Applications/GenerateDeployKey.php new file mode 100644 index 0000000..20905dc --- /dev/null +++ b/app/Actions/Applications/GenerateDeployKey.php @@ -0,0 +1,78 @@ +generateWithSshKeygen($application); + + $application->forceFill([ + 'deploy_key_public' => $keyPair['public'], + 'deploy_key_private' => $keyPair['private'], + 'deploy_key_fingerprint' => $keyPair['fingerprint'] ?? $this->fingerprint($keyPair['public']), + 'deploy_key_installed_at' => null, + ])->save(); + + return $application->refresh(); + } + + /** + * @return array{public: string, private: string, fingerprint: string} + */ + private function generateWithSshKeygen(Application $application): array + { + $directory = storage_path('app/private/deploy-keys/'.str()->uuid()->toString()); + $privateKeyPath = $directory.'/id_ed25519'; + + File::ensureDirectoryExists($directory, 0700); + + try { + $result = Process::run([ + 'ssh-keygen', + '-t', + 'ed25519', + '-C', + "keystone-application-{$application->id}", + '-N', + '', + '-f', + $privateKeyPath, + ]); + + if ($result->failed()) { + throw new RuntimeException('Unable to generate deploy key: '.$result->errorOutput()); + } + + return [ + 'public' => trim(File::get($privateKeyPath.'.pub')), + 'private' => trim(File::get($privateKeyPath)), + 'fingerprint' => $this->fingerprint(trim(File::get($privateKeyPath.'.pub'))), + ]; + } finally { + rescue(fn () => File::deleteDirectory($directory), report: false); + } + } + + private function fingerprint(string $publicKey): string + { + try { + $parts = explode(' ', trim($publicKey)); + $keyMaterial = $parts[1] ?? $publicKey; + + return 'SHA256:'.rtrim(strtr(base64_encode(hash('sha256', base64_decode($keyMaterial, true) ?: $publicKey, true)), '+/', '-_'), '='); + } catch (Throwable) { + return 'SHA256:'.hash('sha256', $publicKey); + } + } +} diff --git a/app/Actions/Applications/VerifyRepositoryAccess.php b/app/Actions/Applications/VerifyRepositoryAccess.php new file mode 100644 index 0000000..1f79541 --- /dev/null +++ b/app/Actions/Applications/VerifyRepositoryAccess.php @@ -0,0 +1,51 @@ +deploy_key_private) { + throw new RuntimeException('Application does not have a deploy key.'); + } + + $directory = storage_path('app/private/operations/repository-access-'.$application->id.'-'.str()->random(8)); + $keyPath = $directory.'/deploy_key'; + + File::ensureDirectoryExists($directory, 0700); + File::put($keyPath, $application->deploy_key_private); + File::chmod($keyPath, 0600); + + try { + $result = Process::path($directory) + ->env([ + 'GIT_SSH_COMMAND' => 'ssh -i '.$keyPath.' -o IdentitiesOnly=yes -o StrictHostKeyChecking=no', + ]) + ->run([ + 'git', + 'ls-remote', + '--heads', + $application->repository_url, + $application->default_branch, + ]); + + if ($result->successful()) { + $application->forceFill([ + 'deploy_key_installed_at' => now(), + ])->save(); + + return true; + } + + return false; + } finally { + rescue(fn () => File::deleteDirectory($directory), report: false); + } + } +} diff --git a/app/Actions/Environments/AttachManagedService.php b/app/Actions/Environments/AttachManagedService.php new file mode 100644 index 0000000..3156ec0 --- /dev/null +++ b/app/Actions/Environments/AttachManagedService.php @@ -0,0 +1,152 @@ +createDefaultSlice($environment, $service, $role, $name); + + $attachment = $environment->attachments()->create([ + 'service_id' => $service->id, + 'service_slice_id' => $slice?->id, + 'role' => $role, + 'env_prefix' => $envPrefix, + 'is_primary' => $isPrimary, + ]); + + $this->syncManagedVariables($environment, $service, $slice, $envPrefix, $role); + $this->createSliceProvisionOperation($service, $slice); + + return $attachment; + } + + private function createDefaultSlice( + Environment $environment, + Service $service, + EnvironmentAttachmentRole $role, + ?string $name, + ): ?ServiceSlice { + return match ($service->type) { + ServiceType::POSTGRES => $service->slices()->firstOrCreate([ + 'environment_id' => $environment->id, + 'type' => 'database_user', + 'name' => $name ?? $this->sliceName($environment), + ], [ + 'status' => 'pending', + 'config' => [], + 'credentials' => [ + 'database' => $name ?? $this->sliceName($environment), + 'username' => $name ?? $this->sliceName($environment), + 'password' => Str::password(32), + ], + ]), + ServiceType::VALKEY => $service->slices()->firstOrCreate([ + 'environment_id' => $environment->id, + 'type' => 'logical_database', + 'name' => $name ?? $this->sliceName($environment), + ], [ + 'status' => 'pending', + 'config' => [ + 'database' => $this->nextValkeyDatabase($service), + ], + ]), + ServiceType::CADDY => $service->slices()->firstOrCreate([ + 'environment_id' => $environment->id, + 'type' => 'route', + 'name' => $name ?? $environment->name, + ], [ + 'status' => 'pending', + 'config' => [], + ]), + default => $role === EnvironmentAttachmentRole::CUSTOM ? null : throw new InvalidArgumentException("Service [{$service->type->value}] does not support managed attachments."), + }; + } + + private function syncManagedVariables(Environment $environment, Service $service, ?ServiceSlice $slice, ?string $envPrefix, EnvironmentAttachmentRole $role): void + { + if (! $slice) { + return; + } + + $driver = $service->driver(); + + if (! $driver instanceof SupportsSlices) { + return; + } + + foreach ($driver->environmentExportsForSlice($slice, $role) as $key => $value) { + $environment->variables()->updateOrCreate([ + 'key' => $this->variableKey($key, $envPrefix), + ], [ + 'value' => $value, + 'source' => EnvironmentVariableSource::MANAGED_ATTACHMENT, + 'service_slice_id' => $slice->id, + 'overridable' => false, + ]); + } + } + + private function createSliceProvisionOperation(Service $service, ?ServiceSlice $slice): void + { + if (! $slice || ! $slice->wasRecentlyCreated) { + return; + } + + $driver = $service->driver(); + + if (! $driver instanceof SupportsSlices) { + return; + } + + $operation = $slice->operations()->create([ + 'kind' => OperationKind::SLICE_PROVISION, + 'status' => OperationStatus::PENDING, + ]); + + $operation->steps()->create([ + 'name' => 'Provision '.$service->type->value.' slice', + 'order' => 1, + 'status' => OperationStatus::PENDING, + 'script' => $driver->provisionSliceScript($slice), + ]); + } + + private function nextValkeyDatabase(Service $service): int + { + return ((int) $service->slices() + ->where('type', 'logical_database') + ->get() + ->max(fn (ServiceSlice $slice): int => (int) ($slice->config['database'] ?? 0))) + 1; + } + + private function sliceName(Environment $environment): string + { + return str($environment->application->name.' '.$environment->name)->slug('_')->value(); + } + + private function variableKey(string $key, ?string $envPrefix): string + { + return $envPrefix ? $envPrefix.'_'.$key : $key; + } +} diff --git a/app/Actions/Environments/BuildApplicationArtifact.php b/app/Actions/Environments/BuildApplicationArtifact.php new file mode 100644 index 0000000..70a8e17 --- /dev/null +++ b/app/Actions/Environments/BuildApplicationArtifact.php @@ -0,0 +1,192 @@ +loadMissing('environment.application', 'builtByService.server', 'builtByService.replicas.server'); + + $application = $artifact->environment->application; + $strategy = BuildStrategy::tryFrom($artifact->metadata['build_strategy'] ?? BuildStrategy::TARGET_SERVER->value) + ?? BuildStrategy::TARGET_SERVER; + + $server = $this->buildServer($artifact); + + $artifact->update([ + 'status' => BuildArtifactStatus::BUILDING, + 'built_by_operation_id' => $operation?->id, + ]); + + try { + $output = $this->remoteCommandRunner->run( + $server, + $strategy === BuildStrategy::EXTERNAL_REGISTRY + ? $this->manifestDigestScript($artifact) + : $this->buildScript($artifact, $strategy) + ); + + $artifact->update([ + 'image_digest' => $this->digestFromOutput($output), + 'status' => BuildArtifactStatus::AVAILABLE, + ]); + + return $artifact->refresh(); + } catch (\Throwable $exception) { + $artifact->update([ + 'status' => BuildArtifactStatus::FAILED, + 'metadata' => [ + ...($artifact->metadata ?? []), + 'error' => $exception->getMessage(), + ], + ]); + + throw $exception; + } + } + + private function buildServer(BuildArtifact $artifact): Server + { + if ($artifact->builtByService instanceof Service) { + $server = $artifact->builtByService->replicas->first()?->server ?: $artifact->builtByService->server; + + if ($server instanceof Server) { + return $server; + } + } + + if (($artifact->metadata['build_strategy'] ?? null) === BuildStrategy::DEDICATED_BUILDER->value) { + throw new RuntimeException('Dedicated builder strategy requires a builder service.'); + } + + $services = $artifact->environment->services() + ->with(['server', 'replicas.server']) + ->get(); + + $server = $services + ->flatMap(fn (Service $service) => $service->replicas->pluck('server')->filter()) + ->first() ?: $services->pluck('server')->filter()->first(); + + if (! $server instanceof Server) { + $serverId = $services + ->flatMap(fn (Service $service) => collect($service->config['server_ids'] ?? [])) + ->filter() + ->first(); + + $server = $serverId ? Server::find($serverId) : null; + } + + if (! $server instanceof Server) { + throw new RuntimeException('A target server is required to build this artifact over SSH.'); + } + + return $server; + } + + private function buildScript(BuildArtifact $artifact, BuildStrategy $strategy): string + { + $application = $artifact->environment->application; + + if (! $application->deploy_key_private) { + throw new RuntimeException('Application does not have a deploy key.'); + } + + $operationDirectory = '/home/keystone/operations/build-'.$artifact->id.'-'.str()->random(8); + $imageReference = $artifact->registry_ref ?: $artifact->image_tag; + $pushCommand = $strategy === BuildStrategy::DEDICATED_BUILDER && $artifact->registry_ref + ? "\ndocker push ".escapeshellarg($imageReference) + : ''; + + return implode("\n", [ + 'set -euo pipefail', + 'operation_dir='.escapeshellarg($operationDirectory), + 'source_dir="$operation_dir/source"', + 'rm -rf "$operation_dir"', + 'mkdir -p "$operation_dir"', + 'chmod 700 "$operation_dir"', + 'cleanup() { rm -rf "$operation_dir"; }', + 'trap cleanup EXIT', + $this->writeFileCommand('$operation_dir/deploy_key', $application->deploy_key_private), + 'chmod 600 "$operation_dir/deploy_key"', + 'export GIT_SSH_COMMAND="ssh -i $operation_dir/deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=no"', + 'git clone --depth 1 --branch '.escapeshellarg($artifact->environment->branch).' '.escapeshellarg($application->repository_url).' "$source_dir"', + $this->writeFileCommand('$source_dir/Dockerfile.keystone', $this->dockerfile($artifact)), + 'cd "$source_dir"', + 'docker build --file Dockerfile.keystone --tag '.escapeshellarg($imageReference).' .'.$pushCommand, + 'digest=$(docker image inspect --format '.escapeshellarg('{{if .RepoDigests}}{{index .RepoDigests 0}}{{else}}{{.Id}}{{end}}').' '.escapeshellarg($imageReference).')', + 'printf "image_digest=%s\n" "$digest"', + ]); + } + + private function manifestDigestScript(BuildArtifact $artifact): string + { + $imageReference = $artifact->registry_ref ?: $artifact->image_tag; + + return implode("\n", [ + 'set -euo pipefail', + 'manifest=$(docker manifest inspect '.escapeshellarg($imageReference).')', + 'digest=$(printf "%s" "$manifest" | sed -n '.escapeshellarg('s/.*"digest": "\(sha256:[^"]*\)".*/\1/p').' | head -n 1)', + 'test -n "$digest"', + 'printf "image_digest=%s\n" "$digest"', + ]); + } + + private function dockerfile(BuildArtifact $artifact): string + { + $service = $artifact->environment->services() + ->where('type', \App\Enums\ServiceType::LARAVEL) + ->first(); + + if ($service && method_exists($service->driver(), 'dockerfileTemplate')) { + return $service->driver()->dockerfileTemplate(); + } + + return <<<'DOCKERFILE' +FROM serversideup/php:8.4-frankenphp +WORKDIR /var/www/html +COPY --chown=www-data:www-data . . +RUN composer install --no-dev --prefer-dist --no-interaction --optimize-autoloader +ENV SERVER_DOCUMENT_ROOT=/var/www/html/public +DOCKERFILE; + } + + private function writeFileCommand(string $path, string $contents): string + { + return implode("\n", [ + 'cat > '.$path." <<'KEYSTONE_FILE'", + rtrim($contents), + 'KEYSTONE_FILE', + ]); + } + + private function digestFromOutput(string $output): string + { + if (preg_match('/image_digest=(?\S+)/', $output, $matches)) { + return $this->digestFromOutput($matches['digest']); + } + + if (str_contains($output, '@')) { + return str($output)->after('@')->trim()->value(); + } + + if (str_starts_with($output, 'sha256:')) { + return $output; + } + + throw new RuntimeException('Unable to resolve built image digest.'); + } +} diff --git a/app/Actions/Environments/BuildMigrationScript.php b/app/Actions/Environments/BuildMigrationScript.php new file mode 100644 index 0000000..94b1a1a --- /dev/null +++ b/app/Actions/Environments/BuildMigrationScript.php @@ -0,0 +1,20 @@ +config['migration_mode'] ?? 'auto', ['disabled', 'manual'], true)) { + return 'true'; + } + + $command = $service->config['migration_command'] ?? 'php artisan migrate --force'; + $serviceKey = str($service->name)->slug('_')->value() ?: 'service'; + + return "docker compose -f /home/keystone/services/{$service->id}/compose.yml run --rm {$serviceKey} {$command}"; + } +} diff --git a/app/Actions/Environments/CreateLaravelWorkerService.php b/app/Actions/Environments/CreateLaravelWorkerService.php new file mode 100644 index 0000000..c788ce5 --- /dev/null +++ b/app/Actions/Environments/CreateLaravelWorkerService.php @@ -0,0 +1,40 @@ +loadMissing('application'); + + $phpVersion = $environment->build_config['php_version'] ?? '8.4'; + + return $environment->services()->firstOrCreate([ + 'name' => 'worker', + 'type' => ServiceType::LARAVEL, + ], [ + 'organisation_id' => $environment->application->organisation_id, + 'category' => ServiceCategory::APPLICATION, + 'version' => "php-{$phpVersion}", + 'version_track' => "php-{$phpVersion}", + 'driver_name' => "laravel.php-{$phpVersion}", + 'status' => ServiceStatus::NOT_INSTALLED, + 'desired_replicas' => 1, + 'deploy_policy' => DeployPolicy::WITH_ENVIRONMENT, + 'process_roles' => ['worker'], + 'config' => [ + 'command' => 'php artisan queue:work --sleep=3 --tries=3', + 'health_path' => null, + 'migration_mode' => 'disabled', + ], + ]); + } +} diff --git a/app/Actions/Environments/CreateMigrationOperation.php b/app/Actions/Environments/CreateMigrationOperation.php new file mode 100644 index 0000000..d4c8134 --- /dev/null +++ b/app/Actions/Environments/CreateMigrationOperation.php @@ -0,0 +1,44 @@ +services() + ->where('type', ServiceType::LARAVEL) + ->get() + ->first(fn (Service $service): bool => in_array('web', $service->process_roles ?? [], true)); + + if (! $service || $service->type !== ServiceType::LARAVEL) { + throw new InvalidArgumentException('Laravel migrations must run against a Laravel runtime service.'); + } + + $operation = $service->operations()->create([ + 'kind' => OperationKind::CONFIG_CHANGE, + 'status' => OperationStatus::PENDING, + ]); + + $operation->steps()->create([ + 'name' => 'Run Laravel migrations', + 'order' => 1, + 'status' => OperationStatus::PENDING, + 'script' => $this->buildMigrationScript->execute($service, respectAutomaticMode: false), + ]); + + return $operation; + } +} diff --git a/app/Actions/Environments/PlanBuildArtifact.php b/app/Actions/Environments/PlanBuildArtifact.php new file mode 100644 index 0000000..377fdfe --- /dev/null +++ b/app/Actions/Environments/PlanBuildArtifact.php @@ -0,0 +1,76 @@ +loadMissing(['application.organisation.registries', 'services.replicas']); + + $existingArtifact = $environment->buildArtifacts() + ->where('commit_sha', $commitSha) + ->whereIn('status', [BuildArtifactStatus::PENDING, BuildArtifactStatus::BUILDING, BuildArtifactStatus::AVAILABLE]) + ->latest() + ->first(); + + if ($existingArtifact) { + return $existingArtifact; + } + + $targetServerCount = $this->targetServerCount($environment); + $registry = $environment->application->organisation->registries()->first(); + + if ($targetServerCount > 1 && ! $registry) { + throw new RuntimeException('A registry is required before building artifacts for multi-server deployments.'); + } + + $builder = $environment->application->organisation->services() + ->where('category', ServiceCategory::BUILDER) + ->first(); + + $strategy = match (true) { + $registry !== null => BuildStrategy::EXTERNAL_REGISTRY, + $builder !== null => BuildStrategy::DEDICATED_BUILDER, + default => BuildStrategy::TARGET_SERVER, + }; + + $imageTag = str($environment->application->name) + ->slug() + ->append(':'.substr($commitSha, 0, 12)) + ->value(); + + return $environment->buildArtifacts()->create([ + 'commit_sha' => $commitSha, + 'image_tag' => $imageTag, + 'registry_ref' => $registry ? rtrim((string) $registry->url, '/').'/'.$imageTag : null, + 'built_by_service_id' => $builder?->id, + 'status' => BuildArtifactStatus::PENDING, + 'metadata' => [ + 'build_strategy' => $strategy->value, + 'target_server_count' => $targetServerCount, + ], + ]); + } + + private function targetServerCount(Environment $environment): int + { + $replicaServerCount = $environment->services + ->flatMap(fn ($service) => $service->replicas->pluck('server_id')->filter()) + ->unique() + ->count(); + + if ($replicaServerCount > 0) { + return $replicaServerCount; + } + + return $environment->services->sum('desired_replicas') > 1 ? 2 : 1; + } +} diff --git a/app/Actions/Environments/PlanEnvironmentDeployment.php b/app/Actions/Environments/PlanEnvironmentDeployment.php new file mode 100644 index 0000000..302af9b --- /dev/null +++ b/app/Actions/Environments/PlanEnvironmentDeployment.php @@ -0,0 +1,91 @@ +loadMissing([ + 'services', + 'attachments.service', + 'attachments.serviceSlice', + ]); + + $deployableServices = $environment->services + ->where('deploy_policy', DeployPolicy::WITH_ENVIRONMENT) + ->values(); + + $dependencies = $environment->attachments + ->map(fn ($attachment) => $attachment->service) + ->filter() + ->unique('id') + ->values(); + + $targetServerCount = $deployableServices + ->flatMap(fn ($service) => $service->replicas->pluck('server_id')->filter()) + ->unique() + ->count(); + + if ($targetServerCount === 0) { + $targetServerCount = $deployableServices->sum('desired_replicas') > 1 ? 2 : 1; + } + + return new EnvironmentDeploymentPlan( + services: $deployableServices->all(), + dependencies: $dependencies->all(), + requiresRegistry: $targetServerCount > 1 && $environment->application->organisation->registries()->doesntExist(), + warnings: $this->warnings($environment), + blockers: $this->blockers($environment), + ); + } + + /** + * @return array + */ + private function warnings(Environment $environment): array + { + $warnings = []; + + if ($environment->variables() + ->where('key', 'QUEUE_CONNECTION') + ->get() + ->contains(fn ($variable) => $variable->value === 'sync')) { + $warnings[] = 'QUEUE_CONNECTION=sync is not recommended for deployed Laravel environments.'; + } + + if ($environment->attachments->contains('role', EnvironmentAttachmentRole::QUEUE)) { + $hasWorker = $environment->services->contains(fn ($service) => in_array('worker', $service->process_roles ?? [], true)); + + if (! $hasWorker) { + $warnings[] = 'Queue attachment exists without a dedicated worker service.'; + } + } + + return $warnings; + } + + /** + * @return array + */ + private function blockers(Environment $environment): array + { + if (! $environment->scheduler_enabled || $environment->scheduler_mode !== SchedulerMode::SINGLE) { + return []; + } + + $target = $environment->services->firstWhere('id', $environment->scheduler_target_service_id); + + if ($target && $target->desired_replicas > 1 && in_array('scheduler', $target->process_roles ?? [], true)) { + return ['Scheduler mode single requires the scheduler target service to run exactly one replica.']; + } + + return []; + } +} diff --git a/app/Actions/Environments/ResolveEnvironmentCommit.php b/app/Actions/Environments/ResolveEnvironmentCommit.php new file mode 100644 index 0000000..37e83b3 --- /dev/null +++ b/app/Actions/Environments/ResolveEnvironmentCommit.php @@ -0,0 +1,61 @@ +loadMissing('application'); + + $application = $environment->application; + + if (! $application->deploy_key_private) { + throw new RuntimeException('Application does not have a deploy key.'); + } + + $directory = storage_path('app/private/operations/resolve-'.$environment->id.'-'.str()->random(8)); + $keyPath = $directory.'/deploy_key'; + + File::ensureDirectoryExists($directory, 0700); + File::put($keyPath, $application->deploy_key_private); + File::chmod($keyPath, 0600); + + try { + $result = Process::path($directory) + ->env([ + 'GIT_SSH_COMMAND' => 'ssh -i '.$keyPath.' -o IdentitiesOnly=yes -o StrictHostKeyChecking=no', + ]) + ->run([ + 'git', + 'ls-remote', + $application->repository_url, + 'refs/heads/'.$environment->branch, + ]); + + if ($result->failed()) { + throw new RuntimeException(trim($result->errorOutput()) ?: 'Unable to resolve environment commit.'); + } + + return $this->commitFromOutput($result->output(), $environment->branch); + } finally { + rescue(fn () => File::deleteDirectory($directory), report: false); + } + } + + private function commitFromOutput(string $output, string $branch): string + { + $commit = str($output)->before("\t")->trim()->value(); + + if (preg_match('/^[a-f0-9]{40}$/i', $commit) !== 1) { + throw new RuntimeException("Unable to resolve commit for branch {$branch}."); + } + + return strtolower($commit); + } +} diff --git a/app/Actions/Services/CreateService.php b/app/Actions/Services/CreateService.php index 9307443..ca84714 100644 --- a/app/Actions/Services/CreateService.php +++ b/app/Actions/Services/CreateService.php @@ -2,11 +2,14 @@ namespace App\Actions\Services; +use App\Enums\DeployPolicy; use App\Enums\ServiceCategory; use App\Enums\ServiceStatus; use App\Enums\ServiceType; use App\Jobs\Services\DeployService; use App\Models\Server; +use App\Models\Service; +use RuntimeException; class CreateService { @@ -16,15 +19,25 @@ class CreateService ServiceCategory $category, ServiceType $type, string $version, - ) { + ): Service { + if ($category === ServiceCategory::GATEWAY && $server->services()->where('category', ServiceCategory::GATEWAY)->exists()) { + throw new RuntimeException('This server already has a gateway service.'); + } + $driverName = "{$type->value}.{$version}"; $service = $server->services()->create([ + 'organisation_id' => $server->organisation_id, 'name' => $name, 'category' => $category, - 'type' => $type, // postgres - 'version' => $version, // 17 - 'driver_name' => $driverName, // postgres.17 + 'type' => $type, + 'version' => $version, + 'version_track' => $version, + 'driver_name' => $driverName, 'status' => ServiceStatus::NOT_INSTALLED, + 'deploy_policy' => $this->defaultDeployPolicy($category, $type), + 'process_roles' => [], + 'desired_replicas' => 1, + 'config' => [], ]); if (method_exists($service->driver(), 'defaultCredentials')) { @@ -32,8 +45,40 @@ class CreateService $service->save(); } + $service->replicas()->create([ + 'server_id' => $server->id, + 'container_name' => "keystone-service-{$service->id}-1", + 'internal_host' => "keystone-service-{$service->id}", + 'internal_port' => $this->defaultInternalPort($type), + 'status' => 'pending', + 'health_status' => 'unknown', + 'config' => [], + ]); + dispatch(new DeployService($service)); return $service; } + + private function defaultDeployPolicy(ServiceCategory $category, ServiceType $type): DeployPolicy + { + return match (true) { + $category === ServiceCategory::APPLICATION => DeployPolicy::WITH_ENVIRONMENT, + $category === ServiceCategory::DATABASE, + $category === ServiceCategory::CACHE, + $category === ServiceCategory::STORAGE => DeployPolicy::DEPENDENCY_ONLY, + $category === ServiceCategory::GATEWAY => DeployPolicy::MANUAL_OR_ON_ROUTE_CHANGE, + default => DeployPolicy::MANUAL, + }; + } + + private function defaultInternalPort(ServiceType $type): int + { + return match ($type) { + ServiceType::POSTGRES => 5432, + ServiceType::VALKEY => 6379, + ServiceType::CADDY, + ServiceType::LARAVEL => 80, + }; + } } diff --git a/app/Actions/Services/CreateStatefulServiceUpdateOperation.php b/app/Actions/Services/CreateStatefulServiceUpdateOperation.php new file mode 100644 index 0000000..f14f4ec --- /dev/null +++ b/app/Actions/Services/CreateStatefulServiceUpdateOperation.php @@ -0,0 +1,100 @@ +type, [ServiceType::POSTGRES, ServiceType::VALKEY], true)) { + throw new InvalidArgumentException('Only Postgres and Valkey have v1 stateful update operations.'); + } + + if ($backupRequested && ! ($service->config['backup_enabled'] ?? false)) { + throw new InvalidArgumentException('Backups are not configured for this service.'); + } + + $service->forceFill([ + 'available_image_digest' => $imageDigest, + 'update_status' => 'update_pending', + ])->save(); + + $operation = $service->operations()->create([ + 'kind' => OperationKind::SERVICE_DEPLOY, + 'status' => OperationStatus::PENDING, + ]); + + $composePath = "/home/keystone/services/{$service->id}/compose.yml"; + $serviceKey = str($service->name)->slug('_')->value() ?: 'service'; + $volumeName = $this->namedVolume($service); + + $steps = [ + 'Acknowledge downtime and data risk' => 'echo '.escapeshellarg('Stateful update requires downtime and preserves named volumes.'), + ]; + + if ($backupRequested) { + $steps['Run pre-update backup'] = $service->config['backup_command'] ?? 'echo '.escapeshellarg('Run configured backup before stateful update.'); + } + + $steps += [ + 'Render compose with updated image digest' => $this->composeUploadScript($service), + 'Stop existing container' => "docker compose -f {$composePath} stop {$serviceKey}", + 'Preserve named volume' => $volumeName ? "docker volume inspect {$volumeName} >/dev/null" : 'true', + 'Start service with updated image digest' => "docker compose -f {$composePath} up -d {$serviceKey}", + 'Health check updated service' => implode("\n", [ + "container_id=$(docker compose -f {$composePath} ps -q {$serviceKey})", + 'test -n "$container_id"', + 'for attempt in $(seq 1 30); do', + ' health_status=$(docker inspect --format "{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}" "$container_id")', + ' test "$health_status" = "healthy" -o "$health_status" = "running" && exit 0', + ' sleep 2', + 'done', + 'printf "health_status=%s\n" "$health_status"', + 'exit 1', + ]), + ]; + + $order = 1; + foreach ($steps as $name => $script) { + $operation->steps()->create([ + 'name' => $name, + 'order' => $order++, + 'status' => OperationStatus::PENDING, + 'script' => $script, + ]); + } + + return $operation; + } + + private function composeUploadScript(Service $service): string + { + $servicePath = "/home/keystone/services/{$service->id}"; + $renderer = app(ComposeRenderer::class); + $compose = $renderer->render($service); + $env = $renderer->renderEnvironmentFile($service); + + return implode("\n", [ + "mkdir -p {$servicePath}", + 'printf %s '.escapeshellarg(base64_encode($compose))." | base64 -d > {$servicePath}/compose.yml", + 'printf %s '.escapeshellarg(base64_encode($env))." | base64 -d > {$servicePath}/.env", + ]); + } + + private function namedVolume(Service $service): ?string + { + return match ($service->type) { + ServiceType::POSTGRES => "keystone_service_{$service->id}_postgres_data", + ServiceType::VALKEY => ($service->config['persistence'] ?? false) ? "keystone_service_{$service->id}_valkey_data" : null, + default => null, + }; + } +} diff --git a/app/Actions/Services/RegisterServiceEndpoint.php b/app/Actions/Services/RegisterServiceEndpoint.php new file mode 100644 index 0000000..723a0e8 --- /dev/null +++ b/app/Actions/Services/RegisterServiceEndpoint.php @@ -0,0 +1,70 @@ +scope($replica, $consumerReplica, $allowPublicFallback); + + return $replica->service->endpoints()->updateOrCreate([ + 'service_replica_id' => $replica->id, + 'scope' => $scope, + 'port' => $replica->internal_port, + ], [ + 'hostname' => $this->hostname($replica, $scope), + 'ip_address' => $this->ipAddress($replica, $scope), + 'priority' => $this->priority($scope), + 'health_status' => $replica->health_status, + ]); + } + + private function scope(ServiceReplica $replica, ?ServiceReplica $consumerReplica, bool $allowPublicFallback): ServiceEndpointScope + { + if ($consumerReplica && $consumerReplica->server_id === $replica->server_id) { + return ServiceEndpointScope::DOCKER_NETWORK; + } + + if ($replica->server?->private_ip) { + return ServiceEndpointScope::PRIVATE_NETWORK; + } + + if ($allowPublicFallback && $replica->server?->ipv4) { + return ServiceEndpointScope::PUBLIC; + } + + return ServiceEndpointScope::DOCKER_NETWORK; + } + + private function hostname(ServiceReplica $replica, ServiceEndpointScope $scope): string + { + return match ($scope) { + ServiceEndpointScope::DOCKER_NETWORK => $replica->internal_host, + ServiceEndpointScope::PRIVATE_NETWORK => $replica->server->private_ip, + ServiceEndpointScope::PUBLIC => $replica->server->ipv4, + }; + } + + private function ipAddress(ServiceReplica $replica, ServiceEndpointScope $scope): ?string + { + return match ($scope) { + ServiceEndpointScope::DOCKER_NETWORK => null, + ServiceEndpointScope::PRIVATE_NETWORK => $replica->server->private_ip, + ServiceEndpointScope::PUBLIC => $replica->server->ipv4, + }; + } + + private function priority(ServiceEndpointScope $scope): int + { + return match ($scope) { + ServiceEndpointScope::DOCKER_NETWORK => 10, + ServiceEndpointScope::PRIVATE_NETWORK => 20, + ServiceEndpointScope::PUBLIC => 100, + }; + } +} diff --git a/app/Actions/Services/ResolveServiceImageDigest.php b/app/Actions/Services/ResolveServiceImageDigest.php new file mode 100644 index 0000000..8942354 --- /dev/null +++ b/app/Actions/Services/ResolveServiceImageDigest.php @@ -0,0 +1,85 @@ +imageReference($service); + + if (str_starts_with($image, 'sha256:')) { + return $image; + } + + $output = $this->remoteCommandRunner->run($this->targetServer($service), implode("\n", [ + 'set -euo pipefail', + 'image='.escapeshellarg($image), + 'digest=$(docker image inspect --format '.escapeshellarg('{{if .RepoDigests}}{{index .RepoDigests 0}}{{else}}{{.Id}}{{end}}').' "$image" 2>/dev/null || true)', + 'if [ -z "$digest" ]; then', + ' docker pull "$image"', + ' digest=$(docker image inspect --format '.escapeshellarg('{{if .RepoDigests}}{{index .RepoDigests 0}}{{else}}{{.Id}}{{end}}').' "$image")', + 'fi', + 'printf "image_digest=%s\n" "$digest"', + ])); + + if (preg_match('/image_digest=(?\S+)/', $output, $matches)) { + return $this->digestFromOutput($matches['digest'], $image); + } + + return $this->digestFromOutput($output, $image); + } + + private function imageReference(Service $service): string + { + $driver = $service->driver(); + + if (! $driver instanceof RendersCompose) { + throw new RuntimeException("Driver [{$service->driver_name}] cannot resolve an image digest."); + } + + $image = $driver->composeService()['image'] ?? null; + + if (! is_string($image) || $image === '') { + throw new RuntimeException("Driver [{$service->driver_name}] did not provide an image reference."); + } + + return $image; + } + + private function targetServer(Service $service): Server + { + $service->loadMissing('server', 'replicas.server'); + + $server = $service->replicas->first()?->server ?: $service->server; + + if (! $server instanceof Server) { + throw new RuntimeException("Service [{$service->id}] must have a target server before resolving an image digest."); + } + + return $server; + } + + private function digestFromOutput(string $output, string $image): string + { + if (str_contains($output, '@')) { + return str($output)->after('@')->trim()->value(); + } + + if (str_starts_with($output, 'sha256:')) { + return $output; + } + + throw new RuntimeException("Unable to resolve image digest for [{$image}]."); + } +} diff --git a/app/Console/Commands/CreateServiceCommand.php b/app/Console/Commands/CreateServiceCommand.php index 0d2708f..948efce 100644 --- a/app/Console/Commands/CreateServiceCommand.php +++ b/app/Console/Commands/CreateServiceCommand.php @@ -14,7 +14,7 @@ class CreateServiceCommand extends Command protected $description = 'Create a service'; - public function handle() + public function handle(): void { $serverId = $this->components->ask('Enter the server ID'); $server = Server::find($serverId); @@ -26,7 +26,7 @@ class CreateServiceCommand extends Command } $serviceType = $this->components->choice('select the service you want to install', [ - 'postgres-17', + 'postgres-18', ]); $serviceName = $this->components->ask('Enter the service name'); @@ -36,7 +36,7 @@ class CreateServiceCommand extends Command server: $server, name: $serviceName, category: ServiceCategory::DATABASE, - type: ServiceType::tryFrom($type), + type: ServiceType::from($type), version: $version, ); diff --git a/app/Console/Commands/GenerateJSEnums.php b/app/Console/Commands/GenerateJSEnums.php index 506809e..461e284 100644 --- a/app/Console/Commands/GenerateJSEnums.php +++ b/app/Console/Commands/GenerateJSEnums.php @@ -20,7 +20,7 @@ class GenerateJSEnums extends Command public function handle(): int { - $enums = base_path('app/enums'); + $enums = app_path('Enums'); $this->load($enums); @@ -87,7 +87,7 @@ class GenerateJSEnums extends Command // Skip format, JS date formats are different to PHP ones. if ($name !== 'Format') { - file_put_contents(base_path('resources/js/Enums/'.$name.'.js'), $js); + file_put_contents(resource_path('js/enums/'.$name.'.js'), $js); $this->info('Stored '.$enum); } else { $this->info('Skipped '.$name.'s'); diff --git a/app/Data/Environments/EnvironmentDeploymentPlan.php b/app/Data/Environments/EnvironmentDeploymentPlan.php new file mode 100644 index 0000000..f6bbce1 --- /dev/null +++ b/app/Data/Environments/EnvironmentDeploymentPlan.php @@ -0,0 +1,24 @@ + $services + * @param array $dependencies + * @param array $warnings + * @param array $blockers + */ + public function __construct( + public array $services = [], + public array $dependencies = [], + public bool $requiresRegistry = false, + public array $warnings = [], + public array $blockers = [], + ) { + // + } +} diff --git a/app/Data/Deployments/Plan.php b/app/Data/Operations/Plan.php similarity index 84% rename from app/Data/Deployments/Plan.php rename to app/Data/Operations/Plan.php index 9eb2670..70858e0 100644 --- a/app/Data/Deployments/Plan.php +++ b/app/Data/Operations/Plan.php @@ -1,6 +1,6 @@ script = $script(); - } else { - $this->script = $script; - } + $this->script = is_callable($script) ? $script() : $script; } public function getSafeScript(): string { $script = $this->script; foreach ($this->secrets as $key => $value) { - $script = str_replace("[!{$key}]", '********', $script); + $script = str_replace("[!{$key}!]", '********', $script); } return $script; } + public function getScriptTemplate(): string + { + return $this->script; + } + + public function secrets(): array + { + return $this->secrets; + } + public function getScript(): string { $script = $this->script; foreach ($this->secrets as $key => $value) { - $script = str_replace("[!{$key}]", $value, $script); + $script = str_replace("[!{$key}!]", $value, $script); } return $script; diff --git a/app/Drivers/Caddy/Caddy2Driver.php b/app/Drivers/Caddy/Caddy2Driver.php index fc66ae4..957dd14 100644 --- a/app/Drivers/Caddy/Caddy2Driver.php +++ b/app/Drivers/Caddy/Caddy2Driver.php @@ -2,15 +2,20 @@ namespace App\Drivers\Caddy; +use App\Data\Operations\Plan; +use App\Data\Operations\PlannedStep; +use App\Drivers\Concerns\RendersCompose; +use App\Drivers\Concerns\SupportsSlices; use App\Drivers\GatewayDriver; -use App\Data\Deployments\Plan; -use App\Data\Deployments\PlannedStep as Step; -use App\Enums\DeploymentStatus; +use App\Enums\EnvironmentAttachmentRole; +use App\Enums\ServiceType; use App\Models\Service; +use App\Models\ServiceSlice; -class Caddy2Driver extends GatewayDriver +class Caddy2Driver extends GatewayDriver implements RendersCompose, SupportsSlices { public ?string $containerName; + public ?string $containerId; public function __construct( @@ -23,55 +28,112 @@ class Caddy2Driver extends GatewayDriver $this->service = $service; } - public function getDeploymentPlan(string $deploymentHash): Plan + public function getOperationPlan(string $operationHash): Plan { - $previousDeployment = $this->service?->deployments() - ->where('status', DeploymentStatus::COMPLETED) - ->first(); - return new Plan(steps: [ - new Step( - name: 'Generate Caddyfile', - script: function () { - $script = collect(); - $script->push('cd ~'); - $script->push('test -d services || mkdir services'); - $script->push('cd services'); - $script->push("test -d {$this->service->id} || mkdir {$this->service->id}"); - $script->push("cd {$this->service->id}"); - return $script->join("\n"); - } + new PlannedStep( + name: 'Render Caddy Compose files', + script: "mkdir -p /home/keystone/gateway /home/keystone/services/{$this->service?->id}", ), - new Step( - name: 'Run the docker image', - script: function () use ($previousDeployment, $deploymentHash) { - $script = collect(); - if ($this->containerName && $previousDeployment) { - $script->push("docker stop \"{$this->containerName}-{$previousDeployment->hash}\" || true"); - } elseif ($this->containerId) { - $script->push('docker stop ' . $this->containerId . ' || true'); - } - - $runCommand = 'docker run -d'; - if ($this->containerName) { - $runCommand .= " --name \"{$this->containerName}-{$deploymentHash}\""; - } - $runCommand .= ' -p 80:80 -p 443:443 caddy:2'; - - $script->push($runCommand); - - return $script->join(" && "); - } + new PlannedStep( + name: 'Start Caddy gateway', + script: "docker compose -f /home/keystone/services/{$this->service?->id}/compose.yml up -d", ), ]); } - public function buildCaddyfile(): string + public function serviceType(): ServiceType { - $caddyfile = "http://{$this->service->name} {\n"; - $caddyfile .= " reverse_proxy {$this->service->credentials['backend']}\n"; - $caddyfile .= "}\n"; + return ServiceType::CADDY; + } - return $caddyfile; + public function versionTrack(): string + { + return '2'; + } + + public function defaultImage(): string + { + return 'caddy:2'; + } + + public function defaultPorts(): array + { + return [80, 443]; + } + + public function firewallRules(): array + { + return ['80/tcp', '443/tcp']; + } + + public function environmentSchema(): array + { + return []; + } + + public function resourceDefaults(): array + { + return []; + } + + public function updateBehavior(): string + { + return 'stateless_redeploy'; + } + + public function composeService(): array + { + return [ + 'image' => $this->service?->available_image_digest + ?: $this->service?->current_image_digest + ?: $this->defaultImage(), + 'restart' => 'unless-stopped', + 'ports' => ['80:80', '443:443'], + 'volumes' => [ + '/home/keystone/gateway/Caddyfile:/etc/caddy/Caddyfile:ro', + "keystone_service_{$this->service?->id}_caddy_data:/data", + "keystone_service_{$this->service?->id}_caddy_config:/config", + ], + 'healthcheck' => [ + 'test' => ['CMD', 'caddy', 'version'], + 'interval' => '10s', + 'timeout' => '5s', + 'retries' => 5, + ], + ]; + } + + public function composeVolumes(): array + { + return [ + "keystone_service_{$this->service?->id}_caddy_data" => null, + "keystone_service_{$this->service?->id}_caddy_config" => null, + ]; + } + + public function environmentExports(): array + { + return []; + } + + public function supportedSliceTypes(): array + { + return ['route']; + } + + public function environmentExportsForSlice(ServiceSlice $slice, ?EnvironmentAttachmentRole $role = null): array + { + return []; + } + + public function provisionSliceScript(ServiceSlice $slice): string + { + return implode("\n", [ + 'set -euo pipefail', + 'mkdir -p /home/keystone/gateway/Caddyfile.d', + 'test -f /home/keystone/gateway/Caddyfile || touch /home/keystone/gateway/Caddyfile', + "test ! -e /home/keystone/gateway/Caddyfile.d/{$slice->id}.caddy || true", + ]); } } diff --git a/app/Drivers/Concerns/RendersCompose.php b/app/Drivers/Concerns/RendersCompose.php new file mode 100644 index 0000000..0fe1d7e --- /dev/null +++ b/app/Drivers/Concerns/RendersCompose.php @@ -0,0 +1,21 @@ + + */ + public function composeService(): array; + + /** + * @return array + */ + public function composeVolumes(): array; + + /** + * @return array + */ + public function environmentExports(): array; +} diff --git a/app/Drivers/Concerns/SupportsSlices.php b/app/Drivers/Concerns/SupportsSlices.php new file mode 100644 index 0000000..ed42d3e --- /dev/null +++ b/app/Drivers/Concerns/SupportsSlices.php @@ -0,0 +1,21 @@ + + */ + public function supportedSliceTypes(): array; + + /** + * @return array + */ + public function environmentExportsForSlice(ServiceSlice $slice, ?EnvironmentAttachmentRole $role = null): array; + + public function provisionSliceScript(ServiceSlice $slice): string; +} diff --git a/app/Drivers/Driver.php b/app/Drivers/Driver.php index 5747add..0d341a1 100644 --- a/app/Drivers/Driver.php +++ b/app/Drivers/Driver.php @@ -2,7 +2,9 @@ namespace App\Drivers; -use App\Data\Deployments\Plan; +use App\Data\Operations\Plan; +use App\Data\Operations\PlannedStep; +use App\Enums\ServiceType; use App\Models\Service; abstract class Driver @@ -19,5 +21,41 @@ abstract class Driver ?Service $service = null, ); - abstract public function getDeploymentPlan(string $deploymentHash): Plan; + abstract public function getOperationPlan(string $operationHash): Plan; + + abstract public function serviceType(): ServiceType; + + abstract public function versionTrack(): string; + + abstract public function defaultImage(): string; + + /** + * @return array + */ + abstract public function defaultPorts(): array; + + /** + * @return array + */ + abstract public function firewallRules(): array; + + /** + * @return array + */ + abstract public function environmentSchema(): array; + + /** + * @return array{cpu?: string, memory_mb?: int} + */ + abstract public function resourceDefaults(): array; + + abstract public function updateBehavior(): string; + + /** + * @return array + */ + public function preSwitchSteps(): array + { + return []; + } } diff --git a/app/Drivers/Laravel/LaravelRuntimeDriver.php b/app/Drivers/Laravel/LaravelRuntimeDriver.php new file mode 100644 index 0000000..77805ed --- /dev/null +++ b/app/Drivers/Laravel/LaravelRuntimeDriver.php @@ -0,0 +1,205 @@ +service?->id}", + ), + new PlannedStep( + name: 'Run migrations', + script: $this->service + ? app(BuildMigrationScript::class)->execute($this->service) + : 'true', + ), + new PlannedStep( + name: 'Start Laravel replica', + script: "docker compose -f /home/keystone/services/{$this->service?->id}/compose.yml up -d", + ), + ]); + } + + public function serviceType(): ServiceType + { + return ServiceType::LARAVEL; + } + + public function versionTrack(): string + { + return 'php-8.4'; + } + + public function defaultImage(): string + { + return 'serversideup/php:8.4-frankenphp'; + } + + public function defaultPorts(): array + { + return [80]; + } + + public function firewallRules(): array + { + return []; + } + + public function environmentSchema(): array + { + return [ + 'APP_ENV' => 'string', + 'SERVER_NAME' => 'string', + ]; + } + + public function resourceDefaults(): array + { + return []; + } + + public function updateBehavior(): string + { + return 'stateless_gateway_cutover'; + } + + public function composeService(): array + { + $image = $this->service?->available_image_digest + ?: $this->service?->current_image_digest + ?: ($this->service?->config['image'] ?? $this->defaultImage()); + + $service = [ + 'image' => $image, + 'restart' => 'unless-stopped', + 'environment' => $this->environmentExports(), + ]; + + if ($command = $this->service?->config['command'] ?? null) { + $service['command'] = $command; + } + + if (! in_array('worker', $this->service?->process_roles ?? [], true)) { + $service['healthcheck'] = [ + 'test' => ['CMD-SHELL', 'curl -fsS http://localhost'.($this->service?->config['health_path'] ?? '/up').' || exit 1'], + 'interval' => '10s', + 'timeout' => '5s', + 'retries' => 5, + ]; + } + + if ($this->service?->default_cpu_limit) { + $service['cpus'] = (string) $this->service->default_cpu_limit; + } + + if ($this->service?->default_memory_limit_mb) { + $service['mem_limit'] = "{$this->service->default_memory_limit_mb}m"; + $service['memswap_limit'] = "{$this->service->default_memory_limit_mb}m"; + } + + return $service; + } + + public function composeVolumes(): array + { + return []; + } + + public function environmentExports(): array + { + $environment = $this->service?->environment?->variables() + ->pluck('value', 'key') + ->all() ?? []; + + $environment = [ + ...$environment, + 'APP_ENV' => $this->service?->environment?->name ?? 'production', + 'SERVER_NAME' => ':80', + ]; + + if ($this->shouldAutorunScheduler()) { + $environment['AUTORUN_LARAVEL_SCHEDULER'] = 'true'; + } + + return $environment; + } + + private function shouldAutorunScheduler(): bool + { + if (! in_array('scheduler', $this->service?->process_roles ?? [], true)) { + return false; + } + + $environment = $this->service?->environment; + + if (! $environment?->scheduler_enabled) { + return false; + } + + if ($environment->scheduler_target_service_id && $environment->scheduler_target_service_id !== $this->service?->id) { + return false; + } + + return $environment->scheduler_mode !== SchedulerMode::SINGLE + || (int) $this->service?->desired_replicas === 1; + } + + public function dockerfileTemplate(): string + { + $phpVersion = $this->service?->config['php_version'] ?? '8.4'; + $documentRoot = $this->service?->config['document_root'] ?? 'public'; + $jsBuildCommand = $this->service?->config['js_build_command'] ?? $this->service?->environment?->build_config['js_build_command'] ?? null; + $jsPackageManager = $this->service?->config['js_package_manager'] ?? $this->service?->environment?->build_config['js_package_manager'] ?? 'bun'; + $jsBuildSteps = $this->jsBuildSteps($jsPackageManager, $jsBuildCommand); + + return << "\nRUN npm ci && {$buildCommand}", + default => "\nRUN curl -fsSL https://bun.sh/install | bash && export PATH=\"/root/.bun/bin:\$PATH\" && bun install --frozen-lockfile && {$buildCommand}", + }; + } +} diff --git a/app/Drivers/Postgres/Postgres17Driver.php b/app/Drivers/Postgres/Postgres17Driver.php deleted file mode 100644 index 5130327..0000000 --- a/app/Drivers/Postgres/Postgres17Driver.php +++ /dev/null @@ -1,94 +0,0 @@ -defaultCredentials(); - } - - public function getDeploymentPlan(string $deploymentHash): Plan - { - $user = $credentials['user'] ?? null; - $password = $credentials['password'] ?? null; - $db = $credentials['db'] ?? null; - - if (!$user || !$password || !$db) { - throw new \InvalidArgumentException('Missing required credentials'); - } - - $previousDeployment = $this->service?->deployments() - ->where('status', DeploymentStatus::COMPLETED) - ->first(); - - return new Plan(steps: [ - new Step( - name: 'Run the docker image', - secrets: [ - 'password' => $password - ], - script: function () use ($user, $password, $db, $previousDeployment, $deploymentHash) { - $script = collect(); - - if ($this->containerName && $previousDeployment) { - $script->push("docker stop \"{$this->containerName}-{$previousDeployment->hash}\" || true"); - } elseif ($this->containerId) { - $script->push('docker stop ' . $this->containerId . ' || true'); - } - - $runCommand = 'docker run -d'; - if ($this->containerName) { - $runCommand .= " --name \"{$this->containerName}-{$deploymentHash}\""; - } - if ($password) { - $runCommand .= ' -e POSTGRES_PASSWORD=[!password!]'; - } - if ($user) { - $runCommand .= " -e POSTGRES_USER={$user}"; - } - if ($db) { - $runCommand .= " -e POSTGRES_DB={$db}"; - } - $runCommand .= ' -p 5432:5432 postgres:17'; - - $script->push($runCommand); - - return $script->join(" && "); - } - ), - new Step( - name: 'Configure firewall', // @todo this should create a Firewallrule - script: 'ufw allow 5432/tcp || true', - ), - ]); - } - - public function defaultCredentials(): array - { - return [ - 'password' => Str::random(16), - 'user' => 'keystone', - 'db' => 'keystone', - ]; - } - - public function createUser(string $user, string $password): string - { - return "psql -U {$this->credentials['user']} -d {$this->credentials['db']} -c \"CREATE USER {$user} WITH PASSWORD '{$password}';\""; - } -} diff --git a/app/Drivers/Postgres/Postgres18Driver.php b/app/Drivers/Postgres/Postgres18Driver.php new file mode 100644 index 0000000..b277456 --- /dev/null +++ b/app/Drivers/Postgres/Postgres18Driver.php @@ -0,0 +1,203 @@ +credentials = $credentials ?? $this->defaultCredentials(); + } + + public function getOperationPlan(string $operationHash): Plan + { + $credentials = $this->credentials ?? $this->defaultCredentials(); + $user = $credentials['user'] ?? null; + $password = $credentials['password'] ?? null; + $db = $credentials['db'] ?? null; + + if (! $user || ! $password || ! $db) { + throw new \InvalidArgumentException('Missing required credentials'); + } + + return new Plan(steps: [ + new PlannedStep( + name: 'Render Compose file', + script: "mkdir -p /home/keystone/services/{$this->service?->id}", + ), + new PlannedStep( + name: 'Start Postgres service', + script: "docker compose -f /home/keystone/services/{$this->service?->id}/compose.yml up -d", + ), + new PlannedStep( + name: 'Check Postgres health', + script: "docker compose -f /home/keystone/services/{$this->service?->id}/compose.yml ps --status running", + ), + new PlannedStep( + name: 'Configure firewall', + script: 'ufw allow 5432/tcp || true', + ), + ]); + } + + public function serviceType(): ServiceType + { + return ServiceType::POSTGRES; + } + + public function versionTrack(): string + { + return '18'; + } + + public function defaultImage(): string + { + return 'postgres:18'; + } + + public function defaultPorts(): array + { + return [5432]; + } + + public function firewallRules(): array + { + return ['5432/tcp']; + } + + public function environmentSchema(): array + { + return [ + 'POSTGRES_USER' => 'string', + 'POSTGRES_PASSWORD' => 'secret', + 'POSTGRES_DB' => 'string', + ]; + } + + public function resourceDefaults(): array + { + return []; + } + + public function updateBehavior(): string + { + return 'stateful_downtime'; + } + + public function defaultCredentials(): array + { + return [ + 'password' => Str::random(32), + 'user' => 'keystone', + 'db' => 'keystone', + ]; + } + + public function createUser(string $user, string $password): string + { + return "psql -U {$this->credentials['user']} -d {$this->credentials['db']} -c \"CREATE USER {$user} WITH PASSWORD '{$password}';\""; + } + + public function supportedSliceTypes(): array + { + return ['database_user']; + } + + public function environmentExportsForSlice(ServiceSlice $slice, ?EnvironmentAttachmentRole $role = null): array + { + $credentials = $slice->credentials ?? []; + + return [ + 'DB_CONNECTION' => 'pgsql', + 'DB_HOST' => $slice->config['host'] ?? "keystone-service-{$slice->service_id}", + 'DB_PORT' => (string) ($slice->config['port'] ?? 5432), + 'DB_DATABASE' => $credentials['database'] ?? $slice->name, + 'DB_USERNAME' => $credentials['username'] ?? $slice->name, + 'DB_PASSWORD' => $credentials['password'] ?? '', + ]; + } + + public function provisionSliceScript(ServiceSlice $slice): string + { + $credentials = $slice->credentials ?? []; + $database = $credentials['database'] ?? $slice->name; + $username = $credentials['username'] ?? $slice->name; + $password = $credentials['password'] ?? Str::password(32); + $admin = ($this->credentials ?? $this->defaultCredentials())['user'] ?? 'keystone'; + $serviceKey = str($slice->service->name)->slug('_')->value() ?: 'postgres'; + + return implode("\n", [ + 'set -euo pipefail', + "docker compose -f /home/keystone/services/{$slice->service_id}/compose.yml exec -T {$serviceKey} psql -U ".escapeshellarg($admin).' -d postgres <<\'KEYSTONE_SQL\'', + "SELECT 'CREATE DATABASE \"{$this->sqlIdentifier($database)}\"' WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '{$this->sqlLiteral($database)}')\\gexec", + 'DO $$ BEGIN CREATE USER "'.$this->sqlIdentifier($username).'" WITH PASSWORD \''.$this->sqlLiteral($password).'\'; EXCEPTION WHEN duplicate_object THEN ALTER USER "'.$this->sqlIdentifier($username).'" WITH PASSWORD \''.$this->sqlLiteral($password).'\'; END $$;', + "GRANT ALL PRIVILEGES ON DATABASE \"{$this->sqlIdentifier($database)}\" TO \"{$this->sqlIdentifier($username)}\";", + 'KEYSTONE_SQL', + ]); + } + + public function composeService(): array + { + $credentials = $this->credentials ?? $this->defaultCredentials(); + + return [ + 'image' => $this->service?->available_image_digest + ?: $this->service?->current_image_digest + ?: $this->defaultImage(), + 'restart' => 'unless-stopped', + 'environment' => [ + 'POSTGRES_USER' => $credentials['user'], + 'POSTGRES_PASSWORD' => $credentials['password'], + 'POSTGRES_DB' => $credentials['db'], + ], + 'volumes' => [ + "keystone_service_{$this->service?->id}_postgres_data:/var/lib/postgresql/data", + ], + 'healthcheck' => [ + 'test' => ['CMD-SHELL', 'pg_isready -U '.$credentials['user']], + 'interval' => '10s', + 'timeout' => '5s', + 'retries' => 5, + ], + ]; + } + + public function composeVolumes(): array + { + return [ + "keystone_service_{$this->service?->id}_postgres_data" => null, + ]; + } + + public function environmentExports(): array + { + return []; + } + + private function sqlIdentifier(string $value): string + { + return str_replace('"', '""', $value); + } + + private function sqlLiteral(string $value): string + { + return str_replace("'", "''", $value); + } +} diff --git a/app/Drivers/Valkey/Valkey8Driver.php b/app/Drivers/Valkey/Valkey8Driver.php new file mode 100644 index 0000000..e6863c7 --- /dev/null +++ b/app/Drivers/Valkey/Valkey8Driver.php @@ -0,0 +1,154 @@ +service?->id}", + ), + new PlannedStep( + name: 'Start Valkey service', + script: "docker compose -f /home/keystone/services/{$this->service?->id}/compose.yml up -d", + ), + ]); + } + + public function serviceType(): ServiceType + { + return ServiceType::VALKEY; + } + + public function versionTrack(): string + { + return '8'; + } + + public function defaultImage(): string + { + return 'valkey/valkey:8'; + } + + public function defaultPorts(): array + { + return [6379]; + } + + public function firewallRules(): array + { + return ['6379/tcp']; + } + + public function environmentSchema(): array + { + return []; + } + + public function resourceDefaults(): array + { + return []; + } + + public function updateBehavior(): string + { + return 'stateful_downtime'; + } + + public function supportedSliceTypes(): array + { + return ['logical_database']; + } + + public function environmentExportsForSlice(ServiceSlice $slice, ?EnvironmentAttachmentRole $role = null): array + { + $exports = [ + 'REDIS_HOST' => $slice->config['host'] ?? "keystone-service-{$slice->service_id}", + 'REDIS_PORT' => (string) ($slice->config['port'] ?? 6379), + 'REDIS_DB' => (string) ($slice->config['database'] ?? 0), + ]; + + return match ($role) { + EnvironmentAttachmentRole::CACHE => [ + ...$exports, + 'CACHE_STORE' => 'redis', + ], + EnvironmentAttachmentRole::QUEUE => [ + ...$exports, + 'QUEUE_CONNECTION' => 'redis', + ], + EnvironmentAttachmentRole::CUSTOM, + EnvironmentAttachmentRole::DATABASE, + EnvironmentAttachmentRole::GATEWAY, + EnvironmentAttachmentRole::STORAGE, + null => $exports, + }; + } + + public function provisionSliceScript(ServiceSlice $slice): string + { + $serviceKey = str($slice->service->name)->slug('_')->value() ?: 'valkey'; + + return 'docker compose -f /home/keystone/services/'.$slice->service_id.'/compose.yml exec -T '.$serviceKey.' valkey-cli -n '.escapeshellarg((string) ($slice->config['database'] ?? 0)).' PING'; + } + + public function composeService(): array + { + $service = [ + 'image' => $this->service?->available_image_digest + ?: $this->service?->current_image_digest + ?: $this->defaultImage(), + 'restart' => 'unless-stopped', + 'healthcheck' => [ + 'test' => ['CMD', 'valkey-cli', 'ping'], + 'interval' => '10s', + 'timeout' => '5s', + 'retries' => 5, + ], + ]; + + if ($this->service?->config['persistence'] ?? false) { + $service['volumes'] = ["keystone_service_{$this->service->id}_valkey_data:/data"]; + $service['command'] = ['valkey-server', '--appendonly', 'yes']; + } + + return $service; + } + + public function composeVolumes(): array + { + if (! ($this->service?->config['persistence'] ?? false)) { + return []; + } + + return [ + "keystone_service_{$this->service->id}_valkey_data" => null, + ]; + } + + public function environmentExports(): array + { + return []; + } +} diff --git a/app/Enums/BuildArtifactStatus.php b/app/Enums/BuildArtifactStatus.php new file mode 100644 index 0000000..e8af3c8 --- /dev/null +++ b/app/Enums/BuildArtifactStatus.php @@ -0,0 +1,15 @@ + 'The base container image for your application', - self::DATABASE => 'Postgres or MySQL', + self::DATABASE => 'Postgres', self::GATEWAY => 'The first point of contact for your application', self::STORAGE => 'S3 or S3-compatible service', - self::CACHE => 'Redis, Memcached or similar', + self::CACHE => 'Valkey', + self::BUILDER => 'Build service for application artifacts', }; } } diff --git a/app/Enums/ServiceEndpointScope.php b/app/Enums/ServiceEndpointScope.php new file mode 100644 index 0000000..5cee7f6 --- /dev/null +++ b/app/Enums/ServiceEndpointScope.php @@ -0,0 +1,14 @@ +findOrFail($request->route('organisation')); + $organisation = Organisation::with('applications.environments.services')->findOrFail($request->route('organisation')); return inertia('applications/Index', [ 'applications' => $organisation->applications, ]); } - public function show(Request $request) + public function create(Request $request): Response + { + Organisation::findOrFail($request->route('organisation')); + + return inertia('applications/Create'); + } + + public function store(StoreApplicationRequest $request): RedirectResponse + { + $organisation = Organisation::findOrFail($request->route('organisation')); + + $application = $organisation->applications()->create([ + 'name' => $request->string('name')->toString(), + 'repository_url' => $request->string('repository_url')->toString(), + 'repository_type' => RepositoryType::GIT, + 'default_branch' => $request->string('default_branch')->toString(), + ]); + + app(GenerateDeployKey::class)->execute($application); + app(CreateLaravelEnvironment::class)->execute($application->refresh(), $request->string('environment_name')->toString()); + + return redirect() + ->route('applications.show', ['organisation' => $organisation->id, 'application' => $application->id]) + ->with('success', 'Application created. Add the deploy key to your repository before verifying access.'); + } + + public function show(Request $request): Response { $id = $request->route('application'); - $application = Application::with(['instances.server', 'organisation'])->findOrFail($id); + $organisation = Organisation::findOrFail($request->route('organisation')); + $application = Application::with([ + 'environments.services.slices', + 'environments.attachments.service', + 'environments.variables', + 'organisation', + ])->whereBelongsTo($organisation)->findOrFail($id); return inertia('applications/Show', [ 'application' => $application, @@ -35,4 +74,16 @@ class ApplicationController extends Controller }), ]); } + + public function verifyRepository(Request $request): RedirectResponse + { + $organisation = Organisation::findOrFail($request->route('organisation')); + $application = $organisation->applications()->findOrFail($request->route('application')); + + if (! app(VerifyRepositoryAccess::class)->execute($application)) { + return back()->with('error', 'Repository access could not be verified.'); + } + + return back()->with('success', 'Repository access verified.'); + } } diff --git a/app/Http/Controllers/EnvironmentAttachmentController.php b/app/Http/Controllers/EnvironmentAttachmentController.php new file mode 100644 index 0000000..0b10019 --- /dev/null +++ b/app/Http/Controllers/EnvironmentAttachmentController.php @@ -0,0 +1,57 @@ +route('organisation')); + $application = $organisation->applications()->findOrFail($request->route('application')); + $environment = $application->environments()->findOrFail($request->route('environment')); + + return inertia('environment-attachments/Create', [ + 'application' => $application, + 'environment' => $environment, + 'services' => $organisation->services() + ->whereIn('type', [ServiceType::POSTGRES->value, ServiceType::VALKEY->value, ServiceType::CADDY->value]) + ->orderBy('name') + ->get(['id', 'name', 'type', 'category']), + 'roles' => array_values(EnvironmentAttachmentRole::toArray()), + ]); + } + + public function store(StoreEnvironmentAttachmentRequest $request): RedirectResponse + { + $organisation = Organisation::findOrFail($request->route('organisation')); + $application = $organisation->applications()->findOrFail($request->route('application')); + $environment = $application->environments()->findOrFail($request->route('environment')); + $service = $organisation->services()->findOrFail($request->integer('service_id')); + + app(AttachManagedService::class)->execute( + environment: $environment, + service: $service, + role: $request->enum('role', EnvironmentAttachmentRole::class), + name: $request->filled('name') ? $request->string('name')->toString() : null, + envPrefix: $request->filled('env_prefix') ? $request->string('env_prefix')->toString() : null, + isPrimary: $request->boolean('is_primary', true), + ); + + return redirect() + ->route('environments.show', [ + 'organisation' => $organisation->id, + 'application' => $application->id, + 'environment' => $environment->id, + ]) + ->with('success', 'Managed service attached.'); + } +} diff --git a/app/Http/Controllers/EnvironmentController.php b/app/Http/Controllers/EnvironmentController.php new file mode 100644 index 0000000..3eaf463 --- /dev/null +++ b/app/Http/Controllers/EnvironmentController.php @@ -0,0 +1,32 @@ +route('organisation')); + $application = $organisation->applications()->findOrFail($request->route('application')); + $environment = $application->environments() + ->with([ + 'services.replicas', + 'services.slices', + 'services.operations.steps', + 'attachments.service', + 'attachments.serviceSlice', + 'variables', + 'operations.steps', + ]) + ->findOrFail($request->route('environment')); + + return inertia('environments/Show', [ + 'application' => $application, + 'environment' => $environment, + ]); + } +} diff --git a/app/Http/Controllers/EnvironmentDeploymentController.php b/app/Http/Controllers/EnvironmentDeploymentController.php new file mode 100644 index 0000000..379cea7 --- /dev/null +++ b/app/Http/Controllers/EnvironmentDeploymentController.php @@ -0,0 +1,29 @@ +organisation_id === (int) $organisation->id + && (int) $environment->application_id === (int) $application->id, + 404, + ); + + dispatch(new DeployEnvironment($environment)); + + return redirect()->route('environments.show', [ + 'organisation' => $organisation->id, + 'application' => $application->id, + 'environment' => $environment->id, + ]); + } +} diff --git a/app/Http/Controllers/EnvironmentMigrationController.php b/app/Http/Controllers/EnvironmentMigrationController.php new file mode 100644 index 0000000..1e7d7e0 --- /dev/null +++ b/app/Http/Controllers/EnvironmentMigrationController.php @@ -0,0 +1,24 @@ +route('organisation')); + $application = $organisation->applications()->findOrFail($request->route('application')); + $environment = $application->environments()->findOrFail($request->route('environment')); + + app(CreateMigrationOperation::class)->execute($environment); + + return redirect() + ->route('applications.show', ['organisation' => $organisation->id, 'application' => $application->id]) + ->with('success', 'Migration operation created.'); + } +} diff --git a/app/Http/Controllers/EnvironmentVariableController.php b/app/Http/Controllers/EnvironmentVariableController.php new file mode 100644 index 0000000..b54412e --- /dev/null +++ b/app/Http/Controllers/EnvironmentVariableController.php @@ -0,0 +1,45 @@ +route('organisation')); + $application = $organisation->applications()->findOrFail($request->route('application')); + $environment = $application->environments()->findOrFail($request->route('environment')); + + return inertia('environment-variables/Create', [ + 'application' => $application, + 'environment' => $environment, + ]); + } + + public function store(StoreEnvironmentVariableRequest $request): RedirectResponse + { + $organisation = Organisation::findOrFail($request->route('organisation')); + $application = $organisation->applications()->findOrFail($request->route('application')); + $environment = $application->environments()->findOrFail($request->route('environment')); + + $environment->variables()->updateOrCreate([ + 'key' => $request->string('key')->toString(), + ], [ + 'value' => $request->string('value')->toString(), + 'source' => EnvironmentVariableSource::USER, + 'service_slice_id' => null, + 'overridable' => true, + ]); + + return redirect() + ->route('applications.show', ['organisation' => $organisation->id, 'application' => $application->id]) + ->with('success', 'Environment variable saved.'); + } +} diff --git a/app/Http/Controllers/EnvironmentWorkerController.php b/app/Http/Controllers/EnvironmentWorkerController.php new file mode 100644 index 0000000..6dd416a --- /dev/null +++ b/app/Http/Controllers/EnvironmentWorkerController.php @@ -0,0 +1,24 @@ +route('organisation')); + $application = $organisation->applications()->findOrFail($request->route('application')); + $environment = $application->environments()->findOrFail($request->route('environment')); + + app(CreateLaravelWorkerService::class)->execute($environment); + + return redirect() + ->route('applications.show', ['organisation' => $organisation->id, 'application' => $application->id]) + ->with('success', 'Worker service created.'); + } +} diff --git a/app/Http/Controllers/InstanceController.php b/app/Http/Controllers/InstanceController.php deleted file mode 100644 index 0765332..0000000 --- a/app/Http/Controllers/InstanceController.php +++ /dev/null @@ -1,36 +0,0 @@ -validate([ - 'server_id' => 'required|exists:servers,id', - 'branch' => 'required|string|max:255', - 'config' => 'sometimes|array', - ]); - - $server = Server::findOrFail($validated['server_id']); - - $instance = (new CreateInstance())->execute( - $application, - $server, - $validated['branch'], - $validated['config'] ?? [] - ); - - return redirect() - ->route('applications.show', [ - 'organisation' => $application->organisation_id, - 'application' => $application->id - ]) - ->with('success', 'Instance created successfully'); - } -} \ No newline at end of file diff --git a/app/Http/Controllers/OnboardingController.php b/app/Http/Controllers/OnboardingController.php new file mode 100644 index 0000000..7ca2923 --- /dev/null +++ b/app/Http/Controllers/OnboardingController.php @@ -0,0 +1,61 @@ +loadCount(['providers', 'sourceProviders', 'registries', 'servers', 'applications']); + + $steps = [ + [ + 'key' => 'organisation', + 'label' => 'Organisation', + 'complete' => true, + 'href' => route('organisations.show', ['organisation' => $organisation->id]), + ], + [ + 'key' => 'provider', + 'label' => 'Provider', + 'complete' => $organisation->providers_count > 0, + 'href' => route('organisations.show', ['organisation' => $organisation->id]), + ], + [ + 'key' => 'source', + 'label' => 'Source', + 'complete' => $organisation->source_providers_count > 0, + 'href' => route('source-providers.create', ['organisation' => $organisation->id]), + ], + [ + 'key' => 'registry', + 'label' => 'Registry', + 'complete' => $organisation->registries_count > 0, + 'href' => route('registries.create', ['organisation' => $organisation->id]), + ], + [ + 'key' => 'server', + 'label' => 'Server', + 'complete' => $organisation->servers_count > 0, + 'href' => route('servers.create', ['organisation' => $organisation->id]), + ], + [ + 'key' => 'application', + 'label' => 'Application', + 'complete' => $organisation->applications_count > 0, + 'href' => route('applications.create', ['organisation' => $organisation->id]), + ], + ]; + + $next = collect($steps)->firstWhere('complete', false) ?? $steps[array_key_last($steps)]; + + return inertia('onboarding/Show', [ + 'organisation' => $organisation, + 'steps' => $steps, + 'nextStep' => $next, + ]); + } +} diff --git a/app/Http/Controllers/OrganisationController.php b/app/Http/Controllers/OrganisationController.php index bca7630..b3bf5d0 100644 --- a/app/Http/Controllers/OrganisationController.php +++ b/app/Http/Controllers/OrganisationController.php @@ -13,6 +13,8 @@ class OrganisationController extends Controller { return inertia('organisations/Show', [ 'providers' => Inertia::lazy(fn () => Provider::whereOrganisationId($request->route('organisation'))->get()), + 'registries' => Inertia::lazy(fn () => Organisation::findOrFail($request->route('organisation'))->registries()->get()), + 'sourceProviders' => Inertia::lazy(fn () => Organisation::findOrFail($request->route('organisation'))->sourceProviders()->get()), 'organisation' => Organisation::withCount('servers', 'applications', 'members')->findOrFail($request->route('organisation')), ]); } diff --git a/app/Http/Controllers/RegistryController.php b/app/Http/Controllers/RegistryController.php new file mode 100644 index 0000000..adf39da --- /dev/null +++ b/app/Http/Controllers/RegistryController.php @@ -0,0 +1,41 @@ +route('organisation')); + + return inertia('registries/Create', [ + 'registryTypes' => array_values(RegistryType::toArray()), + ]); + } + + public function store(StoreRegistryRequest $request): RedirectResponse + { + $organisation = Organisation::findOrFail($request->route('organisation')); + + $organisation->registries()->create([ + 'name' => $request->string('name')->toString(), + 'type' => $request->enum('type', RegistryType::class), + 'url' => rtrim($request->string('url')->toString(), '/'), + 'credentials' => [ + 'username' => $request->string('username')->toString(), + 'password' => $request->string('password')->toString(), + ], + ]); + + return redirect() + ->route('organisations.show', ['organisation' => $organisation->id]) + ->with('success', 'Registry created.'); + } +} diff --git a/app/Http/Controllers/ServerController.php b/app/Http/Controllers/ServerController.php index b4cf681..77a25e1 100644 --- a/app/Http/Controllers/ServerController.php +++ b/app/Http/Controllers/ServerController.php @@ -75,12 +75,12 @@ class ServerController extends Controller } $networkZone = $request->network_zone ?? 'global'; - + // Look for an existing network with the same network_zone $network = $provider->networks() ->where('network_zone', $networkZone) ->first(); - + if (! $network) { // We need to create a network with the correct network zone $networkName = "keystone-{$networkZone}"; @@ -141,7 +141,7 @@ class ServerController extends Controller $server = $organisation->servers()->findOrFail($request->route('server')); return inertia('servers/Show', [ - 'server' => $server->load('services.slices', 'serviceDeployments.steps', 'serviceDeployments.target'), + 'server' => $server->load('services.slices', 'serviceOperations.steps', 'serviceOperations.target'), ]); } } diff --git a/app/Http/Controllers/ServiceController.php b/app/Http/Controllers/ServiceController.php index eb5fd4f..7e15a0f 100644 --- a/app/Http/Controllers/ServiceController.php +++ b/app/Http/Controllers/ServiceController.php @@ -5,13 +5,16 @@ namespace App\Http\Controllers; use App\Actions\Services\CreateService; use App\Enums\ServiceCategory; use App\Enums\ServiceType; +use App\Http\Requests\StoreServiceRequest; +use App\Http\Requests\UpdateServiceRequest; use App\Models\Server; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; -use Illuminate\Validation\Rule; +use Inertia\Response; class ServiceController extends Controller { - public function create(Request $request) + public function create(Request $request): Response { $server = Server::findOrFail($request->route('server')); @@ -21,19 +24,8 @@ class ServiceController extends Controller ]); } - public function store(Request $request) + public function store(StoreServiceRequest $request): RedirectResponse { - $request->validate([ - 'name' => ['required', 'string', 'max:255'], - 'category' => ['required', Rule::enum(ServiceCategory::class)], - 'type' => ['required', Rule::enum(ServiceType::class)], - 'version' => ['required', 'string', function ($key, $value, $fail) use ($request) { - if (!isset(config('keystone.services')[$request->category][$request->type]['versions'][$value])) { - $fail('The selected version is invalid.'); - } - }], - ]); - $server = Server::findOrFail($request->route('server')); $service = app(CreateService::class)->execute( @@ -52,4 +44,44 @@ class ServiceController extends Controller 'service' => $service, ]); } + + public function show(Request $request): Response + { + $server = Server::findOrFail($request->route('server')); + $service = $server->services() + ->with(['replicas', 'slices', 'operations.steps', 'environment.application']) + ->findOrFail($request->route('service')); + + return inertia('services/Show', [ + 'server' => $server, + 'service' => $service, + ]); + } + + public function edit(Request $request): Response + { + $server = Server::findOrFail($request->route('server')); + $service = $server->services()->findOrFail($request->route('service')); + + return inertia('services/Edit', [ + 'server' => $server, + 'service' => $service, + ]); + } + + public function update(UpdateServiceRequest $request): RedirectResponse + { + $server = Server::findOrFail($request->route('server')); + $service = $server->services()->findOrFail($request->route('service')); + + $service->update($request->validated()); + + return redirect() + ->route('services.show', [ + 'organisation' => $server->organisation_id, + 'server' => $server->id, + 'service' => $service->id, + ]) + ->with('success', 'Service updated.'); + } } diff --git a/app/Http/Controllers/ServiceUpdateController.php b/app/Http/Controllers/ServiceUpdateController.php new file mode 100644 index 0000000..6b7df7d --- /dev/null +++ b/app/Http/Controllers/ServiceUpdateController.php @@ -0,0 +1,48 @@ +organisation_id === (int) $organisation->id && (int) $service->server_id === (int) $server->id, 404); + abort_unless(in_array($service->type, [ServiceType::POSTGRES, ServiceType::VALKEY], true), 404); + + return inertia('services/updates/Create', [ + 'server' => $server, + 'service' => $service, + 'backupAvailable' => (bool) ($service->config['backup_enabled'] ?? false), + ]); + } + + public function store( + StoreServiceUpdateRequest $request, + Organisation $organisation, + Server $server, + Service $service, + CreateStatefulServiceUpdateOperation $createStatefulServiceUpdateOperation, + ): RedirectResponse { + abort_unless((int) $server->organisation_id === (int) $organisation->id && (int) $service->server_id === (int) $server->id, 404); + + $createStatefulServiceUpdateOperation->execute( + service: $service, + imageDigest: $request->string('image_digest')->toString(), + backupRequested: $request->boolean('backup_requested'), + ); + + return redirect()->route('servers.show', [ + 'organisation' => $organisation->id, + 'server' => $server->id, + ]); + } +} diff --git a/app/Http/Controllers/SourceProviderController.php b/app/Http/Controllers/SourceProviderController.php new file mode 100644 index 0000000..9139aa2 --- /dev/null +++ b/app/Http/Controllers/SourceProviderController.php @@ -0,0 +1,38 @@ +route('organisation')); + + return inertia('source-providers/Create', [ + 'sourceProviderTypes' => array_values(SourceProviderType::toArray()), + ]); + } + + public function store(StoreSourceProviderRequest $request): RedirectResponse + { + $organisation = Organisation::findOrFail($request->route('organisation')); + + $organisation->sourceProviders()->create([ + 'name' => $request->string('name')->toString(), + 'type' => $request->enum('type', SourceProviderType::class), + 'url' => $request->filled('url') ? rtrim($request->string('url')->toString(), '/') : null, + 'config' => [], + ]); + + return redirect() + ->route('organisations.show', ['organisation' => $organisation->id]) + ->with('success', 'Source provider created.'); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 99d17a2..384a4fa 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -4,6 +4,7 @@ namespace App\Http\Middleware; use App\Models\Application; use App\Models\Organisation; +use Illuminate\Database\Eloquent\Model; use Illuminate\Http\Request; use Inertia\Middleware; use Tighten\Ziggy\Ziggy; @@ -29,8 +30,12 @@ class HandleInertiaRequests extends Middleware return [ ...parent::share($request), 'name' => config('app.name'), - 'organisation' => $request->route('organisation') ? Organisation::with('applications')->findOrFail($request->route('organisation')) : null, - 'application' => $request->route('application') ? Application::with('environments')->findOrFail($request->route('application')) : null, + 'organisation' => $request->route('organisation') + ? Organisation::with('applications')->findOrFail($this->routeKey($request->route('organisation'))) + : null, + 'application' => $request->route('application') + ? Application::with('environments')->findOrFail($this->routeKey($request->route('application'))) + : null, 'flash' => [ 'server_credentials' => $request->session()->has('sudo_password') ? [ 'sudo_password' => $request->session()->get('sudo_password'), @@ -45,4 +50,9 @@ class HandleInertiaRequests extends Middleware ], ]; } + + private function routeKey(mixed $routeValue): mixed + { + return $routeValue instanceof Model ? $routeValue->getKey() : $routeValue; + } } diff --git a/app/Http/Requests/StoreApplicationRequest.php b/app/Http/Requests/StoreApplicationRequest.php new file mode 100644 index 0000000..86cebf3 --- /dev/null +++ b/app/Http/Requests/StoreApplicationRequest.php @@ -0,0 +1,31 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'repository_url' => ['required', 'string', 'max:255', 'regex:/^(git@[^:]+:.+|ssh:\/\/.+)$/i'], + 'default_branch' => ['required', 'string', 'max:255', 'regex:/^[A-Za-z0-9._\/-]+$/'], + 'environment_name' => ['required', 'string', 'max:255'], + ]; + } +} diff --git a/app/Http/Requests/StoreEnvironmentAttachmentRequest.php b/app/Http/Requests/StoreEnvironmentAttachmentRequest.php new file mode 100644 index 0000000..bcefaa2 --- /dev/null +++ b/app/Http/Requests/StoreEnvironmentAttachmentRequest.php @@ -0,0 +1,34 @@ +|string> + */ + public function rules(): array + { + return [ + 'service_id' => ['required', 'integer', 'exists:services,id'], + 'role' => ['required', Rule::enum(EnvironmentAttachmentRole::class)], + 'name' => ['nullable', 'string', 'max:255'], + 'env_prefix' => ['nullable', 'string', 'max:32', 'regex:/^[A-Z][A-Z0-9_]*$/'], + 'is_primary' => ['boolean'], + ]; + } +} diff --git a/app/Http/Requests/StoreEnvironmentVariableRequest.php b/app/Http/Requests/StoreEnvironmentVariableRequest.php new file mode 100644 index 0000000..bad499d --- /dev/null +++ b/app/Http/Requests/StoreEnvironmentVariableRequest.php @@ -0,0 +1,29 @@ +|string> + */ + public function rules(): array + { + return [ + 'key' => ['required', 'string', 'max:255', 'regex:/^[A-Z][A-Z0-9_]*$/'], + 'value' => ['nullable', 'string'], + ]; + } +} diff --git a/app/Http/Requests/StoreRegistryRequest.php b/app/Http/Requests/StoreRegistryRequest.php new file mode 100644 index 0000000..27b61ff --- /dev/null +++ b/app/Http/Requests/StoreRegistryRequest.php @@ -0,0 +1,34 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'type' => ['required', Rule::enum(RegistryType::class)], + 'url' => ['required', 'string', 'max:255'], + 'username' => ['nullable', 'string', 'max:255'], + 'password' => ['nullable', 'string', 'max:255'], + ]; + } +} diff --git a/app/Http/Requests/StoreServiceRequest.php b/app/Http/Requests/StoreServiceRequest.php new file mode 100644 index 0000000..eecc742 --- /dev/null +++ b/app/Http/Requests/StoreServiceRequest.php @@ -0,0 +1,56 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'category' => ['required', Rule::enum(ServiceCategory::class)], + 'type' => ['required', Rule::enum(ServiceType::class)], + 'version' => ['required', 'string', function (string $attribute, mixed $value, \Closure $fail): void { + if (! isset(config('keystone.services')[$this->category][$this->type]['versions'][$value])) { + $fail('The selected version is invalid.'); + } + }], + ]; + } + + public function after(): array + { + return [ + function ($validator): void { + if ($this->category !== ServiceCategory::GATEWAY->value) { + return; + } + + $server = Server::find($this->route('server')); + + if ($server?->services()->where('category', ServiceCategory::GATEWAY)->exists()) { + $validator->errors()->add('category', 'This server already has a gateway service.'); + } + }, + ]; + } +} diff --git a/app/Http/Requests/StoreServiceUpdateRequest.php b/app/Http/Requests/StoreServiceUpdateRequest.php new file mode 100644 index 0000000..0c90c0e --- /dev/null +++ b/app/Http/Requests/StoreServiceUpdateRequest.php @@ -0,0 +1,26 @@ +|string> + */ + public function rules(): array + { + return [ + 'image_digest' => ['required', 'string', 'starts_with:sha256:'], + 'backup_requested' => ['sometimes', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/StoreSourceProviderRequest.php b/app/Http/Requests/StoreSourceProviderRequest.php new file mode 100644 index 0000000..eaebfce --- /dev/null +++ b/app/Http/Requests/StoreSourceProviderRequest.php @@ -0,0 +1,32 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'type' => ['required', Rule::enum(SourceProviderType::class)], + 'url' => ['nullable', 'string', 'max:255'], + ]; + } +} diff --git a/app/Http/Requests/UpdateServiceRequest.php b/app/Http/Requests/UpdateServiceRequest.php new file mode 100644 index 0000000..1a7ffe4 --- /dev/null +++ b/app/Http/Requests/UpdateServiceRequest.php @@ -0,0 +1,31 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'desired_replicas' => ['required', 'integer', 'min:0', 'max:25'], + 'default_cpu_limit' => ['nullable', 'numeric', 'min:0.125', 'max:64'], + 'default_memory_limit_mb' => ['nullable', 'integer', 'min:64', 'max:1048576'], + ]; + } +} diff --git a/app/Jobs/Applications/DeployApplication.php b/app/Jobs/Applications/DeployApplication.php deleted file mode 100644 index dd73757..0000000 --- a/app/Jobs/Applications/DeployApplication.php +++ /dev/null @@ -1,59 +0,0 @@ -deployment = $this->application->deployments()->create([ - 'status' => DeploymentStatus::PENDING, - ]); - - foreach ($this->application->instances as $instance) { - $step = $this->deployment->steps()->create([ - 'name' => "Deploy to {$instance->server->name}", - 'order' => $instance->id, - 'status' => DeploymentStatus::PENDING, - 'script' => $this->getDeploymentScript($instance), - 'secrets' => [], - ]); - - $step->dispatchJob(); - } - } - - protected function getDeploymentScript($instance): string - { - return "#!/bin/bash\n" . - "cd /opt/apps/{$this->application->name}-{$instance->id}\n" . - "git fetch origin\n" . - "git checkout {$instance->branch}\n" . - "git pull origin {$instance->branch}\n"; - } - - public function failed(\Throwable $exception): void - { - if (isset($this->deployment)) { - $this->deployment->update([ - 'status' => DeploymentStatus::FAILED, - ]); - } - } -} \ No newline at end of file diff --git a/app/Jobs/Environments/DeployEnvironment.php b/app/Jobs/Environments/DeployEnvironment.php new file mode 100644 index 0000000..afeaacb --- /dev/null +++ b/app/Jobs/Environments/DeployEnvironment.php @@ -0,0 +1,497 @@ +execute($this->environment); + + if ($plan->requiresRegistry) { + throw new RuntimeException('A registry is required before deploying this environment across multiple servers.'); + } + + if ($plan->blockers !== []) { + throw new RuntimeException($plan->blockers[0]); + } + + $operation = $this->environment->operations()->create([ + 'kind' => OperationKind::ENVIRONMENT_DEPLOY, + 'status' => OperationStatus::PENDING, + 'started_at' => now(), + ]); + + $commitSha = app(ResolveEnvironmentCommit::class)->execute($this->environment); + $services = $this->servicesNeedingDeployment($plan->services, $commitSha); + + if ($services === []) { + $operation->update([ + 'status' => OperationStatus::COMPLETED, + 'finished_at' => now(), + ]); + + return; + } + + $artifact = app(PlanBuildArtifact::class)->execute($this->environment, $commitSha); + $artifact = app(BuildApplicationArtifact::class)->execute($artifact, $operation); + + foreach ($services as $service) { + $service->update([ + 'available_image_digest' => $artifact->image_digest, + 'desired_revision' => $commitSha, + ]); + + $child = $service->operations()->create([ + 'parent_id' => $operation->id, + 'kind' => OperationKind::SERVICE_DEPLOY, + 'status' => OperationStatus::PENDING, + ]); + + $this->createServiceDeploySteps($child, $service, $commitSha, $artifact->image_digest); + $this->createReplicaDeployOperations($child, $service, $artifact->registry_ref); + } + + $this->createGatewayOperations($operation); + $this->dispatchChildOperations($operation); + } + + /** + * @param array $services + * @return array + */ + private function servicesNeedingDeployment(array $services, string $commitSha): array + { + return collect($services) + ->filter(fn (Service $service): bool => $service->desired_revision !== $commitSha || ! $service->available_image_digest) + ->values() + ->all(); + } + + private function createServiceDeploySteps(Operation $operation, Service $service, string $commitSha, string $imageDigest): void + { + foreach ($this->serviceDeployScripts($service, $commitSha, $imageDigest) as $index => $step) { + $operation->steps()->create([ + 'name' => $step['name'], + 'order' => $index + 1, + 'status' => OperationStatus::PENDING, + 'script' => $step['script'], + ]); + } + } + + private function createReplicaDeployOperations(Operation $parent, Service $service, ?string $imageReference = null): void + { + $replicas = $this->ensureServiceReplicas($service); + + for ($replica = 1; $replica <= max(1, $service->desired_replicas); $replica++) { + $serviceReplica = $replicas[$replica - 1] ?? null; + $target = $serviceReplica ?: $service; + + $operation = $target->operations()->create([ + 'parent_id' => $parent->id, + 'kind' => OperationKind::REPLICA_DEPLOY, + 'status' => OperationStatus::PENDING, + ]); + + $serviceReplica?->update([ + 'operation_id' => $operation->id, + 'image_digest' => $service->available_image_digest, + 'status' => 'pending', + 'health_status' => 'unknown', + ]); + + foreach ($this->replicaDeployScripts($service, $replica, $imageReference) as $index => $step) { + $operation->steps()->create([ + 'name' => $step['name'], + 'order' => $index + 1, + 'status' => OperationStatus::PENDING, + 'script' => $step['script'], + ]); + } + } + } + + /** + * @return array + */ + private function ensureServiceReplicas(Service $service): array + { + $service->loadMissing('replicas'); + + $serverIds = $this->placementServerIds($service); + + if ($service->replicas->count() < $service->desired_replicas && $serverIds !== []) { + for ($index = $service->replicas->count() + 1; $index <= $service->desired_replicas; $index++) { + $service->replicas()->create([ + 'server_id' => $serverIds[($index - 1) % count($serverIds)], + 'container_name' => "keystone-service-{$service->id}-{$index}", + 'internal_host' => "keystone-service-{$service->id}", + 'internal_port' => $this->defaultInternalPort($service), + 'status' => 'pending', + 'health_status' => 'unknown', + 'config' => [], + ]); + } + + $service->load('replicas'); + } + + return $service->replicas + ->take(max(1, $service->desired_replicas)) + ->values() + ->all(); + } + + /** + * @return array + */ + private function placementServerIds(Service $service): array + { + $configured = collect($service->config['server_ids'] ?? []) + ->map(fn (mixed $serverId): int => (int) $serverId) + ->filter() + ->unique() + ->values() + ->all(); + + if ($configured !== []) { + return $configured; + } + + $existing = $service->replicas + ->pluck('server_id') + ->filter() + ->unique() + ->values() + ->all(); + + if ($existing !== []) { + return $existing; + } + + return $service->server_id ? [(int) $service->server_id] : []; + } + + private function createGatewayOperations(Operation $parent): void + { + $this->environment->loadMissing('attachments.service.replicas', 'attachments.serviceSlice'); + + foreach ($this->environment->attachments->where('role', EnvironmentAttachmentRole::GATEWAY) as $attachment) { + $target = $attachment->serviceSlice ?: $this->environment; + + $sliceConfigure = $target->operations()->create([ + 'parent_id' => $parent->id, + 'kind' => OperationKind::SLICE_CONFIGURE, + 'status' => OperationStatus::PENDING, + ]); + $sliceConfigure->steps()->create([ + 'name' => 'Configure Caddy route', + 'order' => 1, + 'status' => OperationStatus::PENDING, + 'script' => $this->configureCaddyRouteScript($attachment), + ]); + + $gatewayCutover = $this->environment->operations()->create([ + 'parent_id' => $parent->id, + 'kind' => OperationKind::GATEWAY_CUTOVER, + 'status' => OperationStatus::PENDING, + ]); + + foreach ($this->gatewayCutoverSteps($attachment) as $index => $step) { + $gatewayCutover->steps()->create([ + 'name' => $step['name'], + 'order' => $index + 1, + 'status' => OperationStatus::PENDING, + 'script' => $step['script'], + ]); + } + } + } + + /** + * @return array + */ + private function serviceDeployScripts(Service $service, string $commitSha, string $imageDigest): array + { + $servicePath = $this->servicePath($service); + $composePath = "{$servicePath}/compose.yml"; + $serviceKey = $this->serviceKey($service); + + $steps = [ + [ + 'name' => 'Resolve target commit', + 'script' => implode("\n", [ + "mkdir -p {$servicePath}", + 'printf %s '.escapeshellarg($commitSha)." > {$servicePath}/REVISION", + ]), + ], + [ + 'name' => 'Create or reuse build artifact', + 'script' => 'printf %s '.escapeshellarg($imageDigest)." > {$servicePath}/IMAGE_DIGEST", + ], + [ + 'name' => 'Render Compose files', + 'script' => $this->composeUploadScript($service), + ], + ]; + + foreach ($service->driver()->preSwitchSteps() as $step) { + $steps[] = [ + 'name' => $step->name, + 'script' => $step->getScriptTemplate(), + ]; + } + + if (($service->config['migration_timing'] ?? 'pre_switch') === 'pre_switch') { + $steps[] = [ + 'name' => 'Run migrations', + 'script' => app(BuildMigrationScript::class)->execute($service), + ]; + } + + $steps = [ + ...$steps, + [ + 'name' => 'Deploy replicas', + 'script' => "docker compose -f {$composePath} up -d --scale {$serviceKey}=".max(1, $service->desired_replicas), + ], + [ + 'name' => 'Health check replicas', + 'script' => "docker compose -f {$composePath} ps --status running {$serviceKey}", + ], + ]; + + if (($service->config['migration_timing'] ?? 'pre_switch') === 'post_switch') { + $steps[] = [ + 'name' => 'Run migrations', + 'script' => app(BuildMigrationScript::class)->execute($service), + ]; + } + + return [ + ...$steps, + [ + 'name' => 'Drain old replicas', + 'script' => "docker ps --filter 'label=keystone.service_id={$service->id}' --filter 'label=keystone.draining=true' --format '{{.ID}}' | xargs -r docker stop", + ], + ]; + } + + /** + * @return array + */ + private function replicaDeployScripts(Service $service, int $replica, ?string $imageReference = null): array + { + $composePath = $this->servicePath($service).'/compose.yml'; + $project = "keystone_service_{$service->id}_replica_{$replica}"; + $serviceKey = $this->serviceKey($service); + + $steps = []; + + if ($imageReference && $service->available_image_digest) { + $steps[] = [ + 'name' => "Pull image for replica {$replica}", + 'script' => 'docker pull '.escapeshellarg($imageReference.'@'.$service->available_image_digest), + ]; + } + + return [ + ...$steps, + [ + 'name' => "Render replica {$replica}", + 'script' => "docker compose -p {$project} -f {$composePath} config --quiet", + ], + [ + 'name' => "Start replica {$replica}", + 'script' => implode("\n", [ + "docker compose -p {$project} -f {$composePath} up -d {$serviceKey}", + "container_id=$(docker compose -p {$project} -f {$composePath} ps -q {$serviceKey})", + 'printf "container_id=%s\n" "$container_id"', + ]), + ], + [ + 'name' => "Health check replica {$replica}", + 'script' => implode("\n", [ + "docker compose -p {$project} -f {$composePath} ps --status running {$serviceKey}", + "container_id=$(docker compose -p {$project} -f {$composePath} ps -q {$serviceKey})", + 'health_status=$(docker inspect --format "{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}" "$container_id")', + 'printf "health_status=%s\n" "$health_status"', + ]), + ], + ]; + } + + private function composeUploadScript(Service $service): string + { + $servicePath = $this->servicePath($service); + + try { + $renderer = app(ComposeRenderer::class); + $compose = $renderer->render($service); + $env = $renderer->renderEnvironmentFile($service); + } catch (InvalidArgumentException) { + $compose = "services:\n {$this->serviceKey($service)}:\n image: \"{$service->available_image_digest}\"\n"; + $env = ''; + } + + return implode("\n", [ + "mkdir -p {$servicePath}", + 'printf %s '.escapeshellarg(base64_encode($compose))." | base64 -d > {$servicePath}/compose.yml", + 'printf %s '.escapeshellarg(base64_encode($env))." | base64 -d > {$servicePath}/.env", + ]); + } + + /** + * @return array + */ + private function gatewayCutoverSteps(EnvironmentAttachment $attachment): array + { + $containerName = $attachment->service->replicas()->first()?->container_name; + $reloadCommand = $containerName + ? 'docker exec '.escapeshellarg($containerName).' caddy reload --config /etc/caddy/Caddyfile' + : "docker compose -f /home/keystone/services/{$attachment->service_id}/compose.yml exec -T {$this->serviceKey($attachment->service)} caddy reload --config /etc/caddy/Caddyfile"; + + return [ + [ + 'name' => 'Validate Caddy route configuration', + 'script' => 'test -s /home/keystone/gateway/Caddyfile', + ], + [ + 'name' => 'Reload Caddy', + 'script' => $reloadCommand, + ], + [ + 'name' => 'Verify new upstreams are reachable', + 'script' => 'curl --fail --silent --show-error http://127.0.0.1/ >/dev/null || true', + ], + [ + 'name' => 'Drain old upstreams', + 'script' => implode("\n", [ + "docker ps --filter 'label=keystone.environment_id={$this->environment->id}' --filter 'label=keystone.draining=true' --format '{{.ID}}' | xargs -r docker stop --time 30", + ]), + ], + ]; + } + + private function configureCaddyRouteScript(EnvironmentAttachment $attachment): string + { + $route = $attachment->serviceSlice?->name ?? $this->environment->name; + $upstreams = $this->gatewayUpstreams($attachment); + + return implode("\n", [ + 'mkdir -p /home/keystone/gateway/Caddyfile.d', + "cat > /home/keystone/gateway/Caddyfile.d/{$attachment->id}.caddy <<'KEYSTONE_CADDY_ROUTE'", + "{$route} {", + ' reverse_proxy '.implode(' ', $upstreams), + '}', + 'KEYSTONE_CADDY_ROUTE', + 'cat /home/keystone/gateway/Caddyfile.d/*.caddy > /home/keystone/gateway/Caddyfile', + ]); + } + + /** + * @return array + */ + private function gatewayUpstreams(EnvironmentAttachment $attachment): array + { + $gatewayReplica = $attachment->service->replicas()->first(); + + return $this->environment->services() + ->where('type', \App\Enums\ServiceType::LARAVEL) + ->get() + ->filter(fn (Service $service): bool => in_array('web', $service->process_roles ?? [], true)) + ->flatMap(function (Service $service) use ($gatewayReplica) { + return $service->replicas + ->map(function (ServiceReplica $replica) use ($gatewayReplica) { + $endpoint = app(RegisterServiceEndpoint::class)->execute( + replica: $replica, + consumerReplica: $gatewayReplica, + allowPublicFallback: false, + ); + + return [ + 'priority' => $endpoint->priority, + 'target' => $this->endpointTarget($endpoint->scope, $endpoint->hostname, $endpoint->port), + ]; + }); + }) + ->sortBy('priority') + ->pluck('target') + ->values() + ->whenEmpty(fn ($targets) => $targets->push('web:80')) + ->all(); + } + + private function endpointTarget(ServiceEndpointScope $scope, string $hostname, int $port): string + { + return $scope === ServiceEndpointScope::DOCKER_NETWORK + ? $hostname.':'.$port + : 'http://'.$hostname.':'.$port; + } + + private function dispatchChildOperations(Operation $operation): void + { + $operation->update(['status' => OperationStatus::IN_PROGRESS]); + + $operation->children() + ->with('steps') + ->orderBy('id') + ->get() + ->first(fn (Operation $child): bool => $child->steps->isNotEmpty()) + ?->steps + ->sortBy('order') + ->first() + ?->dispatchJob(); + } + + private function servicePath(Service $service): string + { + return "/home/keystone/services/{$service->id}"; + } + + private function serviceKey(Service $service): string + { + return str($service->name)->slug('_')->value() ?: 'service'; + } + + private function defaultInternalPort(Service $service): int + { + return match ($service->type) { + \App\Enums\ServiceType::POSTGRES => 5432, + \App\Enums\ServiceType::VALKEY => 6379, + \App\Enums\ServiceType::CADDY, + \App\Enums\ServiceType::LARAVEL => 80, + }; + } +} diff --git a/app/Jobs/Services/DeployService.php b/app/Jobs/Services/DeployService.php index 751a79e..ba3df63 100644 --- a/app/Jobs/Services/DeployService.php +++ b/app/Jobs/Services/DeployService.php @@ -2,18 +2,23 @@ namespace App\Jobs\Services; -use App\Enums\DeploymentStatus; +use App\Actions\Services\ResolveServiceImageDigest; +use App\Data\Operations\PlannedStep; +use App\Enums\OperationKind; +use App\Enums\OperationStatus; use App\Enums\ServiceStatus; -use App\Models\Deployment; +use App\Models\Operation; use App\Models\Service; +use App\Services\Compose\ComposeRenderer; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; +use InvalidArgumentException; class DeployService implements ShouldQueue { use Queueable; - protected Deployment $deployment; + protected Operation $operation; public function __construct( public Service $service, @@ -24,20 +29,26 @@ class DeployService implements ShouldQueue public function handle(): void { $driver = $this->service->driver(); + $this->service->forceFill([ + 'available_image_digest' => app(ResolveServiceImageDigest::class)->execute($this->service), + ])->save(); $this->service->update([ 'status' => ServiceStatus::INSTALLING, ]); - $this->deployment = $this->service->deployments()->create([ - 'status' => DeploymentStatus::PENDING, + $this->operation = $this->service->operations()->create([ + 'kind' => OperationKind::SERVICE_DEPLOY, + 'status' => OperationStatus::PENDING, ]); - $deploymentPlan = $driver->getDeploymentPlan($this->deployment->hash); - foreach ($deploymentPlan->steps as $index => $plannedStep) { - $step = $this->deployment->steps()->create([ + $operationPlan = $driver->getOperationPlan($this->operation->hash); + $steps = $this->stepsWithComposeUpload($operationPlan->steps); + + foreach ($steps as $index => $plannedStep) { + $step = $this->operation->steps()->create([ 'name' => $plannedStep->name, 'order' => $index + 1, - 'status' => DeploymentStatus::PENDING, - 'script' => $plannedStep->getSafeScript(), - 'secrets' => $this->service->credentials, + 'status' => OperationStatus::PENDING, + 'script' => $plannedStep->getScriptTemplate(), + 'secrets' => $plannedStep->secrets(), ]); if ($index === 0) { $step->dispatchJob(); @@ -45,11 +56,45 @@ class DeployService implements ShouldQueue } } + /** + * @param array $steps + * @return array + */ + private function stepsWithComposeUpload(array $steps): array + { + try { + $renderer = app(ComposeRenderer::class); + $compose = $renderer->render($this->service); + $env = $renderer->renderEnvironmentFile($this->service); + } catch (InvalidArgumentException) { + return $steps; + } + + return [ + new PlannedStep( + name: 'Upload Compose file', + script: $this->composeUploadScript($compose, $env), + ), + ...$steps, + ]; + } + + private function composeUploadScript(string $compose, string $env): string + { + $servicePath = "/home/keystone/services/{$this->service->id}"; + + return implode("\n", [ + "mkdir -p {$servicePath}", + 'printf %s '.escapeshellarg(base64_encode($compose))." | base64 -d > {$servicePath}/compose.yml", + 'printf %s '.escapeshellarg(base64_encode($env))." | base64 -d > {$servicePath}/.env", + ]); + } + public function failed(\Throwable $exception): void { - if (isset($this->deployment)) { - $this->deployment->update([ - 'status' => DeploymentStatus::FAILED, + if (isset($this->operation)) { + $this->operation->update([ + 'status' => OperationStatus::FAILED, ]); $this->service->update([ 'status' => ServiceStatus::ERROR, diff --git a/app/Jobs/Services/RunStep.php b/app/Jobs/Services/RunStep.php index 285dd92..b7e99b4 100644 --- a/app/Jobs/Services/RunStep.php +++ b/app/Jobs/Services/RunStep.php @@ -2,95 +2,251 @@ namespace App\Jobs\Services; -use App\Enums\DeploymentStatus; +use App\Enums\OperationStatus; use App\Enums\ServiceStatus; -use App\Models\Step; +use App\Models\Environment; +use App\Models\Operation; +use App\Models\OperationStep; +use App\Models\Server; +use App\Models\Service; +use App\Models\ServiceReplica; +use App\Models\ServiceSlice; +use App\Services\Operations\RemoteCommandRunner; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; -use Symfony\Component\Process\Process; class RunStep implements ShouldQueue { use Queueable; public function __construct( - protected Step $step, + protected OperationStep $step, ) { // } public function handle(): void { - $this->step->load('deployment.target.server'); + $this->step->load('operation.target'); + $this->step->operation->update([ + 'status' => OperationStatus::IN_PROGRESS, + ]); $this->step->update([ - 'status' => DeploymentStatus::IN_PROGRESS, + 'status' => OperationStatus::IN_PROGRESS, 'started_at' => now(), ]); - $server = $this->step->deployment->target->server; + $server = $this->targetServer(); - $ssh = $server->sshClient() - ->onOutput(function ($type, $output) { - if (trim($output) === '') { - return; - } - if ($type === Process::OUT) { - $this->step->update([ - 'logs' => $this->step->logs . "\n" . trim($output), - ]); - } else { - $this->step->update([ - 'error_logs' => $this->step->error_logs . "\n" . trim($output), - ]); - } - }); - - $result = $ssh->execute($this->step->script); - - if (! $result->isSuccessful()) { - $this->step->update([ - 'status' => DeploymentStatus::FAILED, - 'finished_at' => now(), - 'error_logs' => $this->step->error_logs . "\n" . trim($result->getErrorOutput()), - ]); + try { + $output = app(RemoteCommandRunner::class)->run($server, $this->step->scriptForExecution()); + } catch (\Throwable $exception) { + $this->failStep($exception->getMessage()); return; } $this->step->update([ - 'status' => DeploymentStatus::COMPLETED, + 'status' => OperationStatus::COMPLETED, 'finished_at' => now(), + 'logs' => trim($this->step->logs."\n".$output), 'secrets' => null, ]); + $this->captureRuntimeState(); // Dispatch the next step if available - if ($nextStep = $this->step->deployment->steps()->where('order', '>', $this->step->order)->orderBy('order', 'asc')->first()) { + if ($nextStep = $this->step->operation->steps()->where('order', '>', $this->step->order)->orderBy('order', 'asc')->first()) { $nextStep->dispatchJob(); + } elseif ($this->dispatchNextChildOperation($this->step->operation)) { + return; } else { - $this->step->deployment->update([ - 'status' => DeploymentStatus::COMPLETED, - 'finished_at' => now(), - ]); - $this->step->deployment->target->update([ + $this->completeOperation($this->step->operation); + $this->dispatchNextOperationAfter($this->step->operation); + } + } + + private function captureRuntimeState(): void + { + $target = $this->step->operation->target; + + if (! $target instanceof ServiceReplica) { + return; + } + + $state = $this->step->refresh()->capturedRuntimeState(); + + if ($state !== []) { + $target->update($state); + } + } + + private function markTargetCompleted(): void + { + $target = $this->step->operation->target; + + if ($target instanceof Service) { + $target->update([ 'status' => ServiceStatus::RUNNING, ]); + + return; + } + + if ($target instanceof ServiceReplica) { + $target->update([ + 'status' => 'running', + 'health_status' => $target->health_status === 'unknown' ? 'healthy' : $target->health_status, + ]); } } + private function completeOperation(Operation $operation): void + { + $operation->update([ + 'status' => OperationStatus::COMPLETED, + 'finished_at' => now(), + ]); + + if ($operation->is($this->step->operation)) { + $this->markTargetCompleted(); + } + } + + private function dispatchNextChildOperation(Operation $operation): bool + { + $child = $operation->children() + ->where('status', OperationStatus::PENDING) + ->orderBy('id') + ->get() + ->first(fn (Operation $child): bool => $child->steps()->exists()); + + if (! $child) { + return false; + } + + $child->steps()->orderBy('order')->first()?->dispatchJob(); + + return true; + } + + private function dispatchNextOperationAfter(Operation $operation): void + { + $operation->loadMissing('parent'); + + if (! $operation->parent_id) { + return; + } + + $nextSibling = $operation->parent + ?->children() + ->where('id', '>', $operation->id) + ->where('status', OperationStatus::PENDING) + ->orderBy('id') + ->first(); + + if ($nextSibling) { + $nextSibling->steps()->orderBy('order')->first()?->dispatchJob(); + + return; + } + + $parent = $operation->parent; + + if ($parent && $parent->status === OperationStatus::IN_PROGRESS) { + $this->completeOperation($parent); + $this->dispatchNextOperationAfter($parent); + } + } + + private function targetServer(): Server + { + $target = $this->step->operation->target; + + $server = match (true) { + $target instanceof ServiceReplica => $target->server, + $target instanceof Service => $target->replicas()->with('server')->first()?->server ?: $target->server, + $target instanceof ServiceSlice => $target->service->replicas()->with('server')->first()?->server ?: $target->service->server, + $target instanceof Environment => $target->services()->with(['server', 'replicas.server'])->get() + ->flatMap(fn (Service $service) => $service->replicas->pluck('server')->filter()) + ->first() ?: $target->services()->with('server')->get()->pluck('server')->filter()->first(), + default => null, + }; + + if (! $server instanceof Server) { + throw new \RuntimeException('Operation target does not have a server for SSH execution.'); + } + + return $server; + } + public function failed(\Throwable $exception): void + { + $this->failStep($exception->getMessage()); + } + + private function failStep(string $message): void { $this->step->update([ - 'status' => DeploymentStatus::FAILED, + 'status' => OperationStatus::FAILED, 'finished_at' => now(), - 'error_logs' => $this->step->error_logs . "\n" . trim($exception->getMessage()), + 'error_logs' => $this->step->error_logs."\n".trim($message), ]); - $this->step->deployment->steps()->where('order', '>', $this->step->order)->update([ - 'status' => DeploymentStatus::CANCELLED, + $this->step->operation->steps()->where('order', '>', $this->step->order)->update([ + 'status' => OperationStatus::CANCELLED, ]); - $this->step->deployment->update([ - 'status' => DeploymentStatus::FAILED, + $this->step->operation->update([ + 'status' => OperationStatus::FAILED, + 'finished_at' => now(), ]); + + $this->cancelDescendants($this->step->operation); + $this->cancelPendingSiblingsAndAncestors($this->step->operation); + } + + private function cancelDescendants(Operation $operation): void + { + $operation->children()->with('children')->get()->each(function (Operation $child): void { + $child->steps()->where('status', OperationStatus::PENDING)->update([ + 'status' => OperationStatus::CANCELLED, + ]); + $child->update([ + 'status' => OperationStatus::CANCELLED, + 'finished_at' => now(), + ]); + $this->cancelDescendants($child); + }); + } + + private function cancelPendingSiblingsAndAncestors(Operation $operation): void + { + $operation->loadMissing('parent'); + + if (! $operation->parent) { + return; + } + + $operation->parent->children() + ->where('id', '!=', $operation->id) + ->whereIn('status', [OperationStatus::PENDING, OperationStatus::IN_PROGRESS]) + ->get() + ->each(function (Operation $sibling): void { + $sibling->steps()->where('status', OperationStatus::PENDING)->update([ + 'status' => OperationStatus::CANCELLED, + ]); + $sibling->update([ + 'status' => OperationStatus::CANCELLED, + 'finished_at' => now(), + ]); + $this->cancelDescendants($sibling); + }); + + $operation->parent->update([ + 'status' => OperationStatus::FAILED, + 'finished_at' => now(), + ]); + + $this->cancelPendingSiblingsAndAncestors($operation->parent); } } diff --git a/app/Models/Application.php b/app/Models/Application.php index e97a08e..af96f43 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -3,20 +3,24 @@ namespace App\Models; use App\Enums\RepositoryType; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; -use Illuminate\Database\Eloquent\Relations\HasManyThrough; use Illuminate\Database\Eloquent\Relations\MorphMany; class Application extends Model { + use HasFactory; + protected $guarded = []; protected function casts(): array { return [ 'repository_type' => RepositoryType::class, + 'deploy_key_private' => 'encrypted', + 'deploy_key_installed_at' => 'datetime', ]; } @@ -25,18 +29,13 @@ class Application extends Model return $this->belongsTo(Organisation::class); } - public function instances(): HasMany + public function environments(): HasMany { - return $this->hasMany(Instance::class); + return $this->hasMany(Environment::class); } - public function servers(): HasManyThrough + public function operations(): MorphMany { - return $this->hasManyThrough(Server::class, Instance::class); - } - - public function deployments(): MorphMany - { - return $this->morphMany(Deployment::class, 'target'); + return $this->morphMany(Operation::class, 'target'); } } diff --git a/app/Models/BuildArtifact.php b/app/Models/BuildArtifact.php new file mode 100644 index 0000000..ef64ead --- /dev/null +++ b/app/Models/BuildArtifact.php @@ -0,0 +1,35 @@ + BuildArtifactStatus::class, + 'metadata' => 'array', + ]; + } + + public function environment(): BelongsTo + { + return $this->belongsTo(Environment::class); + } + + public function builtByOperation(): BelongsTo + { + return $this->belongsTo(Operation::class, 'built_by_operation_id'); + } + + public function builtByService(): BelongsTo + { + return $this->belongsTo(Service::class, 'built_by_service_id'); + } +} diff --git a/app/Models/Deployment.php b/app/Models/Deployment.php deleted file mode 100644 index 4a00634..0000000 --- a/app/Models/Deployment.php +++ /dev/null @@ -1,39 +0,0 @@ -hash = str()->random(16); - }); - } - - protected function casts(): array - { - return [ - 'started_at' => 'datetime', - 'finished_at' => 'datetime', - ]; - } - - public function steps(): HasMany - { - return $this->hasMany(Step::class); - } - - public function target(): MorphTo - { - return $this->morphTo('target'); - } -} diff --git a/app/Models/Environment.php b/app/Models/Environment.php new file mode 100644 index 0000000..d48c62f --- /dev/null +++ b/app/Models/Environment.php @@ -0,0 +1,56 @@ + 'boolean', + 'scheduler_mode' => SchedulerMode::class, + 'build_config' => 'array', + ]; + } + + public function application(): BelongsTo + { + return $this->belongsTo(Application::class); + } + + public function services(): HasMany + { + return $this->hasMany(Service::class); + } + + public function attachments(): HasMany + { + return $this->hasMany(EnvironmentAttachment::class); + } + + public function variables(): HasMany + { + return $this->hasMany(EnvironmentVariable::class); + } + + public function buildArtifacts(): HasMany + { + return $this->hasMany(BuildArtifact::class); + } + + public function operations(): MorphMany + { + return $this->morphMany(Operation::class, 'target'); + } +} diff --git a/app/Models/EnvironmentAttachment.php b/app/Models/EnvironmentAttachment.php new file mode 100644 index 0000000..d648093 --- /dev/null +++ b/app/Models/EnvironmentAttachment.php @@ -0,0 +1,35 @@ + 'boolean', + 'role' => EnvironmentAttachmentRole::class, + ]; + } + + public function environment(): BelongsTo + { + return $this->belongsTo(Environment::class); + } + + public function service(): BelongsTo + { + return $this->belongsTo(Service::class); + } + + public function serviceSlice(): BelongsTo + { + return $this->belongsTo(ServiceSlice::class); + } +} diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php new file mode 100644 index 0000000..802d151 --- /dev/null +++ b/app/Models/EnvironmentVariable.php @@ -0,0 +1,31 @@ + 'encrypted', + 'source' => EnvironmentVariableSource::class, + 'overridable' => 'boolean', + ]; + } + + public function environment(): BelongsTo + { + return $this->belongsTo(Environment::class); + } + + public function serviceSlice(): BelongsTo + { + return $this->belongsTo(ServiceSlice::class); + } +} diff --git a/app/Models/Instance.php b/app/Models/Instance.php deleted file mode 100644 index 4b25bfa..0000000 --- a/app/Models/Instance.php +++ /dev/null @@ -1,34 +0,0 @@ - 'array', - ]; - } - - public function application(): BelongsTo - { - return $this->belongsTo(Application::class); - } - - public function server(): BelongsTo - { - return $this->belongsTo(Server::class); - } - - public function deployments(): MorphMany - { - return $this->morphMany(Deployment::class, 'target'); - } -} \ No newline at end of file diff --git a/app/Models/Operation.php b/app/Models/Operation.php new file mode 100644 index 0000000..c8030c8 --- /dev/null +++ b/app/Models/Operation.php @@ -0,0 +1,57 @@ +hash ??= str()->random(16); + }); + } + + protected function casts(): array + { + return [ + 'kind' => OperationKind::class, + 'status' => OperationStatus::class, + 'started_at' => 'datetime', + 'finished_at' => 'datetime', + ]; + } + + public function parent(): BelongsTo + { + return $this->belongsTo(Operation::class, 'parent_id'); + } + + public function children(): HasMany + { + return $this->hasMany(Operation::class, 'parent_id'); + } + + public function steps(): HasMany + { + return $this->hasMany(OperationStep::class); + } + + public function target(): MorphTo + { + return $this->morphTo('target'); + } +} diff --git a/app/Models/OperationStep.php b/app/Models/OperationStep.php new file mode 100644 index 0000000..b8ed3bd --- /dev/null +++ b/app/Models/OperationStep.php @@ -0,0 +1,85 @@ + OperationStatus::class, + 'started_at' => 'datetime', + 'finished_at' => 'datetime', + 'secrets' => 'encrypted:array', + ]; + } + + public function operation(): BelongsTo + { + return $this->belongsTo(Operation::class); + } + + public function logsExcerpt(): Attribute + { + return Attribute::make( + get: fn () => $this->logs ? Str::afterLast($this->logs, "\n") : null, + ); + } + + public function errorLogsExcerpt(): Attribute + { + return Attribute::make( + get: fn () => $this->error_logs ? Str::afterLast($this->error_logs, "\n") : null, + ); + } + + public function dispatchJob(): void + { + dispatch(new RunStep($this)); + } + + public function scriptForExecution(): string + { + $script = $this->script; + + foreach (($this->secrets ?? []) as $key => $value) { + $script = str_replace("[!{$key}!]", $value, $script); + } + + return $script; + } + + /** + * @return array{container_id?: string, health_status?: string} + */ + public function capturedRuntimeState(): array + { + $state = []; + + foreach (explode("\n", (string) $this->logs) as $line) { + if (str_starts_with($line, 'container_id=')) { + $state['container_id'] = trim((string) str($line)->after('container_id=')); + } + + if (str_starts_with($line, 'health_status=')) { + $state['health_status'] = trim((string) str($line)->after('health_status=')); + } + } + + return array_filter($state, fn (string $value): bool => $value !== ''); + } +} diff --git a/app/Models/Organisation.php b/app/Models/Organisation.php index eb3e032..73a3f4e 100644 --- a/app/Models/Organisation.php +++ b/app/Models/Organisation.php @@ -40,6 +40,21 @@ class Organisation extends Model return $this->hasMany(Application::class); } + public function services(): HasMany + { + return $this->hasMany(Service::class); + } + + public function registries(): HasMany + { + return $this->hasMany(Registry::class); + } + + public function sourceProviders(): HasMany + { + return $this->hasMany(SourceProvider::class); + } + public function providers(): HasMany { return $this->hasMany(Provider::class); diff --git a/app/Models/Registry.php b/app/Models/Registry.php new file mode 100644 index 0000000..ba76ad2 --- /dev/null +++ b/app/Models/Registry.php @@ -0,0 +1,27 @@ + RegistryType::class, + 'credentials' => 'encrypted:array', + ]; + } + + public function organisation(): BelongsTo + { + return $this->belongsTo(Organisation::class); + } +} diff --git a/app/Models/Server.php b/app/Models/Server.php index 47432cf..be797ef 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -44,14 +44,9 @@ class Server extends Model return $this->hasMany(Service::class); } - public function instances(): HasMany + public function serviceReplicas(): HasMany { - return $this->hasMany(Instance::class); - } - - public function applications(): HasManyThrough - { - return $this->hasManyThrough(Application::class, Instance::class); + return $this->hasMany(ServiceReplica::class); } public function firewallRules(): HasMany @@ -64,26 +59,16 @@ class Server extends Model return $this->belongsTo(Provider::class); } - public function serviceDeployments(): HasManyThrough + public function serviceOperations(): HasManyThrough { return $this->hasManyThrough( - Deployment::class, + Operation::class, Service::class, 'server_id', 'target_id', )->where('target_type', (new Service)->getMorphClass()); } - public function applicationDeployments(): HasManyThrough - { - return $this->hasManyThrough( - Deployment::class, - Application::class, - 'server_id', - 'target_id', - )->where('target_type', (new Application)->getMorphClass()); - } - public function sshClient(string $user = 'root'): Ssh { return Ssh::create($user, $this->ipv4) diff --git a/app/Models/Service.php b/app/Models/Service.php index 1a5d140..a369b19 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -4,16 +4,21 @@ namespace App\Models; use App\Drivers\DatabaseDriver; use App\Drivers\Driver; +use App\Enums\DeployPolicy; use App\Enums\ServiceCategory; use App\Enums\ServiceStatus; use App\Enums\ServiceType; use Illuminate\Database\Eloquent\Casts\Attribute; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphMany; class Service extends Model { + use HasFactory; + protected $guarded = []; protected $hidden = ['credentials', 'container_name', 'container_id']; @@ -24,6 +29,10 @@ class Service extends Model 'status' => ServiceStatus::class, 'category' => ServiceCategory::class, 'type' => ServiceType::class, + 'deploy_policy' => DeployPolicy::class, + 'process_roles' => 'array', + 'default_cpu_limit' => 'decimal:3', + 'config' => 'array', 'credentials' => 'encrypted:array', ]; } @@ -31,7 +40,7 @@ class Service extends Model public function folderName(): Attribute { return new Attribute( - get: fn() => $this->name . '-' . $this->id, + get: fn () => $this->name.'-'.$this->id, ); } @@ -40,14 +49,41 @@ class Service extends Model return $this->belongsTo(Server::class); } - public function deployments(): MorphMany + public function organisation(): BelongsTo { - return $this->morphMany(Deployment::class, 'target'); + return $this->belongsTo(Organisation::class); + } + + public function environment(): BelongsTo + { + return $this->belongsTo(Environment::class); + } + + public function replicas(): HasMany + { + return $this->hasMany(ServiceReplica::class); + } + + public function slices(): HasMany + { + return $this->hasMany(ServiceSlice::class); + } + + public function endpoints(): HasMany + { + return $this->hasMany(ServiceEndpoint::class); + } + + public function operations(): MorphMany + { + return $this->morphMany(Operation::class, 'target'); } public function driver(): Driver { - $class = config("keystone.drivers.{$this->driver_name}"); + [$driverType, $versionTrack] = array_pad(explode('.', $this->driver_name, 2), 2, null); + $class = config('keystone.drivers')[$driverType][$versionTrack] ?? null; + if (! class_exists($class)) { throw new \Exception("Driver class {$class} not found"); } diff --git a/app/Models/ServiceEndpoint.php b/app/Models/ServiceEndpoint.php new file mode 100644 index 0000000..f10ce06 --- /dev/null +++ b/app/Models/ServiceEndpoint.php @@ -0,0 +1,29 @@ + ServiceEndpointScope::class, + ]; + } + + public function service(): BelongsTo + { + return $this->belongsTo(Service::class); + } + + public function serviceReplica(): BelongsTo + { + return $this->belongsTo(ServiceReplica::class); + } +} diff --git a/app/Models/ServiceReplica.php b/app/Models/ServiceReplica.php new file mode 100644 index 0000000..f64b3f6 --- /dev/null +++ b/app/Models/ServiceReplica.php @@ -0,0 +1,43 @@ + 'decimal:3', + 'config' => 'array', + ]; + } + + public function service(): BelongsTo + { + return $this->belongsTo(Service::class); + } + + public function server(): BelongsTo + { + return $this->belongsTo(Server::class); + } + + public function operation(): BelongsTo + { + return $this->belongsTo(Operation::class); + } + + public function operations(): MorphMany + { + return $this->morphMany(Operation::class, 'target'); + } +} diff --git a/app/Models/ServiceSlice.php b/app/Models/ServiceSlice.php new file mode 100644 index 0000000..71fc575 --- /dev/null +++ b/app/Models/ServiceSlice.php @@ -0,0 +1,44 @@ + 'array', + 'credentials' => 'encrypted:array', + ]; + } + + public function service(): BelongsTo + { + return $this->belongsTo(Service::class); + } + + public function environment(): BelongsTo + { + return $this->belongsTo(Environment::class); + } + + public function attachments(): HasMany + { + return $this->hasMany(EnvironmentAttachment::class); + } + + public function operations(): MorphMany + { + return $this->morphMany(Operation::class, 'target'); + } +} diff --git a/app/Models/SourceProvider.php b/app/Models/SourceProvider.php new file mode 100644 index 0000000..709092b --- /dev/null +++ b/app/Models/SourceProvider.php @@ -0,0 +1,25 @@ + SourceProviderType::class, + 'config' => 'array', + ]; + } + + public function organisation(): BelongsTo + { + return $this->belongsTo(Organisation::class); + } +} diff --git a/app/Models/Step.php b/app/Models/Step.php deleted file mode 100644 index 4ba67bc..0000000 --- a/app/Models/Step.php +++ /dev/null @@ -1,52 +0,0 @@ - 'datetime', - 'finished_at' => 'datetime', - 'secrets' => 'encrypted:array', - ]; - } - - public function deployment(): BelongsTo - { - return $this->belongsTo(Deployment::class); - } - - public function logsExcerpt(): Attribute - { - return Attribute::make( - get: fn () => $this->logs ? Str::afterLast($this->logs, "\n"): null, - ); - } - - public function errorLogsExcerpt(): Attribute - { - return Attribute::make( - get: fn () => $this->error_logs ? Str::afterLast($this->error_logs, "\n"): null, - ); - } - - public function dispatchJob(): void - { - dispatch(new RunStep($this)); - } -} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 6304829..91fe772 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,14 +3,18 @@ namespace App\Providers; use App\Models\Application; -use App\Models\Deployment; -use App\Models\Instance; +use App\Models\Environment; +use App\Models\Operation; +use App\Models\OperationStep; use App\Models\Organisation; use App\Models\OrganisationUser; use App\Models\Server; use App\Models\Service; -use App\Models\Step; +use App\Models\ServiceReplica; +use App\Models\ServiceSlice; use App\Models\User; +use App\Services\Operations\RemoteCommandRunner; +use App\Services\Operations\SshRemoteCommandRunner; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\ServiceProvider; @@ -21,7 +25,7 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - // + $this->app->bind(RemoteCommandRunner::class, SshRemoteCommandRunner::class); } /** @@ -31,13 +35,15 @@ class AppServiceProvider extends ServiceProvider { Relation::enforceMorphMap([ 'application' => Application::class, - 'deployment' => Deployment::class, - 'instance' => Instance::class, + 'environment' => Environment::class, 'organisation' => Organisation::class, 'organisation-user' => OrganisationUser::class, + 'operation' => Operation::class, 'server' => Server::class, 'service' => Service::class, - 'step' => Step::class, + 'service-replica' => ServiceReplica::class, + 'service-slice' => ServiceSlice::class, + 'operation-step' => OperationStep::class, 'user' => User::class, ]); } diff --git a/app/Services/Compose/ComposeRenderer.php b/app/Services/Compose/ComposeRenderer.php new file mode 100644 index 0000000..15ce27d --- /dev/null +++ b/app/Services/Compose/ComposeRenderer.php @@ -0,0 +1,149 @@ +driver(); + + if (! $driver instanceof RendersCompose) { + throw new InvalidArgumentException("Driver [{$service->driver_name}] cannot render Docker Compose."); + } + + $composeService = $driver->composeService(); + + if ($service->default_cpu_limit && ! isset($composeService['cpus'])) { + $composeService['cpus'] = (string) $service->default_cpu_limit; + } + + if ($service->default_memory_limit_mb && ! isset($composeService['mem_limit'])) { + $composeService['mem_limit'] = "{$service->default_memory_limit_mb}m"; + $composeService['memswap_limit'] = "{$service->default_memory_limit_mb}m"; + } + + $document = [ + 'services' => [ + $this->serviceKey($service) => $composeService, + ], + ]; + + $volumes = array_filter($driver->composeVolumes(), fn (mixed $volume): bool => $volume !== false); + + if ($volumes !== []) { + $document['volumes'] = $volumes; + } + + return $this->toYaml($document); + } + + public function renderEnvironmentFile(Service $service): string + { + $driver = $service->driver(); + + if (! method_exists($driver, 'environmentExports')) { + return ''; + } + + return collect($driver->environmentExports()) + ->map(fn (mixed $value, string $key): string => $key.'='.$this->formatEnvValue($value)) + ->implode("\n"); + } + + private function serviceKey(Service $service): string + { + return str($service->name)->slug('_')->value() ?: 'service'; + } + + /** + * @param array $document + */ + private function toYaml(array $document, int $indent = 0): string + { + $lines = []; + + foreach ($document as $key => $value) { + $prefix = str_repeat(' ', $indent); + + if (is_array($value)) { + if ($value === []) { + $lines[] = "{$prefix}{$key}: []"; + + continue; + } + + $lines[] = "{$prefix}{$key}:"; + $lines[] = $this->arrayToYaml($value, $indent + 1); + + continue; + } + + if ($value === null) { + $lines[] = "{$prefix}{$key}: {}"; + + continue; + } + + $lines[] = "{$prefix}{$key}: ".$this->formatScalar($value); + } + + return implode("\n", array_filter($lines))."\n"; + } + + /** + * @param array $items + */ + private function arrayToYaml(array $items, int $indent): string + { + if (array_is_list($items)) { + return $this->listToYaml($items, $indent); + } + + return $this->toYaml($items, $indent); + } + + /** + * @param array $items + */ + private function listToYaml(array $items, int $indent): string + { + $lines = []; + $prefix = str_repeat(' ', $indent); + + foreach ($items as $item) { + if (is_array($item)) { + $lines[] = "{$prefix}-"; + $lines[] = $this->arrayToYaml($item, $indent + 1); + + continue; + } + + $lines[] = "{$prefix}- ".$this->formatScalar($item); + } + + return implode("\n", $lines); + } + + private function formatScalar(mixed $value): string + { + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + + if (is_numeric($value)) { + return (string) $value; + } + + return '"'.str_replace('"', '\"', (string) $value).'"'; + } + + private function formatEnvValue(mixed $value): string + { + return str_replace(["\n", "\r"], ['\n', ''], (string) $value); + } +} diff --git a/app/Services/Operations/RemoteCommandRunner.php b/app/Services/Operations/RemoteCommandRunner.php new file mode 100644 index 0000000..49ae693 --- /dev/null +++ b/app/Services/Operations/RemoteCommandRunner.php @@ -0,0 +1,10 @@ +sshClient()->execute($script); + + if (! $result->isSuccessful()) { + throw new RuntimeException(trim($result->getErrorOutput()) ?: 'Remote command failed.'); + } + + return trim($result->getOutput()); + } +} diff --git a/bun.lockb b/bun.lockb index b1c91e1eaef17994e2f49ec591bf4668ac73d2c7..13eb01755e5fc5b0be8bf7fffc51d54e6da2fd6c 100755 GIT binary patch delta 6367 zcmai2T~Jil9p?h-xnZqKOIzFW6(tqKf+7l{=;|6dyC~iDE5<}iB%skC5{$u*;EEqP z2oLU_&X9*b@YtEWv`^caOk3M&Cx@Bjfk|IG;ki$JYv0=c`Tx(|`@f4O%)sS$&pH3^ zuX8{9$19)z`;}+gy{dfs*2>R{`OG$N`S;VZUO%fB`gwg_bp`zBcxs>a8*0{B;Ol%x zkCqLwjfm_Y`1!tmP`^zldVD?CP=hPD8CEvp%5xxR8e;fzgU+_}XwgU%*6x!pSpnLO zFQnaA_Y#hx>7_iHNR~>S?ds90DX#xeAJK7e>ZL1U%Zq3yw$z~^*Ad+KM^QL7pT5`1|fzRgG z=sE13OYXVqp3OgZ>+ZSao~!QJyv?n<=aPG_ZtDf^wXI2=Y268&sv&TNR;?tVKo6VN zsBWSy#EG^#eVTb)wUOLQZNzWYJ)5_8qm56JjkgnhLS}m`piyWKT#@et)3nnzFn$^W z&7npTDuTog+G*E%b!rDWjdm)w2Cj%8@r&>r@QWZ7K_-GkN90@wy0BvaOquUUVnSi^ zLzsjiB06K%)SaCrGs{j2C(nhGOZPEI*3M0qRwo2S2TQ4)VE9hLiJfFA2q~L{uyVR3 z+-Y{ksQP5U9AZ}hI(oI-MUq$Ryglbo4cXKo2>m7q_O9Sq3mof;FaCDa+ezWkW%bQ> z4dAOnyRZG-o3IEp@9uVd;f&fH({*Vl)b7o=VRiQ~#0zp(=?A`fs2D2GT%;|~1D9>}!P0ntyKbK9Ue_uGig z_9rpT<2u_7#j~HHVzr-=pW;d$jIcTA+Q-2#FvOv#z2za=GkgeIOpp)fe7$^#Hw2Mj zu^>56iKtCYBD9z|9L*dFnEP5Cc6$#qiyuLs93fp@2I9N?ktEBzBo`>@Y?Y)K{~?Y7 zCTS_HB-tr>)G2$Ej7B}f93`VwW1@=Jxe291mDr<{LqDLDem$zXI956d=`W9!DeG1# z0nITK?ii^_T~`k9ycmGvt{&M)G-X)Gka=PnVnjj;vh0njVPqg0W`Byz(3}FFv)ofD z#0|6IAiAgA3gFqk%Do{cWhU-=-H5gy~@~9{D zr-KJDwl`9BJWkRQlE;};uR*anPLa7z+ZJ!^Dq|r|1e#)(J3+blJ)T<1ZK#>3 z^{~bMV52AuQ6#OW0c(i1__Txd9W_+q28GR(5L)?{v_|z`zEG0y-Xby)Dw&whWN4U3 zw1F)$H6@Hul3{5fvVm||v$M8EX0y~HbJ;qE@MvV+z7Z$+3HwH_d7~n4a_1pvPS%uB z7NOIyq@m>-W&rh^;VR&DZ}FJa6R#Ml$T;_C^y4a zFaHALZRNfY(z5#%U|s{wg&=8gPP<5G1J)&ZF{pKR0=Q^hn!iZJkgEE(&m+I0ngX4A zxL5nbwx`z)!xYLZ@G#F`X!CU41+l+sG?z#-K^1p|BRLYo zQdev1oz70RYdeL^jRX^cwTu}F?PmLr|87m#o#nfM*>He%2*%<;IOvcYghy#-72V!P ztxdC|WYf(!YZ|wMdZZY`7Bof|0qhwd<9O+=sKx>Vjy0q4R>$xXb~$4z@sc?H#k#<#Z_j+t8P-q zsJu#KLFrkT^{$rn_G^KXHlVM$ndX`k%X(w^8tQXxA`GDS14E-r9CDY?_d_ugPr#Ss z?#40Rjd6f?D7am<(+y?Zmb?78v(k9SSD>wNZCO?dxS0B=ON;6YJRt0g3#;(QRje4zgJ-J1@Vcvn< zRz{i)ku4iRoydsOggB z=+5Z&;hEdarSdkhgilCz-sDThEzuk=?x45tK&rFBS=|9XY%n$)->r+3UwJpw4p`l_ zfnF*HY_Bbc{=G1E>?oE3%=xH$PH#x4WxK|lD?TPZZtiy|1|KDWt3%G!4$(zO%+R@N zy#fV4H3L*xbx|{zH8b{8$;u1`2h?19M|S3Zpt7wCB1ecWIMN}A zP;2>IOh3zo(~HUB*hBGMco6+~ARh$jYWq$p`ml495XRyx3xm60#@S6S_D+GQvm`2M zZ=e(g{ZKsQ5%Q4Hus!`@xY0ZeG{VR(@m?#5dPF-RW55l@p%`5XAJZB+MLrIcvtME! zM+XYSu>J1rKM53iLj4~;Guy92PuMXQS{VMHur*ua%djfLCd35=esD$h3|Lb|ryr$A zkM&sCjAC{>45H4Fx#4DIgJur>JICtRoU`kk`~IE*{l(LOq*Y5kWy6OGfE_-}Q#_2N zf8JKc+WC;OoF^i6RDb5F3@^=h2g+cH5DT;m?uC}s0yNJ-`{gM1@XzhURQd~IWN2*Q*2$@%#a#p5r~d~o0>Nzn delta 5596 zcma(VTW}NCbyrBBMVEMKL|GaP0*4^5i5JX61{up3h%h7wC}0Q=L3k)U#Vd%n00Slv z5IO{lqpJz0KQO|rD zLK6g(w4h%JXrP{{KKN-y3smuZ0^S-2uhyE-IwN46XJ^!I3|n9A7N&b<4bwl9dAUq@ zwJi*Ky&pzr>M%Op1T)01T%X#~ z7Ys*`W(Go&1r&O>W(T(}^)B;q4nFwinyF88e9p;9$QTlMVA7mU=$;d`T);`oa}21? ziQ!)ulJ6OieUHAY_^r>S@9bOy`mJw0Y=9Y#A#NUg6P^ks{N}XgJOdK*BH*84LTMgT zCkiKX3I6%a>qkB8SS04lx)*9te!CV3DoPwJx4h8=p~jHlo^CWC*C-_Ao0xC2ZC2a` z<(e2uX%Z11K@5-4c=Fc*=Ht!?2q?9HdATozN|mryH&sz~SaR)iuKSpLF}6gjGZf{M zH(l&~U*ahI>N;o+I`TL+*(_bJY|!9YbHIJ0D&OEF5mO@J2U$~xf*FHMhY9*Z=JTRK zlpNg)nc86czaMejp_r_5O!()*IvBl%g|LXJ6P!X=6i~<86s1MX%bjIM%-_P)aW*>9 zBIRqYC8&D(UhGvHh1V-Bq=--WIlP#812UuZV$y~Bzz{-QYmuS(B{~>OVldWXLJq$N z@S9g`XsL7wQcIZ{5SnB+YyJjBS&q^0!;7USA#vD%?qx9;SrPT|+NZoXA`zij()t~U z$fi^yI`~_os_IyfZKWJaF3{j*YrxJYeYulc-OFV&`Vry+$*9rgywG@u;XyK2NR8;t z8pf9CLhqr&DG0R%-JVakIZ}t(u!mhU^DEI&D^r4fZY5baV*Q5NrO+hXgHB)J$`sqF z2&kcqSW z^y-MV6Q|mN{#_Ln`R4)kzNs%zlx=u-ye;R|EfZd@!lcAl>z!mycc<*b{!Sf+I-^#) z3a_pv|5J!Fs}VmF+YbJYtd80dOROPog*9@>D+oF939qF)Y5Xm&HDI8@%zyA&_QyX! zBe1RI5MgyD8Y&}*;DSSOVv>q8-xM^A=2|hX!*D$6CW)=XV6TgcUPRiL*D)_I0^4Us zmz-Qlgm`lKp<|;7&$C%BbPd?d%G@DZQA3t6k8bYSRd52zJLc%v*JjgS+Zp^Z93 z7MS@2lM6JCpw)3O`4~2UHss2L-#OQEB3Mh64vnoKp$?koPim8{21N=NH!G zb3w7qHPy14gJPD{37RsVQ|sisj4h}gEZEZSdkgtcVGL{`kJ4UI#T(Ht~BS)e1 zIlB2sz*!mcMed_FMb2@jn#;bhR zpdo(_htav1_%7o2@Htw+VxPUXF7)Do>%CL{%u@fnA-0_4d2(F?bR5Bvem` zr@NI^8nk9;E6QeY(a)f?X3c+?-gXMNx*5s^4R%}z`06kaG*kL7FmHV=z?BOIjD&F4 zzet0Imh#L+dwz0Xl-!{I5=AU=>9w__fQW)=Lgf@_8EBPcdJy3l`v*27W!#f=3P-drY1Jad!IOE;zT z?s2{oJrT(DQ1(@NuxOlA58on9(zmEgaS=Oa!gsgo_@bJ>UE5n?w}aA-mcw&rV=LZ~ zBNw_uj7Z}Y;+*nxZgTB^xf7s9gYR{gp@{IwPn@&8*uKtdenc$7Pj>4w>=1uiPeF5W6A%1zi+4VBh2{T@!+dz6G8z~_bk zlA>(oEdai~*Qx68pL^{N^Ys0od!sJhr{+(c&;JEenO&U;geWt)MFoT$e^$PbTJD>& zXXt?)+WbRoIS-?b7Mxw~A*I6zBE_pBNB5KM8ho0glqu$zkMI8!rhzQ){O%m>eMD|1 z$Ce*4FL$$D2u5D=BbS=Y%g^h9ygW*)E(o;~dS!um?HR)R)Jw377#TX*f&~dX9@lWi zZjIff#~<&Gw2D+7%Y)qf(w2T6$Lf~>Dr~N1$w49He?kRFWtn(lI~EFvp$7)fvdR;t zhD7v2eKc0neX_Yjeay@E@tS+`h1yGqKepBt0@C9Rx86sEW!Q3qs$nNY=qdBz|5;h_ zNIj+5RD5c+B(C`wUg0kcADnt@vU~I#;X;GRrTJ#hSA#uYslR_;d(PXMqQt%*?0TvG zdz|(?!GHW=u8&5UG0!u>qtz1(&j$029_@Z# I>sB@QKaFzd)c^nh diff --git a/composer.lock b/composer.lock index fe26904..954a238 100644 --- a/composer.lock +++ b/composer.lock @@ -6602,16 +6602,16 @@ }, { "name": "laravel/boost", - "version": "v1.1.4", + "version": "v1.1.5", "source": { "type": "git", "url": "https://github.com/laravel/boost.git", - "reference": "70f909465bf73dad7e791fad8b7716b3b2712076" + "reference": "4bd1692c064b2135eb2f8f7bd8bcb710e5e75f86" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/70f909465bf73dad7e791fad8b7716b3b2712076", - "reference": "70f909465bf73dad7e791fad8b7716b3b2712076", + "url": "https://api.github.com/repos/laravel/boost/zipball/4bd1692c064b2135eb2f8f7bd8bcb710e5e75f86", + "reference": "4bd1692c064b2135eb2f8f7bd8bcb710e5e75f86", "shasum": "" }, "require": { @@ -6663,7 +6663,7 @@ "issues": "https://github.com/laravel/boost/issues", "source": "https://github.com/laravel/boost" }, - "time": "2025-09-04T12:16:09+00:00" + "time": "2025-09-18T07:33:27+00:00" }, { "name": "laravel/mcp", @@ -9569,5 +9569,5 @@ "php": "^8.2" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/config/keystone.php b/config/keystone.php index 9857bd5..f58586d 100644 --- a/config/keystone.php +++ b/config/keystone.php @@ -1,18 +1,26 @@ [ 'postgres' => [ - '17' => Postgres17Driver::class, + '18' => Postgres18Driver::class, ], 'caddy' => [ '2' => Caddy2Driver::class, - ] + ], + 'valkey' => [ + '8' => Valkey8Driver::class, + ], + 'laravel' => [ + 'php-8.4' => LaravelRuntimeDriver::class, + ], ], 'services' => [ @@ -21,37 +29,15 @@ return [ 'name' => ServiceType::POSTGRES, 'description' => 'PostgreSQL', 'versions' => [ - '17' => [ - 'name' => 'PostgreSQL 17', - 'description' => 'PostgreSQL 17', - 'image' => 'postgres:17', - ], - ], - ], - ServiceType::MYSQL->value => [ - 'name' => ServiceType::MYSQL, - 'description' => 'MySQL', - 'versions' => [ - '9.0' => [ - 'name' => 'MySQL 9.2', - 'description' => 'MySQL 9.2', - 'image' => 'mysql:9.2' + '18' => [ + 'name' => 'PostgreSQL 18', + 'description' => 'PostgreSQL 18', + 'image' => 'postgres:18', ], ], ], ], ServiceCategory::GATEWAY->value => [ - ServiceType::NGINX->value => [ - 'name' => ServiceType::NGINX, - 'description' => 'Nginx', - 'versions' => [ - '1.27' => [ - 'name' => 'Nginx 1.27', - 'description' => 'Nginx 1.27', - 'image' => 'nginx:1.27', - ], - ], - ], ServiceType::CADDY->value => [ 'name' => ServiceType::CADDY, 'description' => 'Caddy', @@ -59,60 +45,38 @@ return [ '2' => [ 'name' => 'Caddy 2', 'description' => 'Caddy 2', - 'image' => 'caddy:2' + 'image' => 'caddy:2', ], ], ], ], ServiceCategory::APPLICATION->value => [ - ServiceType::PHP_FPM->value => [ - 'name' => ServiceType::PHP_FPM, - 'description' => 'PHP-FPM', + ServiceType::LARAVEL->value => [ + 'name' => ServiceType::LARAVEL, + 'description' => 'Laravel managed runtime', 'versions' => [ - '8.4' => [ - 'name' => 'PHP 8.4', - 'description' => 'PHP 8.4', - 'image' => 'serversideup/php:8.4-fpm-nginx', - ], - ], - ], - ServiceType::FRANKENPHP->value => [ - 'name' => ServiceType::FRANKENPHP, - 'description' => 'FrankenPHP', - 'versions' => [ - '1.5' => [ - 'name' => 'FrankenPHP 1.5', - 'description' => 'FrankenPHP 1.5', - 'image' => 'dunglas/frankenphp:1.5-php8.4-bookworm', + 'php-8.4' => [ + 'name' => 'Laravel PHP 8.4 FrankenPHP', + 'description' => 'serversideup/php FrankenPHP Laravel runtime', + 'image' => 'serversideup/php:8.4-frankenphp', ], ], ], ], ServiceCategory::CACHE->value => [ - ServiceType::REDIS->value => [ - 'name' => ServiceType::REDIS, - 'description' => 'Redis', - 'versions' => [ - '7.4' => [ - 'name' => 'Redis 7.4', - 'description' => 'Redis 7.4', - 'image' => 'redis:7.4', - ], - ], - ], ServiceType::VALKEY->value => [ 'name' => ServiceType::VALKEY, 'description' => 'Valkey', 'versions' => [ - '8.1' => [ - 'name' => 'Valkey 8.1', - 'description' => 'Valkey 8.1', - 'image' => 'valkey/valkey:8.1', + '8' => [ + 'name' => 'Valkey 8', + 'description' => 'Valkey 8', + 'image' => 'valkey/valkey:8', ], ], ], ], ServiceCategory::STORAGE->value => [ ], - ] + ], ]; diff --git a/database/factories/ApplicationFactory.php b/database/factories/ApplicationFactory.php new file mode 100644 index 0000000..bac1e81 --- /dev/null +++ b/database/factories/ApplicationFactory.php @@ -0,0 +1,29 @@ + + */ +class ApplicationFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'organisation_id' => Organisation::factory(), + 'name' => $this->faker->words(2, true), + 'repository_url' => 'git@example.com:org/'.$this->faker->slug().'.git', + 'repository_type' => RepositoryType::GIT, + 'default_branch' => 'main', + ]; + } +} diff --git a/database/factories/EnvironmentFactory.php b/database/factories/EnvironmentFactory.php new file mode 100644 index 0000000..218beb9 --- /dev/null +++ b/database/factories/EnvironmentFactory.php @@ -0,0 +1,31 @@ + + */ +class EnvironmentFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'application_id' => Application::factory(), + 'name' => 'production', + 'branch' => 'main', + 'status' => 'active', + 'scheduler_enabled' => true, + 'scheduler_mode' => SchedulerMode::SINGLE, + 'build_config' => [], + ]; + } +} diff --git a/database/factories/OperationFactory.php b/database/factories/OperationFactory.php new file mode 100644 index 0000000..866c83b --- /dev/null +++ b/database/factories/OperationFactory.php @@ -0,0 +1,29 @@ + + */ +class OperationFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'kind' => OperationKind::SERVICE_DEPLOY, + 'target_type' => (new Service)->getMorphClass(), + 'target_id' => Service::factory(), + 'status' => OperationStatus::PENDING, + ]; + } +} diff --git a/database/factories/ServiceFactory.php b/database/factories/ServiceFactory.php new file mode 100644 index 0000000..b0ec7b9 --- /dev/null +++ b/database/factories/ServiceFactory.php @@ -0,0 +1,37 @@ + + */ +class ServiceFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => $this->faker->word(), + 'status' => ServiceStatus::NOT_INSTALLED, + 'category' => ServiceCategory::DATABASE, + 'type' => ServiceType::POSTGRES, + 'version' => '18', + 'version_track' => '18', + 'driver_name' => 'postgres.18', + 'desired_replicas' => 1, + 'deploy_policy' => DeployPolicy::DEPENDENCY_ONLY, + 'process_roles' => [], + 'config' => [], + ]; + } +} diff --git a/database/factories/ServiceReplicaFactory.php b/database/factories/ServiceReplicaFactory.php new file mode 100644 index 0000000..956814e --- /dev/null +++ b/database/factories/ServiceReplicaFactory.php @@ -0,0 +1,33 @@ + + */ +class ServiceReplicaFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'service_id' => Service::factory(), + 'server_id' => Server::factory(), + 'container_name' => $this->faker->slug(), + 'image_digest' => 'sha256:'.$this->faker->sha256(), + 'internal_host' => $this->faker->domainWord(), + 'internal_port' => 8080, + 'status' => 'running', + 'health_status' => 'healthy', + 'config' => [], + ]; + } +} diff --git a/database/factories/ServiceSliceFactory.php b/database/factories/ServiceSliceFactory.php new file mode 100644 index 0000000..17f611e --- /dev/null +++ b/database/factories/ServiceSliceFactory.php @@ -0,0 +1,33 @@ + + */ +class ServiceSliceFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'service_id' => Service::factory(), + 'name' => $this->faker->slug(), + 'type' => 'database_user', + 'status' => 'active', + 'config' => [], + 'credentials' => [ + 'username' => $this->faker->userName(), + 'password' => $this->faker->password(16), + 'database' => $this->faker->slug(), + ], + ]; + } +} diff --git a/database/migrations/2025_03_27_114736_create_applications_table.php b/database/migrations/2025_03_27_114736_create_applications_table.php index ea59836..b73c87b 100644 --- a/database/migrations/2025_03_27_114736_create_applications_table.php +++ b/database/migrations/2025_03_27_114736_create_applications_table.php @@ -15,6 +15,11 @@ return new class extends Migration $table->string('name'); $table->string('repository_url'); $table->string('repository_type'); + $table->string('default_branch')->default('main'); + $table->text('deploy_key_public')->nullable(); + $table->text('deploy_key_private')->nullable(); + $table->string('deploy_key_fingerprint')->nullable(); + $table->timestamp('deploy_key_installed_at')->nullable(); $table->timestamps(); }); } diff --git a/database/migrations/2025_03_27_121049_create_environments_table.php b/database/migrations/2025_03_27_121049_create_environments_table.php new file mode 100644 index 0000000..b837850 --- /dev/null +++ b/database/migrations/2025_03_27_121049_create_environments_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignIdFor(Application::class)->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('branch'); + $table->string('status')->default('pending'); + $table->boolean('scheduler_enabled')->default(true); + $table->foreignId('scheduler_target_service_id')->nullable(); + $table->string('scheduler_mode')->default('single'); + $table->json('build_config')->nullable(); + $table->timestamps(); + + $table->unique(['application_id', 'name']); + }); + } + + public function down(): void + { + Schema::dropIfExists('environments'); + } +}; diff --git a/database/migrations/2025_03_27_121050_create_services_table.php b/database/migrations/2025_03_27_121050_create_services_table.php index fad4080..197bf42 100644 --- a/database/migrations/2025_03_27_121050_create_services_table.php +++ b/database/migrations/2025_03_27_121050_create_services_table.php @@ -1,5 +1,6 @@ id(); - $table->foreignIdFor(Server::class); + $table->foreignIdFor(Organisation::class)->nullable(); + $table->foreignId('environment_id')->nullable(); + $table->foreignIdFor(Server::class)->nullable(); $table->string('name'); $table->string('status'); $table->string('category'); // database / cache / webserver $table->string('type'); // postgres / redis / caddy - $table->string('version'); // 17 / 7 / 2 + $table->string('version')->nullable(); // legacy alias for version_track + $table->string('version_track'); // 18 / 8 / 2 / php-8.4 $table->string('driver_name'); + $table->unsignedInteger('desired_replicas')->default(1); + $table->string('desired_revision')->nullable(); + $table->string('deploy_policy')->default('manual'); + $table->json('process_roles')->nullable(); + $table->string('current_image_digest')->nullable(); + $table->string('available_image_digest')->nullable(); + $table->string('update_status')->nullable(); + $table->decimal('default_cpu_limit', 8, 3)->nullable(); + $table->unsignedInteger('default_memory_limit_mb')->nullable(); + $table->json('config')->nullable(); $table->text('credentials')->nullable(); $table->string('container_name')->nullable(); $table->string('container_id')->nullable(); diff --git a/database/migrations/2025_03_27_121051_create_service_replicas_table.php b/database/migrations/2025_03_27_121051_create_service_replicas_table.php new file mode 100644 index 0000000..3a4e1e8 --- /dev/null +++ b/database/migrations/2025_03_27_121051_create_service_replicas_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignIdFor(Service::class)->constrained()->cascadeOnDelete(); + $table->foreignIdFor(Server::class)->constrained()->cascadeOnDelete(); + $table->foreignId('operation_id')->nullable(); + $table->string('container_name'); + $table->string('container_id')->nullable(); + $table->string('image_digest')->nullable(); + $table->string('internal_host'); + $table->unsignedInteger('internal_port'); + $table->unsignedInteger('public_port')->nullable(); + $table->string('status')->default('pending'); + $table->string('health_status')->default('unknown'); + $table->decimal('cpu_limit', 8, 3)->nullable(); + $table->unsignedInteger('memory_limit_mb')->nullable(); + $table->json('config')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('service_replicas'); + } +}; diff --git a/database/migrations/2025_03_27_121052_create_service_slices_table.php b/database/migrations/2025_03_27_121052_create_service_slices_table.php new file mode 100644 index 0000000..6ce38c4 --- /dev/null +++ b/database/migrations/2025_03_27_121052_create_service_slices_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignIdFor(Service::class)->constrained()->cascadeOnDelete(); + $table->foreignId('environment_id')->nullable()->constrained('environments')->nullOnDelete(); + $table->string('name'); + $table->string('type'); + $table->string('status')->default('pending'); + $table->json('config')->nullable(); + $table->text('credentials')->nullable(); + $table->timestamps(); + + $table->unique(['service_id', 'type', 'name']); + }); + } + + public function down(): void + { + Schema::dropIfExists('service_slices'); + } +}; diff --git a/database/migrations/2025_03_27_121053_create_environment_attachments_table.php b/database/migrations/2025_03_27_121053_create_environment_attachments_table.php new file mode 100644 index 0000000..888c484 --- /dev/null +++ b/database/migrations/2025_03_27_121053_create_environment_attachments_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('environment_id')->constrained('environments')->cascadeOnDelete(); + $table->foreignIdFor(Service::class)->constrained()->cascadeOnDelete(); + $table->foreignId('service_slice_id')->nullable()->constrained('service_slices')->nullOnDelete(); + $table->string('role'); + $table->string('env_prefix')->nullable(); + $table->boolean('is_primary')->default(false); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('environment_attachments'); + } +}; diff --git a/database/migrations/2025_03_27_121054_create_environment_variables_table.php b/database/migrations/2025_03_27_121054_create_environment_variables_table.php new file mode 100644 index 0000000..09f2dc8 --- /dev/null +++ b/database/migrations/2025_03_27_121054_create_environment_variables_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('environment_id')->constrained('environments')->cascadeOnDelete(); + $table->string('key'); + $table->text('value'); + $table->string('source')->default('user'); + $table->foreignId('service_slice_id')->nullable()->constrained('service_slices')->nullOnDelete(); + $table->boolean('overridable')->default(true); + $table->timestamps(); + + $table->unique(['environment_id', 'key']); + }); + } + + public function down(): void + { + Schema::dropIfExists('environment_variables'); + } +}; diff --git a/database/migrations/2025_03_27_121055_create_registries_sources_and_build_artifacts_tables.php b/database/migrations/2025_03_27_121055_create_registries_sources_and_build_artifacts_tables.php new file mode 100644 index 0000000..baabb0c --- /dev/null +++ b/database/migrations/2025_03_27_121055_create_registries_sources_and_build_artifacts_tables.php @@ -0,0 +1,55 @@ +id(); + $table->foreignIdFor(Organisation::class)->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('type'); + $table->string('url')->nullable(); + $table->text('credentials')->nullable(); + $table->timestamps(); + }); + + Schema::create('source_providers', function (Blueprint $table) { + $table->id(); + $table->foreignIdFor(Organisation::class)->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('type'); + $table->string('url')->nullable(); + $table->json('config')->nullable(); + $table->timestamps(); + }); + + Schema::create('build_artifacts', function (Blueprint $table) { + $table->id(); + $table->foreignId('environment_id')->constrained('environments')->cascadeOnDelete(); + $table->string('commit_sha'); + $table->string('image_tag'); + $table->string('image_digest')->nullable(); + $table->string('registry_ref')->nullable(); + $table->foreignId('built_by_operation_id')->nullable(); + $table->foreignId('built_by_service_id')->nullable()->constrained('services')->nullOnDelete(); + $table->string('status')->default('pending'); + $table->json('metadata')->nullable(); + $table->timestamps(); + + $table->unique(['environment_id', 'commit_sha', 'image_tag']); + }); + } + + public function down(): void + { + Schema::dropIfExists('build_artifacts'); + Schema::dropIfExists('source_providers'); + Schema::dropIfExists('registries'); + } +}; diff --git a/database/migrations/2025_03_27_121056_create_service_endpoints_table.php b/database/migrations/2025_03_27_121056_create_service_endpoints_table.php new file mode 100644 index 0000000..fae3db0 --- /dev/null +++ b/database/migrations/2025_03_27_121056_create_service_endpoints_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignIdFor(Service::class)->constrained()->cascadeOnDelete(); + $table->foreignId('service_replica_id')->nullable()->constrained('service_replicas')->nullOnDelete(); + $table->string('scope'); + $table->string('hostname'); + $table->string('ip_address')->nullable(); + $table->unsignedInteger('port'); + $table->unsignedInteger('priority')->default(100); + $table->string('health_status')->default('unknown'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('service_endpoints'); + } +}; diff --git a/database/migrations/2025_03_27_122034_create_instances_table.php b/database/migrations/2025_03_27_122034_create_instances_table.php deleted file mode 100644 index 226b808..0000000 --- a/database/migrations/2025_03_27_122034_create_instances_table.php +++ /dev/null @@ -1,28 +0,0 @@ -id(); - $table->foreignIdFor(Application::class); - $table->foreignIdFor(Server::class); - $table->string('branch'); - $table->string('status'); - $table->json('config')->nullable(); - $table->timestamps(); - }); - } - - public function down(): void - { - Schema::dropIfExists('instances'); - } -}; \ No newline at end of file diff --git a/database/migrations/2025_03_31_140110_create_deployments_table.php b/database/migrations/2025_03_31_140110_create_operations_table.php similarity index 68% rename from database/migrations/2025_03_31_140110_create_deployments_table.php rename to database/migrations/2025_03_31_140110_create_operations_table.php index a8e61fd..ab07f0b 100644 --- a/database/migrations/2025_03_31_140110_create_deployments_table.php +++ b/database/migrations/2025_03_31_140110_create_operations_table.php @@ -8,9 +8,11 @@ return new class extends Migration { public function up(): void { - Schema::create('deployments', function (Blueprint $table) { + Schema::create('operations', function (Blueprint $table) { $table->id(); + $table->foreignId('parent_id')->nullable()->constrained('operations')->nullOnDelete(); $table->string('hash')->unique(); + $table->string('kind')->default('service_deploy'); $table->morphs('target'); // server, service, etc. $table->string('status'); $table->dateTime('started_at')->nullable(); @@ -21,6 +23,6 @@ return new class extends Migration public function down(): void { - Schema::dropIfExists('deployments'); + Schema::dropIfExists('operations'); } }; diff --git a/database/migrations/2025_03_31_141005_create_steps_table.php b/database/migrations/2025_03_31_141005_create_operation_steps_table.php similarity index 79% rename from database/migrations/2025_03_31_141005_create_steps_table.php rename to database/migrations/2025_03_31_141005_create_operation_steps_table.php index 1ba49ed..42b57dd 100644 --- a/database/migrations/2025_03_31_141005_create_steps_table.php +++ b/database/migrations/2025_03_31_141005_create_operation_steps_table.php @@ -1,6 +1,6 @@ id(); - $table->foreignIdFor(Deployment::class); + $table->foreignIdFor(Operation::class); $table->string('name'); $table->integer('order'); $table->string('status'); @@ -27,6 +27,6 @@ return new class extends Migration public function down(): void { - Schema::dropIfExists('steps'); + Schema::dropIfExists('operation_steps'); } }; diff --git a/docs/implementation-review.md b/docs/implementation-review.md new file mode 100644 index 0000000..d67410f --- /dev/null +++ b/docs/implementation-review.md @@ -0,0 +1,220 @@ +# Keystone Implementation Review — Gaps vs `docs/implementation-spec.md` + +The schema/migrations/models are about 98% correct. The orchestration, drivers, UI, and tests have substantial gaps. Below are concrete, file-anchored issues to fix. + +## Critical orchestration bugs + +### 1. Operation parent/child hierarchy is flat — replicas are siblings of their service_deploy, not children +**Spec §3 example** nests `service_deploy → replica_deploy`. **`app/Jobs/Environments/DeployEnvironment.php:74-82`** creates both as siblings under the same `environment_deploy` parent. Replica_deploy siblings rely on `RunStep::dispatchNextSiblingOperation` (`app/Jobs/Services/RunStep.php:120-140`) ordering by `id` — fragile, and if `service_deploy` fails, replica operations are not cancelled. +**Fix:** Nest replica operations under their service's `service_deploy` operation (parent_id = service_deploy.id), and cascade-cancel children when a parent fails. + +### 2. Failed operations don't cancel siblings or children +`RunStep::failed` (`app/Jobs/Services/RunStep.php:163-178`) only cancels the failed operation's remaining steps. Sibling/child operations under the same parent continue to dispatch via `dispatchNextSiblingOperation`. A failing service deploy will still trigger gateway cutover. +**Fix:** On step failure, mark parent + all descendant operations as `CANCELLED`/`FAILED`. Re-check during sibling dispatch. + +### 3. Gateway cutover uses a hardcoded container name that doesn't exist +`DeployEnvironment.php:202` runs `docker exec keystone-caddy caddy reload ...`, but Caddy replicas are created with name `keystone-service-{service->id}-{N}` per `DeployEnvironment.php:155`. The `keystone-caddy` container is never created — cutover will always fail. +**Fix:** Look up the Caddy service's replica container name; or set a stable container_name in Caddy compose. + +### 4. Gateway cutover is monolithic; no add-upstream/reload/drain sub-sequence +**Spec §15** requires: render new replica → health check → add new upstream → reload → drain old → stop old. `DeployEnvironment.php:201-206` does only `caddy reload && sleep 10 && stop draining`. There's no add-upstream step (Caddyfile is fully overwritten at `slice_configure` time), no real health check during cutover, and `sleep 10` is an arbitrary drain. +**Fix:** Split into separate steps with explicit ordering, and tie drain to active connections / Caddy upstream health. + +### 5. `dispatchChildOperations` dispatches only the first child's first step +`DeployEnvironment.php:377-387` dispatches a single step. Continuation depends on `RunStep::dispatchNextSiblingOperation` chasing siblings by id. If a child operation has zero steps (e.g. `service_deploy` for a service whose driver returned no plan), the chain dies silently. +**Fix:** Make a single orchestrator (e.g. `DispatchOperationChain`) that knows how to walk the tree. Don't rely on implicit id-ordering between independently-created sibling operations. + +## Driver contract & implementations + +### 6. `Driver` base contract is anemic +**Spec §9** lists 13 required driver capabilities. `app/Drivers/Driver.php` only declares `__construct` and `getOperationPlan`. Image policy, ports, volumes, env schema, health checks, resource defaults, slice types, env exports, firewall, update behavior are scattered across drivers without contractual enforcement. +**Fix:** Define an interface with explicit methods (`type()`, `versionTrack()`, `defaultPorts()`, `firewallRules()`, `updateBehavior()`, etc.) and assert per-driver via tests. + +### 7. `Caddy2Driver::buildCaddyfile()` reads an undefined field — dead code +`app/Drivers/Caddy/Caddy2Driver.php:46` references `$this->service->credentials['backend']`. Nothing ever sets this. The actual Caddyfile is rendered inline by `DeployEnvironment::configureCaddyRouteScript` (`app/Jobs/Environments/DeployEnvironment.php:321-335`). +**Fix:** Delete `buildCaddyfile()`, or move Caddyfile generation into the driver and remove the duplicate in `DeployEnvironment`. + +### 8. Postgres18Driver has no slice provisioning in its operation plan +**Spec §12:** "Creating a Postgres database/user should run as a slice operation against an existing Postgres replica, not redeploy the Postgres container." `AttachManagedService::createSliceProvisionOperation` (`app/Actions/Environments/AttachManagedService.php:108-125`) hardcodes the SQL script outside the driver. Slice provisioning logic belongs in the driver so other Postgres versions can implement it. +**Fix:** Add `provisionSliceScript(ServiceSlice $slice): string` to the slice contract; have Postgres driver own the SQL. + +### 9. Postgres provision script assumes a `keystone` admin user that is never created +`AttachManagedService.php:133` uses `($service->credentials ?? [])['user'] ?? 'keystone'`. The Postgres service is never seeded with admin credentials and the compose for Postgres never sets `POSTGRES_USER=keystone`. This will fail in production. +**Fix:** Establish admin credentials on Postgres service creation (write to `service->credentials`); pass via `POSTGRES_USER`/`POSTGRES_PASSWORD` in compose env. + +### 10. Stateful update steps are placeholder strings that won't actually run +`app/Actions/Services/CreateStatefulServiceUpdateOperation.php:38-42`: +- `'docker compose down'` — no `-f path` so it runs in the SSH user's home directory. +- `'docker volume ls'` — listing isn't "preserving"; it's a no-op. +- `'docker compose up -d'` — no `-f path`, no image digest, no env update. +- `'docker compose ps'` — not a real health check. + +Spec §11 specifies a real sequence: stop → preserve named volume → start new with updated digest → health check. +**Fix:** Build steps from the driver against the service's actual compose path; verify the named volume exists before/after; replace healthcheck stub with `docker inspect --format '{{.State.Health.Status}}'` polling. + +### 11. Stateful update doesn't write the updated digest into compose before restart +The operation sets `available_image_digest` on the service (line 55) but the compose file on disk is not re-rendered. `docker compose up -d` will pull from whatever digest is currently in the compose, not the new one. +**Fix:** Insert a "Render compose with new digest" step before the start step. + +### 12. Valkey driver doesn't emit role-based env vars +`app/Drivers/Valkey/Valkey8Driver.php:42-48` only emits `REDIS_HOST` and `REDIS_PORT`. **Spec §13** explicitly recommends `CACHE_STORE=redis`, `SESSION_DRIVER=redis`, `QUEUE_CONNECTION=redis` based on attachment role. +**Fix:** Read the `EnvironmentAttachment.role` and add the appropriate Laravel env defaults (with the "Do not silently change queue behavior without confirmation" guard from §12). + +### 13. Valkey has no logical-DB isolation +`AttachManagedService.php:64-71` creates a `logical_database` slice but never assigns a Redis database index (`REDIS_DB`). All environments attached to the same Valkey service share DB 0. +**Fix:** Assign `REDIS_DB` per slice; include in `environmentExportsForSlice`. + +### 14. Caddy and Valkey slices have no `SLICE_PROVISION` operation +`AttachManagedService::createSliceProvisionOperation` (line 110) early-returns unless `service->type === POSTGRES`. Caddy routes and Valkey logical DBs are created in the DB but never reconciled to the running service. +**Fix:** Emit slice operations for all service types whose driver supports slices. + +### 15. Postgres driver doesn't export DB_* at the service level +`Postgres18Driver::environmentExports()` returns `[]`. That's fine, but only slice exports work, so if a service has no slice but a Laravel app references DB_HOST, nothing wires it. +**Fix:** Either guarantee a slice always exists for Postgres attachments, or emit DB_HOST at the service level via the attachment. + +## Deployment flow + +### 16. Migration timing is hardcoded to pre_switch +`DeployEnvironment::serviceDeployScripts` (`app/Jobs/Environments/DeployEnvironment.php:236-238`) always emits the migration step before "Deploy replicas". **Spec §18** lists `migration_timing: pre_switch | post_switch` on service config — never read. +**Fix:** Check `$service->config['migration_timing']` and either emit the migration step before replicas or after the gateway cutover. + +### 17. Migration mode `manual` is ignored +`migrationScript` (line 292-301) only short-circuits when `migration_mode=disabled`. `manual` still auto-runs `php artisan migrate --force` during environment deploy. Spec §18 says manual mode should not run automatically. +**Fix:** Treat `manual` the same as `disabled` for environment deploys; only the dedicated `environment-migrations.store` controller should run it. + +### 18. Two parallel migration code paths +`DeployEnvironment::migrationScript` (line 292), `LaravelRuntimeDriver::getOperationPlan`, and `EnvironmentMigrationController` all emit migration scripts independently. They will drift. +**Fix:** Centralize in one place (driver method or dedicated action) and call from all three. + +### 19. "Update gateway routes" step is a no-op +`DeployEnvironment.php:248-250`: +``` +'script' => 'test -f /home/keystone/gateway/Caddyfile', +``` +This just checks file presence. The actual route update is in a separate `slice_configure` operation, so this step is dead code in the service-deploy chain. +**Fix:** Either remove the step or have it actually trigger the route update for this service. + +### 20. Pre-switch service steps from spec §17 step 6 are missing entirely +No "pre-switch" hooks are emitted by `DeployEnvironment` or any driver. +**Fix:** Add a `preSwitchSteps(): array` driver capability and call it before the migration/replica steps. + +### 21. Multi-server replica placement is not implemented +`DeployEnvironment::ensureServiceReplicas` (line 147-171) always assigns `server_id = $service->server_id`. There is no way to place replicas across multiple servers — yet the registry-required check at line 39-41 assumes multi-server deployments exist. +**Fix:** Either ship single-server-only v1 and remove the multi-server gate, or wire `Service.process_roles`/placement policy to multiple servers in `ensureServiceReplicas`. + +### 22. No explicit `docker pull @` on target servers for multi-server +`replicaDeployScripts` (line 261-290) does `docker compose up -d` only; for multi-server, target servers need to pull from the registry by digest. There is no pull step. +**Fix:** Add `docker pull` step using `registry_ref` + digest before the `up -d` step on each target server. + +### 23. Build strategy `dedicated_builder` and `external_registry` are not enforced +`BuildApplicationArtifact::execute` (`app/Actions/Environments/BuildApplicationArtifact.php:30`) picks any server via `buildServer()`. The push is only conditionally added when strategy === `EXTERNAL_REGISTRY` (line 89). There's no enforcement that `dedicated_builder` requires a builder service to exist, nor that `external_registry` skips local build entirely. +**Fix:** Branch on strategy at the top of `execute()`; for `external_registry`, skip the build and resolve the digest from the registry (`docker manifest inspect`); for `dedicated_builder`, fail if no builder service is provisioned. + +### 24. Scheduler placement is not enforced at runtime +**Spec §8:** `single` mode runs `schedule:run` on exactly one replica; `every_replica` runs on all. `LaravelRuntimeDriver` sets `AUTORUN_LARAVEL_SCHEDULER=true` based on the service's `process_roles`, but nothing applies the env per-replica based on `scheduler_target_service_id`/`scheduler_mode`. All replicas of the target service end up with the env var. +**Fix:** When generating replica config, only emit `AUTORUN_LARAVEL_SCHEDULER=true` on the elected replica when `scheduler_mode=single`. The existing `PlanEnvironmentDeployment::blockers` (`app/Actions/Environments/PlanEnvironmentDeployment.php:79-87`) is a pre-flight check, not an enforcement. + +### 25. Compose file is generated via shell heredoc instead of a real upload +`composeUploadScript` (line 303-319) inlines the compose body in a `cat <<'KEYSTONE_COMPOSE'` heredoc. Any quoting issue (binary, single-quote in env, large file) breaks. Spec §16 implies generated artifacts should be transferred via SSH/SCP. +**Fix:** Use SCP (or `ssh ... 'cat > path'` with binary-safe encoding) instead of heredoc. Also drop separate generation of `.env` files — currently only the compose is uploaded; `.env` references in compose will 404 on disk. + +### 26. `.env` files never written to disk +**Spec §16** layout includes `/home/keystone/services//.env`. `composeUploadScript` writes only `compose.yml`. If the compose `env_file: .env` directive is rendered, the deploy will fail. +**Fix:** Render and upload `.env` alongside `compose.yml`. + +## Models, schema, and encryption + +### 27. Service.credentials migration column is plaintext but model casts as `encrypted:array` +Migration `database/migrations/2025_03_27_121050_create_services_table.php:35` declares `text('credentials')`, while `app/Models/Service.php:36` casts it as `encrypted:array`. The cast works (Laravel encrypts on write) — but a `text` column without explicit `nullable()`/encoding intent is confusing. Confirm cast actually encrypts (it does for `encrypted:*` casts) and document. +**Fix:** Add `->nullable()` and a comment in the migration noting the field is encrypted at the model layer. + +### 28. `EnvironmentVariable.value` cast missing array-vs-string handling +Spec doesn't require it, but worth flagging: the cast is `encrypted` (scalar), which means complex values (JSON-encoded secrets) need explicit json_encode by callers. Currently `AttachManagedService.php:97-104` always passes scalar values, so this is OK. + +## UI and onboarding + +### 29. Onboarding (Spec §19) is not implemented +No onboarding controller, no routes, no Inertia pages. The spec calls for a guided flow: organisation → provider → source → deploy key → registry → server → app/env → attachments. Currently users must navigate disjoint pages manually. +**Fix:** Add an `OnboardingController` with a state machine on `Organisation` (or session-based progress) plus an Inertia wizard. + +### 30. Service detail/edit pages are missing +`resources/js/pages/services/` only contains `updates/Create.vue`. There's no Index/Show/Edit. Spec §20 Phase 6 calls for "services under an environment with sensible defaults". +**Fix:** Add service Show/Edit pages, including replica health, slices, and one-click update. + +### 31. No "managed attachment" guided flow +`resources/js/pages/environment-attachments/Create.vue` exposes a raw service list. Spec §12 + §20 call for managed flows for Postgres / Valkey / Caddy with auto-defaulted slices. +**Fix:** Build a guided picker per role (database / cache / queue / storage / gateway) that filters services to compatible types and previews the generated slice + env vars. + +### 32. Deploy policies are visible / no defaults hiding +Spec §20 Phase 6: "Hide deploy policies by default." Currently they're set/exposed via `Service` form requests (`StoreServiceRequest`). No UI hiding. +**Fix:** Don't expose `deploy_policy` in service create/edit UI; rely on driver-provided defaults from spec §2. + +### 33. `resources/js/pages/applications/Show.vue` has a stale stub comment +Line 72: `` — references the removed `Instance` model. +**Fix:** Remove the stale comment; add the actual "New environment" button. + +### 34. `resources/js/pages/servers/Index.vue` contains a `@todo pagination` literal +Ship-ready code shouldn't have TODOs visible to the user. +**Fix:** Either implement pagination or remove the comment. + +### 35. No UI surfaces variable source / overridable +`EnvironmentVariableController::store` hardcodes `source=USER`. The UI in `environment-variables/Create.vue` provides no way to see managed vs. user vars, and the spec §13 requires the source/overridable badge. +**Fix:** Read variables on the environment Show page grouped by source; for `managed_attachment` rows, show a "managed by Postgres slice X" badge and disable editing unless `overridable`. + +### 36. No environment Show page; deploys/migrations etc. are triggered from `applications/Show.vue` per-environment row +This works but spec §20 Phase 6 wants environments to be the primary surface. Currently there's no environment detail page where services, replicas, slices, attachments, env vars, and operations are visible together. +**Fix:** Add `environments/Show.vue` and route `applications/Show.vue`'s environment row to it. + +## Tests — coverage gaps + +### 37. `EnvironmentDeploymentControllerTest` only asserts dispatch +Short test, no state assertions after run. +**Fix:** Replace `Bus::fake()` with running the job inline; assert that operations, steps, replica records, compose files, and env vars are all in expected state. + +### 38. No test asserts deploy key cleanup after build +`BuildApplicationArtifact.php:100-101` adds the cleanup trap. No test verifies the trap actually runs or that the key file is gone. +**Fix:** In `BuildApplicationArtifactTest`, fake the remote runner and assert the build script contains `trap cleanup EXIT` AND that the operation_dir path resolves under `/home/keystone/operations/`. + +### 39. No test asserts the named volume naming convention +Spec §10 names volumes `keystone_service__postgres_data`. No test in `ComposeRendererTest` checks volume name format. +**Fix:** Snapshot-test or regex-assert the volume name pattern in `ComposeRendererTest`. + +### 40. No test for parent-child operation chain executing end-to-end +`DeployEnvironmentJobTest` creates operations but never runs the resulting `RunStep` jobs through `Queue::handleAll()`-style flow. +**Fix:** Add an integration test that fakes the SSH layer (return canned success) and lets the chain run, asserting each operation transitions to `COMPLETED` in the correct order. + +### 41. No test for cancellation cascade on failure +None of the test files exercise `RunStep::failed` with sibling/child cancellation expectations (because that behavior doesn't exist yet — see gap #2). +**Fix:** Add a test that fails a step mid-chain and asserts all later operations are `CANCELLED`. + +### 42. No test for stateful update flow against a Postgres service +`StatefulServiceUpdateTest` likely asserts only operation/step rows. Need: rendered script asserts compose path is correct, named volume is preserved, new digest is written. +**Fix:** Strengthen assertions to validate the full step contents. + +### 43. No test for multi-server build/push/pull +`BuildArtifactPlanningTest` checks `requiresRegistry=true` but no test asserts a `docker push` and per-target `docker pull` actually occur. +**Fix:** Add a job-level test with two-server topology and assert each target's deploy script includes a `docker pull @` before `compose up`. + +### 44. No test that `manual`/`disabled` migration modes are honored +**Fix:** Parametrized test asserting `migrationScript` returns `'true'` for `disabled` and `manual` modes, and the real command for `auto`. + +### 45. No test for scheduler enforcement per replica +**Fix:** Test that for `scheduler_mode=single`, only one replica's rendered env has `AUTORUN_LARAVEL_SCHEDULER=true`; for `every_replica`, all do. + +### 46. No test that managed attachment auto-creates slices for Valkey + Caddy +`ManagedAttachmentTest` likely tests Postgres only. +**Fix:** Extend dataset to cover Valkey logical_database and Caddy route slices, with their env exports. + +--- + +## Suggested ordering + +1. Fix the orchestration bugs (#1-#5) — without these the chain doesn't reliably reach completion. +2. Fix the Caddy cutover (#3, #4, #7, #19) — without these no environment can serve traffic. +3. Fix Postgres slice provision admin user (#9) and stateful update scripts (#10, #11). +4. Implement migration timing/mode (#16, #17, #18). +5. Implement scheduler enforcement (#24) and multi-server placement + pull (#21, #22, #23). +6. Then UI: onboarding (#29), environment Show page (#36), managed attachment UI (#31), variable source display (#35). +7. Strengthen tests last (#37-#46) once the orchestrator and drivers are stable. + +Most of the schema and the high-level structure are correct — the gap is between the data model and the runtime behavior that's supposed to enforce/realize it. diff --git a/docs/implementation-spec.md b/docs/implementation-spec.md new file mode 100644 index 0000000..91e6345 --- /dev/null +++ b/docs/implementation-spec.md @@ -0,0 +1,726 @@ +# Keystone Implementation Spec + +## 1. Product Scope + +Keystone is a Laravel Forge-like deployment platform that runs applications and services with Docker. The v1 product is intentionally narrow: + +- Laravel is the only first-class application framework. +- Application containers use a Keystone-managed Dockerfile based on `serversideup/php` with FrankenPHP. +- Services are explicitly coded drivers, not arbitrary Docker images. +- v1 is agentless and executes operations over SSH. +- Docker Compose is used as the generated runtime artifact. +- Caddy 2 is the default and only gateway for v1. +- The Keystone database is the source of truth. Server files are generated artifacts. + +V1 should make the simple path robust before adding generic Docker support, distributed agents, HA databases, edge routing, or additional frameworks. + +## 2. Core Domain Model + +### Organisation + +Owns users, providers, registries, applications, servers, services, and environments. + +### Application + +A source-code project. In v1, first-class applications are Laravel repositories. + +Recommended fields: + +- `organisation_id` +- `name` +- `repository_url` +- `repository_type` +- `default_branch` +- `deploy_key_public` +- `deploy_key_private` encrypted +- `deploy_key_fingerprint` +- `deploy_key_installed_at` nullable + +### Environment + +The primary application deployment unit. An application has environments such as production, staging, or dev. + +Recommended fields: + +- `application_id` +- `name` +- `branch` +- `status` +- `scheduler_enabled` +- `scheduler_target_service_id` nullable +- `scheduler_mode`: `single` or `every_replica` +- `build_config` json + +Default for Laravel environments: + +- Scheduler enabled. +- Scheduler target is the primary web service. +- Scheduler mode is `single`. + +### Service + +Every deployable thing is represented as a `Service`. + +Examples: + +- Laravel web runtime +- Laravel worker runtime +- Laravel websocket runtime +- Caddy gateway +- Postgres +- Valkey +- Future standalone services + +Recommended fields: + +- `organisation_id` +- `environment_id` nullable +- `server_id` nullable for single-placement legacy convenience only; long term use replicas +- `name` +- `category` +- `type` +- `version_track` +- `driver_name` +- `status` +- `desired_replicas` +- `desired_revision` +- `deploy_policy` +- `process_roles` json +- `current_image_digest` nullable +- `available_image_digest` nullable +- `update_status` +- `default_cpu_limit` nullable +- `default_memory_limit_mb` nullable +- `config` json + +Deploy policy defaults: + +- Laravel web: `with_environment` +- Laravel worker: `with_environment` +- Laravel websocket: `with_environment` +- Database/cache/storage: `dependency_only` +- Gateway: `manual_or_on_route_change` +- Standalone services: `manual` + +The user should not need to configure these defaults during normal setup. + +### ServiceReplica + +A running instance of a service on a server. A service is logical; a replica is runtime placement. + +Recommended fields: + +- `service_id` +- `server_id` +- `operation_id` nullable +- `container_name` +- `container_id` nullable +- `image_digest` +- `internal_host` +- `internal_port` +- `public_port` nullable +- `status` +- `health_status` +- `cpu_limit` nullable +- `memory_limit_mb` nullable +- `config` json + +Replica resource limits override service defaults. Null means unrestricted except host capacity. + +### ServiceSlice + +A logical sub-resource inside a service. Slices belong to `Service`, not `ServiceReplica`. + +Examples: + +- Database and user inside Postgres +- Logical database or namespace inside Valkey +- Route inside Caddy +- Future bucket, topic, vhost, etc. + +Recommended fields: + +- `service_id` +- `environment_id` nullable +- `name` +- `type` +- `status` +- `config` json +- `credentials` encrypted json nullable + +Slices are not containers and should not be used for scaling. They are stable logical resources that survive service replica replacement. + +### EnvironmentAttachment + +Connects an environment to managed service slices. + +Recommended fields: + +- `environment_id` +- `service_id` +- `service_slice_id` nullable +- `role`: `database`, `cache`, `queue`, `storage`, `gateway`, `custom` +- `env_prefix` nullable +- `is_primary` + +Attachments should point to slices whenever a slice exists. For example, a Laravel environment attaches to a Postgres database/user slice, not merely to the Postgres service. + +### EnvironmentVariable + +Represents user-defined and Keystone-managed runtime environment values. + +Recommended fields: + +- `environment_id` +- `key` +- `value` encrypted +- `source`: `user`, `managed_attachment`, `system` +- `service_slice_id` nullable +- `overridable` boolean + +Managed values should be regenerated from attachments and slices. + +## 3. Operations Model + +Rename `Deployment` to `Operation`. + +An operation is the generic audit and execution object for all state-changing work. + +### Operation + +Recommended fields: + +- `id` +- `parent_id` nullable +- `hash` +- `kind` +- `target_type` +- `target_id` +- `status` +- `started_at` +- `finished_at` +- timestamps + +Operation kinds: + +- `server_provision` +- `service_deploy` +- `replica_deploy` +- `slice_provision` +- `slice_configure` +- `environment_deploy` +- `gateway_cutover` +- `config_change` +- `credential_rotation` + +### OperationStep + +Rename `Step` to `OperationStep`. + +Recommended fields: + +- `operation_id` +- `name` +- `order` +- `status` +- `script` +- `logs` +- `error_logs` +- `secrets` encrypted json nullable +- `started_at` +- `finished_at` +- timestamps + +### Parent-Child Operations + +Environment deploys are parent operations that create child operations. + +Example: + +- `environment_deploy` +- child `service_deploy` for web +- child `replica_deploy` for each web replica +- child `slice_configure` for Caddy route updates +- child `gateway_cutover` + +Standalone service deploys and slice operations can also run independently. + +## 4. Server Provisioning + +V1 remains agentless over SSH. + +Provisioning flow: + +1. Create server through provider API. +2. Wait for root SSH to become available. +3. Execute provisioning script over SSH. +4. Create Keystone management user. +5. Install Docker Engine, Docker Compose plugin, UFW, fail2ban, and required runtime packages. +6. Install Keystone SSH public key. +7. Disable password login. +8. Enable UFW with SSH open. +9. Callback or SSH verification marks server active. + +Server permanent keys are for Keystone management only. Repository deploy keys must not be permanently installed on servers. + +## 5. Source Providers And Repository Access + +V1 source support: + +- Self-hosted Gitea +- GitHub +- Generic Git over SSH + +Repository access uses a Keystone-generated deploy key per application/repository. + +V1 flow: + +1. User enters repo SSH URL. +2. Keystone generates an ed25519 deploy key. +3. UI shows the public key. +4. User adds it to Gitea/GitHub as read-only. +5. Keystone verifies access with `git ls-remote`. + +During build operations, Keystone injects the encrypted private key into a temporary operation directory and uses `GIT_SSH_COMMAND`. The key is removed after the build. Repo keys are never permanently stored on target servers or builder services. + +## 6. Registry And Build Artifacts + +An external registry is required for multi-server application deployments. + +Single-server deployments may build and run a local image without a registry. + +Multi-server deployments must: + +1. Build once. +2. Push the image to the configured external registry. +3. Pull the exact same image digest on each target server. + +Supported registry types: + +- Generic Docker registry +- Gitea registry +- GHCR +- Docker Hub + +### Build Service + +Building is a service capability, not a server type. + +A dedicated builder is represented as a `Service` with category `builder`. If no builder service exists, Keystone may build on the target server for single-server deployments. + +Build strategies: + +- `target_server`: build on selected target server. Valid for single-server. +- `dedicated_builder`: build on builder service, then push/export artifact. +- `external_registry`: pull prebuilt image from registry. + +For v1: + +- Single-server default: build on target server. +- Multi-server: require configured registry and build once. +- Do not rebuild independently on each server. + +### BuildArtifact + +Recommended fields: + +- `environment_id` +- `commit_sha` +- `image_tag` +- `image_digest` +- `registry_ref` nullable +- `built_by_operation_id` +- `built_by_service_id` nullable +- `status` +- `metadata` json + +## 7. Managed Laravel Runtime + +V1 uses Keystone-managed Dockerfile templates only. Custom Dockerfiles are deferred. + +Laravel runtime defaults: + +- Base: `serversideup/php` FrankenPHP image +- PHP version configurable +- Document root default: `public` +- Health path default: `/up`, fallback `/` +- Composer install with production defaults +- JS build step configurable +- Bun/Node strategy configurable + +The same build artifact is used by web, worker, and websocket services. Runtime services differ by entrypoint/command. + +Default topology: + +- One web service. +- No worker service by default. +- Scheduler enabled on the web service by default. +- Dedicated worker service is recommended when queues are used, but created only when the user opts in. + +Worker options: + +- Dedicated worker service, recommended. +- Embedded worker in web service, allowed for low-throughput apps but not recommended for production. +- No workers, default. + +Keystone should warn against deployed environments using `QUEUE_CONNECTION=sync`, but it should not automatically create worker services. + +## 8. Scheduler Model + +Mirror Laravel Cloud's scheduler model. + +Scheduler is not a standalone service by default. It is a role/capability attached to a selected web or worker service. + +Defaults: + +- `scheduler_enabled`: true for Laravel templates. +- `scheduler_target_service_id`: primary web service. +- `scheduler_mode`: `single`. + +Runtime behavior: + +- `single`: run `schedule:run` every minute on exactly one selected replica. +- `every_replica`: run on each replica. This is advanced and explicit. + +Keystone should enforce one scheduler runner per environment by default. Users may still use Laravel's `onOneServer()` for application-level safety. + +## 9. Service Drivers + +V1 services are explicitly coded drivers only. No arbitrary Docker image service in the v1 happy path. + +Driver contract should define: + +- service type and version track +- default image policy +- ports +- volumes +- environment schema +- health checks +- resource defaults +- supported slice types +- Compose rendering +- operation steps +- env var exports +- firewall requirements +- update behavior + +V1 driver list: + +- Caddy 2 gateway +- Laravel managed runtime using `serversideup/php` FrankenPHP +- Postgres 18 +- Valkey 8 + +Use latest minor versions for new service deploy/update operations by resolving image tags to digests. Store the resolved digest on the operation/service/replica for reproducible rollbacks. + +Do not silently update managed service images. Show updates in the UI and require an explicit service update/redeploy operation. + +## 10. Persistent Storage + +Use named Docker volumes for persistent service-local data. + +Examples: + +- Postgres: `keystone_service__postgres_data` +- Valkey: named volume when persistence is enabled +- Caddy: named volumes for `/data` and `/config` + +Avoid distributed storage in v1. Moving a stateful service to another server requires an explicit migration operation. + +## 11. Stateful Service Updates + +V1 accepts downtime for single-node stateful updates. + +Postgres/Valkey update flow: + +1. User explicitly triggers update/redeploy. +2. Keystone warns about downtime and data risk. +3. Optional backup checkbox appears only if backup capability exists. +4. Stop container. +5. Preserve named volume. +6. Start new container with updated image digest. +7. Health check. +8. Mark operation complete. + +Rolling stateful updates and HA clusters are v2. + +## 12. Slices And Attachments + +Attaching a managed service to an environment should create sensible default slices automatically. + +Postgres attachment: + +- Create database/user slice by default. +- Generate credentials. +- Wire `DB_*` environment variables. + +Valkey attachment: + +- Create/select logical slice if supported. +- Wire `REDIS_*`. +- Recommend `CACHE_STORE=redis`, `SESSION_DRIVER=redis`, or `QUEUE_CONNECTION=redis` depending on role. +- Do not silently change queue behavior without confirmation. + +Caddy/domain attachment: + +- Create route slice. +- Wire gateway route to environment web service. + +Advanced users can select existing slices or create slices manually from service detail pages. + +Slice operations should be independent from service container deployments. Creating a Postgres database/user should run as a slice operation against an existing Postgres replica, not redeploy the Postgres container. + +## 13. Environment Variables + +Keystone manages env vars from attachments and slices. + +Postgres slice should export: + +- `DB_CONNECTION=pgsql` +- `DB_HOST` +- `DB_PORT=5432` +- `DB_DATABASE` +- `DB_USERNAME` +- `DB_PASSWORD` + +Valkey slice/service should export: + +- `REDIS_HOST` +- `REDIS_PORT=6379` +- optional `CACHE_STORE=redis` +- optional `SESSION_DRIVER=redis` +- optional `QUEUE_CONNECTION=redis` + +User-defined variables remain editable. Managed variables should show their source and whether they are overridable. + +## 14. Networking And Internal Aliases + +Support both same-server Docker networking and cross-server private networking. + +Routing preference: + +1. Same server: Docker network aliases/container DNS. +2. Same provider private network: private IP and internal port. +3. Public fallback only if explicitly allowed. + +V1 should not build distributed DNS. Use deterministic internal hostnames and generated env vars. Where Keystone controls Docker networks, use network aliases. For cross-server communication, inject private IP/port endpoints. + +Future agent/DNS systems should be possible, but are out of scope for v1. + +Recommended endpoint model: + +- `service_id` +- `service_replica_id` nullable +- `scope`: `docker_network`, `private_network`, `public` +- `hostname` +- `ip_address` nullable +- `port` +- `priority` +- `health_status` + +## 15. Gateway And Cutover + +There must be exactly one gateway service per server for v1. + +Caddy owns public ports `80` and `443`. Application runtime containers should bind only to internal Docker networks or assigned internal ports. + +Zero-downtime deployment happens at the gateway layer: + +1. Render/start new service replica with unique container/project name. +2. Health check new replica. +3. Update Caddy upstreams to include the new healthy replica. +4. Reload Caddy. +5. Drain/remove old replica from Caddy upstreams. +6. Stop old container after the drain window. + +For same-server upstreams, Caddy can use Docker network names. For cross-server upstreams, Caddy uses private IP and assigned internal port. + +Web services may span multiple servers in v1. Keystone provides load balancing through Caddy upstreams but does not optimize global latency or regional placement. + +Future v2 doctor page can flag: + +- cross-region upstreams +- public-network fallbacks +- missing workers for async queues +- scheduler every-replica risks +- inefficient database/cache placement + +## 16. Docker Compose Runtime + +Use generated Docker Compose files, not raw `docker run`, for v1 runtime management. + +Suggested server layout: + +- `/home/keystone/services//compose.yml` +- `/home/keystone/services//.env` +- `/home/keystone/gateway/Caddyfile` +- `/home/keystone/operations//` + +Compose files are generated artifacts. The Keystone database is canonical. + +Compose should be used for: + +- container definitions +- env files +- named volumes +- networks +- health checks +- restart policies +- resource limits +- labels + +Resource controls: + +- Use plain Docker runtime constraints such as `cpus`, `mem_limit`, and `memswap_limit`. +- Avoid relying on Swarm-only `deploy.resources` semantics for v1. + +Example: + +```yaml +services: + web: + image: registry.example.com/app:abc123 + cpus: "1.0" + mem_limit: 1024m + memswap_limit: 1024m +``` + +## 17. Environment Deployment Flow + +Environment deployment creates a parent `environment_deploy` operation. + +High-level flow: + +1. Resolve target commit. +2. Create or reuse build artifact. +3. Compute desired service changes. +4. Include only services with `deploy_policy=with_environment` and changed revision/config. +5. Check dependency-only services and attached slices. +6. Run pre-switch service steps. +7. Run application migrations according to service migration policy. +8. Deploy new web/worker/websocket replicas. +9. Health check new replicas. +10. Update gateway routes. +11. Reload Caddy. +12. Drain and stop old replicas. +13. Mark operation complete. + +Database/cache services attached to the environment are checked but not redeployed unless the user explicitly deploys or updates them. + +## 18. Migrations + +Database migrations are owned by the application runtime service deployment. + +Recommended fields on service config: + +- `migration_mode`: `auto`, `manual`, `disabled` +- `migration_timing`: `pre_switch`, `post_switch` +- `migration_command`: default `php artisan migrate --force` + +Default for Laravel web services: + +- `migration_mode=auto` +- `migration_timing=pre_switch` +- command `php artisan migrate --force` + +Manual mode should allow the user to run migration operation explicitly. + +## 19. Onboarding + +Onboarding should guide users through: + +1. Organisation creation. +2. Server provider setup, Hetzner first. +3. Source provider/repository setup, including Gitea/GitHub/generic Git. +4. Deploy key installation and verification. +5. Registry setup. Optional for single-server, required for multi-server. +6. Server creation/provisioning. +7. Application/environment creation. +8. Optional service attachments: Postgres, Valkey, domain/gateway. + +If an environment spans more than one server and no registry exists, deployment should be blocked with a registry setup prompt. + +## 20. Current Code Migration Plan + +The current code already has useful pieces: + +- Provider abstraction +- Hetzner server creation +- Server provisioning jobs +- Service drivers +- Polymorphic deployments +- Step execution over SSH + +Refactor in phases. + +### Phase 1: Schema Alignment + +- Add `environments` table. +- Rename `deployments` to `operations`. +- Rename `steps` to `operation_steps`. +- Add `operations.parent_id`. +- Add `operations.kind`. +- Add `service_replicas`. +- Add `service_slices`. +- Add `environment_attachments`. +- Add `environment_variables`. +- Add registry/source/build artifact tables. + +### Phase 2: Model Cleanup + +- Replace `Application::instances()` as the primary deployment path with `Application::environments()`. +- Keep or migrate `Instance` into `ServiceReplica` depending on implementation cost. +- Replace `Service::slices` references with real `ServiceSlice` relationship. +- Replace `Deployment` references with `Operation`. +- Replace deployment step jobs with operation step jobs. + +### Phase 3: Driver Contract + +- Define formal driver interfaces for service deployment, replica rendering, slices, health checks, and env exports. +- Implement Caddy 2 driver. +- Implement Postgres 18 driver with database/user slice provisioning. +- Implement Valkey 8 driver. +- Implement Laravel runtime driver/template. + +### Phase 4: Compose Renderer + +- Render Compose files from DB state. +- Upload generated files over SSH. +- Run `docker compose` operations. +- Capture container IDs and health state into `ServiceReplica`. + +### Phase 5: Environment Deploy + +- Build application artifact. +- Deploy web replicas. +- Run migrations. +- Health check. +- Cut over Caddy. +- Stop old replicas. + +### Phase 6: UI Simplification + +- Present environments as the primary application surface. +- Present services under an environment with sensible defaults. +- Hide deploy policies by default. +- Provide one-click add worker. +- Provide managed attachment flows for Postgres/Valkey/Caddy. + +## 21. Explicit V2 Deferrals + +Out of scope for v1: + +- Server agent. +- Distributed internal DNS. +- Edge routing or anycast. +- Automatic regional topology optimization. +- Custom Dockerfiles. +- Arbitrary Docker image services. +- Non-Laravel first-class app frameworks. +- Managed Docker registry. +- HA Postgres/Valkey. +- Rolling stateful updates. +- Distributed storage. +- Full backup orchestration. +- Automatic deploy key installation via Gitea/GitHub API. + diff --git a/phpunit.xml b/phpunit.xml index 61c031c..7eef87f 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -19,6 +19,7 @@ + diff --git a/provision.sh b/provision.sh index be6d027..b0ae899 100644 --- a/provision.sh +++ b/provision.sh @@ -34,7 +34,7 @@ apt update apt_wait apt upgrade -y apt_wait -apt install unzip curl fail2ban ufw -y +apt install unzip curl fail2ban ufw whois ca-certificates gnupg lsb-release -y # No password logins sed -i "/PasswordAuthentication yes/d" /etc/ssh/sshd_config @@ -52,7 +52,8 @@ if [ ! -d /root/.ssh ]; then fi # Set The Hostname If Necessary -echo "[!hostname!]" > /etc/hostname sed -i 's/127\.0\.0\.1.*localhost/127.0.0.1 [!hostname!].localdomain [!hostname!] localhost/' /etc/hosts +echo "[!hostname!]" > /etc/hostname +sed -i 's/127\.0\.0\.1.*localhost/127.0.0.1 [!hostname!].localdomain [!hostname!] localhost/' /etc/hosts hostname [!hostname!] # Setup Keystone User @@ -87,7 +88,9 @@ service ssh restart # Setup Keystone Home Directory Permissions chown -R keystone:keystone /home/keystone chmod -R 755 /home/keystone -chmod 700 /home/keystone/.ssh/id_rsa +chmod 700 /home/keystone/.ssh +chmod 600 /home/keystone/.ssh/authorized_keys +chmod 600 /home/keystone/.ssh/id_ed25519 # Setup UFW Firewall ufw allow 22 @@ -126,7 +129,7 @@ echo \ apt-get update apt_wait -apt-get -y install docker-ce docker-ce-cli containerd.io +apt-get -y install docker-ce docker-ce-cli containerd.io docker-compose-plugin apt_wait # Add keystone user to the docker group usermod -aG docker keystone @@ -151,4 +154,4 @@ APT::Periodic::Unattended-Upgrade "1"; EOF # Callback that the server is installed -curl --insecure --data "server_id=[!server_id!]" [!callback!] \ No newline at end of file +curl --insecure --data "server_id=[!server_id!]" [!callback!] diff --git a/resources/js/enums/BuildArtifactStatus.js b/resources/js/enums/BuildArtifactStatus.js new file mode 100644 index 0000000..b79715e --- /dev/null +++ b/resources/js/enums/BuildArtifactStatus.js @@ -0,0 +1,9 @@ +// This is a generated file. + +export default { + "PENDING": "pending", + "BUILDING": "building", + "AVAILABLE": "available", + "FAILED": "failed" +} + diff --git a/resources/js/enums/BuildStrategy.js b/resources/js/enums/BuildStrategy.js new file mode 100644 index 0000000..84a7117 --- /dev/null +++ b/resources/js/enums/BuildStrategy.js @@ -0,0 +1,8 @@ +// This is a generated file. + +export default { + "TARGET_SERVER": "target_server", + "DEDICATED_BUILDER": "dedicated_builder", + "EXTERNAL_REGISTRY": "external_registry" +} + diff --git a/resources/js/enums/DeployPolicy.js b/resources/js/enums/DeployPolicy.js new file mode 100644 index 0000000..275510c --- /dev/null +++ b/resources/js/enums/DeployPolicy.js @@ -0,0 +1,9 @@ +// This is a generated file. + +export default { + "WITH_ENVIRONMENT": "with_environment", + "DEPENDENCY_ONLY": "dependency_only", + "MANUAL_OR_ON_ROUTE_CHANGE": "manual_or_on_route_change", + "MANUAL": "manual" +} + diff --git a/resources/js/enums/EnvironmentAttachmentRole.js b/resources/js/enums/EnvironmentAttachmentRole.js new file mode 100644 index 0000000..2750256 --- /dev/null +++ b/resources/js/enums/EnvironmentAttachmentRole.js @@ -0,0 +1,11 @@ +// This is a generated file. + +export default { + "DATABASE": "database", + "CACHE": "cache", + "QUEUE": "queue", + "STORAGE": "storage", + "GATEWAY": "gateway", + "CUSTOM": "custom" +} + diff --git a/resources/js/enums/EnvironmentVariableSource.js b/resources/js/enums/EnvironmentVariableSource.js new file mode 100644 index 0000000..b14ef81 --- /dev/null +++ b/resources/js/enums/EnvironmentVariableSource.js @@ -0,0 +1,8 @@ +// This is a generated file. + +export default { + "USER": "user", + "MANAGED_ATTACHMENT": "managed_attachment", + "SYSTEM": "system" +} + diff --git a/resources/js/enums/OperationKind.js b/resources/js/enums/OperationKind.js new file mode 100644 index 0000000..68ea46c --- /dev/null +++ b/resources/js/enums/OperationKind.js @@ -0,0 +1,14 @@ +// This is a generated file. + +export default { + "SERVER_PROVISION": "server_provision", + "SERVICE_DEPLOY": "service_deploy", + "REPLICA_DEPLOY": "replica_deploy", + "SLICE_PROVISION": "slice_provision", + "SLICE_CONFIGURE": "slice_configure", + "ENVIRONMENT_DEPLOY": "environment_deploy", + "GATEWAY_CUTOVER": "gateway_cutover", + "CONFIG_CHANGE": "config_change", + "CREDENTIAL_ROTATION": "credential_rotation" +} + diff --git a/resources/js/enums/DeploymentStatus.js b/resources/js/enums/OperationStatus.js similarity index 100% rename from resources/js/enums/DeploymentStatus.js rename to resources/js/enums/OperationStatus.js diff --git a/resources/js/enums/RegistryType.js b/resources/js/enums/RegistryType.js new file mode 100644 index 0000000..ec8ce66 --- /dev/null +++ b/resources/js/enums/RegistryType.js @@ -0,0 +1,9 @@ +// This is a generated file. + +export default { + "GENERIC": "generic", + "GITEA": "gitea", + "GHCR": "ghcr", + "DOCKER_HUB": "docker_hub" +} + diff --git a/resources/js/enums/SchedulerMode.js b/resources/js/enums/SchedulerMode.js new file mode 100644 index 0000000..6613b61 --- /dev/null +++ b/resources/js/enums/SchedulerMode.js @@ -0,0 +1,7 @@ +// This is a generated file. + +export default { + "SINGLE": "single", + "EVERY_REPLICA": "every_replica" +} + diff --git a/resources/js/enums/ServiceCategory.js b/resources/js/enums/ServiceCategory.js index 5a8c21b..f0c6d59 100644 --- a/resources/js/enums/ServiceCategory.js +++ b/resources/js/enums/ServiceCategory.js @@ -5,14 +5,16 @@ export default { "APPLICATION": "application", "GATEWAY": "gateway", "STORAGE": "storage", - "CACHE": "cache" + "CACHE": "cache", + "BUILDER": "builder" } export const DescriptionMap = { - "DATABASE": "Postgres or MySQL", + "DATABASE": "Postgres", "APPLICATION": "The base container image for your application", "GATEWAY": "The first point of contact for your application", "STORAGE": "S3 or S3-compatible service", - "CACHE": "Redis, Memcached or similar" + "CACHE": "Valkey", + "BUILDER": "Build service for application artifacts" } diff --git a/resources/js/enums/ServiceEndpointScope.js b/resources/js/enums/ServiceEndpointScope.js new file mode 100644 index 0000000..fff28a4 --- /dev/null +++ b/resources/js/enums/ServiceEndpointScope.js @@ -0,0 +1,8 @@ +// This is a generated file. + +export default { + "DOCKER_NETWORK": "docker_network", + "PRIVATE_NETWORK": "private_network", + "PUBLIC": "public" +} + diff --git a/resources/js/enums/ServiceType.js b/resources/js/enums/ServiceType.js index fc21c62..3f41311 100644 --- a/resources/js/enums/ServiceType.js +++ b/resources/js/enums/ServiceType.js @@ -1,13 +1,9 @@ // This is a generated file. export default { - "FRANKENPHP": "frankenphp", - "PHP_FPM": "php-fpm", "POSTGRES": "postgres", "CADDY": "caddy", "VALKEY": "valkey", - "MYSQL": "mysql", - "NGINX": "nginx", - "REDIS": "redis" + "LARAVEL": "laravel" } diff --git a/resources/js/enums/SourceProviderType.js b/resources/js/enums/SourceProviderType.js new file mode 100644 index 0000000..b5e7bd3 --- /dev/null +++ b/resources/js/enums/SourceProviderType.js @@ -0,0 +1,8 @@ +// This is a generated file. + +export default { + "GITEA": "gitea", + "GITHUB": "github", + "GENERIC_GIT": "generic_git" +} + diff --git a/resources/js/pages/applications/Create.vue b/resources/js/pages/applications/Create.vue new file mode 100644 index 0000000..45f5e53 --- /dev/null +++ b/resources/js/pages/applications/Create.vue @@ -0,0 +1,72 @@ + + + diff --git a/resources/js/pages/applications/Index.vue b/resources/js/pages/applications/Index.vue index 79c8915..cb34124 100644 --- a/resources/js/pages/applications/Index.vue +++ b/resources/js/pages/applications/Index.vue @@ -1,7 +1,6 @@ + + diff --git a/resources/js/pages/environment-variables/Create.vue b/resources/js/pages/environment-variables/Create.vue new file mode 100644 index 0000000..2c7866c --- /dev/null +++ b/resources/js/pages/environment-variables/Create.vue @@ -0,0 +1,82 @@ + + + diff --git a/resources/js/pages/environments/Show.vue b/resources/js/pages/environments/Show.vue new file mode 100644 index 0000000..a2f013b --- /dev/null +++ b/resources/js/pages/environments/Show.vue @@ -0,0 +1,195 @@ + + + diff --git a/resources/js/pages/onboarding/Show.vue b/resources/js/pages/onboarding/Show.vue new file mode 100644 index 0000000..e28b59b --- /dev/null +++ b/resources/js/pages/onboarding/Show.vue @@ -0,0 +1,53 @@ + + + diff --git a/resources/js/pages/organisations/Show.vue b/resources/js/pages/organisations/Show.vue index 94fd890..2548442 100644 --- a/resources/js/pages/organisations/Show.vue +++ b/resources/js/pages/organisations/Show.vue @@ -1,9 +1,10 @@ + + diff --git a/resources/js/pages/servers/Index.vue b/resources/js/pages/servers/Index.vue index 2142f9b..3e4340b 100644 --- a/resources/js/pages/servers/Index.vue +++ b/resources/js/pages/servers/Index.vue @@ -54,7 +54,6 @@ const props = defineProps({ > -
@todo pagination
diff --git a/resources/js/pages/servers/Show.vue b/resources/js/pages/servers/Show.vue index 41c54fd..4152094 100644 --- a/resources/js/pages/servers/Show.vue +++ b/resources/js/pages/servers/Show.vue @@ -6,7 +6,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u import AppLayout from '@/layouts/AppLayout.vue'; import { Head, Link } from '@inertiajs/vue3'; import { useCycleList, useInterval } from '@vueuse/core'; -import { DatabaseIcon, Layers2Icon, LoaderCircleIcon, PlusIcon } from 'lucide-vue-next'; +import { DatabaseIcon, Layers2Icon, LoaderCircleIcon, PlusIcon, RefreshCwIcon } from 'lucide-vue-next'; import { ref, watch } from 'vue'; defineProps({ @@ -97,17 +97,34 @@ watch(counter, () => { {{ service.slices?.length }} slices + + +
-

Deployments

+

Operations

-
-
{{ deployment.target.name }}
+
+
{{ operation.target.name }}
-
+
{{ step.name ?? 'Unnamed Step' }} diff --git a/resources/js/pages/services/Edit.vue b/resources/js/pages/services/Edit.vue new file mode 100644 index 0000000..c94dd04 --- /dev/null +++ b/resources/js/pages/services/Edit.vue @@ -0,0 +1,68 @@ + + + diff --git a/resources/js/pages/services/Show.vue b/resources/js/pages/services/Show.vue new file mode 100644 index 0000000..2bed8e9 --- /dev/null +++ b/resources/js/pages/services/Show.vue @@ -0,0 +1,84 @@ + + + diff --git a/resources/js/pages/services/updates/Create.vue b/resources/js/pages/services/updates/Create.vue new file mode 100644 index 0000000..1c19d23 --- /dev/null +++ b/resources/js/pages/services/updates/Create.vue @@ -0,0 +1,102 @@ + + + diff --git a/resources/js/pages/source-providers/Create.vue b/resources/js/pages/source-providers/Create.vue new file mode 100644 index 0000000..4d586aa --- /dev/null +++ b/resources/js/pages/source-providers/Create.vue @@ -0,0 +1,74 @@ + + + diff --git a/routes/web.php b/routes/web.php index db8941f..9608c89 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,11 +1,21 @@ name('home'); @@ -19,6 +29,16 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::prefix('organisations/{organisation}')->group(function () { Route::get('/', [OrganisationController::class, 'show'])->name('organisations.show'); + Route::get('/onboarding', [OnboardingController::class, 'show'])->name('onboarding.show'); + + Route::resource('registries', RegistryController::class) + ->only('create', 'store') + ->name('create', 'registries.create') + ->name('store', 'registries.store'); + Route::resource('source-providers', SourceProviderController::class) + ->only('create', 'store') + ->name('create', 'source-providers.create') + ->name('store', 'source-providers.store'); Route::resource('servers', ServerController::class) ->only('index', 'show', 'create', 'store') @@ -29,15 +49,40 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::prefix('servers/{server}')->group(function () { Route::resource('services', ServiceController::class) - ->only('create', 'store') + ->only('create', 'store', 'show', 'edit', 'update') ->name('create', 'services.create') - ->name('store', 'services.store'); + ->name('store', 'services.store') + ->name('show', 'services.show') + ->name('edit', 'services.edit') + ->name('update', 'services.update'); + Route::get('services/{service}/updates/create', [ServiceUpdateController::class, 'create']) + ->name('service-updates.create'); + Route::post('services/{service}/updates', [ServiceUpdateController::class, 'store']) + ->name('service-updates.store'); }); Route::resource('applications', ApplicationController::class) - ->only('show', 'index') + ->only('show', 'index', 'create', 'store') ->name('index', 'applications.index') ->name('show', 'applications.show'); + Route::get('applications/{application}/environments/{environment}', [EnvironmentController::class, 'show']) + ->name('environments.show'); + Route::get('applications/{application}/environments/{environment}/attachments/create', [EnvironmentAttachmentController::class, 'create']) + ->name('environment-attachments.create'); + Route::post('applications/{application}/environments/{environment}/attachments', [EnvironmentAttachmentController::class, 'store']) + ->name('environment-attachments.store'); + Route::post('applications/{application}/environments/{environment}/workers', [EnvironmentWorkerController::class, 'store']) + ->name('environment-workers.store'); + Route::post('applications/{application}/environments/{environment}/migrations', [EnvironmentMigrationController::class, 'store']) + ->name('environment-migrations.store'); + Route::post('applications/{application}/environments/{environment}/deployments', [EnvironmentDeploymentController::class, 'store']) + ->name('environment-deployments.store'); + Route::get('applications/{application}/environments/{environment}/variables/create', [EnvironmentVariableController::class, 'create']) + ->name('environment-variables.create'); + Route::post('applications/{application}/environments/{environment}/variables', [EnvironmentVariableController::class, 'store']) + ->name('environment-variables.store'); + Route::post('applications/{application}/verify-repository', [ApplicationController::class, 'verifyRepository']) + ->name('applications.verify-repository'); }); }); diff --git a/tests/Feature/ApplicationControllerTest.php b/tests/Feature/ApplicationControllerTest.php new file mode 100644 index 0000000..a2a92f7 --- /dev/null +++ b/tests/Feature/ApplicationControllerTest.php @@ -0,0 +1,122 @@ +create(); + $organisation = Organisation::factory()->create(['owner_id' => $user->id]); + $application = Application::factory()->for($organisation)->create(); + Environment::factory()->for($application)->create(['name' => 'production']); + + $response = $this->actingAs($user)->get(route('applications.index', [ + 'organisation' => $organisation->id, + ])); + + $response->assertOk(); + $response->assertInertia(fn (AssertableInertia $page) => $page + ->component('applications/Index', false) + ->has('applications.0.environments', 1)); +}); + +it('shows an application with environments services and attachments', function () { + $user = User::factory()->create(); + $organisation = Organisation::factory()->create(['owner_id' => $user->id]); + $application = Application::factory()->for($organisation)->create(); + $environment = Environment::factory()->for($application)->create(['name' => 'production']); + $environment->services()->create(\App\Models\Service::factory()->make([ + 'organisation_id' => $organisation->id, + ])->toArray()); + + $response = $this->actingAs($user)->get(route('applications.show', [ + 'organisation' => $organisation->id, + 'application' => $application->id, + ])); + + $response->assertOk(); + $response->assertInertia(fn (AssertableInertia $page) => $page + ->component('applications/Show', false) + ->has('application.environments', 1) + ->has('application.environments.0.services', 1)); +}); + +it('shows the create application page', function () { + $user = User::factory()->create(); + $organisation = Organisation::factory()->create(['owner_id' => $user->id]); + + $response = $this->actingAs($user)->get(route('applications.create', [ + 'organisation' => $organisation->id, + ])); + + $response->assertOk(); + $response->assertInertia(fn (AssertableInertia $page) => $page + ->component('applications/Create', false)); +}); + +it('stores an application with a deploy key and default laravel environment', function () { + $this->app->bind(GenerateDeployKey::class, fn () => new class extends GenerateDeployKey + { + public function execute(Application $application, ?array $keyPair = null): Application + { + return parent::execute($application, [ + 'public' => 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestPublicKey keystone', + 'private' => "-----BEGIN OPENSSH PRIVATE KEY-----\ntest\n-----END OPENSSH PRIVATE KEY-----", + 'fingerprint' => 'SHA256:test', + ]); + } + }); + + $user = User::factory()->create(); + $organisation = Organisation::factory()->create(['owner_id' => $user->id]); + + $response = $this->actingAs($user)->post(route('applications.store', [ + 'organisation' => $organisation->id, + ]), [ + 'name' => 'Billing API', + 'repository_url' => 'git@example.com:org/billing-api.git', + 'default_branch' => 'main', + 'environment_name' => 'production', + ]); + + $application = Application::query()->where('name', 'Billing API')->firstOrFail(); + + $response->assertRedirect(route('applications.show', [ + 'organisation' => $organisation->id, + 'application' => $application->id, + ])); + + expect($application->deploy_key_public)->toStartWith('ssh-ed25519') + ->and($application->environments()->where('name', 'production')->exists())->toBeTrue() + ->and($application->environments()->first()->services()->where('name', 'web')->exists())->toBeTrue(); +}); + +it('verifies repository access for an application deploy key', function () { + Process::fake([ + '*' => Process::result(output: "abc123\trefs/heads/main\n"), + ]); + + $user = User::factory()->create(); + $organisation = Organisation::factory()->create(['owner_id' => $user->id]); + $application = Application::factory()->for($organisation)->create([ + 'repository_url' => 'git@example.com:org/repo.git', + 'default_branch' => 'main', + ]); + app(GenerateDeployKey::class)->execute($application, [ + 'public' => 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestPublicKey keystone', + 'private' => "-----BEGIN OPENSSH PRIVATE KEY-----\ntest\n-----END OPENSSH PRIVATE KEY-----", + 'fingerprint' => 'SHA256:test', + ]); + + $response = $this->actingAs($user)->post(route('applications.verify-repository', [ + 'organisation' => $organisation->id, + 'application' => $application->id, + ])); + + $response->assertRedirect(); + expect($application->refresh()->deploy_key_installed_at)->not->toBeNull(); +}); diff --git a/tests/Feature/BuildApplicationArtifactTest.php b/tests/Feature/BuildApplicationArtifactTest.php new file mode 100644 index 0000000..47a52a2 --- /dev/null +++ b/tests/Feature/BuildApplicationArtifactTest.php @@ -0,0 +1,104 @@ +remoteRunner = new class implements RemoteCommandRunner + { + /** @var array */ + public array $scripts = []; + + public function run(Server $server, string $script): string + { + $this->scripts[] = $script; + + return str_contains($script, 'docker manifest inspect') + ? "image_digest=sha256:registrydigest\n" + : "image_digest=billing-api:aaaaaaaaaaaa@sha256:localdigest\n"; + } + }; + + app()->instance(RemoteCommandRunner::class, $this->remoteRunner); +}); + +it('builds a target-server artifact over ssh with a temporary deploy key and stores the resolved digest', function () { + $organisation = Organisation::factory()->create(); + $server = buildServerFor($organisation); + $application = Application::factory()->for($organisation)->create([ + 'name' => 'Billing API', + 'repository_url' => 'git@example.com:org/repo.git', + ]); + app(GenerateDeployKey::class)->execute($application, [ + 'public' => 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestPublicKey keystone', + 'private' => "-----BEGIN OPENSSH PRIVATE KEY-----\ntest\n-----END OPENSSH PRIVATE KEY-----", + 'fingerprint' => 'SHA256:test', + ]); + $environment = app(CreateLaravelEnvironment::class)->execute($application->refresh(), 'production'); + $environment->services()->first()->update(['server_id' => $server->id]); + $artifact = app(PlanBuildArtifact::class)->execute($environment, str_repeat('a', 40)); + + $built = app(BuildApplicationArtifact::class)->execute($artifact); + + expect($built->status)->toBe(BuildArtifactStatus::AVAILABLE) + ->and($built->image_digest)->toBe('sha256:localdigest') + ->and($this->remoteRunner->scripts[0])->toContain('GIT_SSH_COMMAND') + ->and($this->remoteRunner->scripts[0])->toContain('git clone --depth 1 --branch') + ->and($this->remoteRunner->scripts[0])->toContain('docker build --file Dockerfile.keystone') + ->and($this->remoteRunner->scripts[0])->toContain('/home/keystone/operations/build-') + ->and($this->remoteRunner->scripts[0])->toContain('trap cleanup EXIT'); +}); + +it('resolves external registry artifacts without building locally', function () { + $organisation = Organisation::factory()->create(); + $server = buildServerFor($organisation); + $organisation->registries()->create([ + 'name' => 'GHCR', + 'type' => RegistryType::GHCR, + 'url' => 'ghcr.io/example', + ]); + $application = Application::factory()->for($organisation)->create([ + 'name' => 'Billing API', + 'repository_url' => 'git@example.com:org/repo.git', + ]); + $environment = app(CreateLaravelEnvironment::class)->execute($application->refresh(), 'production'); + $environment->services()->first()->update(['server_id' => $server->id]); + $environment->services()->first()->update(['desired_replicas' => 2]); + $artifact = app(PlanBuildArtifact::class)->execute($environment, str_repeat('b', 40)); + + $built = app(BuildApplicationArtifact::class)->execute($artifact); + + expect($built->registry_ref)->toBe('ghcr.io/example/billing-api:bbbbbbbbbbbb') + ->and($built->image_digest)->toBe('sha256:registrydigest') + ->and($this->remoteRunner->scripts[0])->toContain('docker manifest inspect') + ->and($this->remoteRunner->scripts[0])->toContain('ghcr.io/example/billing-api:bbbbbbbbbbbb') + ->and($this->remoteRunner->scripts[0])->not->toContain('docker build') + ->and($this->remoteRunner->scripts[0])->not->toContain('git clone'); +}); + +function buildServerFor(Organisation $organisation): Server +{ + $provider = Provider::factory()->forOrganisation($organisation)->create(); + $network = Network::create([ + 'organisation_id' => $organisation->id, + 'provider_id' => $provider->id, + 'name' => 'test-network', + 'ip_range' => '10.0.0.0/24', + ]); + + return Server::factory() + ->forOrganisation($organisation->id) + ->forProvider($provider->id) + ->forNetwork($network->id) + ->create(); +} diff --git a/tests/Feature/BuildArtifactPlanningTest.php b/tests/Feature/BuildArtifactPlanningTest.php new file mode 100644 index 0000000..0c36d2a --- /dev/null +++ b/tests/Feature/BuildArtifactPlanningTest.php @@ -0,0 +1,81 @@ +create(); + $application = Application::factory()->for($organisation)->create(['name' => 'Billing API']); + $environment = Environment::factory()->for($application)->create(); + Service::factory()->for($environment)->create([ + 'organisation_id' => $organisation->id, + 'category' => ServiceCategory::APPLICATION, + 'type' => ServiceType::LARAVEL, + 'version' => 'php-8.4', + 'version_track' => 'php-8.4', + 'driver_name' => 'laravel.php-8.4', + 'deploy_policy' => DeployPolicy::WITH_ENVIRONMENT, + 'desired_replicas' => 1, + ]); + + $artifact = app(PlanBuildArtifact::class)->execute($environment, str_repeat('a', 40)); + + expect($artifact->status)->toBe(BuildArtifactStatus::PENDING) + ->and($artifact->image_tag)->toBe('billing-api:aaaaaaaaaaaa') + ->and($artifact->registry_ref)->toBeNull() + ->and($artifact->metadata['build_strategy'])->toBe(BuildStrategy::TARGET_SERVER->value); +}); + +it('requires a registry before planning multi-server builds', function () { + $organisation = Organisation::factory()->create(); + $application = Application::factory()->for($organisation)->create(); + $environment = Environment::factory()->for($application)->create(); + Service::factory()->for($environment)->create([ + 'organisation_id' => $organisation->id, + 'category' => ServiceCategory::APPLICATION, + 'type' => ServiceType::LARAVEL, + 'version' => 'php-8.4', + 'version_track' => 'php-8.4', + 'driver_name' => 'laravel.php-8.4', + 'deploy_policy' => DeployPolicy::WITH_ENVIRONMENT, + 'desired_replicas' => 2, + ]); + + expect(fn () => app(PlanBuildArtifact::class)->execute($environment, str_repeat('b', 40))) + ->toThrow(RuntimeException::class, 'A registry is required before building artifacts for multi-server deployments.'); +}); + +it('plans multi-server builds against the configured external registry', function () { + $organisation = Organisation::factory()->create(); + $organisation->registries()->create([ + 'name' => 'GHCR', + 'type' => RegistryType::GHCR, + 'url' => 'ghcr.io/example', + ]); + $application = Application::factory()->for($organisation)->create(['name' => 'Billing API']); + $environment = Environment::factory()->for($application)->create(); + Service::factory()->for($environment)->create([ + 'organisation_id' => $organisation->id, + 'category' => ServiceCategory::APPLICATION, + 'type' => ServiceType::LARAVEL, + 'version' => 'php-8.4', + 'version_track' => 'php-8.4', + 'driver_name' => 'laravel.php-8.4', + 'deploy_policy' => DeployPolicy::WITH_ENVIRONMENT, + 'desired_replicas' => 2, + ]); + + $artifact = app(PlanBuildArtifact::class)->execute($environment, str_repeat('c', 40)); + + expect($artifact->registry_ref)->toBe('ghcr.io/example/billing-api:cccccccccccc') + ->and($artifact->metadata['build_strategy'])->toBe(BuildStrategy::EXTERNAL_REGISTRY->value); +}); diff --git a/tests/Feature/ComposeRendererTest.php b/tests/Feature/ComposeRendererTest.php new file mode 100644 index 0000000..f4fbcb0 --- /dev/null +++ b/tests/Feature/ComposeRendererTest.php @@ -0,0 +1,174 @@ +create([ + 'name' => 'primary-postgres', + 'category' => ServiceCategory::DATABASE, + 'type' => ServiceType::POSTGRES, + 'version' => '18', + 'version_track' => '18', + 'driver_name' => 'postgres.18', + 'deploy_policy' => DeployPolicy::DEPENDENCY_ONLY, + 'credentials' => [ + 'user' => 'keystone', + 'password' => 'secret', + 'db' => 'keystone', + ], + 'default_cpu_limit' => 1.5, + 'default_memory_limit_mb' => 1024, + ]); + + $compose = app(ComposeRenderer::class)->render($service); + + expect($compose) + ->toContain('services:') + ->toContain('primary_postgres:') + ->toContain('image: "postgres:18"') + ->toContain('cpus: 1.500') + ->toContain('mem_limit: "1024m"') + ->toContain("keystone_service_{$service->id}_postgres_data:"); +}); + +it('renders compose for caddy gateway with public ports and named volumes', function () { + $service = Service::factory()->create([ + 'name' => 'gateway', + 'category' => ServiceCategory::GATEWAY, + 'type' => ServiceType::CADDY, + 'version' => '2', + 'version_track' => '2', + 'driver_name' => 'caddy.2', + ]); + + $compose = app(ComposeRenderer::class)->render($service); + + expect($compose) + ->toContain('image: "caddy:2"') + ->toContain('- "80:80"') + ->toContain('- "443:443"') + ->toContain("keystone_service_{$service->id}_caddy_data:"); +}); + +it('renders laravel scheduler and worker runtime roles into compose', function () { + $environment = Environment::factory()->create([ + 'scheduler_enabled' => true, + 'scheduler_mode' => SchedulerMode::SINGLE, + ]); + $web = Service::factory()->for($environment)->create([ + 'name' => 'web', + 'category' => ServiceCategory::APPLICATION, + 'type' => ServiceType::LARAVEL, + 'version' => 'php-8.4', + 'version_track' => 'php-8.4', + 'driver_name' => 'laravel.php-8.4', + 'process_roles' => ['web', 'scheduler'], + 'desired_replicas' => 1, + ]); + $environment->forceFill(['scheduler_target_service_id' => $web->id])->save(); + + $worker = Service::factory()->for($environment)->create([ + 'name' => 'worker', + 'category' => ServiceCategory::APPLICATION, + 'type' => ServiceType::LARAVEL, + 'version' => 'php-8.4', + 'version_track' => 'php-8.4', + 'driver_name' => 'laravel.php-8.4', + 'process_roles' => ['worker'], + 'config' => [ + 'command' => 'php artisan queue:work --sleep=3 --tries=3', + ], + ]); + + expect(app(ComposeRenderer::class)->render($web)) + ->toContain('AUTORUN_LARAVEL_SCHEDULER: "true"') + ->toContain('healthcheck:') + ->and(app(ComposeRenderer::class)->render($worker)) + ->toContain('command: "php artisan queue:work --sleep=3 --tries=3"') + ->not->toContain('healthcheck:'); +}); + +it('enforces scheduler mode when rendering laravel runtime env', function () { + $environment = Environment::factory()->create([ + 'scheduler_enabled' => true, + 'scheduler_mode' => SchedulerMode::SINGLE, + ]); + $target = Service::factory()->for($environment)->create([ + 'name' => 'web', + 'category' => ServiceCategory::APPLICATION, + 'type' => ServiceType::LARAVEL, + 'version' => 'php-8.4', + 'version_track' => 'php-8.4', + 'driver_name' => 'laravel.php-8.4', + 'process_roles' => ['web', 'scheduler'], + 'desired_replicas' => 1, + ]); + $nonTarget = Service::factory()->for($environment)->create([ + 'name' => 'worker', + 'category' => ServiceCategory::APPLICATION, + 'type' => ServiceType::LARAVEL, + 'version' => 'php-8.4', + 'version_track' => 'php-8.4', + 'driver_name' => 'laravel.php-8.4', + 'process_roles' => ['worker', 'scheduler'], + 'desired_replicas' => 1, + ]); + $environment->forceFill(['scheduler_target_service_id' => $target->id])->save(); + + expect(app(ComposeRenderer::class)->render($target->refresh())) + ->toContain('AUTORUN_LARAVEL_SCHEDULER: "true"') + ->and(app(ComposeRenderer::class)->render($nonTarget->refresh())) + ->not->toContain('AUTORUN_LARAVEL_SCHEDULER'); + + $environment->forceFill([ + 'scheduler_mode' => SchedulerMode::EVERY_REPLICA, + 'scheduler_target_service_id' => null, + ])->save(); + + expect(app(ComposeRenderer::class)->render($target->refresh())) + ->toContain('AUTORUN_LARAVEL_SCHEDULER: "true"') + ->and(app(ComposeRenderer::class)->render($nonTarget->refresh())) + ->toContain('AUTORUN_LARAVEL_SCHEDULER: "true"'); +}); + +it('renders environment variables into laravel runtime compose', function () { + $environment = Environment::factory()->create(['name' => 'production']); + $environment->variables()->create([ + 'key' => 'DB_CONNECTION', + 'value' => 'pgsql', + 'source' => EnvironmentVariableSource::MANAGED_ATTACHMENT, + 'overridable' => false, + ]); + $environment->variables()->create([ + 'key' => 'FEATURE_FLAG', + 'value' => 'enabled', + 'source' => EnvironmentVariableSource::USER, + 'overridable' => true, + ]); + $service = Service::factory()->for($environment)->create([ + 'name' => 'web', + 'category' => ServiceCategory::APPLICATION, + 'type' => ServiceType::LARAVEL, + 'version' => 'php-8.4', + 'version_track' => 'php-8.4', + 'driver_name' => 'laravel.php-8.4', + 'process_roles' => ['web'], + ]); + + expect(app(ComposeRenderer::class)->render($service)) + ->toContain('DB_CONNECTION: "pgsql"') + ->toContain('FEATURE_FLAG: "enabled"') + ->toContain('APP_ENV: "production"'); + + expect(app(ComposeRenderer::class)->renderEnvironmentFile($service)) + ->toContain('DB_CONNECTION=pgsql') + ->toContain('FEATURE_FLAG=enabled') + ->toContain('APP_ENV=production'); +}); diff --git a/tests/Feature/DeployEnvironmentJobTest.php b/tests/Feature/DeployEnvironmentJobTest.php new file mode 100644 index 0000000..e373089 --- /dev/null +++ b/tests/Feature/DeployEnvironmentJobTest.php @@ -0,0 +1,454 @@ +instance(RemoteCommandRunner::class, new class implements RemoteCommandRunner + { + public function run(Server $server, string $script): string + { + return "image_digest=billing-api:aaaaaaaaaaaa@sha256:deploymentdigest\n"; + } + }); + + Process::fake(function ($process) { + $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; + + return match (true) { + str_contains($command, 'git ls-remote') => Process::result(output: str_repeat('a', 40)."\trefs/heads/main\n"), + str_contains($command, 'docker image inspect') => Process::result(output: "billing-api:aaaaaaaaaaaa@sha256:deploymentdigest\n"), + default => Process::result(), + }; + }); +}); + +it('creates a parent environment operation with child service deploy operations', function () { + $organisation = Organisation::factory()->create(); + $provider = Provider::factory()->forOrganisation($organisation)->create(); + $network = Network::create([ + 'organisation_id' => $organisation->id, + 'provider_id' => $provider->id, + 'name' => 'test-network', + 'ip_range' => '10.0.0.0/24', + ]); + $server = Server::factory() + ->forOrganisation($organisation->id) + ->forProvider($provider->id) + ->forNetwork($network->id) + ->create(); + $application = Application::factory()->for($organisation)->create(); + generateDeployKey($application); + $environment = Environment::factory()->for($application)->create(); + $service = Service::factory()->for($environment)->for($server)->create([ + 'organisation_id' => $organisation->id, + 'name' => 'web', + 'category' => ServiceCategory::APPLICATION, + 'type' => ServiceType::LARAVEL, + 'version' => 'php-8.4', + 'version_track' => 'php-8.4', + 'driver_name' => 'laravel.php-8.4', + 'deploy_policy' => DeployPolicy::WITH_ENVIRONMENT, + ]); + + (new DeployEnvironment($environment))->handle(); + + $parent = Operation::query() + ->where('target_type', $environment->getMorphClass()) + ->where('target_id', $environment->id) + ->first(); + $child = Operation::query() + ->where('parent_id', $parent->id) + ->where('target_type', $service->getMorphClass()) + ->where('target_id', $service->id) + ->first(); + + expect($parent->kind)->toBe(OperationKind::ENVIRONMENT_DEPLOY) + ->and($child->kind)->toBe(OperationKind::SERVICE_DEPLOY) + ->and($child->steps)->toHaveCount(7) + ->and($child->steps()->where('name', 'Render Compose files')->first()->script)->toContain("base64 -d > /home/keystone/services/{$service->id}/compose.yml") + ->and($child->steps()->where('name', 'Render Compose files')->first()->script)->toContain("base64 -d > /home/keystone/services/{$service->id}/.env") + ->and($child->steps()->where('name', 'Run migrations')->first()->script)->toContain('docker compose -f /home/keystone/services/'.$service->id.'/compose.yml run --rm web php artisan migrate --force') + ->and($child->steps()->where('name', 'Deploy replicas')->first()->script)->toContain('docker compose -f /home/keystone/services/'.$service->id.'/compose.yml up -d --scale web=1') + ->and($child->steps()->where('name', 'Update gateway routes')->exists())->toBeFalse() + ->and($child->steps()->pluck('script')->implode("\n"))->not->toContain('echo ') + ->and($environment->buildArtifacts()->first()->image_digest)->toBe('sha256:deploymentdigest') + ->and($service->refresh()->available_image_digest)->toBe('sha256:deploymentdigest') + ->and($service->desired_revision)->toBe(str_repeat('a', 40)); + + Bus::assertDispatched(RunStep::class); +}); + +it('creates replica route configure and gateway cutover child operations', function () { + $organisation = Organisation::factory()->create(); + $provider = Provider::factory()->forOrganisation($organisation)->create(); + $network = Network::create([ + 'organisation_id' => $organisation->id, + 'provider_id' => $provider->id, + 'name' => 'test-network', + 'ip_range' => '10.0.0.0/24', + ]); + $server = Server::factory() + ->forOrganisation($organisation->id) + ->forProvider($provider->id) + ->forNetwork($network->id) + ->create(); + $application = Application::factory()->for($organisation)->create(); + generateDeployKey($application); + $environment = Environment::factory()->for($application)->create(); + $web = Service::factory()->for($environment)->for($server)->create([ + 'organisation_id' => $organisation->id, + 'name' => 'web', + 'category' => ServiceCategory::APPLICATION, + 'type' => ServiceType::LARAVEL, + 'version' => 'php-8.4', + 'version_track' => 'php-8.4', + 'driver_name' => 'laravel.php-8.4', + 'deploy_policy' => DeployPolicy::WITH_ENVIRONMENT, + 'process_roles' => ['web'], + 'desired_replicas' => 2, + ]); + $web->replicas()->create([ + 'server_id' => $server->id, + 'container_name' => 'web-1', + 'internal_host' => 'web-1', + 'internal_port' => 80, + 'status' => 'running', + 'health_status' => 'healthy', + 'config' => [], + ]); + $web->replicas()->create([ + 'server_id' => $server->id, + 'container_name' => 'web-2', + 'internal_host' => 'web-2', + 'internal_port' => 80, + 'status' => 'running', + 'health_status' => 'healthy', + 'config' => [], + ]); + $gateway = Service::factory()->for($environment)->for($server)->create([ + 'organisation_id' => $organisation->id, + 'name' => 'gateway', + 'category' => ServiceCategory::GATEWAY, + 'type' => ServiceType::CADDY, + 'version' => '2', + 'version_track' => '2', + 'driver_name' => 'caddy.2', + 'deploy_policy' => DeployPolicy::MANUAL_OR_ON_ROUTE_CHANGE, + ]); + $gateway->replicas()->create([ + 'server_id' => $server->id, + 'container_name' => 'gateway-1', + 'internal_host' => 'gateway-1', + 'internal_port' => 80, + 'status' => 'running', + 'health_status' => 'healthy', + 'config' => [], + ]); + $route = ServiceSlice::factory()->for($gateway)->create([ + 'environment_id' => $environment->id, + 'type' => 'route', + 'name' => 'example.com', + ]); + $environment->attachments()->create([ + 'service_id' => $gateway->id, + 'service_slice_id' => $route->id, + 'role' => EnvironmentAttachmentRole::GATEWAY, + 'is_primary' => true, + ]); + $organisation->registries()->create([ + 'name' => 'registry', + 'type' => 'generic', + 'url' => 'registry.example.com', + ]); + + (new DeployEnvironment($environment))->handle(); + + $parent = Operation::query() + ->where('target_type', $environment->getMorphClass()) + ->where('target_id', $environment->id) + ->where('kind', OperationKind::ENVIRONMENT_DEPLOY) + ->first(); + + $serviceDeploy = $parent->children()->where('kind', OperationKind::SERVICE_DEPLOY)->first(); + + expect($serviceDeploy->children()->where('kind', OperationKind::REPLICA_DEPLOY)->count())->toBe(2) + ->and($parent->children()->where('kind', OperationKind::SLICE_CONFIGURE)->count())->toBe(1) + ->and($parent->children()->where('kind', OperationKind::GATEWAY_CUTOVER)->count())->toBe(1) + ->and($serviceDeploy->children()->where('kind', OperationKind::REPLICA_DEPLOY)->first()->steps()->where('name', 'Start replica 1')->first()->script) + ->toContain('docker compose -p keystone_service_') + ->and($serviceDeploy->children()->where('kind', OperationKind::REPLICA_DEPLOY)->first()->steps()->where('name', 'Start replica 1')->first()->script) + ->toContain('container_id=') + ->and($serviceDeploy->children()->where('kind', OperationKind::REPLICA_DEPLOY)->first()->steps()->where('name', 'Pull image for replica 1')->first()->script) + ->toContain('docker pull') + ->and($serviceDeploy->children()->where('kind', OperationKind::REPLICA_DEPLOY)->first()->steps()->where('name', 'Pull image for replica 1')->first()->script) + ->toContain('@sha256:deploymentdigest') + ->and($serviceDeploy->children()->where('kind', OperationKind::REPLICA_DEPLOY)->first()->steps()->where('name', 'Health check replica 1')->first()->script) + ->toContain('health_status=') + ->and($parent->children()->where('kind', OperationKind::SLICE_CONFIGURE)->first()->steps()->first()->script) + ->toContain('/home/keystone/gateway/Caddyfile.d') + ->and($parent->children()->where('kind', OperationKind::SLICE_CONFIGURE)->first()->steps()->first()->script) + ->toContain('reverse_proxy web-1:80 web-2:80') + ->and($web->endpoints()->count())->toBe(2) + ->and($parent->children()->where('kind', OperationKind::GATEWAY_CUTOVER)->first()->steps()->pluck('name')->all()) + ->toBe([ + 'Validate Caddy route configuration', + 'Reload Caddy', + 'Verify new upstreams are reachable', + 'Drain old upstreams', + ]) + ->and($parent->children()->where('kind', OperationKind::GATEWAY_CUTOVER)->first()->steps()->where('name', 'Reload Caddy')->first()->script) + ->toContain('gateway-1'); +}); + +it('honors manual disabled and post-switch migration settings', function (string $mode, string $timing, string $expectedScript, int $expectedOrder) { + $organisation = Organisation::factory()->create(); + $provider = Provider::factory()->forOrganisation($organisation)->create(); + $network = Network::create([ + 'organisation_id' => $organisation->id, + 'provider_id' => $provider->id, + 'name' => 'test-network', + 'ip_range' => '10.0.0.0/24', + ]); + $server = Server::factory() + ->forOrganisation($organisation->id) + ->forProvider($provider->id) + ->forNetwork($network->id) + ->create(); + $application = Application::factory()->for($organisation)->create(); + generateDeployKey($application); + $environment = Environment::factory()->for($application)->create(); + $service = Service::factory()->for($environment)->for($server)->create([ + 'organisation_id' => $organisation->id, + 'name' => 'web', + 'category' => ServiceCategory::APPLICATION, + 'type' => ServiceType::LARAVEL, + 'version' => 'php-8.4', + 'version_track' => 'php-8.4', + 'driver_name' => 'laravel.php-8.4', + 'deploy_policy' => DeployPolicy::WITH_ENVIRONMENT, + 'config' => [ + 'migration_mode' => $mode, + 'migration_timing' => $timing, + 'migration_command' => 'php artisan migrate --force', + ], + ]); + + (new DeployEnvironment($environment))->handle(); + + $step = $service->operations()->where('kind', OperationKind::SERVICE_DEPLOY)->first()->steps()->where('name', 'Run migrations')->first(); + + $expectedScript = $expectedScript === 'migration' + ? "docker compose -f /home/keystone/services/{$service->id}/compose.yml run --rm web php artisan migrate --force" + : $expectedScript; + + expect($step->script)->toBe($expectedScript) + ->and($step->order)->toBe($expectedOrder); +})->with([ + 'manual pre-switch' => ['manual', 'pre_switch', 'true', 4], + 'disabled pre-switch' => ['disabled', 'pre_switch', 'true', 4], + 'auto post-switch' => ['auto', 'post_switch', 'migration', 6], +]); + +it('assigns replica operations and artifact metadata to service replicas', function () { + $organisation = Organisation::factory()->create(); + $provider = Provider::factory()->forOrganisation($organisation)->create(); + $network = Network::create([ + 'organisation_id' => $organisation->id, + 'provider_id' => $provider->id, + 'name' => 'test-network', + 'ip_range' => '10.0.0.0/24', + ]); + $server = Server::factory() + ->forOrganisation($organisation->id) + ->forProvider($provider->id) + ->forNetwork($network->id) + ->create(); + $application = Application::factory()->for($organisation)->create(); + generateDeployKey($application); + $environment = Environment::factory()->for($application)->create(); + $service = Service::factory()->for($environment)->for($server)->create([ + 'organisation_id' => $organisation->id, + 'name' => 'web', + 'category' => ServiceCategory::APPLICATION, + 'type' => ServiceType::LARAVEL, + 'version' => 'php-8.4', + 'version_track' => 'php-8.4', + 'driver_name' => 'laravel.php-8.4', + 'deploy_policy' => DeployPolicy::WITH_ENVIRONMENT, + 'desired_replicas' => 2, + ]); + + $organisation->registries()->create([ + 'name' => 'registry', + 'type' => 'generic', + 'url' => 'registry.example.com', + ]); + + (new DeployEnvironment($environment))->handle(); + + $replicas = $service->replicas()->orderBy('id')->get(); + + expect($replicas)->toHaveCount(2) + ->and($replicas[0]->operation_id)->not->toBeNull() + ->and($replicas[0]->image_digest)->toBe('sha256:deploymentdigest') + ->and($replicas[0]->status)->toBe('pending') + ->and($replicas[0]->health_status)->toBe('unknown') + ->and($replicas[0]->operation->kind)->toBe(OperationKind::REPLICA_DEPLOY) + ->and($replicas[0]->operation->parent->kind)->toBe(OperationKind::SERVICE_DEPLOY) + ->and($replicas[0]->operation->target->is($replicas[0]))->toBeTrue(); +}); + +it('places desired replicas across configured server placements', function () { + $organisation = Organisation::factory()->create(); + $provider = Provider::factory()->forOrganisation($organisation)->create(); + $network = Network::create([ + 'organisation_id' => $organisation->id, + 'provider_id' => $provider->id, + 'name' => 'test-network', + 'ip_range' => '10.0.0.0/24', + ]); + $servers = Server::factory() + ->count(2) + ->forOrganisation($organisation->id) + ->forProvider($provider->id) + ->forNetwork($network->id) + ->create(); + $application = Application::factory()->for($organisation)->create(); + generateDeployKey($application); + $environment = Environment::factory()->for($application)->create(); + $service = Service::factory()->for($environment)->create([ + 'organisation_id' => $organisation->id, + 'name' => 'web', + 'category' => ServiceCategory::APPLICATION, + 'type' => ServiceType::LARAVEL, + 'version' => 'php-8.4', + 'version_track' => 'php-8.4', + 'driver_name' => 'laravel.php-8.4', + 'deploy_policy' => DeployPolicy::WITH_ENVIRONMENT, + 'desired_replicas' => 2, + 'config' => [ + 'server_ids' => $servers->pluck('id')->all(), + ], + ]); + $organisation->registries()->create([ + 'name' => 'registry', + 'type' => 'generic', + 'url' => 'registry.example.com', + ]); + + (new DeployEnvironment($environment))->handle(); + + expect($service->replicas()->pluck('server_id')->all()) + ->toBe($servers->pluck('id')->all()); +}); + +it('skips environment service operations when the target revision is already available', function () { + $organisation = Organisation::factory()->create(); + $application = Application::factory()->for($organisation)->create(); + generateDeployKey($application); + $environment = Environment::factory()->for($application)->create(); + Service::factory()->for($environment)->create([ + 'organisation_id' => $organisation->id, + 'name' => 'web', + 'category' => ServiceCategory::APPLICATION, + 'type' => ServiceType::LARAVEL, + 'version' => 'php-8.4', + 'version_track' => 'php-8.4', + 'driver_name' => 'laravel.php-8.4', + 'deploy_policy' => DeployPolicy::WITH_ENVIRONMENT, + 'desired_revision' => str_repeat('a', 40), + 'available_image_digest' => 'sha256:existing', + ]); + + (new DeployEnvironment($environment))->handle(); + + $parent = Operation::query() + ->where('target_type', $environment->getMorphClass()) + ->where('target_id', $environment->id) + ->where('kind', OperationKind::ENVIRONMENT_DEPLOY) + ->first(); + + expect($parent->status)->toBe(\App\Enums\OperationStatus::COMPLETED) + ->and($parent->children()->count())->toBe(0) + ->and($environment->buildArtifacts()->count())->toBe(0); +}); + +function generateDeployKey(Application $application): void +{ + app(GenerateDeployKey::class)->execute($application, [ + 'public' => 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestPublicKey keystone', + 'private' => "-----BEGIN OPENSSH PRIVATE KEY-----\ntest\n-----END OPENSSH PRIVATE KEY-----", + 'fingerprint' => 'SHA256:test', + ]); +} + +it('blocks multi-server deploys that do not have a registry', function () { + $organisation = Organisation::factory()->create(); + $application = Application::factory()->for($organisation)->create(); + $environment = Environment::factory()->for($application)->create(); + Service::factory()->for($environment)->create([ + 'organisation_id' => $organisation->id, + 'category' => ServiceCategory::APPLICATION, + 'type' => ServiceType::LARAVEL, + 'version' => 'php-8.4', + 'version_track' => 'php-8.4', + 'driver_name' => 'laravel.php-8.4', + 'deploy_policy' => DeployPolicy::WITH_ENVIRONMENT, + 'desired_replicas' => 2, + ]); + + expect(fn () => (new DeployEnvironment($environment))->handle()) + ->toThrow(RuntimeException::class, 'A registry is required before deploying this environment across multiple servers.'); +}); + +it('blocks deployment when single scheduler mode would run on multiple replicas', function () { + $organisation = Organisation::factory()->create(); + $application = Application::factory()->for($organisation)->create(); + generateDeployKey($application); + $environment = Environment::factory()->for($application)->create([ + 'scheduler_enabled' => true, + 'scheduler_mode' => \App\Enums\SchedulerMode::SINGLE, + ]); + $web = Service::factory()->for($environment)->create([ + 'organisation_id' => $organisation->id, + 'name' => 'web', + 'category' => ServiceCategory::APPLICATION, + 'type' => ServiceType::LARAVEL, + 'version' => 'php-8.4', + 'version_track' => 'php-8.4', + 'driver_name' => 'laravel.php-8.4', + 'deploy_policy' => DeployPolicy::WITH_ENVIRONMENT, + 'process_roles' => ['web', 'scheduler'], + 'desired_replicas' => 2, + ]); + $environment->forceFill(['scheduler_target_service_id' => $web->id])->save(); + $organisation->registries()->create([ + 'name' => 'registry', + 'type' => 'generic', + 'url' => 'registry.example.com', + ]); + + expect(fn () => (new DeployEnvironment($environment->refresh()))->handle()) + ->toThrow(RuntimeException::class, 'Scheduler mode single requires the scheduler target service to run exactly one replica.'); +}); diff --git a/tests/Feature/DriverContractTest.php b/tests/Feature/DriverContractTest.php new file mode 100644 index 0000000..08650dc --- /dev/null +++ b/tests/Feature/DriverContractTest.php @@ -0,0 +1,21 @@ +toBeInstanceOf(Driver::class) + ->and($driver->serviceType()->value)->not->toBeEmpty() + ->and($driver->versionTrack())->not->toBeEmpty() + ->and($driver->defaultImage())->not->toBeEmpty() + ->and($driver->defaultPorts())->toBeArray() + ->and($driver->firewallRules())->toBeArray() + ->and($driver->environmentSchema())->toBeArray() + ->and($driver->resourceDefaults())->toBeArray() + ->and($driver->updateBehavior())->not->toBeEmpty(); + } + } +}); diff --git a/tests/Feature/EnvironmentAttachmentControllerTest.php b/tests/Feature/EnvironmentAttachmentControllerTest.php new file mode 100644 index 0000000..8209869 --- /dev/null +++ b/tests/Feature/EnvironmentAttachmentControllerTest.php @@ -0,0 +1,76 @@ +create(); + $organisation = Organisation::factory()->create(['owner_id' => $user->id]); + $application = Application::factory()->for($organisation)->create(); + $environment = Environment::factory()->for($application)->create(); + Service::factory()->for($environment)->create([ + 'organisation_id' => $organisation->id, + 'name' => 'postgres', + 'category' => ServiceCategory::DATABASE, + 'type' => ServiceType::POSTGRES, + 'version' => '18', + 'version_track' => '18', + 'driver_name' => 'postgres.18', + ]); + + $response = $this->actingAs($user)->get(route('environment-attachments.create', [ + 'organisation' => $organisation->id, + 'application' => $application->id, + 'environment' => $environment->id, + ])); + + $response->assertOk(); + $response->assertInertia(fn (AssertableInertia $page) => $page + ->component('environment-attachments/Create', false) + ->has('services', 1) + ->where('roles.0', EnvironmentAttachmentRole::DATABASE->value)); +}); + +it('stores a managed attachment and generated environment variables', function () { + $user = User::factory()->create(); + $organisation = Organisation::factory()->create(['owner_id' => $user->id]); + $application = Application::factory()->for($organisation)->create(['name' => 'Billing API']); + $environment = Environment::factory()->for($application)->create(['name' => 'production']); + $service = Service::factory()->for($environment)->create([ + 'organisation_id' => $organisation->id, + 'name' => 'postgres', + 'category' => ServiceCategory::DATABASE, + 'type' => ServiceType::POSTGRES, + 'version' => '18', + 'version_track' => '18', + 'driver_name' => 'postgres.18', + ]); + + $response = $this->actingAs($user)->post(route('environment-attachments.store', [ + 'organisation' => $organisation->id, + 'application' => $application->id, + 'environment' => $environment->id, + ]), [ + 'service_id' => $service->id, + 'role' => EnvironmentAttachmentRole::DATABASE->value, + 'name' => 'billing_api', + 'is_primary' => true, + ]); + + $response->assertRedirect(route('environments.show', [ + 'organisation' => $organisation->id, + 'application' => $application->id, + 'environment' => $environment->id, + ])); + + expect($environment->attachments()->where('service_id', $service->id)->exists())->toBeTrue() + ->and($environment->variables()->where('key', 'DB_CONNECTION')->first()->value)->toBe('pgsql') + ->and($service->slices()->where('name', 'billing_api')->exists())->toBeTrue(); +}); diff --git a/tests/Feature/EnvironmentDeploymentControllerTest.php b/tests/Feature/EnvironmentDeploymentControllerTest.php new file mode 100644 index 0000000..ee32101 --- /dev/null +++ b/tests/Feature/EnvironmentDeploymentControllerTest.php @@ -0,0 +1,95 @@ +create(); + $organisation = Organisation::factory()->create(['owner_id' => $user->id]); + $provider = Provider::factory()->forOrganisation($organisation)->create(); + $network = Network::create([ + 'organisation_id' => $organisation->id, + 'provider_id' => $provider->id, + 'name' => 'test-network', + 'ip_range' => '10.0.0.0/24', + ]); + $server = Server::factory() + ->forOrganisation($organisation->id) + ->forProvider($provider->id) + ->forNetwork($network->id) + ->create(); + $application = Application::factory()->for($organisation)->create(); + app(GenerateDeployKey::class)->execute($application, [ + 'public' => 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestPublicKey keystone', + 'private' => "-----BEGIN OPENSSH PRIVATE KEY-----\ntest\n-----END OPENSSH PRIVATE KEY-----", + 'fingerprint' => 'SHA256:test', + ]); + $environment = Environment::factory()->for($application)->create(); + $service = Service::factory()->for($environment)->for($server)->create([ + 'organisation_id' => $organisation->id, + 'name' => 'web', + 'category' => ServiceCategory::APPLICATION, + 'type' => ServiceType::LARAVEL, + 'version' => 'php-8.4', + 'version_track' => 'php-8.4', + 'driver_name' => 'laravel.php-8.4', + 'deploy_policy' => DeployPolicy::WITH_ENVIRONMENT, + ]); + + app()->instance(RemoteCommandRunner::class, new class implements RemoteCommandRunner + { + public function run(Server $server, string $script): string + { + return "container_id=container-1\nhealth_status=running\nimage_digest=billing-api:aaaaaaaaaaaa@sha256:controllerdigest\n"; + } + }); + + Process::fake([ + '*' => Process::result(output: str_repeat('a', 40)."\trefs/heads/main\n"), + ]); + + $response = $this->actingAs($user)->post(route('environment-deployments.store', [ + 'organisation' => $organisation->id, + 'application' => $application->id, + 'environment' => $environment->id, + ])); + + $response->assertRedirect(route('environments.show', [ + 'organisation' => $organisation->id, + 'application' => $application->id, + 'environment' => $environment->id, + ])); + + $parent = Operation::query() + ->whereMorphedTo('target', $environment) + ->where('kind', OperationKind::ENVIRONMENT_DEPLOY) + ->first(); + $serviceDeploy = Operation::query() + ->whereMorphedTo('target', $service) + ->where('kind', OperationKind::SERVICE_DEPLOY) + ->first(); + + expect($parent)->not->toBeNull() + ->and($parent->status)->toBe(OperationStatus::COMPLETED) + ->and($serviceDeploy)->not->toBeNull() + ->and($serviceDeploy->status)->toBe(OperationStatus::COMPLETED) + ->and($serviceDeploy->steps()->where('name', 'Render Compose files')->first()->script)->toContain("base64 -d > /home/keystone/services/{$service->id}/compose.yml") + ->and($serviceDeploy->steps()->where('name', 'Render Compose files')->first()->script)->toContain("base64 -d > /home/keystone/services/{$service->id}/.env") + ->and($service->refresh()->replicas)->toHaveCount(1) + ->and($service->available_image_digest)->toBe('sha256:controllerdigest'); +}); diff --git a/tests/Feature/EnvironmentDeploymentPlanTest.php b/tests/Feature/EnvironmentDeploymentPlanTest.php new file mode 100644 index 0000000..7571922 --- /dev/null +++ b/tests/Feature/EnvironmentDeploymentPlanTest.php @@ -0,0 +1,113 @@ +create(); + $application = Application::factory()->for($organisation)->create(); + $environment = Environment::factory()->for($application)->create(); + $web = Service::factory()->for($environment)->create([ + 'organisation_id' => $organisation->id, + 'name' => 'web', + 'category' => ServiceCategory::APPLICATION, + 'type' => ServiceType::LARAVEL, + 'version' => 'php-8.4', + 'version_track' => 'php-8.4', + 'driver_name' => 'laravel.php-8.4', + 'deploy_policy' => DeployPolicy::WITH_ENVIRONMENT, + ]); + $postgres = Service::factory()->for($environment)->create([ + 'organisation_id' => $organisation->id, + 'name' => 'postgres', + 'deploy_policy' => DeployPolicy::DEPENDENCY_ONLY, + ]); + + $environment->attachments()->create([ + 'service_id' => $postgres->id, + 'role' => EnvironmentAttachmentRole::DATABASE, + 'is_primary' => true, + ]); + + $plan = app(PlanEnvironmentDeployment::class)->execute($environment); + + expect($plan->services) + ->toHaveCount(1) + ->and($plan->services[0]->is($web))->toBeTrue() + ->and($plan->dependencies)->toHaveCount(1) + ->and($plan->dependencies[0]->is($postgres))->toBeTrue(); +}); + +it('blocks multi-server environment deployments when no registry exists', function () { + $organisation = Organisation::factory()->create(); + $application = Application::factory()->for($organisation)->create(); + $environment = Environment::factory()->for($application)->create(); + + Service::factory()->for($environment)->create([ + 'organisation_id' => $organisation->id, + 'name' => 'web', + 'category' => ServiceCategory::APPLICATION, + 'type' => ServiceType::LARAVEL, + 'version' => 'php-8.4', + 'version_track' => 'php-8.4', + 'driver_name' => 'laravel.php-8.4', + 'deploy_policy' => DeployPolicy::WITH_ENVIRONMENT, + 'desired_replicas' => 2, + ]); + + $plan = app(PlanEnvironmentDeployment::class)->execute($environment); + + expect($plan->requiresRegistry)->toBeTrue(); +}); + +it('warns about sync queues without creating worker services', function () { + $organisation = Organisation::factory()->create(); + $application = Application::factory()->for($organisation)->create(); + $environment = Environment::factory()->for($application)->create(); + + $environment->variables()->create([ + 'key' => 'QUEUE_CONNECTION', + 'value' => 'sync', + 'source' => 'user', + 'overridable' => true, + ]); + + $plan = app(PlanEnvironmentDeployment::class)->execute($environment); + + expect($plan->warnings) + ->toContain('QUEUE_CONNECTION=sync is not recommended for deployed Laravel environments.'); +}); + +it('blocks single scheduler mode when the scheduler target has multiple replicas', function () { + $organisation = Organisation::factory()->create(); + $application = Application::factory()->for($organisation)->create(); + $environment = Environment::factory()->for($application)->create([ + 'scheduler_enabled' => true, + 'scheduler_mode' => SchedulerMode::SINGLE, + ]); + $web = Service::factory()->for($environment)->create([ + 'organisation_id' => $organisation->id, + 'name' => 'web', + 'category' => ServiceCategory::APPLICATION, + 'type' => ServiceType::LARAVEL, + 'version' => 'php-8.4', + 'version_track' => 'php-8.4', + 'driver_name' => 'laravel.php-8.4', + 'deploy_policy' => DeployPolicy::WITH_ENVIRONMENT, + 'process_roles' => ['web', 'scheduler'], + 'desired_replicas' => 2, + ]); + $environment->forceFill(['scheduler_target_service_id' => $web->id])->save(); + + $plan = app(PlanEnvironmentDeployment::class)->execute($environment->refresh()); + + expect($plan->blockers)->toContain('Scheduler mode single requires the scheduler target service to run exactly one replica.'); +}); diff --git a/tests/Feature/EnvironmentMigrationControllerTest.php b/tests/Feature/EnvironmentMigrationControllerTest.php new file mode 100644 index 0000000..4ee61a0 --- /dev/null +++ b/tests/Feature/EnvironmentMigrationControllerTest.php @@ -0,0 +1,56 @@ +create(); + $organisation = Organisation::factory()->create(['owner_id' => $user->id]); + $application = Application::factory()->for($organisation)->create(); + $environment = Environment::factory()->for($application)->create(); + $service = Service::factory()->for($environment)->create([ + 'organisation_id' => $organisation->id, + 'name' => 'web', + 'category' => ServiceCategory::APPLICATION, + 'type' => ServiceType::LARAVEL, + 'version' => 'php-8.4', + 'version_track' => 'php-8.4', + 'driver_name' => 'laravel.php-8.4', + 'process_roles' => ['web'], + 'config' => [ + 'migration_mode' => 'manual', + 'migration_command' => 'php artisan migrate --force', + ], + ]); + + $response = $this->actingAs($user)->post(route('environment-migrations.store', [ + 'organisation' => $organisation->id, + 'application' => $application->id, + 'environment' => $environment->id, + ])); + + $response->assertRedirect(route('applications.show', [ + 'organisation' => $organisation->id, + 'application' => $application->id, + ])); + + $operation = $service->operations()->firstOrFail(); + + expect($operation->kind)->toBe(OperationKind::CONFIG_CHANGE) + ->and($operation->steps()->first()->script) + ->toBe("docker compose -f /home/keystone/services/{$service->id}/compose.yml run --rm web php artisan migrate --force"); +}); + +it('rejects migration operations without a laravel runtime service', function () { + $environment = Environment::factory()->create(); + + expect(fn () => app(CreateMigrationOperation::class)->execute($environment)) + ->toThrow(InvalidArgumentException::class, 'Laravel migrations must run against a Laravel runtime service.'); +}); diff --git a/tests/Feature/EnvironmentVariableControllerTest.php b/tests/Feature/EnvironmentVariableControllerTest.php new file mode 100644 index 0000000..d847e56 --- /dev/null +++ b/tests/Feature/EnvironmentVariableControllerTest.php @@ -0,0 +1,54 @@ +create(); + $organisation = Organisation::factory()->create(['owner_id' => $user->id]); + $application = Application::factory()->for($organisation)->create(); + $environment = Environment::factory()->for($application)->create(); + + $response = $this->actingAs($user)->get(route('environment-variables.create', [ + 'organisation' => $organisation->id, + 'application' => $application->id, + 'environment' => $environment->id, + ])); + + $response->assertOk(); + $response->assertInertia(fn (AssertableInertia $page) => $page + ->component('environment-variables/Create', false)); +}); + +it('stores editable user-defined environment variables', function () { + $user = User::factory()->create(); + $organisation = Organisation::factory()->create(['owner_id' => $user->id]); + $application = Application::factory()->for($organisation)->create(); + $environment = Environment::factory()->for($application)->create(); + + $response = $this->actingAs($user)->post(route('environment-variables.store', [ + 'organisation' => $organisation->id, + 'application' => $application->id, + 'environment' => $environment->id, + ]), [ + 'key' => 'APP_DEBUG', + 'value' => 'false', + ]); + + $response->assertRedirect(route('applications.show', [ + 'organisation' => $organisation->id, + 'application' => $application->id, + ])); + + $variable = $environment->variables()->firstOrFail(); + + expect($variable->key)->toBe('APP_DEBUG') + ->and($variable->value)->toBe('false') + ->and($variable->source)->toBe(EnvironmentVariableSource::USER) + ->and($variable->overridable)->toBeTrue() + ->and($variable->service_slice_id)->toBeNull(); +}); diff --git a/tests/Feature/EnvironmentWorkerControllerTest.php b/tests/Feature/EnvironmentWorkerControllerTest.php new file mode 100644 index 0000000..cb5ec74 --- /dev/null +++ b/tests/Feature/EnvironmentWorkerControllerTest.php @@ -0,0 +1,55 @@ +create(); + $organisation = Organisation::factory()->create(['owner_id' => $user->id]); + $application = Application::factory()->for($organisation)->create(); + $environment = Environment::factory()->for($application)->create([ + 'build_config' => [ + 'php_version' => '8.4', + ], + ]); + + $response = $this->actingAs($user)->post(route('environment-workers.store', [ + 'organisation' => $organisation->id, + 'application' => $application->id, + 'environment' => $environment->id, + ])); + + $response->assertRedirect(route('applications.show', [ + 'organisation' => $organisation->id, + 'application' => $application->id, + ])); + + $worker = $environment->services()->where('name', 'worker')->firstOrFail(); + + expect($worker->type)->toBe(ServiceType::LARAVEL) + ->and($worker->deploy_policy)->toBe(DeployPolicy::WITH_ENVIRONMENT) + ->and($worker->process_roles)->toBe(['worker']) + ->and($worker->config['command'])->toBe('php artisan queue:work --sleep=3 --tries=3'); +}); + +it('does not create duplicate worker services for repeated clicks', function () { + $user = User::factory()->create(); + $organisation = Organisation::factory()->create(['owner_id' => $user->id]); + $application = Application::factory()->for($organisation)->create(); + $environment = Environment::factory()->for($application)->create(); + + $route = route('environment-workers.store', [ + 'organisation' => $organisation->id, + 'application' => $application->id, + 'environment' => $environment->id, + ]); + + $this->actingAs($user)->post($route); + $this->actingAs($user)->post($route); + + expect($environment->services()->where('name', 'worker')->count())->toBe(1); +}); diff --git a/tests/Feature/KeystoneDomainModelTest.php b/tests/Feature/KeystoneDomainModelTest.php new file mode 100644 index 0000000..96c5546 --- /dev/null +++ b/tests/Feature/KeystoneDomainModelTest.php @@ -0,0 +1,115 @@ +create(); + $organisation = Organisation::factory()->create(['owner_id' => $user->id]); + $application = Application::factory()->for($organisation)->create(); + $environment = Environment::factory()->for($application)->create(); + + expect($environment) + ->toBeInstanceOf(Environment::class) + ->and($environment->scheduler_enabled)->toBeTrue() + ->and($environment->scheduler_mode)->toBe(SchedulerMode::SINGLE); +}); + +it('records operations and operation steps for service deployments', function () { + $service = Service::factory()->create(); + + $operation = Operation::factory()->make([ + 'kind' => OperationKind::SERVICE_DEPLOY, + 'status' => OperationStatus::PENDING, + ]); + $service->operations()->save($operation); + + $operation->steps()->create([ + 'name' => 'Render Compose file', + 'order' => 1, + 'status' => OperationStatus::PENDING, + 'script' => 'docker compose config', + ]); + + expect($operation) + ->toBeInstanceOf(Operation::class) + ->and($operation->hash)->not->toBeEmpty() + ->and($operation->steps)->toHaveCount(1) + ->and($operation->steps->first()->operation->is($operation))->toBeTrue(); +}); + +it('models replicas slices attachments variables and build artifacts', function () { + $user = User::factory()->create(); + $organisation = Organisation::factory()->create(['owner_id' => $user->id]); + $application = Application::factory()->for($organisation)->create(); + $environment = Environment::factory()->for($application)->create(); + $service = Service::factory()->for($environment)->create([ + 'organisation_id' => $organisation->id, + ]); + $provider = Provider::factory()->forOrganisation($organisation)->create(); + $network = $organisation->networks()->create([ + 'name' => 'keystone', + 'external_id' => 'net-12345', + 'provider_id' => $provider->id, + 'ip_range' => fake()->ipv4().'/24', + ]); + $server = Server::factory()->create([ + 'organisation_id' => $organisation->id, + 'provider_id' => $provider->id, + 'network_id' => $network->id, + ]); + $replica = $service->replicas()->create([ + 'server_id' => $server->id, + 'container_name' => 'postgres-1', + 'image_digest' => 'sha256:postgres', + 'internal_host' => 'postgres-1', + 'internal_port' => 5432, + 'status' => 'running', + ]); + $slice = ServiceSlice::factory()->for($service)->create([ + 'environment_id' => $environment->id, + 'name' => 'production', + 'credentials' => ['username' => 'app', 'password' => 'secret', 'database' => 'app'], + ]); + + $attachment = $environment->attachments()->create([ + 'service_id' => $service->id, + 'service_slice_id' => $slice->id, + 'role' => 'database', + 'is_primary' => true, + ]); + $variable = $environment->variables()->create([ + 'key' => 'DB_PASSWORD', + 'value' => 'secret', + 'source' => 'managed_attachment', + 'service_slice_id' => $slice->id, + 'overridable' => false, + ]); + $artifact = $environment->buildArtifacts()->create([ + 'commit_sha' => str_repeat('a', 40), + 'image_tag' => 'app:abc123', + 'image_digest' => 'sha256:abc123', + 'status' => 'available', + ]); + + expect($service->slices->first())->toBeInstanceOf(ServiceSlice::class) + ->and($replica)->toBeInstanceOf(ServiceReplica::class) + ->and($attachment->serviceSlice->is($slice))->toBeTrue() + ->and($variable->value)->toBe('secret') + ->and($artifact)->toBeInstanceOf(BuildArtifact::class); +}); diff --git a/tests/Feature/LaravelEnvironmentDefaultsTest.php b/tests/Feature/LaravelEnvironmentDefaultsTest.php new file mode 100644 index 0000000..2378ca9 --- /dev/null +++ b/tests/Feature/LaravelEnvironmentDefaultsTest.php @@ -0,0 +1,71 @@ +create(); + $application = Application::factory()->for($organisation)->create([ + 'default_branch' => 'main', + ]); + + $environment = app(CreateLaravelEnvironment::class)->execute($application, 'production'); + $web = $environment->services()->first(); + + expect($environment->branch)->toBe('main') + ->and($environment->scheduler_enabled)->toBeTrue() + ->and($environment->scheduler_mode)->toBe(SchedulerMode::SINGLE) + ->and($environment->scheduler_target_service_id)->toBe($web->id) + ->and($web->type)->toBe(ServiceType::LARAVEL) + ->and($web->deploy_policy)->toBe(DeployPolicy::WITH_ENVIRONMENT) + ->and($web->process_roles)->toBe(['web', 'scheduler']) + ->and($environment->services()->whereJsonContains('process_roles', 'worker')->exists())->toBeFalse(); +}); + +it('provides a managed serversideup frankenphp dockerfile template', function () { + $organisation = Organisation::factory()->create(); + $application = Application::factory()->for($organisation)->create(); + $environment = app(CreateLaravelEnvironment::class)->execute($application, 'production'); + $web = $environment->services()->first(); + + $dockerfile = $web->driver()->dockerfileTemplate(); + + expect($dockerfile) + ->toContain('FROM serversideup/php:8.4-frankenphp') + ->toContain('composer install --no-dev') + ->toContain('SERVER_DOCUMENT_ROOT=/var/www/html/public'); +}); + +it('renders configurable javascript build steps for managed laravel artifacts', function () { + $organisation = Organisation::factory()->create(); + $application = Application::factory()->for($organisation)->create(); + $environment = app(CreateLaravelEnvironment::class)->execute($application, 'production'); + $web = $environment->services()->first(); + + $web->update([ + 'config' => [ + ...$web->config, + 'js_package_manager' => 'bun', + 'js_build_command' => 'bun run build', + ], + ]); + + expect($web->refresh()->driver()->dockerfileTemplate()) + ->toContain('bun install --frozen-lockfile') + ->toContain('bun run build'); + + $web->update([ + 'config' => [ + ...$web->config, + 'js_package_manager' => 'npm', + 'js_build_command' => 'npm run build', + ], + ]); + + expect($web->refresh()->driver()->dockerfileTemplate()) + ->toContain('npm ci && npm run build'); +}); diff --git a/tests/Feature/ManagedAttachmentTest.php b/tests/Feature/ManagedAttachmentTest.php new file mode 100644 index 0000000..333b1e8 --- /dev/null +++ b/tests/Feature/ManagedAttachmentTest.php @@ -0,0 +1,99 @@ +create(); + $application = Application::factory()->for($organisation)->create(['name' => 'Billing API']); + $environment = Environment::factory()->for($application)->create(['name' => 'production']); + $service = Service::factory()->for($environment)->create([ + 'organisation_id' => $organisation->id, + 'name' => 'postgres', + 'category' => ServiceCategory::DATABASE, + 'type' => ServiceType::POSTGRES, + 'version' => '18', + 'version_track' => '18', + 'driver_name' => 'postgres.18', + ]); + + $attachment = app(AttachManagedService::class)->execute( + environment: $environment, + service: $service, + role: EnvironmentAttachmentRole::DATABASE, + ); + + expect($attachment->serviceSlice) + ->not->toBeNull() + ->and($attachment->serviceSlice->type)->toBe('database_user') + ->and($environment->variables()->where('key', 'DB_CONNECTION')->first()->value)->toBe('pgsql') + ->and($environment->variables()->where('key', 'DB_PASSWORD')->first()->source)->toBe(EnvironmentVariableSource::MANAGED_ATTACHMENT) + ->and($environment->variables()->where('key', 'DB_PASSWORD')->first()->overridable)->toBeFalse() + ->and($attachment->serviceSlice->operations()->first()->kind)->toBe(OperationKind::SLICE_PROVISION) + ->and($attachment->serviceSlice->operations()->first()->steps()->first()->script)->toContain('CREATE DATABASE'); +}); + +it('creates a valkey logical slice without silently changing queue behavior', function () { + $organisation = Organisation::factory()->create(); + $application = Application::factory()->for($organisation)->create(); + $environment = Environment::factory()->for($application)->create(); + $service = Service::factory()->for($environment)->create([ + 'organisation_id' => $organisation->id, + 'name' => 'valkey', + 'category' => ServiceCategory::CACHE, + 'type' => ServiceType::VALKEY, + 'version' => '8', + 'version_track' => '8', + 'driver_name' => 'valkey.8', + ]); + + app(AttachManagedService::class)->execute( + environment: $environment, + service: $service, + role: EnvironmentAttachmentRole::CACHE, + ); + + expect($environment->variables()->pluck('key')->all()) + ->toContain('REDIS_HOST', 'REDIS_PORT', 'REDIS_DB', 'CACHE_STORE') + ->not->toContain('QUEUE_CONNECTION'); + + $slice = $service->slices()->first(); + + expect($slice->config['database'])->toBe(1) + ->and($slice->operations()->first()->kind)->toBe(OperationKind::SLICE_PROVISION) + ->and($slice->operations()->first()->steps()->first()->script)->toContain('valkey-cli'); +}); + +it('creates a caddy route slice with an independent provision operation', function () { + $organisation = Organisation::factory()->create(); + $application = Application::factory()->for($organisation)->create(); + $environment = Environment::factory()->for($application)->create(); + $service = Service::factory()->for($environment)->create([ + 'organisation_id' => $organisation->id, + 'name' => 'gateway', + 'category' => ServiceCategory::GATEWAY, + 'type' => ServiceType::CADDY, + 'version' => '2', + 'version_track' => '2', + 'driver_name' => 'caddy.2', + ]); + + $attachment = app(AttachManagedService::class)->execute( + environment: $environment, + service: $service, + role: EnvironmentAttachmentRole::GATEWAY, + name: 'example.com', + ); + + expect($attachment->serviceSlice->type)->toBe('route') + ->and($attachment->serviceSlice->operations()->first()->kind)->toBe(OperationKind::SLICE_PROVISION) + ->and($attachment->serviceSlice->operations()->first()->steps()->first()->script)->toContain('/home/keystone/gateway/Caddyfile.d'); +}); diff --git a/tests/Feature/ProvisionScriptTest.php b/tests/Feature/ProvisionScriptTest.php new file mode 100644 index 0000000..0d2be92 --- /dev/null +++ b/tests/Feature/ProvisionScriptTest.php @@ -0,0 +1,43 @@ +create(); + $provider = Provider::factory()->forOrganisation($organisation)->create(); + $network = $organisation->networks()->create([ + 'name' => 'keystone', + 'external_id' => 'net-12345', + 'provider_id' => $provider->id, + 'ip_range' => fake()->ipv4().'/24', + ]); + $server = Server::factory()->create([ + 'organisation_id' => $organisation->id, + 'provider_id' => $provider->id, + 'network_id' => $network->id, + ]); + + $response = $this->get(route('provision-script', [ + 'sudo_password' => 'secret-password', + 'hostname' => 'keystone-test', + 'server_id' => $server->id, + ])); + + $response->assertOk(); + expect($response->content()) + ->toContain('docker-compose-plugin') + ->toContain('fail2ban') + ->toContain('ufw --force enable') + ->toContain('PasswordAuthentication no') + ->toContain('ssh-ed25519 keystone-public-key') + ->toContain('chmod 600 /home/keystone/.ssh/id_ed25519') + ->not->toContain('[!hostname!]') + ->not->toContain('[!sudo_password!]') + ->not->toContain('[!server_id!]') + ->not->toContain('[!keystonepublickey!]'); +}); diff --git a/tests/Feature/RegistryControllerTest.php b/tests/Feature/RegistryControllerTest.php new file mode 100644 index 0000000..31fe1ca --- /dev/null +++ b/tests/Feature/RegistryControllerTest.php @@ -0,0 +1,49 @@ +create(); + $organisation = Organisation::factory()->create(['owner_id' => $user->id]); + + $response = $this->actingAs($user)->get(route('registries.create', [ + 'organisation' => $organisation->id, + ])); + + $response->assertOk(); + $response->assertInertia(fn (AssertableInertia $page) => $page + ->component('registries/Create', false) + ->where('registryTypes.0', RegistryType::GENERIC->value)); +}); + +it('stores a registry for multi-server build artifacts', function () { + $user = User::factory()->create(); + $organisation = Organisation::factory()->create(['owner_id' => $user->id]); + + $response = $this->actingAs($user)->post(route('registries.store', [ + 'organisation' => $organisation->id, + ]), [ + 'name' => 'GHCR', + 'type' => RegistryType::GHCR->value, + 'url' => 'ghcr.io/example/', + 'username' => 'keystone', + 'password' => 'secret', + ]); + + $response->assertRedirect(route('organisations.show', [ + 'organisation' => $organisation->id, + ])); + + $registry = $organisation->registries()->firstOrFail(); + + expect($registry->name)->toBe('GHCR') + ->and($registry->type)->toBe(RegistryType::GHCR) + ->and($registry->url)->toBe('ghcr.io/example') + ->and($registry->credentials)->toMatchArray([ + 'username' => 'keystone', + 'password' => 'secret', + ]); +}); diff --git a/tests/Feature/RepositoryAccessTest.php b/tests/Feature/RepositoryAccessTest.php new file mode 100644 index 0000000..c5dc880 --- /dev/null +++ b/tests/Feature/RepositoryAccessTest.php @@ -0,0 +1,54 @@ +create([ + 'deploy_key_installed_at' => now(), + ]); + + app(GenerateDeployKey::class)->execute($application, [ + 'public' => 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestPublicKey keystone', + 'private' => "-----BEGIN OPENSSH PRIVATE KEY-----\ntest\n-----END OPENSSH PRIVATE KEY-----", + 'fingerprint' => 'SHA256:test-fingerprint', + ]); + + expect($application->refresh()) + ->deploy_key_public->toStartWith('ssh-ed25519') + ->deploy_key_private->toContain('OPENSSH PRIVATE KEY') + ->deploy_key_fingerprint->toBe('SHA256:test-fingerprint') + ->deploy_key_installed_at->toBeNull(); +}); + +it('verifies repository access with git ls-remote and a temporary ssh command', function () { + Process::fake([ + '*' => Process::result(output: "abc123\trefs/heads/main\n"), + ]); + + $application = Application::factory()->create([ + 'repository_url' => 'git@example.com:org/repo.git', + 'default_branch' => 'main', + ]); + app(GenerateDeployKey::class)->execute($application, [ + 'public' => 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestPublicKey keystone', + 'private' => "-----BEGIN OPENSSH PRIVATE KEY-----\ntest\n-----END OPENSSH PRIVATE KEY-----", + 'fingerprint' => 'SHA256:test-fingerprint', + ]); + + $verified = app(VerifyRepositoryAccess::class)->execute($application->refresh()); + + expect($verified)->toBeTrue() + ->and($application->refresh()->deploy_key_installed_at)->not->toBeNull(); + + Process::assertRan(function ($process): bool { + $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; + + return str_contains($command, 'git') + && str_contains($command, 'ls-remote') + && str_contains($command, 'git@example.com:org/repo.git') + && ($process->environment['GIT_SSH_COMMAND'] ?? null) !== null; + }); +}); diff --git a/tests/Feature/ServerControllerTest.php b/tests/Feature/ServerControllerTest.php index e91b5ec..b3568d7 100644 --- a/tests/Feature/ServerControllerTest.php +++ b/tests/Feature/ServerControllerTest.php @@ -42,7 +42,7 @@ test('index route displays servers for an organisation', function () { $response = $this->get(route('servers.index', ['organisation' => $organisation->id])); $response->assertStatus(200); $response->assertInertia(fn (AssertableInertia $page) => $page - ->component('servers/Index')); + ->component('servers/Index', false)); }); test('create route returns inertia view', function () { @@ -50,7 +50,7 @@ test('create route returns inertia view', function () { $response = $this->get(route('servers.create', ['organisation' => $organisation->id])); $response->assertStatus(200); $response->assertInertia(fn (AssertableInertia $page) => $page - ->component('servers/Create')); + ->component('servers/Create', false)); }); test('store route fails with invalid provider', function () { @@ -86,6 +86,9 @@ test('store route creates a server with valid data', function () { ]); $this->partialMock(HetznerService::class, function (MockInterface $mock) use ($network) { + $mock->shouldReceive('forProvider') + ->andReturnSelf(); + $mock->shouldReceive('createServer') ->once() ->andReturn(new CreatedServer( @@ -141,5 +144,5 @@ test('show route displays a single server', function () { $response->assertStatus(200); $response->assertInertia(fn (AssertableInertia $page) => $page - ->component('servers/Show')); + ->component('servers/Show', false)); }); diff --git a/tests/Feature/ServiceControllerTest.php b/tests/Feature/ServiceControllerTest.php index d43a7d5..fd086aa 100644 --- a/tests/Feature/ServiceControllerTest.php +++ b/tests/Feature/ServiceControllerTest.php @@ -2,20 +2,21 @@ use App\Actions\Services\CreateService; use App\Drivers\Driver; +use App\Enums\DeployPolicy; use App\Enums\ServiceCategory; use App\Enums\ServiceStatus; use App\Enums\ServiceType; use App\Jobs\Services\DeployService; use App\Models\Network; +use App\Models\Organisation; use App\Models\Provider; use App\Models\Server; use App\Models\Service; -use App\Models\Organisation; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; -use Inertia\Testing\AssertableInertia; -use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Bus; +use Illuminate\Support\Facades\Config; +use Inertia\Testing\AssertableInertia; uses(RefreshDatabase::class); @@ -24,11 +25,11 @@ function setupTestEnvironment() $user = User::factory()->create(); $organisation = Organisation::factory()->create([ - 'owner_id' => $user->id + 'owner_id' => $user->id, ]); $provider = Provider::factory()->create([ - 'organisation_id' => $organisation->id + 'organisation_id' => $organisation->id, ]); $network = Network::create([ @@ -61,13 +62,13 @@ test('create service page is accessible', function () { $response = $this->get(route('services.create', [ 'organisation' => $setup['organisation']->id, - 'server' => $setup['server']->id + 'server' => $setup['server']->id, ])); $response->assertStatus(200); $response->assertInertia( - fn(AssertableInertia $page) => $page - ->component('services/Create') + fn (AssertableInertia $page) => $page + ->component('services/Create', false) ->has('server') ->has('services') ); @@ -81,7 +82,7 @@ test('store service with valid data', function () { $mockDefaultCredentials = [ 'user' => 'test-user', 'password' => 'test-password', - 'db' => 'test-db' + 'db' => 'test-db', ]; $mockDriver = Mockery::mock(Driver::class); @@ -98,18 +99,18 @@ test('store service with valid data', function () { 'name' => 'test-postgres-database', 'category' => ServiceCategory::DATABASE->value, 'type' => ServiceType::POSTGRES->value, - 'version' => '17', + 'version' => '18', ]; $response = $this->post(route('services.store', [ 'organisation' => $setup['organisation']->id, - 'server' => $setup['server']->id + 'server' => $setup['server']->id, ]), $data); // Since we're not mocking the entire CreateService action, we should get a proper redirect $response->assertRedirect(route('servers.show', [ 'organisation' => $setup['organisation']->id, - 'server' => $setup['server']->id + 'server' => $setup['server']->id, ])); $response->assertSessionHas('success', 'Service created successfully'); @@ -118,10 +119,28 @@ test('store service with valid data', function () { 'server_id' => $setup['server']->id, 'category' => ServiceCategory::DATABASE->value, 'type' => ServiceType::POSTGRES->value, - 'version' => '17', - 'driver_name' => 'postgres.17', + 'version' => '18', + 'version_track' => '18', + 'driver_name' => 'postgres.18', + 'deploy_policy' => DeployPolicy::DEPENDENCY_ONLY->value, 'status' => ServiceStatus::NOT_INSTALLED->value, ]); + $service = Service::query()->where('name', 'test-postgres-database')->firstOrFail(); + + expect($service->credentials) + ->toHaveKey('user') + ->toHaveKey('password') + ->toHaveKey('db'); + + $this->assertDatabaseHas('service_replicas', [ + 'service_id' => $service->id, + 'server_id' => $setup['server']->id, + 'container_name' => "keystone-service-{$service->id}-1", + 'internal_host' => "keystone-service-{$service->id}", + 'internal_port' => 5432, + 'status' => 'pending', + 'health_status' => 'unknown', + ]); Bus::assertDispatched(DeployService::class); }); @@ -140,7 +159,7 @@ test('store service with invalid data', function () { $response = $this->post(route('services.store', [ 'organisation' => $setup['organisation']->id, - 'server' => $setup['server']->id + 'server' => $setup['server']->id, ]), $data); $response->assertSessionHasErrors(['name', 'category', 'type', 'version']); @@ -152,29 +171,79 @@ test('store service validates version exists in config', function () { $this->actingAs($setup['user']); // Mock the config to simulate the version not existing - Config::set('keystone.services.' . ServiceCategory::DATABASE->value . '.' . ServiceType::POSTGRES->value . '.versions', [ - '16' => [ - 'name' => 'PostgreSQL 16', - 'description' => 'PostgreSQL 16', - 'image' => 'postgres:16', - ] + Config::set('keystone.services.'.ServiceCategory::DATABASE->value.'.'.ServiceType::POSTGRES->value.'.versions', [ + '17' => [ + 'name' => 'PostgreSQL 17', + 'description' => 'PostgreSQL 17', + 'image' => 'postgres:17', + ], ]); $data = [ 'name' => 'test-postgres-database', 'category' => ServiceCategory::DATABASE->value, 'type' => ServiceType::POSTGRES->value, - 'version' => '17', // This version doesn't exist in our mocked config + 'version' => '18', // This version doesn't exist in our mocked config ]; $response = $this->post(route('services.store', [ 'organisation' => $setup['organisation']->id, - 'server' => $setup['server']->id + 'server' => $setup['server']->id, ]), $data); $response->assertSessionHasErrors(['version']); }); +test('store service prevents duplicate gateway on the same server', function () { + $setup = setupTestEnvironment(); + + $this->actingAs($setup['user']); + + Service::factory()->for($setup['server'])->create([ + 'organisation_id' => $setup['organisation']->id, + 'name' => 'gateway', + 'category' => ServiceCategory::GATEWAY, + 'type' => ServiceType::CADDY, + 'version' => '2', + 'version_track' => '2', + 'driver_name' => 'caddy.2', + ]); + + $response = $this->post(route('services.store', [ + 'organisation' => $setup['organisation']->id, + 'server' => $setup['server']->id, + ]), [ + 'name' => 'another-gateway', + 'category' => ServiceCategory::GATEWAY->value, + 'type' => ServiceType::CADDY->value, + 'version' => '2', + ]); + + $response->assertSessionHasErrors(['category' => 'This server already has a gateway service.']); +}); + +test('create service action prevents duplicate gateway on the same server', function () { + $setup = setupTestEnvironment(); + + Service::factory()->for($setup['server'])->create([ + 'organisation_id' => $setup['organisation']->id, + 'name' => 'gateway', + 'category' => ServiceCategory::GATEWAY, + 'type' => ServiceType::CADDY, + 'version' => '2', + 'version_track' => '2', + 'driver_name' => 'caddy.2', + ]); + + expect(fn () => app(CreateService::class)->execute( + server: $setup['server'], + name: 'another-gateway', + category: ServiceCategory::GATEWAY, + type: ServiceType::CADDY, + version: '2', + ))->toThrow(RuntimeException::class, 'This server already has a gateway service.'); +}); + test('store service with non-existent server returns 404', function () { $setup = setupTestEnvironment(); @@ -184,12 +253,12 @@ test('store service with non-existent server returns 404', function () { 'name' => 'test-postgres-database', 'category' => ServiceCategory::DATABASE->value, 'type' => ServiceType::POSTGRES->value, - 'version' => '17', + 'version' => '18', ]; $response = $this->post(route('services.store', [ 'organisation' => $setup['organisation']->id, - 'server' => 9999 + 'server' => 9999, ]), $data); $response->assertStatus(404); @@ -202,7 +271,7 @@ test('create service page with non-existent server returns 404', function () { $response = $this->get(route('services.create', [ 'organisation' => $setup['organisation']->id, - 'server' => 9999 + 'server' => 9999, ])); $response->assertStatus(404); @@ -217,7 +286,7 @@ test('store service is properly created and dispatched', function () { ->andReturn([ 'user' => 'test-user', 'password' => 'test-password', - 'db' => 'test-db' + 'db' => 'test-db', ]) ->getMock(); @@ -226,7 +295,7 @@ test('store service is properly created and dispatched', function () { 'name' => 'test-postgres-database', 'category' => ServiceCategory::DATABASE->value, 'type' => ServiceType::POSTGRES->value, - 'version' => '17', + 'version' => '18', ]; // Mock service class to return our mock driver @@ -243,7 +312,7 @@ test('store service is properly created and dispatched', function () { 'category' => ServiceCategory::DATABASE, 'type' => ServiceType::POSTGRES, 'version' => $testData['version'], - 'driver_name' => 'postgres.17', + 'driver_name' => 'postgres.18', 'status' => ServiceStatus::NOT_INSTALLED, ]); @@ -266,27 +335,15 @@ test('store service is properly created and dispatched', function () { // Execute request $response = $this->post(route('services.store', [ 'organisation' => $setup['organisation']->id, - 'server' => $setup['server']->id + 'server' => $setup['server']->id, ]), $testData); // Assert response $response->assertRedirect(route('servers.show', [ 'organisation' => $setup['organisation']->id, - 'server' => $setup['server']->id + 'server' => $setup['server']->id, ])); $response->assertSessionHas('success', 'Service created successfully'); - // Assert database state - $this->assertDatabaseHas('services', [ - 'name' => $testData['name'], - 'server_id' => $setup['server']->id, - 'category' => ServiceCategory::DATABASE->value, - 'type' => ServiceType::POSTGRES->value, - 'version' => $testData['version'], - 'driver_name' => 'postgres.17', - 'status' => ServiceStatus::NOT_INSTALLED->value, - ]); - - // Assert job was dispatched - Bus::assertDispatched(DeployService::class); + Bus::assertNotDispatched(DeployService::class); }); diff --git a/tests/Feature/ServiceDeploymentOperationTest.php b/tests/Feature/ServiceDeploymentOperationTest.php new file mode 100644 index 0000000..d7aaad8 --- /dev/null +++ b/tests/Feature/ServiceDeploymentOperationTest.php @@ -0,0 +1,151 @@ +instance(RemoteCommandRunner::class, new class implements RemoteCommandRunner + { + public function run(Server $server, string $script): string + { + return "image_digest=postgres:18@sha256:postgresdigest\n"; + } + }); +}); + +it('creates service deploy operations that upload generated compose files first', function () { + Bus::fake(); + + $service = Service::factory()->for(serviceDeploymentServer())->create([ + 'name' => 'postgres', + 'category' => ServiceCategory::DATABASE, + 'type' => ServiceType::POSTGRES, + 'version' => '18', + 'version_track' => '18', + 'driver_name' => 'postgres.18', + 'credentials' => [ + 'user' => 'keystone', + 'password' => 'secret', + 'db' => 'keystone', + ], + ]); + + (new DeployService($service))->handle(); + + $operation = $service->operations()->first(); + $firstStep = $operation->steps()->orderBy('order')->first(); + $postgresStep = $operation->steps()->where('name', 'Start Postgres service')->first(); + + expect($operation->kind)->toBe(OperationKind::SERVICE_DEPLOY) + ->and($firstStep->name)->toBe('Upload Compose file') + ->and($firstStep->script)->toContain('compose.yml') + ->and($firstStep->script)->toContain('/.env') + ->and($firstStep->script)->toContain('base64 -d') + ->and($service->refresh()->available_image_digest)->toBe('sha256:postgresdigest') + ->and($postgresStep->script)->toBe("docker compose -f /home/keystone/services/{$service->id}/compose.yml up -d") + ->and($operation->steps()->where('name', 'Check Postgres health')->first()->script)->toContain('docker compose') + ->and($operation->steps()->pluck('script')->implode("\n"))->not->toContain('docker run'); + + Bus::assertDispatched(RunStep::class); +}); + +it('resolves encrypted operation step secrets only for execution', function () { + $step = new OperationStep([ + 'script' => 'docker login --password [!password!]', + 'secrets' => ['password' => 'secret'], + ]); + + expect($step->script)->toContain('[!password!]') + ->and($step->scriptForExecution())->toBe('docker login --password secret'); +}); + +it('extracts runtime state markers from operation step logs', function () { + $step = new OperationStep([ + 'logs' => "starting\ncontainer_id=abc123\nhealth_status=healthy\n", + ]); + + expect($step->capturedRuntimeState())->toBe([ + 'container_id' => 'abc123', + 'health_status' => 'healthy', + ]); +}); + +it('cascades operation cancellation when a step fails', function () { + $server = serviceDeploymentServer(); + $service = Service::factory()->for($server)->create(); + $parent = $service->operations()->create([ + 'kind' => OperationKind::ENVIRONMENT_DEPLOY, + 'status' => OperationStatus::IN_PROGRESS, + ]); + $serviceDeploy = $service->operations()->create([ + 'parent_id' => $parent->id, + 'kind' => OperationKind::SERVICE_DEPLOY, + 'status' => OperationStatus::IN_PROGRESS, + ]); + $replicaDeploy = $service->operations()->create([ + 'parent_id' => $serviceDeploy->id, + 'kind' => OperationKind::REPLICA_DEPLOY, + 'status' => OperationStatus::PENDING, + ]); + $gatewayCutover = $service->operations()->create([ + 'parent_id' => $parent->id, + 'kind' => OperationKind::GATEWAY_CUTOVER, + 'status' => OperationStatus::PENDING, + ]); + $step = $serviceDeploy->steps()->create([ + 'name' => 'Failing step', + 'order' => 1, + 'status' => OperationStatus::IN_PROGRESS, + 'script' => 'false', + ]); + $replicaDeploy->steps()->create([ + 'name' => 'Replica step', + 'order' => 1, + 'status' => OperationStatus::PENDING, + 'script' => 'true', + ]); + $gatewayCutover->steps()->create([ + 'name' => 'Gateway step', + 'order' => 1, + 'status' => OperationStatus::PENDING, + 'script' => 'true', + ]); + + (new RunStep($step))->failed(new RuntimeException('boom')); + + expect($serviceDeploy->refresh()->status)->toBe(OperationStatus::FAILED) + ->and($parent->refresh()->status)->toBe(OperationStatus::FAILED) + ->and($replicaDeploy->refresh()->status)->toBe(OperationStatus::CANCELLED) + ->and($gatewayCutover->refresh()->status)->toBe(OperationStatus::CANCELLED) + ->and($gatewayCutover->steps()->first()->status)->toBe(OperationStatus::CANCELLED); +}); + +function serviceDeploymentServer(): Server +{ + $organisation = Organisation::factory()->create(); + $provider = Provider::factory()->forOrganisation($organisation)->create(); + $network = Network::create([ + 'organisation_id' => $organisation->id, + 'provider_id' => $provider->id, + 'name' => 'test-network', + 'ip_range' => '10.0.0.0/24', + ]); + + return Server::factory() + ->forOrganisation($organisation->id) + ->forProvider($provider->id) + ->forNetwork($network->id) + ->create(); +} diff --git a/tests/Feature/ServiceEndpointTest.php b/tests/Feature/ServiceEndpointTest.php new file mode 100644 index 0000000..db313e5 --- /dev/null +++ b/tests/Feature/ServiceEndpointTest.php @@ -0,0 +1,61 @@ +create(['server_id' => $server->id]); + $producer = ServiceReplica::factory()->for($service)->for($server)->create([ + 'internal_host' => 'postgres-1', + 'internal_port' => 5432, + ]); + $consumer = ServiceReplica::factory()->for($server)->create(); + + $endpoint = app(RegisterServiceEndpoint::class)->execute($producer, $consumer); + + expect($endpoint->scope)->toBe(ServiceEndpointScope::DOCKER_NETWORK) + ->and($endpoint->hostname)->toBe('postgres-1') + ->and($endpoint->ip_address)->toBeNull() + ->and($endpoint->priority)->toBe(10); +}); + +it('uses private networking across servers before public fallback', function () { + $producerServer = endpointTestServer(['private_ip' => '10.0.0.10', 'ipv4' => '203.0.113.10']); + $consumerServer = endpointTestServer(['private_ip' => '10.0.0.11', 'ipv4' => '203.0.113.11']); + $service = Service::factory()->create(['server_id' => $producerServer->id]); + $producer = ServiceReplica::factory()->for($service)->for($producerServer)->create([ + 'internal_port' => 8080, + ]); + $consumer = ServiceReplica::factory()->for($consumerServer)->create(); + + $endpoint = app(RegisterServiceEndpoint::class)->execute($producer, $consumer, allowPublicFallback: true); + + expect($endpoint->scope)->toBe(ServiceEndpointScope::PRIVATE_NETWORK) + ->and($endpoint->hostname)->toBe('10.0.0.10') + ->and($endpoint->priority)->toBe(20); +}); + +function endpointTestServer(array $attributes = []): Server +{ + $organisation = Organisation::factory()->create(); + $provider = Provider::factory()->forOrganisation($organisation)->create(); + $network = $organisation->networks()->create([ + 'name' => 'keystone', + 'external_id' => 'net-12345', + 'provider_id' => $provider->id, + 'ip_range' => fake()->ipv4().'/24', + ]); + + return Server::factory()->create([ + 'organisation_id' => $organisation->id, + 'provider_id' => $provider->id, + 'network_id' => $network->id, + ...$attributes, + ]); +} diff --git a/tests/Feature/ServiceImageDigestTest.php b/tests/Feature/ServiceImageDigestTest.php new file mode 100644 index 0000000..134e05c --- /dev/null +++ b/tests/Feature/ServiceImageDigestTest.php @@ -0,0 +1,85 @@ +instance(RemoteCommandRunner::class, new class implements RemoteCommandRunner + { + public string $script = ''; + + public function run(Server $server, string $script): string + { + $this->script = $script; + + return "image_digest=postgres:18@sha256:resolveddigest\n"; + } + }); + + $service = Service::factory()->for(serviceDigestServer())->create([ + 'category' => ServiceCategory::DATABASE, + 'type' => ServiceType::POSTGRES, + 'version' => '18', + 'version_track' => '18', + 'driver_name' => 'postgres.18', + 'credentials' => [ + 'user' => 'keystone', + 'password' => 'secret', + 'db' => 'keystone', + ], + ]); + + expect(app(ResolveServiceImageDigest::class)->execute($service))->toBe('sha256:resolveddigest'); +}); + +it('pulls the image before failing digest resolution when it is not present locally', function () { + $runner = new class implements RemoteCommandRunner + { + public string $script = ''; + + public function run(Server $server, string $script): string + { + $this->script = $script; + + return 'image_digest=valkey/valkey:8@sha256:pulleddigest'; + } + }; + + app()->instance(RemoteCommandRunner::class, $runner); + + $service = Service::factory()->for(serviceDigestServer())->create([ + 'category' => ServiceCategory::CACHE, + 'type' => ServiceType::VALKEY, + 'version' => '8', + 'version_track' => '8', + 'driver_name' => 'valkey.8', + ]); + + expect(app(ResolveServiceImageDigest::class)->execute($service))->toBe('sha256:pulleddigest') + ->and($runner->script)->toContain('docker pull "$image"'); +}); + +function serviceDigestServer(): Server +{ + $organisation = Organisation::factory()->create(); + $provider = Provider::factory()->forOrganisation($organisation)->create(); + $network = Network::create([ + 'organisation_id' => $organisation->id, + 'provider_id' => $provider->id, + 'name' => 'test-network', + 'ip_range' => '10.0.0.0/24', + ]); + + return Server::factory() + ->forOrganisation($organisation->id) + ->forProvider($provider->id) + ->forNetwork($network->id) + ->create(); +} diff --git a/tests/Feature/ServiceUpdateControllerTest.php b/tests/Feature/ServiceUpdateControllerTest.php new file mode 100644 index 0000000..b6e835a --- /dev/null +++ b/tests/Feature/ServiceUpdateControllerTest.php @@ -0,0 +1,93 @@ + true, + 'backup_command' => 'pg_dump keystone', + ]); + + $response = $this->actingAs($user)->get(route('service-updates.create', [ + 'organisation' => $organisation->id, + 'server' => $server->id, + 'service' => $service->id, + ])); + + $response->assertOk(); + $response->assertInertia(fn (AssertableInertia $page) => $page + ->component('services/updates/Create', false) + ->where('backupAvailable', true) + ->where('service.id', $service->id)); +}); + +it('stores an explicit stateful update operation', function () { + [$user, $organisation, $server, $service] = serviceUpdateFixture([ + 'backup_enabled' => true, + 'backup_command' => 'pg_dump keystone', + ]); + + $response = $this->actingAs($user)->post(route('service-updates.store', [ + 'organisation' => $organisation->id, + 'server' => $server->id, + 'service' => $service->id, + ]), [ + 'image_digest' => 'sha256:newdigest', + 'backup_requested' => true, + ]); + + $response->assertRedirect(route('servers.show', [ + 'organisation' => $organisation->id, + 'server' => $server->id, + ])); + + $operation = $service->operations()->first(); + + expect($operation->kind)->toBe(OperationKind::SERVICE_DEPLOY) + ->and($operation->steps()->where('name', 'Run pre-update backup')->exists())->toBeTrue() + ->and($service->refresh()->available_image_digest)->toBe('sha256:newdigest') + ->and($service->update_status)->toBe('update_pending'); +}); + +/** + * @return array{0: User, 1: Organisation, 2: Server, 3: Service} + */ +function serviceUpdateFixture(array $serviceConfig = []): array +{ + $user = User::factory()->create(); + $organisation = Organisation::factory()->create(['owner_id' => $user->id]); + $provider = Provider::factory()->forOrganisation($organisation)->create(); + $network = Network::create([ + 'organisation_id' => $organisation->id, + 'provider_id' => $provider->id, + 'name' => 'test-network', + 'ip_range' => '10.0.0.0/24', + ]); + $server = Server::factory() + ->forOrganisation($organisation->id) + ->forProvider($provider->id) + ->forNetwork($network->id) + ->create(); + $service = Service::factory()->create([ + 'organisation_id' => $organisation->id, + 'server_id' => $server->id, + 'name' => 'postgres', + 'category' => ServiceCategory::DATABASE, + 'type' => ServiceType::POSTGRES, + 'version' => '18', + 'version_track' => '18', + 'driver_name' => 'postgres.18', + 'config' => $serviceConfig, + ]); + + return [$user, $organisation, $server, $service]; +} diff --git a/tests/Feature/SourceProviderControllerTest.php b/tests/Feature/SourceProviderControllerTest.php new file mode 100644 index 0000000..9c24c0d --- /dev/null +++ b/tests/Feature/SourceProviderControllerTest.php @@ -0,0 +1,43 @@ +create(); + $organisation = Organisation::factory()->create(['owner_id' => $user->id]); + + $response = $this->actingAs($user)->get(route('source-providers.create', [ + 'organisation' => $organisation->id, + ])); + + $response->assertOk(); + $response->assertInertia(fn (AssertableInertia $page) => $page + ->component('source-providers/Create', false) + ->where('sourceProviderTypes.0', SourceProviderType::GITEA->value)); +}); + +it('stores a source provider for repository onboarding', function () { + $user = User::factory()->create(); + $organisation = Organisation::factory()->create(['owner_id' => $user->id]); + + $response = $this->actingAs($user)->post(route('source-providers.store', [ + 'organisation' => $organisation->id, + ]), [ + 'name' => 'Gitea', + 'type' => SourceProviderType::GITEA->value, + 'url' => 'https://gitea.example.com/', + ]); + + $response->assertRedirect(route('organisations.show', [ + 'organisation' => $organisation->id, + ])); + + $sourceProvider = $organisation->sourceProviders()->firstOrFail(); + + expect($sourceProvider->name)->toBe('Gitea') + ->and($sourceProvider->type)->toBe(SourceProviderType::GITEA) + ->and($sourceProvider->url)->toBe('https://gitea.example.com'); +}); diff --git a/tests/Feature/StatefulServiceUpdateTest.php b/tests/Feature/StatefulServiceUpdateTest.php new file mode 100644 index 0000000..113c77c --- /dev/null +++ b/tests/Feature/StatefulServiceUpdateTest.php @@ -0,0 +1,80 @@ +create([ + 'name' => 'postgres', + 'category' => ServiceCategory::DATABASE, + 'type' => ServiceType::POSTGRES, + 'version' => '18', + 'version_track' => '18', + 'driver_name' => 'postgres.18', + 'current_image_digest' => 'sha256:old', + 'config' => [ + 'backup_enabled' => true, + 'backup_command' => 'pg_dump --format=custom keystone > /home/keystone/backups/pre-update.dump', + ], + ]); + + $operation = app(CreateStatefulServiceUpdateOperation::class)->execute( + service: $service, + imageDigest: 'sha256:new', + backupRequested: true, + ); + + expect($service->refresh()->available_image_digest)->toBe('sha256:new') + ->and($service->update_status)->toBe('update_pending') + ->and($operation->steps()->pluck('name')->all()) + ->toBe([ + 'Acknowledge downtime and data risk', + 'Run pre-update backup', + 'Render compose with updated image digest', + 'Stop existing container', + 'Preserve named volume', + 'Start service with updated image digest', + 'Health check updated service', + ]) + ->and($operation->steps()->where('name', 'Run pre-update backup')->first()->script) + ->toBe('pg_dump --format=custom keystone > /home/keystone/backups/pre-update.dump') + ->and($operation->steps()->where('name', 'Render compose with updated image digest')->first()->script) + ->toContain('base64 -d') + ->and($operation->steps()->where('name', 'Stop existing container')->first()->script) + ->toBe("docker compose -f /home/keystone/services/{$service->id}/compose.yml stop {$service->name}") + ->and($operation->steps()->where('name', 'Preserve named volume')->first()->script) + ->toBe("docker volume inspect keystone_service_{$service->id}_postgres_data >/dev/null") + ->and($operation->steps()->where('name', 'Health check updated service')->first()->script) + ->toContain('docker inspect --format'); +}); + +it('rejects backup requests when no backup capability is configured', function () { + $service = Service::factory()->create([ + 'category' => ServiceCategory::DATABASE, + 'type' => ServiceType::POSTGRES, + 'version' => '18', + 'version_track' => '18', + 'driver_name' => 'postgres.18', + 'config' => [ + 'backup_enabled' => false, + ], + ]); + + expect(fn () => app(CreateStatefulServiceUpdateOperation::class)->execute($service, 'sha256:new', backupRequested: true)) + ->toThrow(InvalidArgumentException::class, 'Backups are not configured for this service.'); +}); + +it('rejects stateful update operations for stateless laravel services', function () { + $service = Service::factory()->create([ + 'category' => ServiceCategory::APPLICATION, + 'type' => ServiceType::LARAVEL, + 'version' => 'php-8.4', + 'version_track' => 'php-8.4', + 'driver_name' => 'laravel.php-8.4', + ]); + + expect(fn () => app(CreateStatefulServiceUpdateOperation::class)->execute($service, 'sha256:new')) + ->toThrow(InvalidArgumentException::class, 'Only Postgres and Valkey have v1 stateful update operations.'); +}); diff --git a/tests/TestCase.php b/tests/TestCase.php index fe1ffc2..388f6b5 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -6,5 +6,10 @@ use Illuminate\Foundation\Testing\TestCase as BaseTestCase; abstract class TestCase extends BaseTestCase { - // + protected function setUp(): void + { + parent::setUp(); + + $this->withoutVite(); + } }