diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 5743eea..023ab8d 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -80,7 +80,13 @@ jobs: service: 'ptah-server-prod' processes: | - name: svc - dockerImage: ghcr.io/ptah-sh/ptah-server:${{ needs.release-please.outputs.tag_name }} + workers: + - name: main + dockerImage: ghcr.io/ptah-sh/ptah-server:${{ needs.release-please.outputs.tag_name }} + - name: scheduler + dockerImage: ghcr.io/ptah-sh/ptah-server:${{ needs.release-please.outputs.tag_name }} + - name: queue + dockerImage: ghcr.io/ptah-sh/ptah-server:${{ needs.release-please.outputs.tag_name }} envVars: - name: SENTRY_RELEASE value: ${{ needs.release-please.outputs.tag_name }} diff --git a/api-nodes/Http/Controllers/MetricsController.php b/api-nodes/Http/Controllers/MetricsController.php index eac7133..d5c98e4 100644 --- a/api-nodes/Http/Controllers/MetricsController.php +++ b/api-nodes/Http/Controllers/MetricsController.php @@ -88,10 +88,6 @@ public function __invoke(Request $request, Logger $log, Node $node) ], ]; - $log->info('Services:', [ - 'services' => $services, - ]); - foreach ($request->all() as $metricsDoc) { if ($metricsDoc === null) { continue; diff --git a/app/Console/Commands/DispatchProcessBackupTask.php b/app/Console/Commands/DispatchProcessBackupTask.php deleted file mode 100644 index 80afb25..0000000 --- a/app/Console/Commands/DispatchProcessBackupTask.php +++ /dev/null @@ -1,115 +0,0 @@ -dispatchBackupTask(); - }); - } - - /** - * @throws Exception - */ - protected function dispatchBackupTask(): void - { - /* @var Service $service */ - $service = Service::findOrFail($this->option('service-id')); - - $deployment = $service->latestDeployment; - - $process = $deployment->data->findProcess($this->option('process')); - if ($process === null) { - throw new Exception("Could not find process {$this->option('process')} in deployment {$deployment->id}."); - } - - $backupCmd = $process->findProcessBackup($this->option('backup-cmd-id')); - if ($backupCmd === null) { - throw new Exception("Could not find backup command {$this->option('backup-cmd-id')} in process {$process->name}."); - } - - $node = Node::findOrFail($process->placementNodeId); - - $taskGroup = $node->taskGroups()->create([ - 'swarm_id' => $node->swarm_id, - 'node_id' => $node->id, - 'type' => NodeTaskGroupType::CreateBackup, - 'invoker_id' => $deployment->latestTaskGroup->invoker_id, - 'team_id' => $service->team_id, - ]); - - $date = now()->format('Y-m-d_His'); - $backupFilePath = "svc_{$service->id}/{$process->name}/cmd/{$backupCmd->name}/{$process->name}-cmd-{$backupCmd->name}-{$date}.tar.gz"; - $backupFileSlug = Str::slug($backupFilePath, separator: '_'); - $archivePath = "{$process->backupVolume->path}/$backupFileSlug"; - $backupCommand = "mkdir -p /tmp/{$backupCmd->id} && cd /tmp/{$backupCmd->id} && {$backupCmd->command} && tar czfv $archivePath -C /tmp/{$backupCmd->id} . && rm -rf /tmp/{$backupCmd->id}"; - - $s3Storage = $node->swarm->data->findS3Storage($backupCmd->backupSchedule->s3StorageId); - if ($s3Storage === null) { - throw new Exception("Could not find S3 storage {$backupCmd->backupSchedule->s3StorageId} in swarm {$node->swarm_id}."); - } - - $taskGroup->tasks()->createMany([ - [ - 'type' => NodeTaskType::ServiceExec, - 'meta' => [ - 'serviceId' => $service->id, - 'command' => $backupCommand, - ], - 'payload' => [ - 'ProcessName' => $process->dockerName, - 'ExecSpec' => [ - 'AttachStdout' => true, - 'AttachStderr' => true, - 'Cmd' => ['sh', '-c', $backupCommand], - ], - ], - ], - [ - 'type' => NodeTaskType::UploadS3File, - 'meta' => [ - 'serviceId' => $service->id, - ], - 'payload' => [ - 'S3StorageConfigName' => $s3Storage->dockerName, - 'VolumeSpec' => [ - 'Type' => 'volume', - 'Source' => $process->backupVolume->dockerName, - 'Target' => $process->backupVolume->path, - ], - 'SrcFilePath' => $archivePath, - 'DestFilePath' => $s3Storage->pathPrefix.'/'.$backupFilePath, - ], - ], - ]); - } -} diff --git a/app/Console/Commands/DispatchVolumeBackupTask.php b/app/Console/Commands/DispatchVolumeBackupTask.php deleted file mode 100644 index 3ca05e0..0000000 --- a/app/Console/Commands/DispatchVolumeBackupTask.php +++ /dev/null @@ -1,118 +0,0 @@ -dispatchBackupTask(); - }); - } - - /** - * @throws Exception - */ - protected function dispatchBackupTask(): void - { - /* @var Service $service */ - $service = Service::withoutGlobalScope(TeamScope::class)->with(['latestDeployment'])->findOrFail($this->option('service-id')); - - $deployment = $service->latestDeployment; - - $process = $deployment->data->findProcess($this->option('process')); - if ($process === null) { - throw new Exception("Could not find process {$this->option('process')} in deployment {$deployment->id}."); - } - - $volume = $process->findVolume($this->option('volume-id')); - if ($volume === null) { - throw new Exception("Could not find volume {$this->option('volume-id')} in process {$process->name}."); - } - - $node = Node::withoutGlobalScope(TeamScope::class)->findOrFail($process->placementNodeId); - - $taskGroup = $node->taskGroups()->create([ - 'swarm_id' => $node->swarm_id, - 'node_id' => $node->id, - 'type' => NodeTaskGroupType::CreateBackup, - 'invoker_id' => $deployment->latestTaskGroup->invoker_id, - 'team_id' => $service->team_id, - ]); - - $date = now()->format('Y-m-d_His'); - $backupFilePath = "svc_{$service->id}/{$process->name}/vol/{$volume->name}/{$process->name}-vol-{$volume->name}-{$date}.tar.gz"; - $backupFileSlug = Str::slug($backupFilePath, separator: '_'); - $archivePath = "{$process->backupVolume->path}/$backupFileSlug"; - $backupCommand = "tar czfv $archivePath -C {$volume->path} ."; - - // TODO: get rid of copy-pasted code. - $s3Storage = $node->swarm->data->findS3Storage($volume->backupSchedule->s3StorageId); - if ($s3Storage === null) { - throw new Exception("Could not find S3 storage {$volume->backupSchedule->s3StorageId} in swarm {$node->swarm_id}."); - } - - $taskGroup->tasks()->createMany([ - [ - 'type' => NodeTaskType::ServiceExec, - 'meta' => [ - 'serviceId' => $service->id, - 'command' => $backupCommand, - ], - 'payload' => [ - 'ProcessName' => $process->dockerName, - 'ExecSpec' => [ - 'User' => 'root', - 'AttachStdout' => true, - 'AttachStderr' => true, - 'Cmd' => ['sh', '-c', $backupCommand], - ], - ], - ], - [ - 'type' => NodeTaskType::UploadS3File, - 'meta' => [ - 'serviceId' => $service->id, - ], - 'payload' => [ - 'S3StorageConfigName' => $s3Storage->dockerName, - 'VolumeSpec' => [ - 'Type' => 'volume', - 'Source' => $process->backupVolume->dockerName, - 'Target' => $process->backupVolume->path, - ], - 'SrcFilePath' => $archivePath, - 'DestFilePath' => $s3Storage->pathPrefix.'/'.$backupFilePath, - ], - ], - ]); - } -} diff --git a/app/Console/Commands/ExecuteScheduledWorker.php b/app/Console/Commands/ExecuteScheduledWorker.php new file mode 100644 index 0000000..72bd888 --- /dev/null +++ b/app/Console/Commands/ExecuteScheduledWorker.php @@ -0,0 +1,100 @@ +executeWorker(); + }); + } + + protected function executeWorker(): void + { + /* @var Service $service */ + $service = Service::findOrFail($this->option('service-id')); + + $deployment = $service->latestDeployment; + + $process = $deployment->data->findProcess($this->option('process')); + if ($process === null) { + throw new Exception("Could not find process {$this->option('process')} in deployment {$deployment->id}."); + } + + $worker = $process->findWorker($this->option('worker')); + if ($worker === null) { + throw new Exception("Could not find worker {$this->option('worker')} in process {$process->name}."); + } + + $node = $process->placementNodeId ? Node::findOrFail($process->placementNodeId) : null; + + $taskGroup = NodeTaskGroup::create([ + 'type' => NodeTaskGroupType::LaunchService, + 'swarm_id' => $service->swarm_id, + 'node_id' => $node->id, + 'invoker_id' => $deployment->latestTaskGroup->invoker_id, + 'team_id' => $service->team_id, + ]); + + $tasks = []; + + $tasks = [ + ...$tasks, + ...$worker->asNodeTasks($deployment, $process, desiredReplicas: 1), + ]; + + if ($worker->backupOptions) { + $s3Storage = $node->swarm->data->findS3Storage($worker->backupOptions->s3StorageId); + if ($s3Storage === null) { + throw new Exception("Could not find S3 storage {$worker->backupOptions->s3StorageId} in swarm {$node->swarm_id}."); + } + + $archiveFormat = $worker->backupOptions->archive?->format->value; + + $date = now()->format('Y-m-d_His'); + + $ext = $archiveFormat ? ".$archiveFormat" : ''; + $backupFilePath = "/{$service->slug}/{$process->name}/{$worker->name}/{$service->slug}-{$process->name}-{$worker->name}-{$date}$ext"; + + $tasks[] = [ + 'type' => NodeTaskType::UploadS3File, + 'meta' => [ + 'serviceId' => $service->id, + 'destPath' => $backupFilePath, + ], + 'payload' => [ + 'Archive' => [ + 'Enabled' => $worker->backupOptions->archive !== null, + 'Format' => $archiveFormat, + ], + 'S3StorageConfigName' => $s3Storage->dockerName, + 'VolumeSpec' => [ + 'Type' => 'volume', + 'Source' => $worker->backupOptions->backupVolume->dockerName, + 'Target' => $worker->backupOptions->backupVolume->path, + ], + 'SrcFilePath' => $worker->backupOptions->backupVolume->path, + 'DestFilePath' => $backupFilePath, + 'RemoveSrcFile' => true, + ], + ]; + } + + $taskGroup->tasks()->createMany($tasks); + } +} diff --git a/app/Console/Commands/SelfHostPtah.php b/app/Console/Commands/SelfHostPtah.php index c465bd4..d6b26a3 100644 --- a/app/Console/Commands/SelfHostPtah.php +++ b/app/Console/Commands/SelfHostPtah.php @@ -97,6 +97,7 @@ public function handle() 'swarm_id' => $node->swarm_id, ]); + // TODO: create processes from 1-Click App templates StartDeployment::run($user, $service, DeploymentData::validateAndCreate([ 'networkName' => $network->docker_name, 'internalDomain' => 'server.ptah.local', @@ -104,23 +105,27 @@ public function handle() [ 'name' => 'pg', 'placementNodeId' => $node->id, - 'launchMode' => LaunchMode::Daemon->value, - 'dockerRegistryId' => null, - 'dockerImage' => 'bitnami/postgresql:16', - 'releaseCommand' => [ - 'command' => null, - ], - 'command' => null, - 'healthcheck' => [ - 'command' => null, - 'interval' => 10, - 'timeout' => 5, - 'retries' => 3, - 'startPeriod' => 30, - 'startInterval' => 5, + 'workers' => [ + [ + 'name' => 'main', + 'dockerImage' => 'bitnami/postgresql:16', + 'launchMode' => LaunchMode::Daemon->value, + 'replicas' => 1, + 'dockerRegistryId' => null, + 'command' => null, + 'releaseCommand' => [ + 'command' => null, + ], + 'healthcheck' => [ + 'command' => null, + 'interval' => 10, + 'timeout' => 5, + 'retries' => 3, + 'startPeriod' => 30, + 'startInterval' => 5, + ], + ], ], - 'backups' => [], - 'workers' => [], 'envVars' => [ [ 'name' => 'POSTGRESQL_USERNAME', @@ -145,7 +150,6 @@ public function handle() 'path' => '/bitnami/postgresql', ], ], - 'replicas' => 1, 'ports' => [], 'caddy' => [], 'fastcgiVars' => null, @@ -154,23 +158,27 @@ public function handle() ], [ 'name' => 'pool', - 'launchMode' => LaunchMode::Daemon->value, - 'dockerRegistryId' => null, - 'dockerImage' => 'bitnami/pgbouncer', - 'releaseCommand' => [ - 'command' => null, - ], - 'command' => null, - 'healthcheck' => [ - 'command' => null, - 'interval' => 10, - 'timeout' => 5, - 'retries' => 3, - 'startPeriod' => 30, - 'startInterval' => 5, + 'workers' => [ + [ + 'name' => 'main', + 'launchMode' => LaunchMode::Daemon->value, + 'replicas' => 1, + 'dockerRegistryId' => null, + 'dockerImage' => 'bitnami/pgbouncer', + 'releaseCommand' => [ + 'command' => null, + ], + 'command' => null, + 'healthcheck' => [ + 'command' => null, + 'interval' => 10, + 'timeout' => 5, + 'retries' => 3, + 'startPeriod' => 30, + 'startInterval' => 5, + ], + ], ], - 'backups' => [], - 'workers' => [], 'envVars' => [ [ 'name' => 'PGBOUNCER_POOL_MODE', @@ -205,7 +213,6 @@ public function handle() 'configFiles' => [], 'secretFiles' => [], 'volumes' => [], - 'replicas' => 1, 'ports' => [], 'caddy' => [], 'fastcgiVars' => null, @@ -215,23 +222,27 @@ public function handle() [ 'name' => 'victoriametrics', 'placementNodeId' => $node->id, - 'launchMode' => LaunchMode::Daemon->value, - 'dockerRegistryId' => null, - 'dockerImage' => 'victoriametrics/victoria-metrics', - 'releaseCommand' => [ - 'command' => null, - ], - 'command' => null, - 'healthcheck' => [ - 'command' => null, - 'interval' => 10, - 'timeout' => 5, - 'retries' => 3, - 'startPeriod' => 30, - 'startInterval' => 5, + 'workers' => [ + [ + 'name' => 'main', + 'launchMode' => LaunchMode::Daemon->value, + 'replicas' => 1, + 'dockerRegistryId' => null, + 'dockerImage' => 'victoriametrics/victoria-metrics', + 'releaseCommand' => [ + 'command' => null, + ], + 'command' => null, + 'healthcheck' => [ + 'command' => null, + 'interval' => 10, + 'timeout' => 5, + 'retries' => 3, + 'startPeriod' => 30, + 'startInterval' => 5, + ], + ], ], - 'backups' => [], - 'workers' => [], 'envVars' => [], 'secretVars' => [], 'configFiles' => [], @@ -243,7 +254,6 @@ public function handle() 'path' => '/victoria-metrics-data', ], ], - 'replicas' => 1, 'ports' => [], 'caddy' => [], 'fastcgiVars' => null, @@ -252,32 +262,65 @@ public function handle() ], [ 'name' => 'ptah-server', - 'launchMode' => LaunchMode::Daemon->value, - 'dockerRegistryId' => null, - 'dockerImage' => 'ghcr.io/ptah-sh/ptah-server:latest', - 'releaseCommand' => [ - 'command' => 'php artisan config:cache && php artisan migrate --no-interaction --verbose --ansi --force', - ], - 'command' => null, - 'healthcheck' => [ - 'command' => null, - 'interval' => 10, - 'timeout' => 5, - 'retries' => 3, - 'startPeriod' => 30, - 'startInterval' => 5, - ], - 'backups' => [], 'workers' => [ [ - 'name' => 'schedule', + 'name' => 'main', + 'launchMode' => LaunchMode::Daemon->value, + 'replicas' => 2, + 'dockerRegistryId' => null, + 'dockerImage' => 'ghcr.io/ptah-sh/ptah-server:latest', + 'releaseCommand' => [ + 'command' => 'php artisan config:cache && php artisan migrate --no-interaction --verbose --ansi --force', + ], + 'command' => null, + 'healthcheck' => [ + 'command' => null, + 'interval' => 10, + 'timeout' => 5, + 'retries' => 3, + 'startPeriod' => 30, + 'startInterval' => 5, + ], + ], + [ + 'name' => 'scheduler', + 'launchMode' => LaunchMode::Daemon->value, 'replicas' => 1, - 'command' => 'php artisan config:cache && php artisan schedule:work', + 'dockerRegistryId' => null, + 'dockerImage' => 'ghcr.io/ptah-sh/ptah-server:latest', + 'replicas' => 1, + 'command' => 'APP_SCHEDULER=main php artisan config:cache && php artisan schedule:work', + 'releaseCommand' => [ + 'command' => null, + ], + 'healthcheck' => [ + 'command' => null, + 'interval' => 10, + 'timeout' => 5, + 'retries' => 3, + 'startPeriod' => 30, + 'startInterval' => 5, + ], ], [ 'name' => 'queue', + 'launchMode' => LaunchMode::Daemon->value, + 'replicas' => 1, + 'dockerRegistryId' => null, + 'dockerImage' => 'ghcr.io/ptah-sh/ptah-server:latest', 'replicas' => 1, 'command' => 'php artisan config:cache && php artisan queue:work', + 'releaseCommand' => [ + 'command' => null, + ], + 'healthcheck' => [ + 'command' => null, + 'interval' => 10, + 'timeout' => 5, + 'retries' => 3, + 'startPeriod' => 30, + 'startInterval' => 5, + ], ], ], 'envVars' => [ @@ -334,7 +377,6 @@ public function handle() 'configFiles' => [], 'secretFiles' => [], 'volumes' => [], - 'replicas' => 2, 'ports' => [], 'caddy' => [ [ diff --git a/app/Http/Controllers/NodeController.php b/app/Http/Controllers/NodeController.php index 504761c..ff38485 100644 --- a/app/Http/Controllers/NodeController.php +++ b/app/Http/Controllers/NodeController.php @@ -70,7 +70,7 @@ public function store(StoreNodeRequest $request) $node->save(); }); - return to_route('nodes.show', ['node' => $node->id]); + return to_route('nodes.settings', ['node' => $node->id]); } public function show(Node $node) diff --git a/app/Models/Deployment.php b/app/Models/Deployment.php index d70853a..9aa01f3 100644 --- a/app/Models/Deployment.php +++ b/app/Models/Deployment.php @@ -41,6 +41,7 @@ public function taskGroups(): HasManyThrough public function latestTaskGroup(): HasOneThrough { + // FIXME: make sure that the "deployments" page displays only the actual daemon deployments. Keep "task launch" separate. return $this->hasOneThrough(NodeTaskGroup::class, NodeTask::class, 'meta__deployment_id', 'id', 'id', 'task_group_id')->latest('id'); } diff --git a/app/Models/DeploymentData.php b/app/Models/DeploymentData.php index 185298b..85f0801 100644 --- a/app/Models/DeploymentData.php +++ b/app/Models/DeploymentData.php @@ -4,7 +4,6 @@ use App\Models\DeploymentData\Process; use App\Rules\UniqueInArray; -use App\Util\Arrays; use Illuminate\Validation\ValidationException; use Spatie\LaravelData\Attributes\DataCollectionOf; use Spatie\LaravelData\Attributes\Validation\Rule; @@ -50,28 +49,14 @@ public function copyWith(array $attributes): DeploymentData continue; } - $processExists = false; - - foreach ($result['processes'] as $existingIdx => $existingProcess) { - if ($existingProcess['name'] === $process['name']) { - if (isset($process['envVars'])) { - $updatedVars = collect($process['envVars'])->pluck('name')->toArray(); - - $existingProcess['envVars'] = collect($result['processes'][$existingIdx]['envVars']) - ->reject(fn ($var) => in_array($var['name'], $updatedVars)) - ->values() - ->toArray(); - } - - $result['processes'][$existingIdx] = Arrays::niceMerge($existingProcess, $process); + $existingProcess = $this->findProcessByName($process['name']); + if (! $existingProcess) { + $errors["processes.{$idx}.name"] = "Process {$process['name']} does not exist"; - $processExists = true; - } + continue; } - if (! $processExists) { - $errors["processes.{$idx}.name"] = "Process {$process['name']} does not exist"; - } + $result['processes'][$idx] = $existingProcess->copyWith($process)->toArray(); } } @@ -79,11 +64,16 @@ public function copyWith(array $attributes): DeploymentData throw ValidationException::withMessages($errors); } - return DeploymentData::validateAndCreate($result); + return self::validateAndCreate($result); } public function findProcess(string $dockerName): ?Process { return collect($this->processes)->first(fn (Process $process) => $process->dockerName === $dockerName); } + + public function findProcessByName(string $name): ?Process + { + return collect($this->processes)->first(fn (Process $process) => $process->name === $name); + } } diff --git a/app/Models/DeploymentData/ArchiveFormat.php b/app/Models/DeploymentData/ArchiveFormat.php new file mode 100644 index 0000000..96eac55 --- /dev/null +++ b/app/Models/DeploymentData/ArchiveFormat.php @@ -0,0 +1,9 @@ + 1, }; } + + public function isDaemon(): bool + { + return $this === self::Daemon; + } + + public function isBackup(): bool + { + return $this === self::BackupCreate || $this === self::BackupRestore; + } + + public function maxInitialReplicas(): int + { + if ($this->isDaemon()) { + return PHP_INT_MAX; + } + + return 0; + } } diff --git a/app/Models/DeploymentData/Process.php b/app/Models/DeploymentData/Process.php index 4bd78ed..ae779b4 100644 --- a/app/Models/DeploymentData/Process.php +++ b/app/Models/DeploymentData/Process.php @@ -7,12 +7,12 @@ use App\Models\NodeTasks\CreateConfig\CreateConfigMeta; use App\Models\NodeTasks\CreateSecret\CreateSecretMeta; use App\Models\NodeTasks\DeleteService\DeleteServiceMeta; -use App\Models\NodeTasks\PullDockerImage\PullDockerImageMeta; use App\Models\NodeTaskType; use App\Rules\RequiredIfArrayHas; use App\Rules\UniqueInArray; -use App\Util\ResourceId; +use App\Util\Arrays; use Exception; +use Illuminate\Validation\ValidationException; use Spatie\LaravelData\Attributes\DataCollectionOf; use Spatie\LaravelData\Attributes\Validation\Exists; use Spatie\LaravelData\Attributes\Validation\RequiredWith; @@ -51,7 +51,6 @@ public function __construct( #[Rule(new UniqueInArray('name'))] /* @var Volume[] */ public array $volumes, - public ?BackupVolume $backupVolume, #[DataCollectionOf(NodePort::class)] // TODO: unique across all services of the swarm cluster #[Rule(new UniqueInArray('targetPort'))] @@ -75,11 +74,6 @@ public function findVolume(string $id): ?Volume return collect($this->volumes)->first(fn (Volume $volume) => $volume->id === $id); } - public function findProcessBackup(string $id): ?ProcessBackup - { - return collect($this->backups)->first(fn (ProcessBackup $backup) => $backup->id === $id); - } - public function findConfigFile(string $path): ?ConfigFile { return collect($this->configFiles)->first(fn (ConfigFile $file) => $file->path === $path); @@ -195,15 +189,6 @@ public function asNodeTasks(Deployment $deployment): array } } - if ($this->backupVolume == null) { - $this->backupVolume = BackupVolume::validateAndCreate([ - 'id' => ResourceId::make('volume'), - 'name' => 'backups', - 'dockerName' => $this->makeResourceName('/ptah/backups'), - 'path' => '/ptah/backups', - ]); - } - $tasks = [ ...$tasks, ...$this->getPullImageTasks($deployment), @@ -212,7 +197,7 @@ public function asNodeTasks(Deployment $deployment): array foreach ($this->workers as $worker) { $tasks = [ ...$tasks, - ...$worker->asNodeTasks($deployment, $this), + ...$worker->asNodeTasks($deployment, $this, pullImage: false), ]; } @@ -242,37 +227,13 @@ public function getMounts(Deployment $deployment): array $labels = $this->resourceLabels($deployment); $mounts = collect($this->volumes) - ->map(fn (Volume $volume) => [ - 'Type' => 'volume', - 'Source' => $volume->dockerName, - 'Target' => $volume->path, - 'VolumeOptions' => [ - 'Labels' => dockerize_labels([ - ...$labels, - 'volume.id' => $volume->id, - 'volume.path' => $volume->path, - ]), - ], - ]) + ->map(fn (Volume $volume) => $volume->asMount($labels)) ->toArray(); - $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, - ]), - ], - ]; - return $mounts; } - private function findWorker(?string $dockerName): ?Worker + public function findWorker(?string $dockerName): ?Worker { if (! $dockerName) { return null; @@ -281,6 +242,11 @@ private function findWorker(?string $dockerName): ?Worker return collect($this->workers)->first(fn (Worker $worker) => $worker->dockerName === $dockerName); } + public function findWorkerByName(string $name): ?Worker + { + return collect($this->workers)->first(fn (Worker $worker) => $worker->name === $name); + } + private function getPullImageTasks(Deployment $deployment): array { $pulledImages = []; @@ -294,33 +260,7 @@ private function getPullImageTasks(Deployment $deployment): array $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::PullDockerImage, - 'meta' => PullDockerImageMeta::from([ - 'deploymentId' => $deployment->id, - 'processName' => $this->dockerName, - 'serviceId' => $deployment->service_id, - 'serviceName' => $deployment->service->name, - 'dockerImage' => $worker->dockerImage, - ]), - 'payload' => [ - 'AuthConfigName' => $authConfigName, - 'Image' => $worker->dockerImage, - 'PullOptions' => (object) [], - ], - ]; + $tasks[] = $worker->getPullImageTask($deployment); } return $tasks; @@ -354,4 +294,34 @@ public static function make(array $attributes): static ...$attributes, ]); } + + public function copyWith(array $attributes): static + { + $result = $this->toArray(); + + if (isset($attributes['envVars'])) { + $attributes['envVars'] = Arrays::niceMergeByKey($result['envVars'], $attributes['envVars'], 'name'); + } + + $errors = []; + + if (isset($attributes['workers'])) { + foreach ($attributes['workers'] as $idx => $worker) { + if (! $this->findWorkerByName($worker['name'])) { + $errors["workers.{$idx}.name"] = 'Worker '.$worker['name'].' does not exist'; + } + } + + $attributes['workers'] = Arrays::niceMergeByKey($result['workers'], $attributes['workers'], 'name'); + } + + if (! empty($errors)) { + throw ValidationException::withMessages($errors); + } + + return self::validateAndCreate([ + ...$result, + ...$attributes, + ]); + } } diff --git a/app/Models/DeploymentData/ProcessBackup.php b/app/Models/DeploymentData/ProcessBackup.php deleted file mode 100644 index ebcc22e..0000000 --- a/app/Models/DeploymentData/ProcessBackup.php +++ /dev/null @@ -1,15 +0,0 @@ - 'volume', + 'Source' => $this->dockerName, + 'Target' => $this->path, + 'VolumeOptions' => [ + 'Labels' => dockerize_labels([ + ...$labels, + 'volume.id' => $this->id, + 'volume.path' => $this->path, + ]), + ], + ]; + } } diff --git a/app/Models/DeploymentData/Worker.php b/app/Models/DeploymentData/Worker.php index f334f44..1bd66c9 100644 --- a/app/Models/DeploymentData/Worker.php +++ b/app/Models/DeploymentData/Worker.php @@ -4,9 +4,16 @@ use App\Models\Deployment; use App\Models\NodeTasks\LaunchService\LaunchServiceMeta; +use App\Models\NodeTasks\PullDockerImage\PullDockerImageMeta; use App\Models\NodeTaskType; +use App\Rules\Crontab; +use Exception; +use Illuminate\Support\Str; use Spatie\LaravelData\Attributes\Validation\Enum; use Spatie\LaravelData\Attributes\Validation\Min; +use Spatie\LaravelData\Attributes\Validation\ProhibitedIf; +use Spatie\LaravelData\Attributes\Validation\RequiredUnless; +use Spatie\LaravelData\Attributes\Validation\Rule; use Spatie\LaravelData\Data; class Worker extends Data @@ -17,18 +24,29 @@ public function __construct( public string $dockerImage, public ?string $dockerName, public ?string $command, - #[Min(1)] + #[Min(0)] public int $replicas, #[Enum(LaunchMode::class)] public LaunchMode $launchMode, - public ?string $schedule, + #[RequiredUnless('launchMode', [LaunchMode::Daemon, LaunchMode::Manual]), ProhibitedIf('launchMode', [LaunchMode::Daemon, LaunchMode::Manual]), Rule(new Crontab)] + public ?string $crontab, public ReleaseCommand $releaseCommand, public Healthcheck $healthcheck, + #[RequiredUnless('launchMode', [LaunchMode::Daemon, LaunchMode::Manual]), ProhibitedIf('launchMode', [LaunchMode::Daemon, LaunchMode::Manual])] + public ?BackupOptions $backupOptions, ) { - $this->replicas = min($this->replicas, $this->launchMode->maxReplicas()); + $maxReplicas = $this->launchMode->maxReplicas(); + if ($this->replicas > $maxReplicas) { + $this->replicas = $maxReplicas; + } + + $maxInitialReplicas = $this->launchMode->maxInitialReplicas(); + if ($this->replicas > $maxInitialReplicas) { + $this->replicas = $maxInitialReplicas; + } } - public function asNodeTasks(Deployment $deployment, Process $process): array + public function asNodeTasks(Deployment $deployment, Process $process, bool $pullImage = true, ?int $desiredReplicas = null): array { [$command, $args] = $this->getCommandAndArgs(); @@ -42,10 +60,29 @@ public function asNodeTasks(Deployment $deployment, Process $process): array $this->dockerName = $process->makeResourceName('wkr_'.$this->name); } - $labels = [ + if ($this->launchMode->isBackup() && ! $this->backupOptions->backupVolume) { + $dockerName = dockerize_name($this->dockerName.'_vol_ptah_backup'); + + $this->backupOptions->backupVolume = Volume::validateAndCreate([ + 'id' => 'volume-'.Str::random(11), + 'name' => $dockerName, + 'dockerName' => $dockerName, + 'path' => '/ptah/backups', + ]); + } + + $labels = dockerize_labels([ ...$process->resourceLabels($deployment), + 'kind' => 'worker', + // Cookie is used to filter out stale tasks for the same Docker Service on the Docker Engine's side + // and avoid transferring loads of data between Ptah.sh Agent and Docker Engine. + 'cookie' => Str::random(32), 'worker.name' => $this->name, - ]; + ]); + + if ($pullImage) { + $tasks[] = $this->getPullImageTask($deployment); + } $tasks[] = [ 'type' => NodeTaskType::LaunchService, @@ -69,7 +106,7 @@ public function asNodeTasks(Deployment $deployment, Process $process): array 'Args' => $args, 'Hostname' => $hostname, 'Env' => collect($this->getEnvVars($deployment, $process))->map(fn (EnvVar $var) => "{$var->name}={$var->value}")->toArray(), - 'Mounts' => $process->getMounts($deployment), + 'Mounts' => $this->getMounts($deployment, $process, $labels), 'Hosts' => [ $internalDomain, ], @@ -107,6 +144,7 @@ public function asNodeTasks(Deployment $deployment, Process $process): array 'StartInterval' => $this->healthcheck->startInterval * 1000000000, // Convert to nanoseconds ] : null, ], + 'RestartPolicy' => $this->getRestartPolicy(), 'Networks' => [ [ 'Target' => $deployment->data->networkName, @@ -117,11 +155,7 @@ public function asNodeTasks(Deployment $deployment, Process $process): array ], ], ], - 'Mode' => [ - 'Replicated' => [ - 'Replicas' => $this->replicas, - ], - ], + 'Mode' => $this->getSchedulingMode($desiredReplicas), 'EndpointSpec' => [ 'Ports' => $this->getPorts($process), ], @@ -132,6 +166,37 @@ public function asNodeTasks(Deployment $deployment, Process $process): array return $tasks; } + public function getPullImageTask(Deployment $deployment): array + { + $dockerRegistry = $this->dockerRegistryId + ? $deployment->service->swarm->data->findRegistry($this->dockerRegistryId) + : null; + + if ($this->dockerRegistryId && is_null($dockerRegistry)) { + throw new Exception("Docker registry '{$this->dockerRegistryId}' not found"); + } + + $authConfigName = $dockerRegistry + ? $dockerRegistry->dockerName + : ''; + + return [ + '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) [], + ], + ]; + } + private function getInternalDomain(Deployment $deployment, Process $process): string { $base = $process->getInternalDomain($deployment); @@ -147,19 +212,24 @@ private function getHostname(Deployment $deployment, Process $process): string return "dpl-{$deployment->id}.{$this->name}.{$process->getInternalDomain($deployment)}"; } + private function getMounts(Deployment $deployment, Process $process, array $labels): array + { + $mounts = $process->getMounts($deployment); + + if ($this->backupOptions) { + $mounts[] = $this->backupOptions->backupVolume->asMount($labels); + } + + return $mounts; + } + 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]; + return [['sh'], ['-c', $this->command]]; } private function getReleaseCommandPayload(Process $process, array $labels): array @@ -199,15 +269,12 @@ private function getEnvVars(Deployment $deployment, Process $process): array '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', - ]); + if ($this->backupOptions) { + $envVars[] = EnvVar::validateAndCreate([ + 'name' => 'PTAH_BACKUP_DIR', + 'value' => $this->backupOptions->backupVolume->path, + ]); + } $envVars[] = EnvVar::validateAndCreate([ 'name' => 'PTAH_DEPLOYMENT_ID', @@ -224,6 +291,11 @@ private function getEnvVars(Deployment $deployment, Process $process): array 'value' => $process->name, ]); + $envVars[] = EnvVar::validateAndCreate([ + 'name' => 'PTAH_WORKER_NAME', + 'value' => $this->name, + ]); + return $envVars; } @@ -253,6 +325,38 @@ private function getPorts(Process $process): array ->toArray(); } + private function getSchedulingMode(?int $desiredReplicas): array + { + $desiredReplicas ??= $this->replicas; + + if ($this->launchMode->isDaemon()) { + return [ + 'Replicated' => [ + 'Replicas' => $desiredReplicas, + ], + ]; + } + + return [ + 'ReplicatedJob' => (object) [ + 'MaxConcurrent' => $desiredReplicas, + ], + ]; + } + + public function getRestartPolicy(): array + { + if ($this->launchMode->isDaemon()) { + return [ + 'Condition' => 'any', + ]; + } + + return [ + 'Condition' => 'none', + ]; + } + public static function make(array $attributes): static { $defaults = [ diff --git a/app/Models/NodeTasks/UploadS3File/UploadS3FileResult.php b/app/Models/NodeTasks/UploadS3File/UploadS3FileResult.php index 0d88fb8..5e368f6 100644 --- a/app/Models/NodeTasks/UploadS3File/UploadS3FileResult.php +++ b/app/Models/NodeTasks/UploadS3File/UploadS3FileResult.php @@ -7,12 +7,13 @@ class UploadS3FileResult extends AbstractTaskResult { public function __construct( + public array $output, ) { // } public function formattedHtml(): string { - return 'UploadS3File - Task Result'; + return implode('
', $this->output); } } diff --git a/app/Rules/Crontab.php b/app/Rules/Crontab.php new file mode 100644 index 0000000..52d6c3f --- /dev/null +++ b/app/Rules/Crontab.php @@ -0,0 +1,24 @@ + $value) { + if (isset($array1Keys[$key])) { + $array1[$array1Keys[$key]] = [ + ...$array1[$array1Keys[$key]], + ...$array2[$value], + ]; + } else { + $array1[] = $array2[$value]; + } + } + + return array_values($array1); + } } diff --git a/bootstrap/app.php b/bootstrap/app.php index 6148d75..ebceb1c 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,8 +1,7 @@ runningConsoleCommand('schedule:work') && config('app.env') === 'production') { + $schedule->command('schedule:run') + ->everyMinute() + ->onOneServer() + ->withoutOverlapping(); + + return; + } + $schedule->job(CheckAgentUpdates::class) ->everyMinute() ->onOneServer() @@ -64,35 +72,18 @@ ) use ($schedule) { foreach ($services as $service) { foreach ($service->latestDeployment->data->processes as $process) { - if ($process->replicas === 0) { - continue; - } - - foreach ($process->volumes as $volume) { - $backupSchedule = $volume->backupSchedule; - if ($backupSchedule === null) { + foreach ($process->workers as $worker) { + if (! $worker->crontab) { continue; } $schedule - ->command(DispatchVolumeBackupTask::class, [ - '--service-id' => $service->id, - '--process' => $process->dockerName, - '--volume-id' => $volume->id, - ]) - ->cron($backupSchedule->expr) - ->onOneServer() - ->withoutOverlapping(); - } - - foreach ($process->backups as $backup) { - $schedule - ->command(DispatchProcessBackupTask::class, [ + ->command(ExecuteScheduledWorker::class, [ '--service-id' => $service->id, '--process' => $process->dockerName, - '--backup-cmd-id' => $backup->id, + '--worker' => $worker->dockerName, ]) - ->cron($backup->backupSchedule->expr) + ->cron($worker->crontab) ->onOneServer() ->withoutOverlapping(); } diff --git a/composer.json b/composer.json index 4526cfc..c7acc01 100644 --- a/composer.json +++ b/composer.json @@ -14,6 +14,7 @@ "require": { "php": "^8.3", "ext-pdo": "*", + "dragonmantank/cron-expression": "^3.4", "inertiajs/inertia-laravel": "^1.0", "laravel/cashier-paddle": "^2.5", "laravel/framework": "^11.9", diff --git a/composer.lock b/composer.lock index 5f43027..fa106a8 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ce90ecc8f7ac3e3f26580bf7588fa5f9", + "content-hash": "8b7c3fdac21ab4d5ef8bffdf91929935", "packages": [ { "name": "amphp/amp", @@ -1434,16 +1434,16 @@ }, { "name": "dragonmantank/cron-expression", - "version": "v3.3.3", + "version": "v3.4.0", "source": { "type": "git", "url": "https://github.com/dragonmantank/cron-expression.git", - "reference": "adfb1f505deb6384dc8b39804c5065dd3c8c8c0a" + "reference": "8c784d071debd117328803d86b2097615b457500" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/adfb1f505deb6384dc8b39804c5065dd3c8c8c0a", - "reference": "adfb1f505deb6384dc8b39804c5065dd3c8c8c0a", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/8c784d071debd117328803d86b2097615b457500", + "reference": "8c784d071debd117328803d86b2097615b457500", "shasum": "" }, "require": { @@ -1456,10 +1456,14 @@ "require-dev": { "phpstan/extension-installer": "^1.0", "phpstan/phpstan": "^1.0", - "phpstan/phpstan-webmozart-assert": "^1.0", "phpunit/phpunit": "^7.0|^8.0|^9.0" }, "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, "autoload": { "psr-4": { "Cron\\": "src/Cron/" @@ -1483,7 +1487,7 @@ ], "support": { "issues": "https://github.com/dragonmantank/cron-expression/issues", - "source": "https://github.com/dragonmantank/cron-expression/tree/v3.3.3" + "source": "https://github.com/dragonmantank/cron-expression/tree/v3.4.0" }, "funding": [ { @@ -1491,7 +1495,7 @@ "type": "github" } ], - "time": "2023-08-10T19:36:49+00:00" + "time": "2024-10-09T13:47:03+00:00" }, { "name": "egulias/email-validator", diff --git a/database/migrations/2024_10_10_113926_update_node_tasks_add_output_to_s3upload.php b/database/migrations/2024_10_10_113926_update_node_tasks_add_output_to_s3upload.php new file mode 100644 index 0000000..d9a6ec2 --- /dev/null +++ b/database/migrations/2024_10_10_113926_update_node_tasks_add_output_to_s3upload.php @@ -0,0 +1,23 @@ + -import Select from "@/Components/Select.vue"; -import FormField from "@/Components/FormField.vue"; -import TextInput from "@/Components/TextInput.vue"; -import {effect} from "vue"; - -const model = defineModel(); - -const props = defineProps({ - 's3Storages': Array, - 'errors': Object, -}) - -effect(() => { - if (model.value.preset === 'daily') { - model.value.expr = '0 0 * * *'; - } -}); - - - \ No newline at end of file diff --git a/resources/js/Components/FormField.vue b/resources/js/Components/FormField.vue index aa5e4ff..42162f4 100644 --- a/resources/js/Components/FormField.vue +++ b/resources/js/Components/FormField.vue @@ -27,7 +27,7 @@ onMounted(() => { - + diff --git a/resources/js/Components/InputLabel.vue b/resources/js/Components/InputLabel.vue index 3a2d325..bd715ca 100644 --- a/resources/js/Components/InputLabel.vue +++ b/resources/js/Components/InputLabel.vue @@ -5,8 +5,10 @@ defineProps({ diff --git a/resources/js/Components/Warning.vue b/resources/js/Components/Warning.vue new file mode 100644 index 0000000..1babcb7 --- /dev/null +++ b/resources/js/Components/Warning.vue @@ -0,0 +1,26 @@ + + + diff --git a/resources/js/Pages/Nodes/Show.vue b/resources/js/Pages/Nodes/Show.vue index bd0d184..2c73181 100644 --- a/resources/js/Pages/Nodes/Show.vue +++ b/resources/js/Pages/Nodes/Show.vue @@ -4,6 +4,7 @@ import { ref, onMounted, onUnmounted, computed } from "vue"; import VueApexCharts from "vue3-apexcharts"; import { router } from "@inertiajs/vue3"; import Card from "@/Components/Card.vue"; +import Warning from "@/Components/Warning.vue"; const props = defineProps(["node", "metrics"]); @@ -243,7 +244,7 @@ const httpRequestsDurationSeries = computed(() => { : []; return { - name: bucket + " ms", + name: bucket + " s", data: getSeries(metrics, "http_requests_duration", { le: bucket, }).map((value, index) => { @@ -274,6 +275,20 @@ const httpRequestsDurationChartOptions = { @@ -1069,7 +1055,7 @@ const selectedWorkerErrors = computed(() => { - + diff --git a/resources/js/Pages/Services/Partials/DeploymentData/WorkerForm.vue b/resources/js/Pages/Services/Partials/DeploymentData/WorkerForm.vue index b4c57ce..f886288 100644 --- a/resources/js/Pages/Services/Partials/DeploymentData/WorkerForm.vue +++ b/resources/js/Pages/Services/Partials/DeploymentData/WorkerForm.vue @@ -1,5 +1,5 @@