Files
keystone/database/seeders/SimulatedEnvironmentSeeder.php
Harry Bayliss 85c44296ac
Some checks failed
CI / Tests (push) Failing after 56s
CI / Lint (push) Failing after 1m35s
Restructure UX and seed a fully simulated organisation
Rework the dashboard, environment topology view, header navigation, and
status rendering, and standardise selects on a shadcn-vue component.

Replace the thin database seeder with a SimulatedEnvironmentSeeder that
builds a fully wired, mostly-running organisation (ACTIVE server fleet,
managed + GHCR registries, Gitea source provider, ClipBin app with
production/staging environments, services, slices, endpoints, managed
variables, build artifacts, and a completed/in-progress/failed operations
history) so the new UI renders against realistic data.
2026-06-08 22:09:57 +01:00

442 lines
17 KiB
PHP

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