diff --git a/api-nodes/Http/Controllers/EventController.php b/api-nodes/Http/Controllers/EventController.php index 17fbb73..28965e3 100644 --- a/api-nodes/Http/Controllers/EventController.php +++ b/api-nodes/Http/Controllers/EventController.php @@ -14,8 +14,6 @@ public function started(Node $node, NodeData $data) $node->save(); - dd($node->data); - return [ 'settings' => [ 'poll_interval' => 5 diff --git a/api-nodes/Http/Controllers/NextTaskController.php b/api-nodes/Http/Controllers/NextTaskController.php new file mode 100644 index 0000000..7b29556 --- /dev/null +++ b/api-nodes/Http/Controllers/NextTaskController.php @@ -0,0 +1,54 @@ +taskGroups()->inProgress()->first(); + + $task = $taskGroup ? $this->getNextTaskFromGroup($taskGroup) : $this->pickNextTask($node); + if ($task) { + return $task; + } + + return new Response([ + 'message' => 'No task found.' + ], 404); + } + + protected function getNextTaskFromGroup(NodeTaskGroup $taskGroup) + { + if ($taskGroup->tasks()->running()->first()) { + return new Response([ + 'error_message' => 'Another task should be already running.' + ], 409); + } + + $task = $taskGroup->tasks()->pending()->first(); + + $task->start(); + + return $task; + } + + protected function pickNextTask(Node $node) + { + $taskGroup = NodeTaskGroup::where('swarm_id', $node->swarm_id)->where(function (Builder $query) use ($node) { + return $query->where('node_id', $node->id)->orWhere('node_id', null); + })->pending()->first(); + + $task = $taskGroup->tasks()->first(); + + $taskGroup->startTask($node, $task); + + return $task; + } +} \ No newline at end of file diff --git a/api-nodes/Http/Controllers/TaskController.php b/api-nodes/Http/Controllers/TaskController.php new file mode 100644 index 0000000..935804f --- /dev/null +++ b/api-nodes/Http/Controllers/TaskController.php @@ -0,0 +1,46 @@ +is_ended) { + return new Response(['error' => 'Already ended.'], 409); + } + + if ($task->is_pending) { + return new Response(['error' => "Task didn't start yet."], 409); + } + + $result = TaskResultCast::RESULT_BY_TYPE[$task->type]::validateAndCreate($request->all()); + + $task->complete($result); + + return new Response('', 204); + } + + public function fail(NodeTask $task, Request $request) + { + if ($task->is_ended) { + return new Response(['error' => 'Already ended.'], 409); + } + + if ($task->is_pending) { + return new Response(['error' => "Task didn't start yet."], 409); + } + + $result = ErrorResult::validateAndCreate($request->all()); + + $task->fail($result); + + return new Response('', 204); + } +} \ No newline at end of file diff --git a/api-nodes/Http/Middleware/AgentTokenAuth.php b/api-nodes/Http/Middleware/AgentTokenAuth.php index a8bb372..14cc6e6 100644 --- a/api-nodes/Http/Middleware/AgentTokenAuth.php +++ b/api-nodes/Http/Middleware/AgentTokenAuth.php @@ -3,7 +3,9 @@ namespace ApiNodes\Http\Middleware; use App\Models\Node; +use App\Models\NodeTask; use App\Models\Scopes\TeamScope; +use App\Models\Team; use Closure; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; @@ -34,6 +36,7 @@ public function handle(Request $request, Closure $next): Response $node->save(); app()->singleton(Node::class, fn() => $node); + app()->singleton(Team::class, fn() => $node->team); return $next($request); } diff --git a/app/Casts/TaskPayloadCast.php b/app/Casts/TaskPayloadCast.php new file mode 100644 index 0000000..86e7086 --- /dev/null +++ b/app/Casts/TaskPayloadCast.php @@ -0,0 +1,51 @@ + 0, + InitSwarmTaskPayload::class => 1 + ]; + + public const PAYLOAD_BY_TYPE = [ + 0 => CreateNetworkTaskPayload::class, + 1 => InitSwarmTaskPayload::class + ]; + + /** + * Cast the given value. + * + * @param array $attributes + */ + public function get(Model $model, string $key, mixed $value, array $attributes): mixed + { + if (!($model instanceof NodeTask)) { + throw new InvalidArgumentException('Model must be an instance of NodeTask'); + } + + return self::PAYLOAD_BY_TYPE[$model->type]::from($value); + } + + /** + * Prepare the given value for storage. + * + * @param array $attributes + */ + public function set(Model $model, string $key, mixed $value, array $attributes): mixed + { + if (!($model instanceof NodeTask)) { + throw new InvalidArgumentException('Model must be an instance of NodeTask'); + } + + return $value->toJson(); + } +} diff --git a/app/Casts/TaskResultCast.php b/app/Casts/TaskResultCast.php new file mode 100644 index 0000000..4e9401b --- /dev/null +++ b/app/Casts/TaskResultCast.php @@ -0,0 +1,61 @@ + 0, + InitSwarmTaskResult::class => 1 + ]; + + public const RESULT_BY_TYPE = [ + 0 => CreateNetworkTaskResult::class, + 1 => InitSwarmTaskResult::class + ]; + + /** + * Cast the given value. + * + * @param array $attributes + */ + public function get(Model $model, string $key, mixed $value, array $attributes): mixed + { + if (!($model instanceof NodeTask)) { + throw new InvalidArgumentException('Model must be an instance of NodeTask'); + } + + if ($model->is_failed) { + return ErrorResult::from($value); + } + + if ($model->is_ended) { + return self::RESULT_BY_TYPE[$model->type]::from($value); + } + + return null; + } + + /** + * Prepare the given value for storage. + * + * @param array $attributes + */ + public function set(Model $model, string $key, mixed $value, array $attributes): mixed + { + if (!($model instanceof NodeTask)) { + throw new InvalidArgumentException('Model must be an instance of NodeTask'); + } + + return $value->toJson(); + } +} diff --git a/app/Http/Controllers/NodeController.php b/app/Http/Controllers/NodeController.php index 9cf2262..26c20f9 100644 --- a/app/Http/Controllers/NodeController.php +++ b/app/Http/Controllers/NodeController.php @@ -5,6 +5,8 @@ use App\Http\Requests\StoreNodeRequest; use App\Http\Requests\UpdateNodeRequest; use App\Models\Node; +use App\Models\NodeTask; +use App\Models\NodeTask\InitSwarmTaskPayload; use Inertia\Inertia; class NodeController extends Controller @@ -40,7 +42,12 @@ public function store(StoreNodeRequest $request) */ public function show(Node $node) { - return Inertia::render('Nodes/Show', ['node' => $node]); + $initTaskGroup = $node->tasks()->inProgress()->ofType(InitSwarmTaskPayload::class)->first()?->taskGroup->with('tasks')->first(); + if (!$initTaskGroup) { + $initTaskGroup = $node->tasks()->failed()->ofType(InitSwarmTaskPayload::class)->first()?->taskGroup->with('tasks')->first(); + } + + return Inertia::render('Nodes/Show', ['node' => $node, 'initTaskGroup' => $initTaskGroup]); } /** diff --git a/app/Http/Controllers/SwarmTaskController.php b/app/Http/Controllers/SwarmTaskController.php new file mode 100644 index 0000000..6a009ca --- /dev/null +++ b/app/Http/Controllers/SwarmTaskController.php @@ -0,0 +1,39 @@ + $request->name, + ]); + + Node::whereId($request->node_id)->update([ + 'swarm_id' => $swarm->id, + ]); + + $taskGroup = NodeTaskGroup::create([ + 'swarm_id' => $swarm->id, + 'node_id' => $request->node_id, + 'invoker_id' => auth()->user()->id, + ]); + + $taskGroup->tasks()->createMany([ + [ + 'payload' => new CreateNetworkTaskPayload('ptah-net'), + ], + [ + 'payload' => new InitSwarmTaskPayload($request->name, $request->force_new_cluster), + ] + ]); + } +} \ No newline at end of file diff --git a/app/Http/Requests/NodeTask/InitClusterFormRequest.php b/app/Http/Requests/NodeTask/InitClusterFormRequest.php new file mode 100644 index 0000000..2613cdf --- /dev/null +++ b/app/Http/Requests/NodeTask/InitClusterFormRequest.php @@ -0,0 +1,23 @@ + ['required'], + 'name' => ['required', 'string', 'max:255'], + 'advertise_addr' => ['required', 'ipv4'], + 'force_new_cluster' => ['boolean'], + ]; + } +} \ No newline at end of file diff --git a/app/Models/Node.php b/app/Models/Node.php index 3537f81..2cc8a0a 100644 --- a/app/Models/Node.php +++ b/app/Models/Node.php @@ -2,10 +2,11 @@ namespace App\Models; -use App\HasOwningTeam; +use App\Traits\HasOwningTeam; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use Illuminate\Support\Facades\Auth; +use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasManyThrough; use Illuminate\Support\Str; class Node extends Model @@ -32,6 +33,16 @@ protected static function booted() }); } + public function taskGroups(): HasMany + { + return $this->hasMany(NodeTaskGroup::class); + } + + public function tasks(): HasManyThrough + { + return $this->hasManyThrough(NodeTask::class, NodeTaskGroup::class, 'node_id', 'task_group_id', 'id', 'id'); + } + public function getOnlineAttribute() { return true; diff --git a/app/Models/NodeTask.php b/app/Models/NodeTask.php new file mode 100644 index 0000000..0c0d585 --- /dev/null +++ b/app/Models/NodeTask.php @@ -0,0 +1,119 @@ + TaskPayloadCast::class, + 'result' => TaskResultCast::class + ]; + + protected $appends = [ + 'formatted_payload', + 'formatted_result', + ]; + + protected static function booted() + { + self::creating(function (NodeTask $nodeTask) { + $nodeTask->type = TaskPayloadCast::TYPE_BY_PAYLOAD[get_class($nodeTask->payload)]; + }); + } + + public function taskGroup(): BelongsTo + { + return $this->belongsTo(NodeTaskGroup::class); + } + + public function getFormattedPayloadAttribute(): string + { + return $this->payload->formattedHtml(); + } + + public function getFormattedResultAttribute(): string | null + { + if (is_null($this->result)) { + return null; + } + + return $this->result->formattedHtml(); + } + + public function getIsEndedAttribute() + { + return $this->status->isEnded(); + } + + public function getIsPendingAttribute() + { + return $this->status === TaskStatus::Pending; + } + + public function getIsFailedAttribute() + { + return $this->status === TaskStatus::Failed; + } + + /** + * @throws IllegalArgumentException + */ + public function setPayloadAttribute($payload): void + { + if (!($payload instanceof AbstractTaskPayload)) { + throw new IllegalArgumentException('Payload must be an instance of AbstractTaskPayload'); + } + + $this->attributes['payload'] = $payload; + } + + public function start(): void + { + $this->status = TaskStatus::Running; + $this->started_at = now(); + $this->save(); + } + + public function complete(AbstractTaskResult $result): void + { + $this->status = TaskStatus::Completed; + + $this->endTask($result); + } + + public function fail(AbstractTaskResult $result): void + { + $this->status = TaskStatus::Failed; + + $this->endTask($result); + } + + protected function endTask(AbstractTaskResult $result): void + { + $this->ended_at = now(); + $this->result = $result; + $this->save(); + } +} diff --git a/app/Models/NodeTask/AbstractTaskPayload.php b/app/Models/NodeTask/AbstractTaskPayload.php new file mode 100644 index 0000000..358633a --- /dev/null +++ b/app/Models/NodeTask/AbstractTaskPayload.php @@ -0,0 +1,9 @@ +'.$this->name.''; + } +} \ No newline at end of file diff --git a/app/Models/NodeTask/CreateNetworkTaskResult.php b/app/Models/NodeTask/CreateNetworkTaskResult.php new file mode 100644 index 0000000..abf9658 --- /dev/null +++ b/app/Models/NodeTask/CreateNetworkTaskResult.php @@ -0,0 +1,19 @@ +' . $this->docker->id . ''; + } +} \ No newline at end of file diff --git a/app/Models/NodeTask/DockerId.php b/app/Models/NodeTask/DockerId.php new file mode 100644 index 0000000..71c84c4 --- /dev/null +++ b/app/Models/NodeTask/DockerId.php @@ -0,0 +1,14 @@ +message; + } +} \ No newline at end of file diff --git a/app/Models/NodeTask/InitSwarmTaskPayload.php b/app/Models/NodeTask/InitSwarmTaskPayload.php new file mode 100644 index 0000000..8cf3ab9 --- /dev/null +++ b/app/Models/NodeTask/InitSwarmTaskPayload.php @@ -0,0 +1,23 @@ +'.$this->name.''; + if ($this->forceNewCluster) { + $msg .= ' with force cluster creation'; + } + + return $msg; + } +} \ No newline at end of file diff --git a/app/Models/NodeTask/InitSwarmTaskResult.php b/app/Models/NodeTask/InitSwarmTaskResult.php new file mode 100644 index 0000000..a7353e7 --- /dev/null +++ b/app/Models/NodeTask/InitSwarmTaskResult.php @@ -0,0 +1,20 @@ +' . $this->docker->id . ''; + } +} \ No newline at end of file diff --git a/app/Models/NodeTask/TaskStatus.php b/app/Models/NodeTask/TaskStatus.php new file mode 100644 index 0000000..095e7e4 --- /dev/null +++ b/app/Models/NodeTask/TaskStatus.php @@ -0,0 +1,19 @@ +value === TaskStatus::Completed->value + || $this->value === TaskStatus::Failed->value + || $this->value === TaskStatus::Cancelled->value; + } +} \ No newline at end of file diff --git a/app/Models/NodeTaskGroup.php b/app/Models/NodeTaskGroup.php new file mode 100644 index 0000000..c1d2492 --- /dev/null +++ b/app/Models/NodeTaskGroup.php @@ -0,0 +1,49 @@ +belongsTo(Swarm::class); + } + + public function tasks(): HasMany + { + return $this->hasMany(NodeTask::class, 'task_group_id'); + } + + public function node(): BelongsTo + { + return $this->belongsTo(Node::class); + } + + public function startTask(Node $node, NodeTask $task): void + { + $this->status = TaskStatus::Running; + $this->node_id = $node->id; + $this->started_at = now(); + $this->save(); + + $task->start(); + } +} diff --git a/app/Models/Scopes/TeamScope.php b/app/Models/Scopes/TeamScope.php index 6af67d5..9870199 100644 --- a/app/Models/Scopes/TeamScope.php +++ b/app/Models/Scopes/TeamScope.php @@ -2,6 +2,7 @@ namespace App\Models\Scopes; +use App\Models\Team; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Scope; @@ -13,6 +14,8 @@ class TeamScope implements Scope */ public function apply(Builder $builder, Model $model): void { - $builder->where('team_id', auth()->user()->currentTeam->id); + $teamId = auth()->user()?->currentTeam->id ?: app(Team::class)->id; + + $builder->where($builder->qualifyColumn('team_id'), $teamId); } } diff --git a/app/Models/Swarm.php b/app/Models/Swarm.php index 1a4f292..6d64d98 100644 --- a/app/Models/Swarm.php +++ b/app/Models/Swarm.php @@ -2,10 +2,22 @@ namespace App\Models; +use App\Traits\HasOwningTeam; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; class Swarm extends Model { use HasFactory; + use HasOwningTeam; + + protected $fillable = [ + 'name' + ]; + + public function nodes(): HasMany + { + return $this->hasMany(Node::class); + } } diff --git a/app/HasOwningTeam.php b/app/Traits/HasOwningTeam.php similarity index 95% rename from app/HasOwningTeam.php rename to app/Traits/HasOwningTeam.php index 464dc54..ce91f08 100644 --- a/app/HasOwningTeam.php +++ b/app/Traits/HasOwningTeam.php @@ -1,6 +1,6 @@ withCasts([ + 'status' => TaskStatus::class + ])->orderBy('id'); + }); + } + + public function scopePending(Builder $query): Builder + { + return $query->where($query->qualifyColumn('status'), TaskStatus::Pending); + } + + public function scopeRunning(Builder $query): Builder + { + return $query->where($query->qualifyColumn('status'), TaskStatus::Running); + } + + public function scopeFailed(Builder $builder): Builder + { + return $builder->where($builder->qualifyColumn('status'), TaskStatus::Failed); + } + + public function getIsRunningAttribute() : bool + { + return $this->status === TaskStatus::Running; + } + + public function scopeOfType(Builder $query, string $typeClass): Builder + { + return $query->where('type', TaskPayloadCast::TYPE_BY_PAYLOAD[$typeClass]); + } + + public function scopeInProgress(Builder $query): Builder + { + return $query->whereIn($query->qualifyColumn('status'), [TaskStatus::Pending, TaskStatus::Running]); + } +} \ No newline at end of file diff --git a/database/factories/NodeTaskFactory.php b/database/factories/NodeTaskFactory.php new file mode 100644 index 0000000..99c4c05 --- /dev/null +++ b/database/factories/NodeTaskFactory.php @@ -0,0 +1,23 @@ + + */ +class NodeTaskFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + // + ]; + } +} diff --git a/database/factories/NodeTaskGroupFactory.php b/database/factories/NodeTaskGroupFactory.php new file mode 100644 index 0000000..f2749a6 --- /dev/null +++ b/database/factories/NodeTaskGroupFactory.php @@ -0,0 +1,23 @@ + + */ +class NodeTaskGroupFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + // + ]; + } +} diff --git a/database/migrations/2024_06_19_184050_alter_nodes_add_data_column.php b/database/migrations/2024_06_19_184050_alter_nodes_add_data_column.php index a9ddff1..44d3571 100644 --- a/database/migrations/2024_06_19_184050_alter_nodes_add_data_column.php +++ b/database/migrations/2024_06_19_184050_alter_nodes_add_data_column.php @@ -12,7 +12,7 @@ public function up(): void { Schema::table('nodes', function (Blueprint $table) { - $table->json('data')->nullable(); + $table->jsonb('data')->nullable(); }); } diff --git a/database/migrations/2024_06_19_202417_create_node_task_groups_table.php b/database/migrations/2024_06_19_202417_create_node_task_groups_table.php new file mode 100644 index 0000000..bb03b27 --- /dev/null +++ b/database/migrations/2024_06_19_202417_create_node_task_groups_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('team_id')->constrained()->cascadeOnDelete(); + $table->foreignId('swarm_id')->constrained()->cascadeOnDelete(); + $table->foreignId('node_id')->nullable()->constrained()->cascadeOnDelete(); + $table->enum('status', ['pending', 'running', 'completed', 'failed', 'canceled'])->default('pending'); + $table->foreignId('invoker_id')->constrained('users')->cascadeOnDelete(); + $table->timestamp('started_at')->nullable(); + $table->timestamp('ended_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('node_task_groups'); + } +}; diff --git a/database/migrations/2024_06_19_204239_create_node_tasks_table.php b/database/migrations/2024_06_19_204239_create_node_tasks_table.php new file mode 100644 index 0000000..5c8884f --- /dev/null +++ b/database/migrations/2024_06_19_204239_create_node_tasks_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('team_id')->constrained()->cascadeOnDelete(); + $table->foreignId('task_group_id')->constrained('node_task_groups')->cascadeOnDelete(); + $table->smallInteger('type'); + $table->jsonb('payload'); + $table->enum('status', ['pending', 'running', 'completed', 'failed', 'canceled'])->default('pending'); + $table->timestamp('started_at')->nullable(); + $table->timestamp('ended_at')->nullable(); + $table->jsonb('result')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('node_tasks'); + } +}; diff --git a/database/migrations/2024_06_20_142704_alter_nodes_add_swarm_id_column.php b/database/migrations/2024_06_20_142704_alter_nodes_add_swarm_id_column.php new file mode 100644 index 0000000..10ba016 --- /dev/null +++ b/database/migrations/2024_06_20_142704_alter_nodes_add_swarm_id_column.php @@ -0,0 +1,28 @@ +foreignId('swarm_id')->nullable()->constrained('swarms')->nullOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('nodes', function (Blueprint $table) { + $table->dropColumn('swarm_id'); + }); + } +}; diff --git a/resources/js/Components/NodeTasks/TaskGroup.vue b/resources/js/Components/NodeTasks/TaskGroup.vue new file mode 100644 index 0000000..9cbf3b6 --- /dev/null +++ b/resources/js/Components/NodeTasks/TaskGroup.vue @@ -0,0 +1,15 @@ + + + \ No newline at end of file diff --git a/resources/js/Components/NodeTasks/TaskResult.vue b/resources/js/Components/NodeTasks/TaskResult.vue new file mode 100644 index 0000000..4c2a25e --- /dev/null +++ b/resources/js/Components/NodeTasks/TaskResult.vue @@ -0,0 +1,77 @@ + + + \ No newline at end of file diff --git a/resources/js/Components/Select.vue b/resources/js/Components/Select.vue index 8c8ab63..dd0ee21 100644 --- a/resources/js/Components/Select.vue +++ b/resources/js/Components/Select.vue @@ -1,13 +1,15 @@ \ No newline at end of file diff --git a/resources/js/Pages/Nodes/Partials/InitSwarmProgress.vue b/resources/js/Pages/Nodes/Partials/InitSwarmProgress.vue new file mode 100644 index 0000000..5653eb6 --- /dev/null +++ b/resources/js/Pages/Nodes/Partials/InitSwarmProgress.vue @@ -0,0 +1,25 @@ + + \ No newline at end of file diff --git a/resources/js/Pages/Nodes/Partials/NewSwarmCluster.vue b/resources/js/Pages/Nodes/Partials/NewSwarmCluster.vue index 7a7bdd1..a3e910f 100644 --- a/resources/js/Pages/Nodes/Partials/NewSwarmCluster.vue +++ b/resources/js/Pages/Nodes/Partials/NewSwarmCluster.vue @@ -1,5 +1,5 @@ diff --git a/routes/api.php b/routes/api.php index 587c58b..017fab2 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,14 +1,22 @@ AgentTokenAuth::class, 'prefix' => '/_nodes/v1'], function () { +Route::prependMiddlewareToGroup('api', AgentTokenAuth::class)->group(['prefix' => '/_nodes/v1'], function () { Route::group(['prefix' => '/events'], function () { Route::post('/started', [EventController::class, 'started']); }); + + Route::group(['prefix' => '/tasks'], function () { + Route::get('/next', NextTaskController::class); + Route::post('/{task}/complete', [TaskController::class, 'complete']); + Route::post('/{task}/fail', [TaskController::class, 'fail']); + }); }); Route::get('/user', function (Request $request) { diff --git a/routes/web.php b/routes/web.php index 771f071..d8b22a8 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,7 @@ name('dashboard'); - Route::resource("nodes", \App\Http\Controllers\NodeController::class); + Route::post('/swarm-tasks/init-cluster', [SwarmTaskController::class, 'initCluster'])->name('swarm-tasks.init-cluster'); + + Route::resource("nodes", NodeController::class); }); diff --git a/tailwind.config.js b/tailwind.config.js index e8be2da..d195928 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -19,6 +19,15 @@ export default { fontFamily: { sans: ['Figtree', ...defaultTheme.fontFamily.sans], }, + keyframes: { + rotate: { + '0%': { transform: 'rotate(0deg)' }, + '100%': { transform: 'rotate(360deg)' }, + }, + }, + animation: { + rotate: 'rotate 2s linear infinite', + }, }, },