From 61d392cc6a4ea71e0b5e42ad29a547121977448d Mon Sep 17 00:00:00 2001 From: Bohdan Shulha Date: Mon, 7 Oct 2024 20:31:32 +0200 Subject: [PATCH] feat: #225 rework workers ui definition --- app/Actions/Nodes/InitCluster.php | 39 +- app/Actions/Services/StartDeployment.php | 2 +- app/Http/Controllers/ServiceController.php | 12 + app/Models/Deployment.php | 8 +- app/Models/DeploymentData.php | 33 +- app/Models/DeploymentData/LaunchMode.php | 17 +- app/Models/DeploymentData/Process.php | 409 +++----- app/Models/DeploymentData/Worker.php | 267 ++++- app/Models/NodeTaskGroupType.php | 1 + ...move_worker_props_into_workers_section.php | 75 ++ package-lock.json | 4 +- package.json | 1 + resources/js/Components/CloseButton.vue | 8 + .../Service/RemoveComponentButton.vue | 13 - .../js/Components/Service/ToggleComponent.vue | 60 -- resources/js/Components/Tabs/TabItem.vue | 62 +- resources/js/Pages/Services/Create.vue | 4 + .../Services/Partials/DeploymentData.vue | 978 +++--------------- .../Partials/DeploymentData/WorkerForm.vue | 430 ++++++++ .../Pages/Services/Partials/ProcessTabs.vue | 48 +- resources/js/Pages/Services/Show.vue | 4 + 21 files changed, 1197 insertions(+), 1278 deletions(-) create mode 100644 database/migrations/2024_10_07_120151_alter_deployments_move_worker_props_into_workers_section.php create mode 100644 resources/js/Components/CloseButton.vue delete mode 100644 resources/js/Components/Service/RemoveComponentButton.vue delete mode 100644 resources/js/Components/Service/ToggleComponent.vue create mode 100644 resources/js/Pages/Services/Partials/DeploymentData/WorkerForm.vue diff --git a/app/Actions/Nodes/InitCluster.php b/app/Actions/Nodes/InitCluster.php index 2caf437..68d470e 100644 --- a/app/Actions/Nodes/InitCluster.php +++ b/app/Actions/Nodes/InitCluster.php @@ -195,23 +195,29 @@ private function getCaddyProcessConfig(Node $node): array return [ 'name' => 'svc', 'placementNodeId' => $node->id, - 'launchMode' => LaunchMode::Daemon->value, - 'dockerRegistryId' => null, - 'dockerImage' => 'ghcr.io/ptah-sh/ptah-caddy:latest', - 'releaseCommand' => [ - 'command' => null, - ], - 'command' => 'sh /start.sh', - 'healthcheck' => [ - 'command' => null, - 'interval' => 10, - 'timeout' => 5, - 'retries' => 3, - 'startPeriod' => 30, - 'startInterval' => 5, + 'workers' => [ + [ + 'name' => 'main', + 'dockerRegistryId' => null, + 'dockerImage' => 'ghcr.io/ptah-sh/ptah-caddy:latest', + 'dockerName' => 'caddy', + 'command' => 'sh /start.sh', + 'replicas' => 1, + 'launchMode' => LaunchMode::Daemon, + 'schedule' => null, + 'releaseCommand' => [ + 'command' => null, + ], + 'healthcheck' => [ + 'command' => null, + 'interval' => 10, + 'timeout' => 5, + 'retries' => 3, + 'startPeriod' => 30, + 'startInterval' => 5, + ], + ], ], - 'backups' => [], - 'workers' => [], 'envVars' => [ [ 'name' => 'CADDY_ADMIN', @@ -242,7 +248,6 @@ private function getCaddyProcessConfig(Node $node): array 'path' => '/config', ], ], - 'replicas' => 1, 'ports' => [ ['targetPort' => '80', 'publishedPort' => '80'], ['targetPort' => '443', 'publishedPort' => '443'], diff --git a/app/Actions/Services/StartDeployment.php b/app/Actions/Services/StartDeployment.php index 3163520..68f81ba 100644 --- a/app/Actions/Services/StartDeployment.php +++ b/app/Actions/Services/StartDeployment.php @@ -39,7 +39,7 @@ public function handle(User $user, Service $service, DeploymentData $deploymentD 'swarm_id' => $service->swarm_id, 'team_id' => $service->team_id, 'invoker_id' => $user->id, - 'type' => $service->deployments()->exists() ? NodeTaskGroupType::UpdateService : NodeTaskGroupType::CreateService, + 'type' => NodeTaskGroupType::LaunchService, ]); $deployment = $service->deployments()->create([ diff --git a/app/Http/Controllers/ServiceController.php b/app/Http/Controllers/ServiceController.php index c308b21..a644ab4 100644 --- a/app/Http/Controllers/ServiceController.php +++ b/app/Http/Controllers/ServiceController.php @@ -4,6 +4,8 @@ use App\Http\Requests\UpdateServiceRequest; use App\Models\DeploymentData; +use App\Models\DeploymentData\Process; +use App\Models\DeploymentData\Worker; use App\Models\Service; use App\Models\Swarm; use Illuminate\Support\Facades\DB; @@ -52,6 +54,9 @@ public function create() 'networkName' => $networks->first()->name, ]); + $blankProcess = Process::make([]); + $blankWorker = Worker::make([]); + return Inertia::render('Services/Create', [ 'networks' => $networks, 'nodes' => $nodes, @@ -62,6 +67,8 @@ public function create() 'node' => [ 'swarm' => $swarm, ], + 'blankProcess' => $blankProcess, + 'blankWorker' => $blankWorker, ]); } @@ -78,6 +85,9 @@ public function show(Service $service) $s3Storages = $service->swarm->data->s3Storages; $swarm = $service->swarm; + $blankProcess = Process::make([]); + $blankWorker = Worker::make([]); + return Inertia::render('Services/Show', [ 'service' => $service, 'networks' => $networks, @@ -87,6 +97,8 @@ public function show(Service $service) 'node' => [ 'swarm' => $swarm, ], + 'blankProcess' => $blankProcess, + 'blankWorker' => $blankWorker, ]); } diff --git a/app/Models/Deployment.php b/app/Models/Deployment.php index f46abee..d70853a 100644 --- a/app/Models/Deployment.php +++ b/app/Models/Deployment.php @@ -84,7 +84,10 @@ public function asNodeTasks(): array $tasks = []; foreach ($data->processes as $process) { - $tasks = array_merge($tasks, $process->asNodeTasks($this)); + $tasks = [ + ...$tasks, + ...$process->asNodeTasks($this), + ]; } $previousProcesses = $this->previousDeployment()?->data->processes ?? []; @@ -101,7 +104,8 @@ public function asNodeTasks(): array } } - // Why is this needed? :) + // Question: Why is this needed? :) + // Answer: process asNodeTasks() method makes modifications to the process object. "asNodeTasks" is not the best name. $this->saveQuietly(); return $tasks; diff --git a/app/Models/DeploymentData.php b/app/Models/DeploymentData.php index 9f571ba..185298b 100644 --- a/app/Models/DeploymentData.php +++ b/app/Models/DeploymentData.php @@ -2,10 +2,7 @@ namespace App\Models; -use App\Models\DeploymentData\Healthcheck; -use App\Models\DeploymentData\LaunchMode; use App\Models\DeploymentData\Process; -use App\Models\DeploymentData\ReleaseCommand; use App\Rules\UniqueInArray; use App\Util\Arrays; use Illuminate\Validation\ValidationException; @@ -26,38 +23,12 @@ public function __construct( public static function make(array $attributes): static { - $processDefaults = [ - 'name' => 'svc', - 'placementNodeId' => null, - 'dockerRegistryId' => null, - 'dockerImage' => '', - 'releaseCommand' => ReleaseCommand::from([ - 'command' => null, - ]), - 'command' => '', - 'healthcheck' => Healthcheck::from([ - 'command' => null, - ]), - 'backups' => [], - 'workers' => [], - 'launchMode' => LaunchMode::Daemon->value, - 'envVars' => [], - 'secretVars' => [], - 'configFiles' => [], - 'secretFiles' => [], - 'volumes' => [], - 'ports' => [], - 'replicas' => 1, - 'caddy' => [], - 'fastCgi' => null, - 'redirectRules' => [], - 'rewriteRules' => [], - ]; + $processDefaults = Process::make([]); $defaults = [ 'networkName' => '', 'internalDomain' => '', - 'processes' => empty($attributes['processes']) ? [$processDefaults] : $attributes['processes'], + 'processes' => [$processDefaults], ]; return self::from([ diff --git a/app/Models/DeploymentData/LaunchMode.php b/app/Models/DeploymentData/LaunchMode.php index b3581b8..33deb8c 100644 --- a/app/Models/DeploymentData/LaunchMode.php +++ b/app/Models/DeploymentData/LaunchMode.php @@ -5,8 +5,19 @@ enum LaunchMode: string { case Daemon = 'daemon'; - case Scheduled = 'scheduled'; - case Backup = 'backup'; - case Lifecycle = 'lifecycle'; + case Cronjob = 'cronjob'; + case BackupCreate = 'backup_create'; + case BackupRestore = 'backup_restore'; case Manual = 'manual'; + + public function maxReplicas(): int + { + return match ($this) { + self::Daemon => PHP_INT_MAX, + self::Cronjob => 1, + self::BackupCreate => 1, + self::BackupRestore => 1, + self::Manual => 1, + }; + } } diff --git a/app/Models/DeploymentData/Process.php b/app/Models/DeploymentData/Process.php index 464a0d3..4bd78ed 100644 --- a/app/Models/DeploymentData/Process.php +++ b/app/Models/DeploymentData/Process.php @@ -7,14 +7,13 @@ use App\Models\NodeTasks\CreateConfig\CreateConfigMeta; use App\Models\NodeTasks\CreateSecret\CreateSecretMeta; use App\Models\NodeTasks\DeleteService\DeleteServiceMeta; -use App\Models\NodeTasks\LaunchService\LaunchServiceMeta; use App\Models\NodeTasks\PullDockerImage\PullDockerImageMeta; use App\Models\NodeTaskType; use App\Rules\RequiredIfArrayHas; +use App\Rules\UniqueInArray; use App\Util\ResourceId; use Exception; use Spatie\LaravelData\Attributes\DataCollectionOf; -use Spatie\LaravelData\Attributes\Validation\Enum; use Spatie\LaravelData\Attributes\Validation\Exists; use Spatie\LaravelData\Attributes\Validation\RequiredWith; use Spatie\LaravelData\Attributes\Validation\Rule; @@ -28,37 +27,34 @@ public function __construct( #[RequiredWith('volumes')] public ?int $placementNodeId, public ?string $dockerName, - public ?string $dockerRegistryId, - public string $dockerImage, - public ReleaseCommand $releaseCommand, - public ?string $command, - public Healthcheck $healthcheck, - #[DataCollectionOf(ProcessBackup::class)] - /* @var ProcessBackup[] */ - public array $backups, #[DataCollectionOf(Worker::class)] + #[Rule(new UniqueInArray('name'))] /* @var Worker[] */ public array $workers, - #[Enum(LaunchMode::class)] - public string $launchMode, #[DataCollectionOf(EnvVar::class)] + #[Rule(new UniqueInArray('name'))] /* @var EnvVar[] */ public array $envVars, #[DataCollectionOf(SecretVar::class)] + #[Rule(new UniqueInArray('name'))] /* @var SecretVar[] */ public array $secretVars, #[DataCollectionOf(ConfigFile::class)] + #[Rule(new UniqueInArray('path'))] /* @var ConfigFile[] */ public array $configFiles, #[DataCollectionOf(SecretFile::class)] + #[Rule(new UniqueInArray('path'))] /* @var SecretFile[] */ public array $secretFiles, #[DataCollectionOf(Volume::class)] + #[Rule(new UniqueInArray('name'))] /* @var Volume[] */ public array $volumes, public ?BackupVolume $backupVolume, - public int $replicas, #[DataCollectionOf(NodePort::class)] + // TODO: unique across all services of the swarm cluster + #[Rule(new UniqueInArray('targetPort'))] /* @var NodePort[] */ public array $ports, #[DataCollectionOf(Caddy::class)] @@ -126,13 +122,6 @@ public function asNodeTasks(Deployment $deployment): array } } - foreach ($this->workers as $worker) { - if (! $worker->dockerName) { - // TODO: add validation - allow only unique worker commands - $worker->dockerName = $this->makeResourceName('wkr_'.$worker->name); - } - } - foreach ($this->configFiles as $configFile) { $previousConfig = $previous?->findConfigFile($configFile->path); if ($previousConfig && $configFile->sameAs($previousConfig)) { @@ -206,258 +195,130 @@ public function asNodeTasks(Deployment $deployment): array } } - $internalDomain = "{$this->name}.{$deployment->data->internalDomain}"; - - $command = null; - $args = null; + if ($this->backupVolume == null) { + $this->backupVolume = BackupVolume::validateAndCreate([ + 'id' => ResourceId::make('volume'), + 'name' => 'backups', + 'dockerName' => $this->makeResourceName('/ptah/backups'), + 'path' => '/ptah/backups', + ]); + } - if ($this->command) { - // FIXME: use smarter CLI split - need to handle values with spaces, surrounded by the double quotes - $splitCmd = explode(' ', $this->command); + $tasks = [ + ...$tasks, + ...$this->getPullImageTasks($deployment), + ]; - $command = [$splitCmd[0]]; - $args = array_slice($splitCmd, 1); + foreach ($this->workers as $worker) { + $tasks = [ + ...$tasks, + ...$worker->asNodeTasks($deployment, $this), + ]; } - $dockerRegistry = $this->dockerRegistryId - ? $deployment->service->swarm->data->findRegistry($this->dockerRegistryId) - : null; + return $tasks; + } - if ($this->dockerRegistryId && is_null($dockerRegistry)) { - throw new Exception("Docker registry '{$this->dockerRegistryId}' not found"); - } + public function resourceLabels(Deployment $deployment): array + { + return dockerize_labels([ + ...$deployment->resourceLabels(), + 'process.name' => $this->name, + ]); + } - $authConfigName = $dockerRegistry - ? $dockerRegistry->dockerName - : ''; - - $tasks[] = [ - 'type' => NodeTaskType::PullDockerImage, - 'meta' => PullDockerImageMeta::from([ - 'deploymentId' => $deployment->id, - 'processName' => $this->dockerName, - 'serviceId' => $deployment->service_id, - 'serviceName' => $deployment->service->name, - 'dockerImage' => $this->dockerImage, - ]), - 'payload' => [ - 'AuthConfigName' => $authConfigName, - 'Image' => $this->dockerImage, - 'PullOptions' => (object) [], - ], - ]; + public function makeResourceName(string $name): string + { + return dockerize_name($this->dockerName.'_'.$name); + } - $serviceTaskMeta = [ - 'deploymentId' => $deployment->id, - 'dockerName' => $this->dockerName, - 'serviceId' => $deployment->service_id, - 'serviceName' => $deployment->service->name, - ]; + public function getInternalDomain(Deployment $deployment): string + { + return "{$this->name}.{$deployment->data->internalDomain}"; + } - $volumes = $this->volumes; + public function getMounts(Deployment $deployment): array + { + $labels = $this->resourceLabels($deployment); - $mounts = collect($volumes) + $mounts = collect($this->volumes) ->map(fn (Volume $volume) => [ 'Type' => 'volume', 'Source' => $volume->dockerName, 'Target' => $volume->path, 'VolumeOptions' => [ 'Labels' => dockerize_labels([ - 'id' => $volume->id, ...$labels, + 'volume.id' => $volume->id, + 'volume.path' => $volume->path, ]), ], ]) ->toArray(); - // TODO: if (has volumes with backups enabled OR has a Backup Script defined) - if (count($this->volumes)) { - if ($this->backupVolume == null) { - $this->backupVolume = BackupVolume::validateAndCreate([ - 'id' => ResourceId::make('volume'), - 'name' => 'backups', - 'dockerName' => $this->makeResourceName('/ptah/backups'), - 'path' => '/ptah/backups', - ]); - } + $mounts[] = [ + 'Type' => 'volume', + 'Source' => $this->backupVolume->dockerName, + 'Target' => $this->backupVolume->path, + 'VolumeOptions' => [ + 'Labels' => dockerize_labels([ + ...$labels, + 'volume.id' => $this->backupVolume->id, + 'volume.path' => $this->backupVolume->path, + ]), + ], + ]; - $mounts[] = [ - 'Type' => 'volume', - 'Source' => $this->backupVolume->dockerName, - 'Target' => $this->backupVolume->path, - 'VolumeOptions' => [ - 'Labels' => dockerize_labels([ - 'id' => $this->backupVolume->id, - ...$labels, - ]), - ], - ]; + return $mounts; + } + + private function findWorker(?string $dockerName): ?Worker + { + if (! $dockerName) { + return null; } - $envVars = $this->envVars; - $envVars[] = EnvVar::validateAndCreate([ - 'name' => 'PTAH_HOSTNAME', - 'value' => $internalDomain, - ]); + return collect($this->workers)->first(fn (Worker $worker) => $worker->dockerName === $dockerName); + } - $serviceSecretVars = $this->getSecretVars(); - - $tasks[] = [ - 'type' => NodeTaskType::LaunchService, - 'meta' => LaunchServiceMeta::from($serviceTaskMeta), - 'payload' => [ - 'AuthConfigName' => $authConfigName, - 'ReleaseCommand' => $this->getReleaseCommandPayload($deployment, $labels), - 'SecretVars' => $serviceSecretVars, - 'SwarmServiceSpec' => [ - 'Name' => $this->dockerName, - 'Labels' => $labels, - 'TaskTemplate' => [ - 'ContainerSpec' => [ - 'Image' => $this->dockerImage, - 'Labels' => $labels, - 'Command' => $command, - 'Args' => $args, - 'Hostname' => "dpl-{$deployment->id}.{$internalDomain}", - 'Env' => collect($envVars)->map(fn (EnvVar $var) => "{$var->name}={$var->value}")->toArray(), - 'Mounts' => $mounts, - 'Hosts' => [ - $internalDomain, - ], - 'Secrets' => collect($this->secretFiles)->map(fn (SecretFile $secretFile) => [ - 'File' => [ - 'Name' => $secretFile->path, - // TODO: figure out better permissions settings (if any) - 'UID' => '0', - 'GID' => '0', - 'Mode' => 0777, - ], - 'SecretName' => $secretFile->dockerName, - ])->values()->toArray(), - 'Configs' => collect($this->configFiles)->map(fn (ConfigFile $configFile) => [ - 'File' => [ - 'Name' => $configFile->path, - // TODO: figure out better permissions settings (if any) - 'UID' => '0', - 'GID' => '0', - 'Mode' => 0777, - ], - 'ConfigName' => $configFile->dockerName, - ])->values()->toArray(), - 'Placement' => $this->placementNodeId ? [ - 'Constraints' => [ - "node.labels.sh.ptah.node.id=={$this->placementNodeId}", - ], - ] : [], - 'HealthCheck' => $this->healthcheck->command ? [ - 'Test' => ['CMD-SHELL', $this->healthcheck->command], - 'Interval' => $this->healthcheck->interval * 1000000000, // Convert to nanoseconds - 'Timeout' => $this->healthcheck->timeout * 1000000000, // Convert to nanoseconds - 'Retries' => $this->healthcheck->retries, - 'StartPeriod' => $this->healthcheck->startPeriod * 1000000000, // Convert to nanoseconds - 'StartInterval' => $this->healthcheck->startInterval * 1000000000, // Convert to nanoseconds - ] : null, - ], - 'Networks' => [ - [ - 'Target' => $deployment->data->networkName, - 'Aliases' => [$internalDomain], - ], - ], - ], - 'Mode' => [ - 'Replicated' => [ - 'Replicas' => $this->replicas, - ], - ], - 'EndpointSpec' => [ - 'Ports' => collect($this->ports)->map(fn (NodePort $port) => [ - 'Protocol' => 'tcp', - 'TargetPort' => $port->targetPort, - 'PublishedPort' => $port->publishedPort, - 'PublishMode' => 'ingress', - ])->toArray(), - ], - ], - ], - ]; + private function getPullImageTasks(Deployment $deployment): array + { + $pulledImages = []; + + $tasks = []; foreach ($this->workers as $worker) { - $workerTaskMeta = [ - ...$serviceTaskMeta, - 'dockerName' => $worker->dockerName, - ]; + if (in_array($worker->dockerImage, $pulledImages)) { + continue; + } + + $pulledImages[] = $worker->dockerImage; + + $dockerRegistry = $worker->dockerRegistryId + ? $deployment->service->swarm->data->findRegistry($worker->dockerRegistryId) + : null; + + if ($worker->dockerRegistryId && is_null($dockerRegistry)) { + throw new Exception("Docker registry '{$worker->dockerRegistryId}' not found"); + } + + $authConfigName = $dockerRegistry + ? $dockerRegistry->dockerName + : ''; $tasks[] = [ - 'type' => NodeTaskType::LaunchService, - 'meta' => LaunchServiceMeta::from($workerTaskMeta), + 'type' => NodeTaskType::PullDockerImage, + 'meta' => PullDockerImageMeta::from([ + 'deploymentId' => $deployment->id, + 'processName' => $this->dockerName, + 'serviceId' => $deployment->service_id, + 'serviceName' => $deployment->service->name, + 'dockerImage' => $worker->dockerImage, + ]), 'payload' => [ 'AuthConfigName' => $authConfigName, - 'ReleaseCommand' => (object) [], - 'SecretVars' => $serviceSecretVars, - 'SwarmServiceSpec' => [ - 'Name' => $worker->dockerName, - 'Labels' => $labels, - 'TaskTemplate' => [ - 'ContainerSpec' => [ - 'Image' => $this->dockerImage, - 'Labels' => $labels, - 'Command' => ['sh', '-c'], - 'Args' => [ - $worker->command, - ], - 'Hostname' => "dpl-{$deployment->id}.{$worker->name}.{$internalDomain}", - 'Env' => collect($this->envVars)->map(fn (EnvVar $var) => "{$var->name}={$var->value}")->toArray(), - 'Mounts' => [], - 'HealthCheck' => [ - 'Test' => ['NONE'], - ], - 'Hosts' => [ - "{$worker->name}.{$internalDomain}", - ], - 'Secrets' => collect($this->secretFiles)->map(fn (SecretFile $secretFile) => [ - 'File' => [ - 'Name' => $secretFile->path, - // TODO: figure out better permissions settings (if any) - 'UID' => '0', - 'GID' => '0', - 'Mode' => 0777, - ], - 'SecretName' => $secretFile->dockerName, - ])->values()->toArray(), - 'Configs' => collect($this->configFiles)->map(fn (ConfigFile $configFile) => [ - 'File' => [ - 'Name' => $configFile->path, - // TODO: figure out better permissions settings (if any) - 'UID' => '0', - 'GID' => '0', - 'Mode' => 0777, - ], - 'ConfigName' => $configFile->dockerName, - ])->values()->toArray(), - 'Placement' => [], - ], - 'Networks' => [ - [ - 'Target' => $deployment->data->networkName, - 'Aliases' => [ - "{$worker->name}.{$internalDomain}", - ], - ], - ], - ], - 'Mode' => [ - 'Replicated' => [ - 'Replicas' => $worker->replicas, - ], - ], - 'UpdateConfig' => [ - 'Parallelism' => 1, - ], - 'EndpointSpec' => [ - 'Ports' => [], - ], - ], + 'Image' => $worker->dockerImage, + 'PullOptions' => (object) [], ], ]; } @@ -465,50 +326,32 @@ public function asNodeTasks(Deployment $deployment): array return $tasks; } - protected function getSecretVars(): object + public static function make(array $attributes): static { - return (object) collect($this->secretVars) - ->reduce(function ($carry, SecretVar $var) { - $carry[$var->name] = $var->value; - - return $carry; - }, []); - } - - public function makeResourceName(string $name): string - { - return dockerize_name($this->dockerName.'_'.$name); - } - - private function getReleaseCommandPayload(Deployment $deployment, array $labels): array - { - if (! $this->releaseCommand->command) { - return [ - 'ConfigName' => '', - 'ConfigLabels' => (object) [], - 'Command' => '', - ]; - } - - // Always create a new config, as the command may be the same, but the image/entrypoint may be different. - $this->releaseCommand->dockerName = $deployment->makeResourceName('release_command'); - - return [ - 'ConfigName' => $this->releaseCommand->dockerName, - 'ConfigLabels' => dockerize_labels([ - ...$labels, - 'kind' => 'release-command', - ]), - 'Command' => $this->releaseCommand->command, + $workerDefaults = Worker::make([]); + + $defaults = [ + 'name' => 'service', + 'networkName' => '', + 'internalDomain' => '', + 'workers' => [$workerDefaults], + 'launchMode' => LaunchMode::Daemon->value, + 'envVars' => [], + 'secretVars' => [], + 'configFiles' => [], + 'secretFiles' => [], + 'volumes' => [], + 'ports' => [], + 'replicas' => 1, + 'caddy' => [], + 'fastCgi' => null, + 'redirectRules' => [], + 'rewriteRules' => [], ]; - } - private function findWorker(?string $dockerName): ?Worker - { - if (! $dockerName) { - return null; - } - - return collect($this->workers)->first(fn (Worker $worker) => $worker->dockerName === $dockerName); + return self::from([ + ...$defaults, + ...$attributes, + ]); } } diff --git a/app/Models/DeploymentData/Worker.php b/app/Models/DeploymentData/Worker.php index 23884f7..f334f44 100644 --- a/app/Models/DeploymentData/Worker.php +++ b/app/Models/DeploymentData/Worker.php @@ -2,16 +2,277 @@ namespace App\Models\DeploymentData; +use App\Models\Deployment; +use App\Models\NodeTasks\LaunchService\LaunchServiceMeta; +use App\Models\NodeTaskType; +use Spatie\LaravelData\Attributes\Validation\Enum; +use Spatie\LaravelData\Attributes\Validation\Min; use Spatie\LaravelData\Data; class Worker extends Data { public function __construct( public string $name, + public ?string $dockerRegistryId, + public string $dockerImage, public ?string $dockerName, - public string $command, - public int $replicas + public ?string $command, + #[Min(1)] + public int $replicas, + #[Enum(LaunchMode::class)] + public LaunchMode $launchMode, + public ?string $schedule, + public ReleaseCommand $releaseCommand, + public Healthcheck $healthcheck, ) { - // + $this->replicas = min($this->replicas, $this->launchMode->maxReplicas()); + } + + public function asNodeTasks(Deployment $deployment, Process $process): array + { + [$command, $args] = $this->getCommandAndArgs(); + + $internalDomain = $this->getInternalDomain($deployment, $process); + + $hostname = $this->getHostname($deployment, $process); + + $tasks = []; + + if (! $this->dockerName) { + $this->dockerName = $process->makeResourceName('wkr_'.$this->name); + } + + $labels = [ + ...$process->resourceLabels($deployment), + 'worker.name' => $this->name, + ]; + + $tasks[] = [ + 'type' => NodeTaskType::LaunchService, + 'meta' => LaunchServiceMeta::from([ + 'deploymentId' => $deployment->id, + 'serviceId' => $deployment->service_id, + 'serviceName' => $deployment->service->name, + 'dockerName' => $this->dockerName, + ]), + 'payload' => [ + 'ReleaseCommand' => $this->getReleaseCommandPayload($process, $labels), + 'SecretVars' => $this->getSecretVars($process), + 'SwarmServiceSpec' => [ + 'Name' => $this->dockerName, + 'Labels' => $labels, + 'TaskTemplate' => [ + 'ContainerSpec' => [ + 'Image' => $this->dockerImage, + 'Labels' => $labels, + 'Command' => $command, + 'Args' => $args, + 'Hostname' => $hostname, + 'Env' => collect($this->getEnvVars($deployment, $process))->map(fn (EnvVar $var) => "{$var->name}={$var->value}")->toArray(), + 'Mounts' => $process->getMounts($deployment), + 'Hosts' => [ + $internalDomain, + ], + 'Secrets' => collect($process->secretFiles)->map(fn (SecretFile $secretFile) => [ + 'File' => [ + 'Name' => $secretFile->path, + // TODO: figure out better permissions settings (if any) + 'UID' => '0', + 'GID' => '0', + 'Mode' => 0777, + ], + 'SecretName' => $secretFile->dockerName, + ])->values()->toArray(), + 'Configs' => collect($process->configFiles)->map(fn (ConfigFile $configFile) => [ + 'File' => [ + 'Name' => $configFile->path, + // TODO: figure out better permissions settings (if any) + 'UID' => '0', + 'GID' => '0', + 'Mode' => 0777, + ], + 'ConfigName' => $configFile->dockerName, + ])->values()->toArray(), + 'Placement' => $process->placementNodeId ? [ + 'Constraints' => [ + "node.labels.sh.ptah.node.id=={$process->placementNodeId}", + ], + ] : [], + 'HealthCheck' => $this->healthcheck->command ? [ + 'Test' => ['CMD-SHELL', $this->healthcheck->command], + 'Interval' => $this->healthcheck->interval * 1000000000, // Convert to nanoseconds + 'Timeout' => $this->healthcheck->timeout * 1000000000, // Convert to nanoseconds + 'Retries' => $this->healthcheck->retries, + 'StartPeriod' => $this->healthcheck->startPeriod * 1000000000, // Convert to nanoseconds + 'StartInterval' => $this->healthcheck->startInterval * 1000000000, // Convert to nanoseconds + ] : null, + ], + 'Networks' => [ + [ + 'Target' => $deployment->data->networkName, + 'Aliases' => [ + $internalDomain, + $hostname, + ], + ], + ], + ], + 'Mode' => [ + 'Replicated' => [ + 'Replicas' => $this->replicas, + ], + ], + 'EndpointSpec' => [ + 'Ports' => $this->getPorts($process), + ], + ], + ], + ]; + + return $tasks; + } + + private function getInternalDomain(Deployment $deployment, Process $process): string + { + $base = $process->getInternalDomain($deployment); + if ($this->name === 'main') { + return $base; + } + + return "{$this->name}.{$base}"; + } + + private function getHostname(Deployment $deployment, Process $process): string + { + return "dpl-{$deployment->id}.{$this->name}.{$process->getInternalDomain($deployment)}"; + } + + private function getCommandAndArgs(): array + { + if (! $this->command) { + return [null, null]; + } + + // FIXME: use smarter CLI split - need to handle values with spaces, surrounded by the double quotes + $splitCmd = explode(' ', $this->command); + + $command = [$splitCmd[0]]; + $args = array_slice($splitCmd, 1); + + return [$command, $args]; + } + + private function getReleaseCommandPayload(Process $process, array $labels): array + { + if (! $this->releaseCommand->command) { + return [ + 'ConfigName' => '', + 'ConfigLabels' => (object) [], + 'Command' => '', + ]; + } + + // Always create a new config, as the command may be the same, but the image/entrypoint may be different. + $this->releaseCommand->dockerName = $process->makeResourceName('rel_cmd'); + + return [ + 'ConfigName' => $this->releaseCommand->dockerName, + 'ConfigLabels' => dockerize_labels([ + ...$labels, + 'kind' => 'release-command', + ]), + 'Command' => $this->releaseCommand->command, + ]; + } + + private function getEnvVars(Deployment $deployment, Process $process): array + { + $envVars = $process->envVars; + + $envVars[] = EnvVar::validateAndCreate([ + 'name' => 'PTAH_INTERNAL_DOMAIN', + 'value' => $this->getInternalDomain($deployment, $process), + ]); + + $envVars[] = EnvVar::validateAndCreate([ + 'name' => 'PTAH_HOSTNAME', + 'value' => $this->getHostname($deployment, $process), + ]); + + $envVars[] = EnvVar::validateAndCreate([ + 'name' => 'PTAH_WORKER_NAME', + 'value' => $this->name, + ]); + + $envVars[] = EnvVar::validateAndCreate([ + 'name' => 'PTAH_BACKUP_DIR', + 'value' => '/ptah/backups', + ]); + + $envVars[] = EnvVar::validateAndCreate([ + 'name' => 'PTAH_DEPLOYMENT_ID', + 'value' => strval($deployment->id), + ]); + + $envVars[] = EnvVar::validateAndCreate([ + 'name' => 'PTAH_SERVICE_ID', + 'value' => $deployment->service->slug, + ]); + + $envVars[] = EnvVar::validateAndCreate([ + 'name' => 'PTAH_PROCESS_NAME', + 'value' => $process->name, + ]); + + return $envVars; + } + + private function getSecretVars(Process $process): object + { + return (object) collect($process->secretVars) + ->reduce(function ($carry, SecretVar $var) { + $carry[$var->name] = $var->value; + + return $carry; + }, []); + } + + private function getPorts(Process $process): array + { + if ($this->name !== 'main') { + return []; + } + + return collect($process->ports) + ->map(fn (NodePort $port) => [ + 'Protocol' => 'tcp', + 'TargetPort' => $port->targetPort, + 'PublishedPort' => $port->publishedPort, + 'PublishMode' => 'ingress', + ]) + ->toArray(); + } + + public static function make(array $attributes): static + { + $defaults = [ + 'name' => 'main', + 'dockerRegistryId' => null, + 'dockerImage' => '', + 'launchMode' => LaunchMode::Daemon->value, + 'replicas' => 1, + 'command' => null, + 'releaseCommand' => ReleaseCommand::from([ + 'command' => null, + ]), + 'healthcheck' => Healthcheck::from([ + 'command' => null, + ]), + ]; + + return self::from([ + ...$defaults, + ...$attributes, + ]); } } diff --git a/app/Models/NodeTaskGroupType.php b/app/Models/NodeTaskGroupType.php index ff0bac2..baa5d06 100644 --- a/app/Models/NodeTaskGroupType.php +++ b/app/Models/NodeTaskGroupType.php @@ -14,4 +14,5 @@ enum NodeTaskGroupType: int case CreateBackup = 7; case JoinSwarm = 8; case UpdateDirdConfig = 9; + case LaunchService = 10; } diff --git a/database/migrations/2024_10_07_120151_alter_deployments_move_worker_props_into_workers_section.php b/database/migrations/2024_10_07_120151_alter_deployments_move_worker_props_into_workers_section.php new file mode 100644 index 0000000..410295f --- /dev/null +++ b/database/migrations/2024_10_07_120151_alter_deployments_move_worker_props_into_workers_section.php @@ -0,0 +1,75 @@ +from('deployments')->orderBy('id', 'asc')->chunk(100, function ($deployments) { + foreach ($deployments as $deployment) { + $data = json_decode($deployment->data); + + $healthcheck = new stdClass; + $healthcheck->command = null; + + $releaseCommand = new stdClass; + $releaseCommand->command = null; + + foreach ($data->processes as $process) { + foreach ($process->workers as $worker) { + $worker->dockerRegistryId = $process->dockerRegistryId; + $worker->dockerImage = $process->dockerImage; + $worker->launchMode = $process->launchMode; + $worker->replicas = $process->replicas; + $worker->releaseCommand = $releaseCommand; + $worker->healthcheck = $healthcheck; + } + + $process->workers = [ + [ + 'name' => 'main', + 'dockerName' => $process->dockerName.'_'.'wkr_main', + 'dockerRegistryId' => $process->dockerRegistryId, + 'dockerImage' => $process->dockerImage, + 'launchMode' => $process->launchMode, + 'replicas' => $process->replicas, + 'releaseCommand' => $process->releaseCommand, + 'healthcheck' => $process->healthcheck, + 'command' => $process->command, + ], + ...$process->workers, + ]; + } + + DB::query()->from('deployments')->where('id', $deployment->id)->update([ + 'data' => json_encode($data), + ]); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + DB::query()->from('deployments')->orderBy('id', 'asc')->chunk(100, function ($deployments) { + foreach ($deployments as $deployment) { + $data = json_decode($deployment->data); + + foreach ($data->processes as $process) { + $process->workers = array_slice($process->workers, 1); + } + + DB::query()->from('deployments')->where('id', $deployment->id)->update([ + 'data' => json_encode($data), + ]); + } + }); + } +}; diff --git a/package-lock.json b/package-lock.json index 8707ae5..e118067 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "apexcharts": "^3.54.0", "dayjs": "^1.11.11", "handlebars": "^4.7.8", + "lodash.clonedeep": "^4.5.0", "lodash.set": "^4.3.2", "swrv": "^1.0.4", "vue3-apexcharts": "^1.6.0" @@ -2352,8 +2353,7 @@ "node_modules/lodash.clonedeep": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", - "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", - "dev": true + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==" }, "node_modules/lodash.isequal": { "version": "4.5.0", diff --git a/package.json b/package.json index ef68ccf..bd27f47 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "apexcharts": "^3.54.0", "dayjs": "^1.11.11", "handlebars": "^4.7.8", + "lodash.clonedeep": "^4.5.0", "lodash.set": "^4.3.2", "swrv": "^1.0.4", "vue3-apexcharts": "^1.6.0" diff --git a/resources/js/Components/CloseButton.vue b/resources/js/Components/CloseButton.vue new file mode 100644 index 0000000..f4aa693 --- /dev/null +++ b/resources/js/Components/CloseButton.vue @@ -0,0 +1,8 @@ + diff --git a/resources/js/Components/Service/RemoveComponentButton.vue b/resources/js/Components/Service/RemoveComponentButton.vue deleted file mode 100644 index 42d0265..0000000 --- a/resources/js/Components/Service/RemoveComponentButton.vue +++ /dev/null @@ -1,13 +0,0 @@ - - - \ No newline at end of file diff --git a/resources/js/Components/Service/ToggleComponent.vue b/resources/js/Components/Service/ToggleComponent.vue deleted file mode 100644 index 4944d6c..0000000 --- a/resources/js/Components/Service/ToggleComponent.vue +++ /dev/null @@ -1,60 +0,0 @@ - - - diff --git a/resources/js/Components/Tabs/TabItem.vue b/resources/js/Components/Tabs/TabItem.vue index 51f5684..2740676 100644 --- a/resources/js/Components/Tabs/TabItem.vue +++ b/resources/js/Components/Tabs/TabItem.vue @@ -1,30 +1,50 @@ \ No newline at end of file +
+ + +
+ diff --git a/resources/js/Pages/Services/Create.vue b/resources/js/Pages/Services/Create.vue index 6a6d81e..dcbec6f 100644 --- a/resources/js/Pages/Services/Create.vue +++ b/resources/js/Pages/Services/Create.vue @@ -17,6 +17,8 @@ const props = defineProps({ dockerRegistries: Array, s3Storages: Array, marketplaceUrl: String, + blankProcess: Object, + blankWorker: Object, }); const { encryptDeploymentData } = useCrypto(); @@ -126,6 +128,8 @@ const applyTemplate = (template) => { :docker-registries="props.dockerRegistries" :s3-storages="props.s3Storages" :initial-secret-vars="[]" + :blank-process="props.blankProcess" + :blank-worker="props.blankWorker" />
diff --git a/resources/js/Pages/Services/Partials/DeploymentData.vue b/resources/js/Pages/Services/Partials/DeploymentData.vue index c96a7d9..2eef358 100644 --- a/resources/js/Pages/Services/Partials/DeploymentData.vue +++ b/resources/js/Pages/Services/Partials/DeploymentData.vue @@ -12,12 +12,13 @@ import ProcessTabs from "@/Pages/Services/Partials/ProcessTabs.vue"; import DangerButton from "@/Components/DangerButton.vue"; import DialogModal from "@/Components/DialogModal.vue"; import AddComponentButton from "@/Components/Service/AddComponentButton.vue"; -import RemoveComponentButton from "@/Components/Service/RemoveComponentButton.vue"; +import WorkerForm from "@/Pages/Services/Partials/DeploymentData/WorkerForm.vue"; import ComponentBlock from "@/Components/Service/ComponentBlock.vue"; import BackupSchedule from "@/Components/BackupSchedule.vue"; -import ToggleComponent from "@/Components/Service/ToggleComponent.vue"; import { evaluate } from "@/expr-lang.js"; import ExternalLink from "@/Components/ExternalLink.vue"; +import cloneDeep from "lodash.clonedeep"; +import CloseButton from "@/Components/CloseButton.vue"; const model = defineModel(); @@ -29,6 +30,8 @@ const props = defineProps({ s3Storages: Array, errors: Object, initialSecretVars: Array, + blankProcess: Object, + blankWorker: Object, }); const state = reactive({ @@ -41,6 +44,7 @@ const state = reactive({ secretFiles: 0, volumes: 0, caddy: 0, + workers: 0, }, }); @@ -138,70 +142,51 @@ const addFastCgiVar = () => { const addProcess = () => { const newIndex = model.value.processes.length; - model.value.processes.push({ - id: makeId("process"), - name: "process_" + newIndex, - placementNodeId: null, - dockerRegistryId: null, - dockerImage: "", - releaseCommand: { - command: null, - }, - command: null, - healthcheck: { - command: null, - interval: 10, - timeout: 5, - retries: 10, - startPeriod: 60, - startInterval: 10, - }, - backups: [], - workers: [], - launchMode: "daemon", - envVars: [], - secretVars: [], - configFiles: [], - secretFiles: [], - volumes: [], - ports: [], - replicas: 1, - caddy: [], - redirectRules: [], - rewriteRules: [], - fastCgi: null, - }); + const newProcess = cloneDeep(props.blankProcess); + + newProcess.id = makeId("process"); + newProcess.name = "process_" + newIndex; + + model.value.processes.push(newProcess); state.selectedProcessIndex["processes"] = newIndex; + state.selectedProcessIndex["workers"] = 0; }; const removeProcess = (index) => { + model.value.processes.splice(index, 1); + for (const [key, selectedIndex] of Object.entries( state.selectedProcessIndex, )) { if (selectedIndex >= index) { - state.selectedProcessIndex[key] = - state.selectedProcessIndex[key] - 1; + state.selectedProcessIndex[key] = model.value.processes.length - 1; } } - - model.value.processes.splice(index, 1); }; const addWorker = () => { + const newWorker = cloneDeep(props.blankWorker); + + newWorker.id = makeId("worker"); + newWorker.name = `worker_${model.value.processes[state.selectedProcessIndex["processes"]].workers.length + 1}`; + model.value.processes[state.selectedProcessIndex["processes"]].workers.push( - { - id: makeId("worker"), - name: `worker_${model.value.processes[state.selectedProcessIndex["processes"]].workers.length + 1}`, - replicas: 1, - }, + newWorker, ); + + state.selectedProcessIndex["workers"] = + model.value.processes[state.selectedProcessIndex["processes"]].workers + .length - 1; }; const removeWorker = (index) => { - model.value.processes[ - state.selectedProcessIndex["processes"] - ].workers.splice(index, 1); + selectedProcess.value.workers.splice(index, 1); + + const lastWorkerIndex = selectedProcess.value.workers.length - 1; + if (state.selectedProcessIndex["workers"] > lastWorkerIndex) { + state.selectedProcessIndex["workers"] = lastWorkerIndex; + } }; const hasFastCgiHandlers = computed(() => { @@ -235,19 +220,6 @@ const toggleVolumeBackups = (volume) => { } }; -const addProcessBackup = () => { - model.value.processes[state.selectedProcessIndex["processes"]].backups.push( - { - id: makeId("backup-cmd"), - backupSchedule: { - preset: "daily", - s3StorageId: props.s3Storages[0].id, - expr: "0 0 * * *", - }, - }, - ); -}; - const processRemoveInput = ref(); const processRemoval = reactive({ @@ -257,6 +229,9 @@ const processRemoval = reactive({ }); const confirmProcessRemoval = (index) => { + state.selectedProcessIndex["processes"] = index; + state.selectedProcessIndex["workers"] = 0; + processRemoval.open = true; nextTick(() => { @@ -295,76 +270,22 @@ const extractFieldErrors = (basePath) => { ); }; -const toggleReleaseCommand = () => { - model.value.processes[ - state.selectedProcessIndex["processes"] - ].releaseCommand.command = - model.value.processes[state.selectedProcessIndex["processes"]] - .releaseCommand.command === null - ? "" - : null; -}; - -const toggleCommand = () => { - model.value.processes[state.selectedProcessIndex["processes"]].command = - model.value.processes[state.selectedProcessIndex["processes"]] - .command === null - ? "" - : null; -}; - -const toggleHealthcheck = () => { - const currentHealthcheck = - model.value.processes[state.selectedProcessIndex["processes"]] - .healthcheck; - model.value.processes[state.selectedProcessIndex["processes"]].healthcheck = - { - command: currentHealthcheck.command === null ? "" : null, - interval: 10, - timeout: 5, - retries: 10, - startPeriod: 60, - startInterval: 10, - }; -}; - -const calculateHealthcheckTimes = computed(() => { - const healthcheck = - model.value.processes[state.selectedProcessIndex["processes"]] - .healthcheck; - if (healthcheck.command === null) return null; - - const interval = Number(healthcheck.interval) || 10; - const timeout = Number(healthcheck.timeout) || 5; - const retries = Number(healthcheck.retries) || 10; - const startPeriod = Number(healthcheck.startPeriod) || 60; - - const optimisticTime = interval; - const pessimisticTime = - retries * interval + retries * timeout + startPeriod; - - return { - optimistic: optimisticTime, - pessimistic: pessimisticTime, - }; -}); - const errors = ref({}); const evaluateEnvVarTemplate = (envVar, index) => { if (envVar.value) { try { envVar.value = evaluate(envVar.value, model.value); - // Clear any previous error for this field + delete errors.value[ `processes.${state.selectedProcessIndex["envVars"]}.envVars.${index}.value` ]; } catch (error) { console.error("Error evaluating env var template:", error); - // Set an error for this specific field + errors.value[ `processes.${state.selectedProcessIndex["envVars"]}.envVars.${index}.value` - ] = error.message; // 'Invalid template expression'; + ] = error.message; } } }; @@ -373,24 +294,44 @@ const evaluateSecretVarTemplate = (secretVar, index) => { if (secretVar.value) { try { secretVar.value = evaluate(secretVar.value, model.value); - // Clear any previous error for this field + delete errors.value[ `processes.${state.selectedProcessIndex["secretVars"]}.secretVars.${index}.value` ]; } catch (error) { console.error("Error evaluating secret var template:", error); - // Set an error for this specific field + errors.value[ `processes.${state.selectedProcessIndex["secretVars"]}.secretVars.${index}.value` - ] = error.message; // 'Invalid template expression'; + ] = error.message; } } }; + +const selectedProcess = computed(() => { + return model.value.processes[state.selectedProcessIndex["processes"]]; +}); + +const selectedProcessKey = computed(() => { + return `processes.${state.selectedProcessIndex["processes"]}`; +}); + +const selectedWorker = computed(() => { + return selectedProcess.value.workers[state.selectedProcessIndex["workers"]]; +}); + +const selectedWorkerKey = computed(() => { + return `${selectedProcessKey.value}.workers.${state.selectedProcessIndex["workers"]}`; +}); + +const selectedWorkerErrors = computed(() => { + return extractFieldErrors(selectedWorkerKey.value); +});