From 3888641070bbd60c02a334e12ba12c3607a45b9a Mon Sep 17 00:00:00 2001 From: Marcel Menk Date: Sun, 20 Jul 2025 20:57:38 +0200 Subject: [PATCH 1/6] feat: internal kops s3 bucket support add s3 server support for storing kops lockfiles alongside kublade --- composer.json | 1 + composer.lock | 48 ++++++++++++++++++- config/s3server.php | 16 +++++++ ...000_create_s3_access_credentials_table.php | 37 ++++++++++++++ ..._bucket_to_s3_access_credentials_table.php | 35 ++++++++++++++ docker/8.0/Dockerfile | 4 ++ docker/8.1/Dockerfile | 4 ++ docker/8.2/Dockerfile | 4 ++ docker/8.3/Dockerfile | 4 ++ docker/8.4/Dockerfile | 4 ++ docker/production/Dockerfile.fpm | 5 ++ docker/production/Dockerfile.worker | 5 ++ 12 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 config/s3server.php create mode 100644 database/migrations/2025_07_19_000000_create_s3_access_credentials_table.php create mode 100644 database/migrations/2025_07_20_000000_add_bucket_to_s3_access_credentials_table.php diff --git a/composer.json b/composer.json index d9ebf04..e42c1d7 100644 --- a/composer.json +++ b/composer.json @@ -25,6 +25,7 @@ "ext-yaml": "*", "ext-zlib": "*", "bchalier/php-k8s": "^3.11", + "forepath/laravel-s3-server": "^1.2", "laravel/framework": "^12.0", "laravel/horizon": "^5.31", "laravel/socialite": "^5.21", diff --git a/composer.lock b/composer.lock index 5d003de..fdbf065 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": "f13a0be9b663777d4113e22a96e19791", + "content-hash": "aef025b8c8b01d23d5291b1b3dd72c21", "packages": [ { "name": "bchalier/php-k8s", @@ -694,6 +694,52 @@ }, "time": "2025-04-09T20:32:01+00:00" }, + { + "name": "forepath/laravel-s3-server", + "version": "v1.2.1", + "source": { + "type": "git", + "url": "https://github.com/forepath/laravel-s3-server.git", + "reference": "e2f6887142d541205316c6d42e5d6e0a0c395ede" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/forepath/laravel-s3-server/zipball/e2f6887142d541205316c6d42e5d6e0a0c395ede", + "reference": "e2f6887142d541205316c6d42e5d6e0a0c395ede", + "shasum": "" + }, + "require": { + "laravel/framework": "^10|^11|^12", + "php": "^8.1" + }, + "require-dev": { + "laravel/pint": "^1.13", + "phpstan/phpstan": "^1.10" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "LaravelS3Server\\S3ServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "LaravelS3Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Lightweight Laravel-compatible S3 protocol server", + "support": { + "issues": "https://github.com/forepath/laravel-s3-server/issues", + "source": "https://github.com/forepath/laravel-s3-server/tree/v1.2.1" + }, + "time": "2025-07-20T15:54:26+00:00" + }, { "name": "fruitcake/php-cors", "version": "v1.3.0", diff --git a/config/s3server.php b/config/s3server.php new file mode 100644 index 0000000..7a08ae1 --- /dev/null +++ b/config/s3server.php @@ -0,0 +1,16 @@ + + */ +return [ + 'auth' => true, + 'auth_driver' => LaravelS3Server\Drivers\DatabaseAuthenticationDriver::class, + 'storage_driver' => LaravelS3Server\Drivers\FileStorageDriver::class, +]; diff --git a/database/migrations/2025_07_19_000000_create_s3_access_credentials_table.php b/database/migrations/2025_07_19_000000_create_s3_access_credentials_table.php new file mode 100644 index 0000000..41ceebd --- /dev/null +++ b/database/migrations/2025_07_19_000000_create_s3_access_credentials_table.php @@ -0,0 +1,37 @@ +id(); + $table->string('access_key_id')->unique(); + $table->text('secret_access_key'); + $table->string('description')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('s3_access_credentials'); + } +} diff --git a/database/migrations/2025_07_20_000000_add_bucket_to_s3_access_credentials_table.php b/database/migrations/2025_07_20_000000_add_bucket_to_s3_access_credentials_table.php new file mode 100644 index 0000000..c5d0a6c --- /dev/null +++ b/database/migrations/2025_07_20_000000_add_bucket_to_s3_access_credentials_table.php @@ -0,0 +1,35 @@ +string('bucket')->nullable()->after('description'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('s3_access_credentials', function (Blueprint $table) { + $table->dropColumn('bucket'); + }); + } +} diff --git a/docker/8.0/Dockerfile b/docker/8.0/Dockerfile index 3edb0af..ccadfe5 100644 --- a/docker/8.0/Dockerfile +++ b/docker/8.0/Dockerfile @@ -57,6 +57,10 @@ RUN curl -fsSL -o get_helm.sh https://raw.githubusercontent.com/helm/helm/main/s chmod 700 get_helm.sh && \ ./get_helm.sh +RUN curl -Lo kops https://github.com/kubernetes/kops/releases/download/$(curl -s https://api.github.com/repos/kubernetes/kops/releases/latest | grep tag_name | cut -d '"' -f 4)/kops-linux-amd64 && \ + chmod +x kops && \ + mv kops /usr/local/bin/kops + RUN update-alternatives --set php /usr/bin/php8.0 RUN setcap "cap_net_bind_service=+ep" /usr/bin/php8.0 diff --git a/docker/8.1/Dockerfile b/docker/8.1/Dockerfile index 2812b1b..e0b9034 100644 --- a/docker/8.1/Dockerfile +++ b/docker/8.1/Dockerfile @@ -58,6 +58,10 @@ RUN curl -fsSL -o get_helm.sh https://raw.githubusercontent.com/helm/helm/main/s chmod 700 get_helm.sh && \ ./get_helm.sh +RUN curl -Lo kops https://github.com/kubernetes/kops/releases/download/$(curl -s https://api.github.com/repos/kubernetes/kops/releases/latest | grep tag_name | cut -d '"' -f 4)/kops-linux-amd64 && \ + chmod +x kops && \ + mv kops /usr/local/bin/kops + RUN setcap "cap_net_bind_service=+ep" /usr/bin/php8.1 RUN userdel -r ubuntu diff --git a/docker/8.2/Dockerfile b/docker/8.2/Dockerfile index 2ebf600..fb2f79b 100644 --- a/docker/8.2/Dockerfile +++ b/docker/8.2/Dockerfile @@ -59,6 +59,10 @@ RUN curl -fsSL -o get_helm.sh https://raw.githubusercontent.com/helm/helm/main/s chmod 700 get_helm.sh && \ ./get_helm.sh +RUN curl -Lo kops https://github.com/kubernetes/kops/releases/download/$(curl -s https://api.github.com/repos/kubernetes/kops/releases/latest | grep tag_name | cut -d '"' -f 4)/kops-linux-amd64 && \ + chmod +x kops && \ + mv kops /usr/local/bin/kops + RUN setcap "cap_net_bind_service=+ep" /usr/bin/php8.2 RUN userdel -r ubuntu diff --git a/docker/8.3/Dockerfile b/docker/8.3/Dockerfile index 1ed4799..109b3df 100644 --- a/docker/8.3/Dockerfile +++ b/docker/8.3/Dockerfile @@ -60,6 +60,10 @@ RUN curl -fsSL -o get_helm.sh https://raw.githubusercontent.com/helm/helm/main/s chmod 700 get_helm.sh && \ ./get_helm.sh +RUN curl -Lo kops https://github.com/kubernetes/kops/releases/download/$(curl -s https://api.github.com/repos/kubernetes/kops/releases/latest | grep tag_name | cut -d '"' -f 4)/kops-linux-amd64 && \ + chmod +x kops && \ + mv kops /usr/local/bin/kops + RUN setcap "cap_net_bind_service=+ep" /usr/bin/php8.3 RUN userdel -r ubuntu diff --git a/docker/8.4/Dockerfile b/docker/8.4/Dockerfile index 3d08b77..5db4261 100644 --- a/docker/8.4/Dockerfile +++ b/docker/8.4/Dockerfile @@ -60,6 +60,10 @@ RUN curl -fsSL -o get_helm.sh https://raw.githubusercontent.com/helm/helm/main/s chmod 700 get_helm.sh && \ ./get_helm.sh +RUN curl -Lo kops https://github.com/kubernetes/kops/releases/download/$(curl -s https://api.github.com/repos/kubernetes/kops/releases/latest | grep tag_name | cut -d '"' -f 4)/kops-linux-amd64 && \ + chmod +x kops && \ + mv kops /usr/local/bin/kops + RUN setcap "cap_net_bind_service=+ep" /usr/bin/php8.4 RUN userdel -r ubuntu diff --git a/docker/production/Dockerfile.fpm b/docker/production/Dockerfile.fpm index f5e9ba8..cf44866 100644 --- a/docker/production/Dockerfile.fpm +++ b/docker/production/Dockerfile.fpm @@ -57,6 +57,11 @@ RUN pecl channel-update pecl.php.net && \ RUN rm -rf /tmp/pear && \ rm /var/cache/apk/* +# Install kops +RUN curl -Lo kops https://github.com/kubernetes/kops/releases/download/$(curl -s https://api.github.com/repos/kubernetes/kops/releases/latest | grep tag_name | cut -d '"' -f 4)/kops-linux-amd64 && \ + chmod +x kops && \ + mv kops /usr/local/bin/kops + # Copy application COPY . /var/www/html diff --git a/docker/production/Dockerfile.worker b/docker/production/Dockerfile.worker index 80ef207..efcacce 100644 --- a/docker/production/Dockerfile.worker +++ b/docker/production/Dockerfile.worker @@ -57,6 +57,11 @@ RUN pecl channel-update pecl.php.net && \ RUN rm -rf /tmp/pear && \ rm /var/cache/apk/* +# Install kops +RUN curl -Lo kops https://github.com/kubernetes/kops/releases/download/$(curl -s https://api.github.com/repos/kubernetes/kops/releases/latest | grep tag_name | cut -d '"' -f 4)/kops-linux-amd64 && \ + chmod +x kops && \ + mv kops /usr/local/bin/kops + # Copy application COPY . /var/www/html From 87bf467b9771ccbba469132c81af8e68c4e2c020 Mon Sep 17 00:00:00 2001 From: Marcel Menk Date: Wed, 23 Jul 2025 21:54:46 +0200 Subject: [PATCH 2/6] feat: kops cluster template support --- app/Helpers/API/Response.php | 3 +- .../Controllers/API/ClusterController.php | 322 +++++++++++++++++- .../Controllers/API/DeploymentController.php | 1 + .../Controllers/API/TemplateController.php | 19 +- app/Http/Controllers/ClusterController.php | 294 +++++++++++++++- app/Http/Controllers/DeploymentController.php | 3 +- app/Http/Controllers/TemplateController.php | 41 ++- app/Models/Kubernetes/Clusters/Cluster.php | 82 +++++ .../Kubernetes/Clusters/ClusterData.php | 85 +++++ .../Kubernetes/Clusters/ClusterSecretData.php | 85 +++++ app/Models/Kubernetes/Clusters/Status.php | 8 + app/Models/Projects/Templates/Template.php | 2 + app/Swagger/SwaggerConfig.php | 9 + ...25_07_23_000001_update_template_tables.php | 77 +++++ resources/views/activity/index.blade.php | 2 +- resources/views/cluster/add.blade.php | 244 ++++++++++++- resources/views/cluster/index.blade.php | 15 +- resources/views/cluster/update.blade.php | 217 +++++++++++- resources/views/layouts/content.blade.php | 2 +- resources/views/template/add.blade.php | 16 +- resources/views/template/import.blade.php | 2 +- resources/views/template/index.blade.php | 46 ++- resources/views/template/sync.blade.php | 14 +- resources/views/template/update.blade.php | 14 +- routes/api.php | 1 + routes/web.php | 1 + 26 files changed, 1534 insertions(+), 71 deletions(-) create mode 100644 app/Models/Kubernetes/Clusters/ClusterData.php create mode 100644 app/Models/Kubernetes/Clusters/ClusterSecretData.php create mode 100644 database/migrations/2025_07_23_000001_update_template_tables.php diff --git a/app/Helpers/API/Response.php b/app/Helpers/API/Response.php index 532922d..5cbdf51 100644 --- a/app/Helpers/API/Response.php +++ b/app/Helpers/API/Response.php @@ -5,6 +5,7 @@ namespace App\Helpers\API; use Illuminate\Http\JsonResponse; +use Illuminate\Support\MessageBag; use Throwable; class Response @@ -19,7 +20,7 @@ class Response * * @return JsonResponse */ - public static function generate(int $code, string $status, string $message, array | Throwable | null $data = null): JsonResponse + public static function generate(int $code, string $status, string $message, array | Throwable | MessageBag | null $data = null): JsonResponse { $response = [ 'status' => $status, diff --git a/app/Http/Controllers/API/ClusterController.php b/app/Http/Controllers/API/ClusterController.php index 522fbc5..8f59ad2 100644 --- a/app/Http/Controllers/API/ClusterController.php +++ b/app/Http/Controllers/API/ClusterController.php @@ -7,13 +7,19 @@ use App\Helpers\API\Response; use App\Http\Controllers\Controller; use App\Models\Kubernetes\Clusters\Cluster; +use App\Models\Kubernetes\Clusters\ClusterData; +use App\Models\Kubernetes\Clusters\ClusterSecretData; use App\Models\Kubernetes\Clusters\GitCredential; use App\Models\Kubernetes\Clusters\K8sCredential; use App\Models\Kubernetes\Clusters\Ns; use App\Models\Kubernetes\Clusters\Resource; +use App\Models\Projects\Templates\Template; +use App\Models\Projects\Templates\TemplateField; +use Carbon\Carbon; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Validator; +use Illuminate\Validation\Rule; /** * Class ClusterController. @@ -231,6 +237,7 @@ public function action_get(string $project_id, string $cluster_id) public function action_add(Request $request) { $validator = Validator::make($request->all(), [ + 'template_id' => ['nullable', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'], 'git' => ['required', 'array'], 'git.url' => ['required', 'string', 'max:255'], @@ -271,6 +278,131 @@ public function action_add(Request $request) 'name' => $request->name, ]) ) { + if ( + ! empty($request->template_id) && + ! empty( + $template = Template::where('id', '=', $request->template_id) + ->where('type', '=', 'cluster') + ->first() + ) + ) { + $validationRules = []; + + $template->fields->each(function (TemplateField $field) use ($template, &$validationRules) { + if (! $field->set_on_create) { + return; + } + + $rules = []; + + if ($field->required) { + $rules[] = 'required'; + } else { + $rules[] = 'nullable'; + } + + switch ($field->type) { + case 'input_number': + case 'input_range': + $rules[] = 'numeric'; + + if (! empty($field->min)) { + $rules[] = 'min:' . $field->min; + } + + if (! empty($field->max)) { + $rules[] = 'max:' . $field->max; + } + + if (! empty($field->step)) { + $rules[] = 'multiple_of:' . $field->step; + } + + break; + case 'input_radio': + case 'input_radio_image': + case 'select': + $availableOptions = $field->options + ->pluck('value') + ->toArray(); + + if (! empty($field->value)) { + $availableOptions[] = $field->value; + } + + $rules[] = Rule::in($availableOptions); + + break; + case 'input_text': + case 'textarea': + default: + $rules[] = 'string'; + + break; + } + + $validationRules['data.' . $template->id . '.' . $field->key] = $rules; + }); + + $validator = Validator::make($request->toArray(), $validationRules); + + if ($validator->fails()) { + $cluster->delete(); + + return Response::generate(400, 'error', 'Validation failed', $validator->errors()); + } + + $requestFields = (object) (array_key_exists($request->template_id, $request->data ?? []) ? $request->data[$request->template_id] : []); + + $template->fields->each(function (TemplateField $field) use ($requestFields, $cluster) { + if ($field->type === 'input_radio' || $field->type === 'input_radio_image') { + $option = null; + + if ($field->set_on_create) { + $option = $field->options + ->where('value', '=', $requestFields->{$field->key}) + ->first(); + } + + if (empty($option)) { + $option = $field->options + ->where('default', '=', true) + ->first(); + } + + if (! empty($option)) { + $value = $option->value; + } + + if (empty($value)) { + $value = $requestFields->{$field->key}; + } + } else { + if ($field->set_on_create) { + $value = $requestFields->{$field->key} ?? ''; + } else { + $value = $field->value ?? ''; + } + } + + if ($field->secret) { + ClusterSecretData::create([ + 'cluster_id' => $cluster->id, + 'template_field_id' => $field->id, + 'key' => $field->key, + 'value' => $value, + ]); + } else { + ClusterData::create([ + 'cluster_id' => $cluster->id, + 'template_field_id' => $field->id, + 'key' => $field->key, + 'value' => $value, + ]); + } + }); + } + GitCredential::create([ 'cluster_id' => $cluster->id, 'url' => $request->git['url'], @@ -435,9 +567,113 @@ public function action_update(string $project_id, string $cluster_id, Request $r } if ($cluster = Cluster::where('id', $cluster_id)->first()) { - $cluster->update([ - 'name' => $request->name, - ]); + if (! empty($cluster->template)) { + $cluster->template->fields->each(function (TemplateField $field) use ($cluster, &$validationRules) { + if (! $field->set_on_update) { + return; + } + + $rules = []; + + if ($field->required) { + $rules[] = 'required'; + } else { + $rules[] = 'nullable'; + } + + switch ($field->type) { + case 'input_number': + case 'input_range': + $rules[] = 'numeric'; + + if (! empty($field->min)) { + $rules[] = 'min:' . $field->min; + } + + if (! empty($field->max)) { + $rules[] = 'max:' . $field->max; + } + + if (! empty($field->step)) { + $rules[] = 'multiple_of:' . $field->step; + } + + break; + case 'input_radio': + case 'input_radio_image': + case 'select': + $availableOptions = $field->options + ->pluck('value') + ->toArray(); + + if (! empty($field->value)) { + $availableOptions[] = $field->value; + } + + $rules[] = Rule::in($availableOptions); + + break; + case 'input_text': + case 'textarea': + default: + $rules[] = 'string'; + + break; + } + + $validationRules['data.' . $cluster->template->id . '.' . $field->key] = $rules; + }); + + $validator = Validator::make($request->toArray(), $validationRules); + + if ($validator->fails()) { + return Response::generate(400, 'error', 'Validation failed', $validator->errors()); + } + + $requestFields = (object) (array_key_exists($cluster->template->id, $request->data ?? []) ? $request->data[$cluster->template->id] : []); + + $cluster->template->fields->each(function (TemplateField $field) use ($requestFields, $cluster) { + if (! $field->set_on_update) { + return; + } + + if ($field->type === 'input_radio' || $field->type === 'input_radio_image') { + $option = $field->options + ->where('value', '=', $requestFields->{$field->key}) + ->first(); + + if (empty($option)) { + $option = $field->options + ->where('default', '=', true) + ->first(); + } + + if (! empty($option)) { + $value = $option->value; + } + + if (empty($value)) { + $value = $requestFields->{$field->key}; + } + } else { + $value = $requestFields->{$field->key} ?? ''; + } + + if ($field->secret) { + $cluster->clusterSecretData->where('template_field_id', '=', $field->id)->each(function (ClusterSecretData $clusterSecretData) use ($value) { + $clusterSecretData->update([ + 'value' => $value, + ]); + }); + } else { + $cluster->clusterData->where('template_field_id', '=', $field->id)->each(function (ClusterData $clusterData) use ($value) { + $clusterData->update([ + 'value' => $value, + ]); + }); + } + }); + } if ($cluster->gitCredentials) { $cluster->gitCredentials->update([ @@ -537,6 +773,14 @@ public function action_update(string $project_id, string $cluster_id, Request $r ]); } + $cluster->update([ + 'name' => $request->name, + ...($cluster->deployed_at ? [ + 'update' => true, + 'approved_at' => null, + ] : []), + ]); + return Response::generate(200, 'success', 'Cluster updated successfully', [ 'cluster' => $cluster->toArray(), ]); @@ -603,4 +847,76 @@ public function action_delete(string $project_id, string $cluster_id) return Response::generate(404, 'error', 'Cluster not found'); } + + /** + * Approve the deployment. + * + * @OA\Patch( + * path="/projects/{project_id}/clusters/{cluster_id}/approve", + * summary="Approve a cluster", + * tags={"Clusters"}, + * + * @OA\Parameter(ref="#/components/parameters/project_id"), + * @OA\Parameter(ref="#/components/parameters/cluster_id"), + * + * @OA\Response( + * response=200, + * description="Cluster approved", + * + * @OA\JsonContent( + * type="object", + * + * @OA\Property(property="status", type="string", example="success"), + * @OA\Property(property="message", type="string", example="Cluster approved"), + * @OA\Property(property="data", type="object", + * @OA\Property(property="cluster", ref="#/components/schemas/Cluster") + * ) + * ) + * ), + * + * @OA\Response(response=400, ref="#/components/responses/ValidationErrorResponse"), + * @OA\Response(response=401, ref="#/components/responses/UnauthorizedResponse"), + * @OA\Response(response=404, ref="#/components/responses/NotFoundResponse"), + * @OA\Response(response=500, ref="#/components/responses/ServerErrorResponse") + * ) + * + * @param string $project_id + * @param string $cluster_id + * + * @return \Illuminate\Contracts\Support\Renderable + */ + public function action_approve(string $project_id, string $cluster_id) + { + $validator = Validator::make([ + 'project_id' => $project_id, + 'cluster_id' => $cluster_id, + ], [ + 'project_id' => ['required', 'string', 'max:255'], + 'cluster_id' => ['required', 'string', 'max:255'], + ]); + + if ($validator->fails()) { + return Response::generate(400, 'error', 'Validation failed', $validator->errors()); + } + + $cluster = Cluster::where('id', '=', $cluster_id) + ->whereNotNull('template_id') + ->first(); + + if (empty($cluster)) { + return Response::generate(404, 'error', 'Cluster not found'); + } + + if ($cluster->approved_at) { + return Response::generate(400, 'error', 'Cluster already approved'); + } + + $cluster->update([ + 'approved_at' => Carbon::now(), + ]); + + return Response::generate(200, 'success', 'Cluster approved', [ + 'cluster' => $cluster->toArray(), + ]); + } } diff --git a/app/Http/Controllers/API/DeploymentController.php b/app/Http/Controllers/API/DeploymentController.php index 10ddcff..82c0f2d 100644 --- a/app/Http/Controllers/API/DeploymentController.php +++ b/app/Http/Controllers/API/DeploymentController.php @@ -263,6 +263,7 @@ public function action_add(string $project_id, Request $request) ) && ! empty( $template = Template::where('id', '=', $request->template_id) + ->where('type', '=', 'application') ->first() ) && ! empty( diff --git a/app/Http/Controllers/API/TemplateController.php b/app/Http/Controllers/API/TemplateController.php index 259fc9f..e7222d9 100644 --- a/app/Http/Controllers/API/TemplateController.php +++ b/app/Http/Controllers/API/TemplateController.php @@ -97,6 +97,7 @@ class TemplateController extends Controller * tags={"Templates"}, * * @OA\Parameter(ref="#/components/parameters/cursor"), + * @OA\Parameter(ref="#/components/parameters/type", default="application"), * * @OA\Response( * response=200, @@ -125,11 +126,13 @@ class TemplateController extends Controller * @OA\Response(response=500, ref="#/components/responses/ServerErrorResponse") * ) * + * @param Request $request + * * @return \Illuminate\Contracts\Support\Renderable */ - public function action_list() + public function action_list(Request $request) { - $templates = Template::cursorPaginate(10); + $templates = Template::where('type', $request->type ?? 'application')->cursorPaginate(10); return Response::generate(200, 'success', 'Templates retrieved successfully', [ 'templates' => collect($templates->items())->map(function ($item) { @@ -245,6 +248,7 @@ public function action_get(string $template_id) public function action_add(Request $request) { $validator = Validator::make($request->toArray(), [ + 'type' => ['required', 'string', 'in:application,cluster'], 'name' => ['required', 'string', 'max:255'], 'netpol' => ['nullable', 'boolean'], ]); @@ -256,6 +260,7 @@ public function action_add(Request $request) if ( $template = Template::create([ 'user_id' => Auth::id(), + 'type' => $request->type, 'name' => $request->name, 'netpol' => ! empty($request->netpol), ]) @@ -413,6 +418,7 @@ public function action_import(Request $request) public function action_sync(Request $request) { $validator = Validator::make($request->toArray(), [ + 'type' => ['required', 'string', 'in:application,cluster'], 'name' => ['required', 'string', 'max:255'], 'netpol' => ['nullable', 'boolean'], 'git' => ['required', 'array'], @@ -431,6 +437,7 @@ public function action_sync(Request $request) if ( $template = Template::create([ 'user_id' => Auth::id(), + 'type' => $request->type, 'name' => $request->name, 'netpol' => ! empty($request->netpol), ]) @@ -2259,9 +2266,15 @@ public function action_add_port(string $template_id, Request $request) return Response::generate(400, 'error', 'Validation failed', $validator->errors()); } + $template = Template::where('id', $template_id)->first(); + + if ($template->type == 'cluster') { + return Response::generate(400, 'error', 'Cluster templates do not support ports'); + } + if ( $port = TemplatePort::create([ - 'template_id' => $request->template_id, + 'template_id' => $template->id, 'group' => $request->group, 'claim' => $request->claim, 'preferred_port' => $request->preferred_port, diff --git a/app/Http/Controllers/ClusterController.php b/app/Http/Controllers/ClusterController.php index 7019c51..03def5e 100644 --- a/app/Http/Controllers/ClusterController.php +++ b/app/Http/Controllers/ClusterController.php @@ -5,13 +5,19 @@ namespace App\Http\Controllers; use App\Models\Kubernetes\Clusters\Cluster; +use App\Models\Kubernetes\Clusters\ClusterData; +use App\Models\Kubernetes\Clusters\ClusterSecretData; use App\Models\Kubernetes\Clusters\GitCredential; use App\Models\Kubernetes\Clusters\K8sCredential; use App\Models\Kubernetes\Clusters\Ns; use App\Models\Kubernetes\Clusters\Resource; +use App\Models\Projects\Templates\Template; +use App\Models\Projects\Templates\TemplateField; +use Carbon\Carbon; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Validator; +use Illuminate\Validation\Rule; /** * Class ClusterController. @@ -45,7 +51,9 @@ public function page_index(string $project_id, string $cluster_id = null) */ public function page_add() { - return view('cluster.add'); + return view('cluster.add', [ + 'templates' => Template::where('type', '=', 'cluster')->get(), + ]); } /** @@ -58,6 +66,7 @@ public function page_add() public function action_add(Request $request) { Validator::make($request->all(), [ + 'template_id' => ['nullable', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'], 'git' => ['required', 'array'], 'git.url' => ['required', 'string', 'max:255'], @@ -89,11 +98,135 @@ public function action_add(Request $request) if ( $cluster = Cluster::create([ - 'project_id' => $request->project_id, - 'user_id' => Auth::user()->id, - 'name' => $request->name, + 'project_id' => $request->project_id, + 'user_id' => Auth::user()->id, + 'template_id' => $request->template_id, + 'name' => $request->name, ]) ) { + if ( + ! empty($request->template_id) && + ! empty( + $template = Template::where('id', '=', $request->template_id) + ->where('type', '=', 'cluster') + ->first() + ) + ) { + $template->fields->each(function (TemplateField $field) use ($template, &$validationRules) { + if (! $field->set_on_create) { + return; + } + + $rules = []; + + if ($field->required) { + $rules[] = 'required'; + } else { + $rules[] = 'nullable'; + } + + switch ($field->type) { + case 'input_number': + case 'input_range': + $rules[] = 'numeric'; + + if (! empty($field->min)) { + $rules[] = 'min:' . $field->min; + } + + if (! empty($field->max)) { + $rules[] = 'max:' . $field->max; + } + + if (! empty($field->step)) { + $rules[] = 'multiple_of:' . $field->step; + } + + break; + case 'input_radio': + case 'input_radio_image': + case 'select': + $availableOptions = $field->options + ->pluck('value') + ->toArray(); + + if (! empty($field->value)) { + $availableOptions[] = $field->value; + } + + $rules[] = Rule::in($availableOptions); + + break; + case 'input_text': + case 'textarea': + default: + $rules[] = 'string'; + + break; + } + + $validationRules['data.' . $template->id . '.' . $field->key] = $rules; + }); + + $validator = Validator::make($request->toArray(), $validationRules); + + if ($validator->fails()) { + $cluster->delete(); + + return redirect()->back()->with('warning', __('Ooops, something went wrong.')); + } + + $requestFields = (object) (array_key_exists($request->template_id, $request->data ?? []) ? $request->data[$request->template_id] : []); + + $template->fields->each(function (TemplateField $field) use ($requestFields, $cluster) { + if ($field->type === 'input_radio' || $field->type === 'input_radio_image') { + $option = null; + + if ($field->set_on_create) { + $option = $field->options + ->where('value', '=', $requestFields->{$field->key}) + ->first(); + } + + if (empty($option)) { + $option = $field->options + ->where('default', '=', true) + ->first(); + } + + if (! empty($option)) { + $value = $option->value; + } + + if (empty($value)) { + $value = $requestFields->{$field->key}; + } + } else { + if ($field->set_on_create) { + $value = $requestFields->{$field->key} ?? ''; + } else { + $value = $field->value ?? ''; + } + } + + if ($field->secret) { + ClusterSecretData::create([ + 'cluster_id' => $cluster->id, + 'template_field_id' => $field->id, + 'key' => $field->key, + 'value' => $value, + ]); + } else { + ClusterData::create([ + 'cluster_id' => $cluster->id, + 'template_field_id' => $field->id, + 'key' => $field->key, + 'value' => $value, + ]); + } + }); + } + GitCredential::create([ 'cluster_id' => $cluster->id, 'url' => $request->git['url'], @@ -213,9 +346,113 @@ public function action_update(string $project_id, string $cluster_id, Request $r ])->validate(); if ($cluster = Cluster::where('id', $cluster_id)->first()) { - $cluster->update([ - 'name' => $request->name, - ]); + if (! empty($cluster->template)) { + $cluster->template->fields->each(function (TemplateField $field) use ($cluster, &$validationRules) { + if (! $field->set_on_update) { + return; + } + + $rules = []; + + if ($field->required) { + $rules[] = 'required'; + } else { + $rules[] = 'nullable'; + } + + switch ($field->type) { + case 'input_number': + case 'input_range': + $rules[] = 'numeric'; + + if (! empty($field->min)) { + $rules[] = 'min:' . $field->min; + } + + if (! empty($field->max)) { + $rules[] = 'max:' . $field->max; + } + + if (! empty($field->step)) { + $rules[] = 'multiple_of:' . $field->step; + } + + break; + case 'input_radio': + case 'input_radio_image': + case 'select': + $availableOptions = $field->options + ->pluck('value') + ->toArray(); + + if (! empty($field->value)) { + $availableOptions[] = $field->value; + } + + $rules[] = Rule::in($availableOptions); + + break; + case 'input_text': + case 'textarea': + default: + $rules[] = 'string'; + + break; + } + + $validationRules['data.' . $cluster->template->id . '.' . $field->key] = $rules; + }); + + $validator = Validator::make($request->toArray(), $validationRules); + + if ($validator->fails()) { + return redirect()->back()->with('warning', __('Ooops, something went wrong.')); + } + + $requestFields = (object) (array_key_exists($cluster->template->id, $request->data ?? []) ? $request->data[$cluster->template->id] : []); + + $cluster->template->fields->each(function (TemplateField $field) use ($requestFields, $cluster) { + if (! $field->set_on_update) { + return; + } + + if ($field->type === 'input_radio' || $field->type === 'input_radio_image') { + $option = $field->options + ->where('value', '=', $requestFields->{$field->key}) + ->first(); + + if (empty($option)) { + $option = $field->options + ->where('default', '=', true) + ->first(); + } + + if (! empty($option)) { + $value = $option->value; + } + + if (empty($value)) { + $value = $requestFields->{$field->key}; + } + } else { + $value = $requestFields->{$field->key} ?? ''; + } + + if ($field->secret) { + $cluster->clusterSecretData->where('template_field_id', '=', $field->id)->each(function (ClusterSecretData $clusterSecretData) use ($value) { + $clusterSecretData->update([ + 'value' => $value, + ]); + }); + } else { + $cluster->clusterData->where('template_field_id', '=', $field->id)->each(function (ClusterData $clusterData) use ($value) { + $clusterData->update([ + 'value' => $value, + ]); + }); + } + }); + } if ($cluster->gitCredentials) { $cluster->gitCredentials->update([ @@ -315,6 +552,14 @@ public function action_update(string $project_id, string $cluster_id, Request $r ]); } + $cluster->update([ + 'name' => $request->name, + ...($cluster->deployed_at ? [ + 'update' => true, + 'approved_at' => null, + ] : []), + ]); + return redirect()->route('cluster.index', ['project_id' => $project_id])->with('success', __('Cluster updated successfully.')); } @@ -347,4 +592,39 @@ public function action_delete(string $project_id, string $cluster_id) return redirect()->back()->with('warning', __('Ooops, something went wrong.')); } + + /** + * Approve the cluster. + * + * @param string $project_id + * @param string $cluster_id + * + * @return \Illuminate\Contracts\Support\Renderable + */ + public function action_approve(string $project_id, string $cluster_id) + { + Validator::make([ + 'cluster_id' => $cluster_id, + ], [ + 'cluster_id' => ['required', 'string'], + ])->validate(); + + $cluster = Cluster::where('id', '=', $cluster_id) + ->whereNotNull('template_id') + ->first(); + + if (empty($cluster)) { + return redirect()->back()->with('warning', __('Ooops, something went wrong.')); + } + + if ($cluster->approved_at) { + return redirect()->back()->with('warning', __('Cluster already approved.')); + } + + $cluster->update([ + 'approved_at' => Carbon::now(), + ]); + + return redirect()->back()->with('success', __('Cluster approved.')); + } } diff --git a/app/Http/Controllers/DeploymentController.php b/app/Http/Controllers/DeploymentController.php index 61fa93b..66fb386 100644 --- a/app/Http/Controllers/DeploymentController.php +++ b/app/Http/Controllers/DeploymentController.php @@ -231,7 +231,7 @@ public function page_add(string $project_id) { return view('deployment.add', [ 'clusters' => Cluster::all(), - 'templates' => Template::all(), + 'templates' => Template::where('type', '=', 'application')->get(), ]); } @@ -262,6 +262,7 @@ public function action_add(string $project_id, Request $request) ) && ! empty( $template = Template::where('id', '=', $request->template_id) + ->where('type', '=', 'application') ->first() ) && ! empty( diff --git a/app/Http/Controllers/TemplateController.php b/app/Http/Controllers/TemplateController.php index cf7d42e..f73abf7 100644 --- a/app/Http/Controllers/TemplateController.php +++ b/app/Http/Controllers/TemplateController.php @@ -30,15 +30,16 @@ class TemplateController extends Controller /** * Show the template dashboard. * - * @param string $template_id - * @param string $file_id + * @param Request $request + * @param string $template_id + * @param string $file_id * * @return \Illuminate\Contracts\Support\Renderable */ - public function page_index(string $template_id = null, string $file_id = null) + public function page_index(Request $request, ?string $template_id = null, ?string $file_id = null) { return view('template.index', [ - 'templates' => Template::paginate(10), + 'templates' => Template::where('type', $request->type ?? 'application')->paginate(10), 'template' => $template_id ? Template::where('id', $template_id)->first() : null, 'file' => $file_id ? TemplateFile::where('id', $file_id)->first() : null, ]); @@ -64,6 +65,7 @@ public function page_add() public function action_add(Request $request) { Validator::make($request->toArray(), [ + 'type' => ['required', 'string', 'in:application,cluster'], 'name' => ['required', 'string', 'max:255'], 'netpol' => ['nullable', 'boolean'], ])->validate(); @@ -71,6 +73,7 @@ public function action_add(Request $request) if ( $template = Template::create([ 'user_id' => Auth::id(), + 'type' => $request->type, 'name' => $request->name, 'netpol' => ! empty($request->netpol), ]) @@ -145,7 +148,7 @@ public function action_update(string $template_id, Request $request) $template->gitCredentials()->delete(); } - return redirect()->route('template.index')->with('success', __('Template updated.')); + return redirect()->route('template.index', ['type' => $template->type])->with('success', __('Template updated.')); } return redirect()->back()->with('warning', __('Ooops, something went wrong.')); @@ -164,7 +167,7 @@ public function action_delete(string $template_id) $template->gitCredentials()->delete(); $template->delete(); - return redirect()->route('template.index')->with('success', __('Template deleted.')); + return redirect()->route('template.index', ['type' => $template->type])->with('success', __('Template deleted.')); } return redirect()->back()->with('warning', __('Ooops, something went wrong.')); @@ -769,8 +772,14 @@ public function action_delete_option(string $template_id, string $field_id, stri */ public function page_add_port(string $template_id) { + $template = Template::where('id', $template_id)->first(); + + if ($template->type == 'cluster') { + return redirect()->back()->with('warning', __('Cluster templates do not support ports.')); + } + return view('template.add-port', [ - 'template' => Template::where('id', $template_id)->first(), + 'template' => $template, ]); } @@ -792,8 +801,14 @@ public function action_add_port(string $template_id, Request $request) 'random' => ['nullable', 'boolean'], ])->validate(); + $template = Template::where('id', $template_id)->first(); + + if ($template->type == 'cluster') { + return redirect()->back()->with('warning', __('Cluster templates do not support ports.')); + } + TemplatePort::create([ - 'template_id' => $request->template_id, + 'template_id' => $template->id, 'group' => $request->group, 'claim' => $request->claim, 'preferred_port' => $request->preferred_port, @@ -883,6 +898,10 @@ public function action_delete_port(string $template_id, string $port_id) */ public function page_import() { + if (request()->type == 'cluster') { + return redirect()->back()->with('warning', __('Cluster templates do not support imports.')); + } + return view('template.import'); } @@ -950,7 +969,8 @@ public function page_sync() */ public function action_sync(Request $request) { - $validator = Validator::make($request->toArray(), [ + Validator::make($request->toArray(), [ + 'type' => ['required', 'string', 'in:application,cluster'], 'name' => ['required', 'string', 'max:255'], 'netpol' => ['nullable', 'boolean'], 'git' => ['required', 'array'], @@ -960,11 +980,12 @@ public function action_sync(Request $request) 'git.username' => ['required', 'string', 'max:255'], 'git.email' => ['required', 'email', 'max:255'], 'git.base_path' => ['required', 'string', 'max:255'], - ]); + ])->validate(); if ( $template = Template::create([ 'user_id' => Auth::id(), + 'type' => $request->type, 'name' => $request->name, 'netpol' => ! empty($request->netpol), ]) diff --git a/app/Models/Kubernetes/Clusters/Cluster.php b/app/Models/Kubernetes/Clusters/Cluster.php index 532d13f..ce96e58 100644 --- a/app/Models/Kubernetes/Clusters/Cluster.php +++ b/app/Models/Kubernetes/Clusters/Cluster.php @@ -9,6 +9,7 @@ use App\Models\Kubernetes\Resources\Node; use App\Models\Projects\Deployments\Deployment; use App\Models\Projects\Projects\Project; +use App\Models\Projects\Templates\Template; use App\Models\User; use App\Traits\LogsActivity; use Carbon\Carbon; @@ -31,7 +32,15 @@ * @OA\Property(property="id", type="string", format="uuid", example="123e4567-e89b-12d3-a456-426614174000"), * @OA\Property(property="user_id", type="string", format="uuid", example="123e4567-e89b-12d3-a456-426614174000"), * @OA\Property(property="project_id", type="string", format="uuid", example="123e4567-e89b-12d3-a456-426614174000"), + * @OA\Property(property="template_id", type="string", format="uuid", example="123e4567-e89b-12d3-a456-426614174000"), * @OA\Property(property="name", type="string", example="Cluster 1"), + * @OA\Property(property="update", type="boolean", example=false), + * @OA\Property(property="delete", type="boolean", example=false), + * @OA\Property(property="deployed_at", type="string", format="date-time", example="2021-01-01 00:00:00", nullable=true), + * @OA\Property(property="deployment_updated_at", type="string", format="date-time", example="2021-01-01 00:00:00", nullable=true), + * @OA\Property(property="creation_dispatched_at", type="string", format="date-time", example="2021-01-01 00:00:00", nullable=true), + * @OA\Property(property="update_dispatched_at", type="string", format="date-time", example="2021-01-01 00:00:00", nullable=true), + * @OA\Property(property="deletion_dispatched_at", type="string", format="date-time", example="2021-01-01 00:00:00", nullable=true), * @OA\Property(property="created_at", type="string", format="date-time", example="2021-01-01 00:00:00", nullable=true), * @OA\Property(property="updated_at", type="string", format="date-time", example="2021-01-01 00:00:00", nullable=true), * @OA\Property(property="deleted_at", type="string", format="date-time", example="2021-01-01 00:00:00", nullable=true), @@ -43,6 +52,13 @@ * @property string $user_id * @property string $project_id * @property string $name + * @property bool $update + * @property bool $delete + * @property Carbon $deployed_at + * @property Carbon $deployment_updated_at + * @property Carbon $creation_dispatched_at + * @property Carbon $update_dispatched_at + * @property Carbon $deletion_dispatched_at * @property Carbon $created_at * @property Carbon $updated_at * @property Carbon $deleted_at @@ -70,6 +86,22 @@ class Cluster extends Model 'id', ]; + /** + * The attributes that should be cast to native types. + * + * @var array + */ + protected $casts = [ + 'update' => 'boolean', + 'delete' => 'boolean', + 'approved_at' => 'datetime', + 'deployed_at' => 'datetime', + 'deployment_updated_at' => 'datetime', + 'creation_dispatched_at' => 'datetime', + 'update_dispatched_at' => 'datetime', + 'deletion_dispatched_at' => 'datetime', + ]; + /** * Relation to user. * @@ -90,6 +122,16 @@ public function project(): HasOne return $this->hasOne(Project::class, 'id', 'project_id'); } + /** + * Relation to template. + * + * @return HasOne + */ + public function template(): HasOne + { + return $this->hasOne(Template::class, 'id', 'template_id'); + } + /** * Relation to k8s credentials. * @@ -170,6 +212,26 @@ public function metrics(): HasMany return $this->hasMany(ClusterMetric::class, 'cluster_id', 'id'); } + /** + * Relation to deployment data. + * + * @return HasMany + */ + public function clusterData(): HasMany + { + return $this->hasMany(ClusterData::class, 'cluster_id', 'id'); + } + + /** + * Relation to deployment secret data. + * + * @return HasMany + */ + public function clusterSecretData(): HasMany + { + return $this->hasMany(ClusterSecretData::class, 'cluster_id', 'id'); + } + /** * Get the utility namespace. * @@ -237,6 +299,26 @@ public function getRepositoryDeploymentPathAttribute(): string */ public function getStatusAttribute(): string { + if ($this->template) { + if ($this->delete) { + return Status::STATUS_DELETING; + } + + if (! $this->approved_at) { + return Status::STATUS_APPROVING; + } + + if ($this->update) { + return Status::STATUS_UPDATING; + } + + if ($this->deployed_at) { + return $this->statuses()->orderByDesc('created_at')->first()?->status ?? Status::STATUS_OFFLINE; + } + + return Status::STATUS_PENDING; + } + return $this->statuses()->orderByDesc('created_at')->first()?->status ?? Status::STATUS_OFFLINE; } diff --git a/app/Models/Kubernetes/Clusters/ClusterData.php b/app/Models/Kubernetes/Clusters/ClusterData.php new file mode 100644 index 0000000..d3cd9d9 --- /dev/null +++ b/app/Models/Kubernetes/Clusters/ClusterData.php @@ -0,0 +1,85 @@ + + * + * @property string $id + * @property string $cluster_id + * @property string $template_field_id + * @property string $key + * @property string $value + * @property Carbon $created_at + * @property Carbon $updated_at + * @property Carbon $deleted_at + */ +class ClusterData extends Model +{ + use SoftDeletes; + use HasUuids; + use Encryptable; + use HasFactory; + use LogsActivity; + + /** + * The table associated with the model. + * + * @var string + */ + protected $table = 'cluster_data'; + + /** + * The attributes that aren't mass assignable. + * + * @var bool|string[] + */ + protected $guarded = [ + 'id', + ]; + + /** + * The attributes that should be encrypted. + * + * @var array + */ + protected $encryptable = [ + 'value', + ]; + + /** + * Relation to cluster. + * + * @return HasOne + */ + public function cluster(): HasOne + { + return $this->hasOne(Cluster::class, 'id', 'cluster_id'); + } + + /** + * Relation to template field. + * + * @return HasOne + */ + public function field(): HasOne + { + return $this->hasOne(TemplateField::class, 'id', 'template_field_id'); + } +} diff --git a/app/Models/Kubernetes/Clusters/ClusterSecretData.php b/app/Models/Kubernetes/Clusters/ClusterSecretData.php new file mode 100644 index 0000000..2eafc31 --- /dev/null +++ b/app/Models/Kubernetes/Clusters/ClusterSecretData.php @@ -0,0 +1,85 @@ + + * + * @property string $id + * @property string $cluster_id + * @property string $template_field_id + * @property string $key + * @property string $value + * @property Carbon $created_at + * @property Carbon $updated_at + * @property Carbon $deleted_at + */ +class ClusterSecretData extends Model +{ + use SoftDeletes; + use HasUuids; + use Encryptable; + use HasFactory; + use LogsActivity; + + /** + * The table associated with the model. + * + * @var string + */ + protected $table = 'cluster_secret_data'; + + /** + * The attributes that aren't mass assignable. + * + * @var bool|string[] + */ + protected $guarded = [ + 'id', + ]; + + /** + * The attributes that should be encrypted. + * + * @var array + */ + protected $encryptable = [ + 'value', + ]; + + /** + * Relation to cluster. + * + * @return HasOne + */ + public function cluster(): HasOne + { + return $this->hasOne(Cluster::class, 'id', 'cluster_id'); + } + + /** + * Relation to template field. + * + * @return HasOne + */ + public function field(): HasOne + { + return $this->hasOne(TemplateField::class, 'id', 'template_field_id'); + } +} diff --git a/app/Models/Kubernetes/Clusters/Status.php b/app/Models/Kubernetes/Clusters/Status.php index 42edd65..51fd57b 100644 --- a/app/Models/Kubernetes/Clusters/Status.php +++ b/app/Models/Kubernetes/Clusters/Status.php @@ -29,10 +29,18 @@ class Status extends Model use SoftDeletes; use HasUuids; + public const STATUS_DELETING = 'deleting'; + + public const STATUS_APPROVING = 'approving'; + + public const STATUS_UPDATING = 'updating'; + public const STATUS_ONLINE = 'online'; public const STATUS_OFFLINE = 'offline'; + public const STATUS_PENDING = 'pending'; + /** * The table associated with the model. * diff --git a/app/Models/Projects/Templates/Template.php b/app/Models/Projects/Templates/Template.php index 5fc1d05..52a3c77 100644 --- a/app/Models/Projects/Templates/Template.php +++ b/app/Models/Projects/Templates/Template.php @@ -26,6 +26,7 @@ * * @OA\Property(property="id", type="string", format="uuid", example="123e4567-e89b-12d3-a456-426614174000"), * @OA\Property(property="user_id", type="integer", format="int64", example="1"), + * @OA\Property(property="type", type="string", enum={"application", "cluster"}, example="application"), * @OA\Property(property="name", type="string", example="Template 1"), * @OA\Property(property="netpol", type="boolean", example=false), * @OA\Property(property="created_at", type="string", format="date-time", example="2021-01-01 00:00:00", nullable=true), @@ -37,6 +38,7 @@ * * @property string $id * @property string $user_id + * @property string $type * @property string $name * @property bool $netpol * @property Carbon $created_at diff --git a/app/Swagger/SwaggerConfig.php b/app/Swagger/SwaggerConfig.php index 1e4d30a..d14ac55 100644 --- a/app/Swagger/SwaggerConfig.php +++ b/app/Swagger/SwaggerConfig.php @@ -33,6 +33,15 @@ * @OA\Schema(type="string") * ), * + * @OA\Parameter( + * name="type", + * in="query", + * required=false, + * description="Type of an object", + * + * @OA\Schema(type="string") + * ), + * * @OA\Response( * response="ForbiddenResponse", * description="Forbidden", diff --git a/database/migrations/2025_07_23_000001_update_template_tables.php b/database/migrations/2025_07_23_000001_update_template_tables.php new file mode 100644 index 0000000..186f2ba --- /dev/null +++ b/database/migrations/2025_07_23_000001_update_template_tables.php @@ -0,0 +1,77 @@ +enum('type', ['application', 'cluster'])->default('application')->after('user_id'); + }); + + Schema::table('clusters', function (Blueprint $table) { + $table->foreignUuid('template_id')->nullable()->after('project_id')->references('id')->on('templates'); + $table->boolean('update')->default(false)->after('name'); + $table->boolean('delete')->default(false)->after('update'); + $table->timestamp('approved_at')->nullable()->after('delete'); + $table->timestamp('deployed_at')->nullable()->after('approved_at'); + $table->timestamp('deployment_updated_at')->nullable()->after('deployed_at'); + $table->timestamp('creation_dispatched_at')->nullable()->after('deployment_updated_at'); + $table->timestamp('update_dispatched_at')->nullable()->after('creation_dispatched_at'); + $table->timestamp('deletion_dispatched_at')->nullable()->after('update_dispatched_at'); + }); + + Schema::create('cluster_data', function (Blueprint $table) { + $table->uuid('id')->primary(); + $table->foreignUuid('cluster_id')->references('id')->on('clusters'); + $table->foreignUuid('template_field_id')->references('id')->on('template_fields'); + $table->string('key'); + $table->longText('value'); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('cluster_secret_data', function (Blueprint $table) { + $table->uuid('id')->primary(); + $table->foreignUuid('cluster_id')->references('id')->on('clusters'); + $table->foreignUuid('template_field_id')->references('id')->on('template_fields'); + $table->string('key'); + $table->longText('value'); + $table->timestamps(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down() + { + Schema::dropIfExists('cluster_secret_data'); + Schema::dropIfExists('cluster_data'); + + Schema::table('clusters', function (Blueprint $table) { + $table->dropColumn('deletion_dispatched_at'); + $table->dropColumn('update_dispatched_at'); + $table->dropColumn('creation_dispatched_at'); + $table->dropColumn('deployment_updated_at'); + $table->dropColumn('deployed_at'); + $table->dropColumn('approved_at'); + $table->dropColumn('delete'); + $table->dropColumn('update'); + $table->dropForeign(['template_id']); + $table->dropColumn('template_id'); + }); + + Schema::table('templates', function (Blueprint $table) { + $table->dropColumn('type'); + }); + } +}; diff --git a/resources/views/activity/index.blade.php b/resources/views/activity/index.blade.php index 1141780..7f7882f 100644 --- a/resources/views/activity/index.blade.php +++ b/resources/views/activity/index.blade.php @@ -25,7 +25,7 @@ @foreach ($activities as $activity) {{ $activity->created_at->format('Y-m-d H:i:s') }} - {{ $activity->causer->name }} + {{ $activity->causer?->name ?? __('System') }} @if ($activity->event == 'created') {{ __('Created') }} diff --git a/resources/views/cluster/add.blade.php b/resources/views/cluster/add.blade.php index a9a8cc7..42c3e1f 100644 --- a/resources/views/cluster/add.blade.php +++ b/resources/views/cluster/add.blade.php @@ -32,6 +32,200 @@ +
+
+
+
{{ __('Provisioning') }}
+
+
+ +
+ + +
+ +
+
+ + @foreach ($templates as $template) + @if ($template->groupedFields->on_create->default->count() > 0 || $template->groupedFields->on_create->advanced->count() > 0) + + @endif + + @foreach ($template->groupedFields->on_create->hidden as $field) + + @endforeach + @endforeach +
+
@@ -238,7 +432,7 @@
- + %
@@ -255,7 +449,7 @@
- + %
@@ -272,7 +466,7 @@
- + %
@@ -289,7 +483,7 @@
- + %
@@ -395,3 +589,45 @@
@endsection + +@section('javascript') + +@endsection diff --git a/resources/views/cluster/index.blade.php b/resources/views/cluster/index.blade.php index 44d38bf..251c53a 100644 --- a/resources/views/cluster/index.blade.php +++ b/resources/views/cluster/index.blade.php @@ -37,8 +37,16 @@ @if ($cluster->status === \App\Models\Kubernetes\Clusters\Status::STATUS_ONLINE) {{ __('Online') }} - @else + @elseif ($cluster->status === \App\Models\Kubernetes\Clusters\Status::STATUS_OFFLINE) {{ __('Offline') }} + @elseif ($cluster->status === \App\Models\Kubernetes\Clusters\Status::STATUS_PENDING) + {{ __('Pending') }} + @elseif ($cluster->status === \App\Models\Kubernetes\Clusters\Status::STATUS_APPROVING) + {{ __('Approving') }} + @elseif ($cluster->status === \App\Models\Kubernetes\Clusters\Status::STATUS_UPDATING) + {{ __('Updating') }} + @elseif ($cluster->status === \App\Models\Kubernetes\Clusters\Status::STATUS_DELETING) + {{ __('Deleting') }} @endif @@ -152,6 +160,11 @@
+ @can('projects.' . request()->get('project')->id . ' . clusters.' . $cluster->id . '.approve') + + + + @endcan diff --git a/resources/views/cluster/update.blade.php b/resources/views/cluster/update.blade.php index f0e2d9e..b856bc8 100644 --- a/resources/views/cluster/update.blade.php +++ b/resources/views/cluster/update.blade.php @@ -32,6 +32,215 @@
+ @if ($cluster->template_id) +
+
+
+
{{ __('Provisioning') }}
+
+
+ +
+ + +
+ +
+
+ + @if ($cluster->template->groupedFields->on_update->default->count() > 0 || $cluster->template->groupedFields->on_update->advanced->count() > 0) +
+ @if ($cluster->template->groupedFields->on_update->default->count() > 0) + @foreach ($cluster->template->groupedFields->on_update->default as $field) + @if ($field->secret) + @php + $value = $cluster->clusterSecretData->where('key', $field->key)->first()?->value; + @endphp + @else + @php + $value = $cluster->clusterData->where('key', $field->key)->first()?->value; + @endphp + @endif + +
+ @if ($field->type !== 'input_checkbox') + + @endif +
+ @switch ($field->type) + @case ('input_text') + + @break + @case ('input_number') + + @break + @case ('input_range') +
+ +
+
+ @break + @case ('input_radio') +
+ @foreach ($field->options as $option) +
+ value || $value === null && $option->default ? ' checked' : '' }}> + +
+ @error($field->key) + + {{ $message }} + + @enderror + @endforeach +
+ @break + @case ('input_radio_image') +
+ @foreach ($field->options as $option) +
+ value || $value === null && $option->default ? ' checked' : '' }}> + +
+ @error($field->key) + + {{ $message }} + + @enderror + @endforeach +
+ @break + @case ('input_checkbox') +
+ value ? ' checked' : '' }}> + +
+ @error($field->key) + + {{ $message }} + + @enderror + @break + @case ('select') + + @break + @case ('textarea') + + @break + @endswitch +
+
+ @endforeach + @endif + + @if ($cluster->template->groupedFields->on_update->advanced->count() > 0) + +
+ @foreach ($cluster->template->groupedFields->on_update->advanced as $field) + @if ($field->secret) + @php + $value = $cluster->clusterSecretData->where('key', $field->key)->first()?->value; + @endphp + @else + @php + $value = $cluster->clusterData->where('key', $field->key)->first()?->value; + @endphp + @endif + +
+ @if ($field->type !== 'input_checkbox') + + @endif +
+ @switch ($field->type) + @case ('input_text') + + @break + @case ('input_number') + + @break + @case ('input_range') +
+ +
+
+ @break + @case ('input_radio') +
+ @foreach ($field->options as $option) +
+ value || $value === null && $option->default ? ' checked' : '' }}> + +
+ @error($field->key) + + {{ $message }} + + @enderror + @endforeach +
+ @break + @case ('input_radio_image') +
+ @foreach ($field->options as $option) +
+ value || $value === null && $option->default ? ' checked' : '' }}> + +
+ @error($field->key) + + {{ $message }} + + @enderror + @endforeach +
+ @break + @case ('input_checkbox') +
+ value ? ' checked' : '' }}> + +
+ @error($field->key) + + {{ $message }} + + @enderror + @break + @case ('select') + + @break + @case ('textarea') + + @break + @endswitch +
+
+ @endforeach +
+ @endif +
+ @endif + + @foreach ($cluster->template->groupedFields->on_update->hidden as $field) + + @endforeach +
+ @endif +
@@ -238,7 +447,7 @@
- + %
@@ -255,7 +464,7 @@
- + %
@@ -272,7 +481,7 @@
- + %
@@ -289,7 +498,7 @@
- + %
diff --git a/resources/views/layouts/content.blade.php b/resources/views/layouts/content.blade.php index 05dc96b..5dca1e0 100644 --- a/resources/views/layouts/content.blade.php +++ b/resources/views/layouts/content.blade.php @@ -96,7 +96,7 @@ @else @can('templates.view') @endcan @canany(['users.view', 'roles.view']) diff --git a/resources/views/template/add.blade.php b/resources/views/template/add.blade.php index cd978fe..62885cb 100644 --- a/resources/views/template/add.blade.php +++ b/resources/views/template/add.blade.php @@ -4,7 +4,7 @@
@@ -18,6 +18,8 @@
@csrf + +
@@ -32,13 +34,15 @@
-
- + @if (!request()->type || request()->type == 'application') +
+ -
- +
+ +
-
+ @endif
diff --git a/resources/views/template/import.blade.php b/resources/views/template/import.blade.php index 7d98d7c..90747ae 100644 --- a/resources/views/template/import.blade.php +++ b/resources/views/template/import.blade.php @@ -4,7 +4,7 @@
diff --git a/resources/views/template/index.blade.php b/resources/views/template/index.blade.php index 3fe6aef..cb624d9 100644 --- a/resources/views/template/index.blade.php +++ b/resources/views/template/index.blade.php @@ -5,7 +5,7 @@ @if (!empty($template))
@@ -43,20 +43,32 @@ @include('template.field-tree', ['template' => $template])
-
-
- {{ __('Ports') }} - - - -
-
- @include('template.port-tree', ['template' => $template]) + @if ($template->type == 'application') +
+
+ {{ __('Ports') }} + + + +
+
+ @include('template.port-tree', ['template' => $template]) +
-
+ @endif
@endif
+ @if (empty($template)) + + @endif
@if (!empty($template)) @if (!empty($file)) @@ -68,13 +80,15 @@
{{ __('Templates') }} diff --git a/resources/views/template/sync.blade.php b/resources/views/template/sync.blade.php index 68896b9..87c924c 100644 --- a/resources/views/template/sync.blade.php +++ b/resources/views/template/sync.blade.php @@ -4,7 +4,7 @@
@@ -32,13 +32,15 @@
-
- + @if (!request()->type || request()->type == 'application') +
+ -
- +
+ +
-
+ @endif
diff --git a/resources/views/template/update.blade.php b/resources/views/template/update.blade.php index bcdcd03..f2d949f 100644 --- a/resources/views/template/update.blade.php +++ b/resources/views/template/update.blade.php @@ -4,7 +4,7 @@
@@ -32,13 +32,15 @@
-
- + @if ($template->type == 'application') +
+ -
- netpol ? 'checked' : '' }}> +
+ netpol ? 'checked' : '' }}> +
-
+ @endif @if ($template->gitCredentials)
diff --git a/routes/api.php b/routes/api.php index 44c70a7..b04ccfa 100644 --- a/routes/api.php +++ b/routes/api.php @@ -72,6 +72,7 @@ Route::get('/projects/{project_id}/clusters/{cluster_id}', [App\Http\Controllers\API\ClusterController::class, 'action_get'])->name('api.cluster.get')->middleware('api.permission.guard:projects.clusters.view'); Route::patch('/projects/{project_id}/clusters/{cluster_id}', [App\Http\Controllers\API\ClusterController::class, 'action_update'])->name('api.cluster.update')->middleware('api.permission.guard:projects.clusters.update'); Route::delete('/projects/{project_id}/clusters/{cluster_id}', [App\Http\Controllers\API\ClusterController::class, 'action_delete'])->name('api.cluster.delete')->middleware('api.permission.guard:projects.clusters.delete'); + Route::patch('/projects/{project_id}/clusters/{cluster_id}/approve', [App\Http\Controllers\API\ClusterController::class, 'action_approve'])->name('api.cluster.approve')->middleware('api.permission.guard:projects.clusters.approve'); Route::get('/projects/{project_id}/deployments', [App\Http\Controllers\API\DeploymentController::class, 'action_list'])->name('api.deployment.list')->middleware('api.permission.guard:projects.deployments.view'); Route::post('/projects/{project_id}/deployments', [App\Http\Controllers\API\DeploymentController::class, 'action_add'])->name('api.deployment.add')->middleware('api.permission.guard:projects.deployments.add'); diff --git a/routes/web.php b/routes/web.php index cc595d2..6447ec7 100644 --- a/routes/web.php +++ b/routes/web.php @@ -83,6 +83,7 @@ Route::get('/projects/{project_id}/clusters/{cluster_id}/update', [App\Http\Controllers\ClusterController::class, 'page_update'])->name('cluster.update')->middleware('ui.permission.guard:projects.clusters.update'); Route::post('/projects/{project_id}/clusters/{cluster_id}/update', [App\Http\Controllers\ClusterController::class, 'action_update'])->name('cluster.update.action')->middleware('ui.permission.guard:projects.clusters.update'); Route::get('/projects/{project_id}/clusters/{cluster_id}/delete', [App\Http\Controllers\ClusterController::class, 'action_delete'])->name('cluster.delete.action')->middleware('ui.permission.guard:projects.clusters.delete'); + Route::get('/projects/{project_id}/clusters/{cluster_id}/approve', [App\Http\Controllers\ClusterController::class, 'action_approve'])->name('cluster.approve.action')->middleware('ui.permission.guard:projects.clusters.approve'); Route::get('/projects/{project_id}/deployments', [App\Http\Controllers\DeploymentController::class, 'page_index'])->name('deployment.index')->middleware('ui.permission.guard:projects.deployments.view'); Route::get('/projects/{project_id}/deployments/add', [App\Http\Controllers\DeploymentController::class, 'page_add'])->name('deployment.add')->middleware('ui.permission.guard:projects.deployments.add'); From a9755f61d64f7bfc0cd3a78f088a2c4bbff33b79 Mon Sep 17 00:00:00 2001 From: Marcel Menk Date: Thu, 24 Jul 2025 07:29:41 +0200 Subject: [PATCH 3/6] feat: kops management helpers and jobs --- .env.example | 2 + app/Exceptions/KopsException.php | 30 + app/Helpers/Kops/KopsDeployment.php | 154 ++++ app/Jobs/Cluster/Actions/ClusterCreation.php | 106 +++ app/Jobs/Cluster/Actions/ClusterDeletion.php | 85 ++ app/Jobs/Cluster/Actions/ClusterUpdate.php | 99 +++ .../Cluster/Dispatchers/ClusterCreation.php | 59 ++ .../Cluster/Dispatchers/ClusterDeletion.php | 58 ++ .../Cluster/Dispatchers/ClusterUpdate.php | 60 ++ app/Models/Kubernetes/Clusters/Cluster.php | 10 + composer.json | 2 +- composer.lock | 765 +++++++++--------- config/horizon.php | 17 + config/s3server.php | 7 +- routes/console.php | 6 + 15 files changed, 1083 insertions(+), 377 deletions(-) create mode 100644 app/Exceptions/KopsException.php create mode 100644 app/Helpers/Kops/KopsDeployment.php create mode 100644 app/Jobs/Cluster/Actions/ClusterCreation.php create mode 100644 app/Jobs/Cluster/Actions/ClusterDeletion.php create mode 100644 app/Jobs/Cluster/Actions/ClusterUpdate.php create mode 100644 app/Jobs/Cluster/Dispatchers/ClusterCreation.php create mode 100644 app/Jobs/Cluster/Dispatchers/ClusterDeletion.php create mode 100644 app/Jobs/Cluster/Dispatchers/ClusterUpdate.php diff --git a/.env.example b/.env.example index db7aa2b..e88002d 100644 --- a/.env.example +++ b/.env.example @@ -91,3 +91,5 @@ AI_URL=https://api.openai.com AI_CHAT_COMPLETIONS_ENDPOINT=/v1/chat/completions AI_EMBEDDING_ENDPOINT=/v1/embeddings AI_REMOTE_EMBEDDING=true + +S3SERVER_STORAGE_PATH=kops/ diff --git a/app/Exceptions/KopsException.php b/app/Exceptions/KopsException.php new file mode 100644 index 0000000..bfa7d53 --- /dev/null +++ b/app/Exceptions/KopsException.php @@ -0,0 +1,30 @@ + + */ +class KopsException extends Exception +{ + /** + * Construct the exception. + * + * @param string $message + * @param int $code + * @param Throwable|null $previous + */ + public function __construct($message, $code, Throwable $previous = null) + { + parent::__construct($message, $code, $previous); + } +} diff --git a/app/Helpers/Kops/KopsDeployment.php b/app/Helpers/Kops/KopsDeployment.php new file mode 100644 index 0000000..e16030c --- /dev/null +++ b/app/Helpers/Kops/KopsDeployment.php @@ -0,0 +1,154 @@ + + */ +class KopsDeployment +{ + /** + * Generate a deployment. + * + * @param Cluster $cluster + * @param array $data + * @param array $secretData + * @param bool $replaceExisting + */ + public static function generate(Cluster $cluster, array $data = [], array $secretData = [], bool $replaceExisting = false) + { + if (Storage::disk('local')->exists($cluster->kopsPath) && !$replaceExisting) { + throw new KopsException('Forbidden', 403); + } + + if ($replaceExisting) { + Storage::disk('local')->deleteDirectory($cluster->kopsPath); + } + + $clusterDirectoryStatus = Storage::disk('local')->makeDirectory($cluster->kopsPath); + + if (!$clusterDirectoryStatus) { + throw new KopsException('Server Error', 500); + } + + $cluster->template->fullTree->each(function ($item) use ($cluster, $data, $secretData) { + if ($item->type === 'file') { + self::createFile($item, $cluster, $data, $secretData); + } elseif ($item->type === 'folder') { + self::createFolder($item, $cluster, $data, $secretData); + } + }); + + // TODO: Properly order files and apply every file separately + // TODO: Set proper s3 state storage backend + $cmd = ['kops', 'create', '-f', $cluster->kopsPath]; + $result = Process::run($cmd); + + if (!$result->successful()) { + throw new KopsException('Failed to create cluster', 500); + } + } + + // TODO: Add a method to update a cluster + + /** + * Delete a deployment. + * + * @param Cluster $cluster + */ + public static function delete(Cluster $cluster) + { + if (!Storage::disk('local')->exists($cluster->kopsPath)) { + throw new KopsException('Not Found', 404); + } + + // TODO: Properly order files and apply every file separately + // TODO: Set proper s3 state storage backend + $cmd = ['kops', 'delete', '-f', $cluster->kopsPath, '--yes']; + $result = Process::run($cmd); + + if (!$result->successful()) { + throw new KopsException('Failed to delete cluster', 500); + } + + if (!Storage::disk('local')->deleteDirectory($cluster->kopsPath)) { + throw new KopsException('Server Error', 500); + } + } + + /** + * Create a folder. + * + * @param object $item + * @param Cluster $cluster + * @param array $data + * @param array $secretData + * @param array $portClaims + */ + private static function createFolder(object $item, Cluster $cluster, array $data = [], array $secretData = [], array $portClaims = []) + { + $path = $cluster->kopsPath . $item->object->path; + + Storage::disk('local')->makeDirectory($path); + + $item->children?->each(function ($child) use ($cluster, $data, $secretData, $portClaims) { + if ($child->type === 'file') { + self::createFile($child, $cluster, $data, $secretData, $portClaims); + } elseif ($child->type === 'folder') { + self::createFolder($child, $cluster, $data, $secretData, $portClaims); + } + }); + } + + /** + * Create a file. + * + * @param object $item + * @param Cluster $cluster + * @param array $data + * @param array $secretData + * @param array $portClaims + */ + private static function createFile(object $item, Cluster $cluster, array $data = [], array $secretData = [], array $portClaims = []) + { + $path = $cluster->kopsPath . $item->object->path; + + try { + $templateContent = Yaml::parse( + Blade::render( + str_replace("\t", ' ', $item->object->content), + [ + 'data' => $data, + 'secret' => $secretData, + ], + false + ) + ); + + if (!$templateContent) { + Storage::disk('local')->delete($path); + + return; + } + + Storage::disk('local')->put($path, YamlFormatter::format(Yaml::dump($templateContent, 10, 2))); + } catch (Exception $e) { + throw new KopsException('Server Error', 500); + } + } +} diff --git a/app/Jobs/Cluster/Actions/ClusterCreation.php b/app/Jobs/Cluster/Actions/ClusterCreation.php new file mode 100644 index 0000000..94fb7f1 --- /dev/null +++ b/app/Jobs/Cluster/Actions/ClusterCreation.php @@ -0,0 +1,106 @@ + + */ +class ClusterCreation extends Job implements ShouldBeUnique +{ + public $cluster_id; + + public static $onQueue = 'cluster'; + + /** + * ClusterCreation constructor. + * + * @param array $data + */ + public function __construct(array $data) + { + $this->cluster_id = $data['cluster_id']; + } + + /** + * Execute job algorithm. + */ + public function handle() + { + $cluster = Cluster::find($this->cluster_id); + + if (!$cluster) { + return; + } + + $publicData = []; + $secretData = []; + + $cluster->clusterData->each(function ($data) use (&$publicData) { + $publicData[$data->key] = $data->value; + }); + + $cluster->clusterSecretData->each(function ($data) use (&$secretData) { + $secretData[$data->key] = $data->value; + }); + + try { + $release = KopsDeployment::generate($cluster, $publicData, $secretData, false); + } catch (KopsException $exception) { + throw $exception; + } + + if ($release) { + $cluster->update([ + 'deployed_at' => Carbon::now(), + ]); + } + } + + /** + * Define tags which the job can be identified by. + * + * @return array + */ + public function tags(): array + { + return [ + 'job', + 'job:cluster', + 'job:cluster:action', + 'job:cluster:action:ClusterCreation', + 'job:cluster:action:ClusterCreation:' . $this->cluster_id, + ]; + } + + /** + * Set a unique identifier to avoid duplicate queuing of the same task. + * + * @return string + */ + public function uniqueId(): string + { + return 'cluster-creation-' . $this->cluster_id; + } + + /** + * Set middleware to avoid job overlapping. + */ + public function middleware() + { + return [new WithoutOverlapping('cluster')]; + } +} diff --git a/app/Jobs/Cluster/Actions/ClusterDeletion.php b/app/Jobs/Cluster/Actions/ClusterDeletion.php new file mode 100644 index 0000000..e34efeb --- /dev/null +++ b/app/Jobs/Cluster/Actions/ClusterDeletion.php @@ -0,0 +1,85 @@ + + */ +class ClusterDeletion extends Job implements ShouldBeUnique +{ + public $cluster_id; + + public static $onQueue = 'cluster'; + + /** + * ClusterDeletion constructor. + * + * @param array $data + */ + public function __construct(array $data) + { + $this->cluster_id = $data['cluster_id']; + } + + /** + * Execute job algorithm. + */ + public function handle() + { + $cluster = Cluster::find($this->cluster_id); + + if (!$cluster) { + return; + } + + if ($release = KopsDeployment::delete($cluster)) { + $cluster->delete(); + } + } + + /** + * Define tags which the job can be identified by. + * + * @return array + */ + public function tags(): array + { + return [ + 'job', + 'job:cluster', + 'job:cluster:action', + 'job:cluster:action:ClusterDeletion', + 'job:cluster:action:ClusterDeletion:' . $this->cluster_id, + ]; + } + + /** + * Set a unique identifier to avoid duplicate queuing of the same task. + * + * @return string + */ + public function uniqueId(): string + { + return 'cluster-deletion-' . $this->cluster_id; + } + + /** + * Set middleware to avoid job overlapping. + */ + public function middleware() + { + return [new WithoutOverlapping('cluster')]; + } +} diff --git a/app/Jobs/Cluster/Actions/ClusterUpdate.php b/app/Jobs/Cluster/Actions/ClusterUpdate.php new file mode 100644 index 0000000..51372c5 --- /dev/null +++ b/app/Jobs/Cluster/Actions/ClusterUpdate.php @@ -0,0 +1,99 @@ + + */ +class ClusterUpdate extends Job implements ShouldBeUnique +{ + public $cluster_id; + + public static $onQueue = 'cluster'; + + /** + * ClusterUpdate constructor. + * + * @param array $data + */ + public function __construct(array $data) + { + $this->cluster_id = $data['cluster_id']; + } + + /** + * Execute job algorithm. + */ + public function handle() + { + $cluster = Cluster::find($this->cluster_id); + + if (!$cluster) { + return; + } + + $publicData = []; + $secretData = []; + + $cluster->clusterData->each(function ($data) use (&$publicData) { + $publicData[$data->key] = $data->value; + }); + + $cluster->clusterSecretData->each(function ($data) use (&$secretData) { + $secretData[$data->key] = $data->value; + }); + + if ($release = KopsDeployment::generate($cluster, $publicData, $secretData, true)) { + $cluster->update([ + 'updated_at' => Carbon::now(), + ]); + } + } + + /** + * Define tags which the job can be identified by. + * + * @return array + */ + public function tags(): array + { + return [ + 'job', + 'job:cluster', + 'job:cluster:action', + 'job:cluster:action:ClusterUpdate', + 'job:cluster:action:ClusterUpdate:' . $this->cluster_id, + ]; + } + + /** + * Set a unique identifier to avoid duplicate queuing of the same task. + * + * @return string + */ + public function uniqueId(): string + { + return 'cluster-update-' . $this->cluster_id; + } + + /** + * Set middleware to avoid job overlapping. + */ + public function middleware() + { + return [new WithoutOverlapping('cluster')]; + } +} diff --git a/app/Jobs/Cluster/Dispatchers/ClusterCreation.php b/app/Jobs/Cluster/Dispatchers/ClusterCreation.php new file mode 100644 index 0000000..be27266 --- /dev/null +++ b/app/Jobs/Cluster/Dispatchers/ClusterCreation.php @@ -0,0 +1,59 @@ + + */ +class ClusterCreation extends Job +{ + public static $onQueue = 'dispatchers'; + + /** + * Execute job algorithm. + */ + public function handle() + { + Cluster::whereNotNull('template_id') + ->whereNull('deployed_at') + ->whereNull('creation_dispatched_at') + ->whereNull('deletion_dispatched_at') + ->where('delete', '=', false) + ->whereNotNull('approved_at') + ->each(function (Cluster $cluster) { + $this->dispatch((new ClusterCreationJob([ + 'cluster_id' => $cluster->id, + ]))->onQueue('cluster')); + + $cluster->update([ + 'creation_dispatched_at' => Carbon::now(), + ]); + }); + } + + /** + * Define tags which the job can be identified by. + * + * @return array + */ + public function tags(): array + { + return [ + 'job', + 'job:cluster', + 'job:cluster:dispatcher', + 'job:cluster:dispatcher:ClusterCreation', + ]; + } +} diff --git a/app/Jobs/Cluster/Dispatchers/ClusterDeletion.php b/app/Jobs/Cluster/Dispatchers/ClusterDeletion.php new file mode 100644 index 0000000..353a97a --- /dev/null +++ b/app/Jobs/Cluster/Dispatchers/ClusterDeletion.php @@ -0,0 +1,58 @@ + + */ +class ClusterDeletion extends Job +{ + public static $onQueue = 'dispatchers'; + + /** + * Execute job algorithm. + */ + public function handle() + { + Cluster::whereNotNull('template_id') + ->whereNotNull('deployed_at') + ->whereNotNull('creation_dispatched_at') + ->whereNull('deletion_dispatched_at') + ->where('delete', '=', true) + ->each(function (Cluster $cluster) { + $this->dispatch((new ClusterDeletionJob([ + 'cluster_id' => $cluster->id, + ]))->onQueue('cluster')); + + $cluster->update([ + 'deletion_dispatched_at' => Carbon::now(), + ]); + }); + } + + /** + * Define tags which the job can be identified by. + * + * @return array + */ + public function tags(): array + { + return [ + 'job', + 'job:cluster', + 'job:cluster:dispatcher', + 'job:cluster:dispatcher:ClusterDeletion', + ]; + } +} diff --git a/app/Jobs/Cluster/Dispatchers/ClusterUpdate.php b/app/Jobs/Cluster/Dispatchers/ClusterUpdate.php new file mode 100644 index 0000000..003d2f0 --- /dev/null +++ b/app/Jobs/Cluster/Dispatchers/ClusterUpdate.php @@ -0,0 +1,60 @@ + + */ +class ClusterUpdate extends Job +{ + public static $onQueue = 'dispatchers'; + + /** + * Execute job algorithm. + */ + public function handle() + { + Cluster::whereNotNull('template_id') + ->whereNotNull('deployed_at') + ->whereNotNull('creation_dispatched_at') + ->where('update', '=', true) + ->where('delete', '=', false) + ->whereNotNull('approved_at') + ->each(function (Cluster $cluster) { + $this->dispatch((new ClusterUpdateJob([ + 'cluster_id' => $cluster->id, + ]))->onQueue('cluster')); + + $cluster->update([ + 'update' => false, + 'update_dispatched_at' => Carbon::now(), + ]); + }); + } + + /** + * Define tags which the job can be identified by. + * + * @return array + */ + public function tags(): array + { + return [ + 'job', + 'job:cluster', + 'job:cluster:dispatcher', + 'job:cluster:dispatcher:ClusterUpdate', + ]; + } +} diff --git a/app/Models/Kubernetes/Clusters/Cluster.php b/app/Models/Kubernetes/Clusters/Cluster.php index ce96e58..0649a11 100644 --- a/app/Models/Kubernetes/Clusters/Cluster.php +++ b/app/Models/Kubernetes/Clusters/Cluster.php @@ -282,6 +282,16 @@ public function getRepositoryPathAttribute(): string return 'flux-repository/' . $this->id; } + /** + * Get the path attribute. + * + * @return string + */ + public function getKopsPathAttribute(): string + { + return config('s3server.storage_path') . $this->id; + } + /** * Get the repository deployment path attribute. * diff --git a/composer.json b/composer.json index e42c1d7..bb3a516 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ "ext-yaml": "*", "ext-zlib": "*", "bchalier/php-k8s": "^3.11", - "forepath/laravel-s3-server": "^1.2", + "forepath/laravel-s3-server": "^1.3", "laravel/framework": "^12.0", "laravel/horizon": "^5.31", "laravel/socialite": "^5.21", diff --git a/composer.lock b/composer.lock index fdbf065..ea0136a 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": "aef025b8c8b01d23d5291b1b3dd72c21", + "content-hash": "bf00cb33967d57c856c30d1cdfa1b50e", "packages": [ { "name": "bchalier/php-k8s", @@ -82,16 +82,16 @@ }, { "name": "brick/math", - "version": "0.12.3", + "version": "0.13.1", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "866551da34e9a618e64a819ee1e01c20d8a588ba" + "reference": "fc7ed316430118cc7836bf45faff18d5dfc8de04" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/866551da34e9a618e64a819ee1e01c20d8a588ba", - "reference": "866551da34e9a618e64a819ee1e01c20d8a588ba", + "url": "https://api.github.com/repos/brick/math/zipball/fc7ed316430118cc7836bf45faff18d5dfc8de04", + "reference": "fc7ed316430118cc7836bf45faff18d5dfc8de04", "shasum": "" }, "require": { @@ -130,7 +130,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.12.3" + "source": "https://github.com/brick/math/tree/0.13.1" }, "funding": [ { @@ -138,7 +138,7 @@ "type": "github" } ], - "time": "2025-02-28T13:11:00+00:00" + "time": "2025-03-29T13:50:30+00:00" }, { "name": "carbonphp/carbon-doctrine-types", @@ -696,16 +696,16 @@ }, { "name": "forepath/laravel-s3-server", - "version": "v1.2.1", + "version": "v1.3.0", "source": { "type": "git", "url": "https://github.com/forepath/laravel-s3-server.git", - "reference": "e2f6887142d541205316c6d42e5d6e0a0c395ede" + "reference": "fe8bda7595a848e5d862caba91e43f45e84e8873" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/forepath/laravel-s3-server/zipball/e2f6887142d541205316c6d42e5d6e0a0c395ede", - "reference": "e2f6887142d541205316c6d42e5d6e0a0c395ede", + "url": "https://api.github.com/repos/forepath/laravel-s3-server/zipball/fe8bda7595a848e5d862caba91e43f45e84e8873", + "reference": "fe8bda7595a848e5d862caba91e43f45e84e8873", "shasum": "" }, "require": { @@ -736,9 +736,9 @@ "description": "Lightweight Laravel-compatible S3 protocol server", "support": { "issues": "https://github.com/forepath/laravel-s3-server/issues", - "source": "https://github.com/forepath/laravel-s3-server/tree/v1.2.1" + "source": "https://github.com/forepath/laravel-s3-server/tree/v1.3.0" }, - "time": "2025-07-20T15:54:26+00:00" + "time": "2025-07-24T06:40:10+00:00" }, { "name": "fruitcake/php-cors", @@ -1286,20 +1286,20 @@ }, { "name": "laravel/framework", - "version": "v12.12.0", + "version": "v12.21.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "8f6cd73696068c28f30f5964556ec9d14e5d90d7" + "reference": "ac8c4e73bf1b5387b709f7736d41427e6af1c93b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/8f6cd73696068c28f30f5964556ec9d14e5d90d7", - "reference": "8f6cd73696068c28f30f5964556ec9d14e5d90d7", + "url": "https://api.github.com/repos/laravel/framework/zipball/ac8c4e73bf1b5387b709f7736d41427e6af1c93b", + "reference": "ac8c4e73bf1b5387b709f7736d41427e6af1c93b", "shasum": "" }, "require": { - "brick/math": "^0.11|^0.12", + "brick/math": "^0.11|^0.12|^0.13", "composer-runtime-api": "^2.2", "doctrine/inflector": "^2.0.5", "dragonmantank/cron-expression": "^3.4", @@ -1316,7 +1316,7 @@ "guzzlehttp/uri-template": "^1.0", "laravel/prompts": "^0.3.0", "laravel/serializable-closure": "^1.3|^2.0", - "league/commonmark": "^2.6", + "league/commonmark": "^2.7", "league/flysystem": "^3.25.1", "league/flysystem-local": "^3.25.1", "league/uri": "^7.5.1", @@ -1408,7 +1408,7 @@ "php-http/discovery": "^1.15", "phpstan/phpstan": "^2.0", "phpunit/phpunit": "^10.5.35|^11.5.3|^12.0.1", - "predis/predis": "^2.3", + "predis/predis": "^2.3|^3.0", "resend/resend-php": "^0.10.0", "symfony/cache": "^7.2.0", "symfony/http-client": "^7.2.0", @@ -1440,7 +1440,7 @@ "pda/pheanstalk": "Required to use the beanstalk queue driver (^5.0).", "php-http/discovery": "Required to use PSR-7 bridging features (^1.15).", "phpunit/phpunit": "Required to use assertions and run tests (^10.5.35|^11.5.3|^12.0.1).", - "predis/predis": "Required to use the predis connector (^2.3).", + "predis/predis": "Required to use the predis connector (^2.3|^3.0).", "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).", "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0|^7.0).", "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0).", @@ -1497,20 +1497,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-05-01T16:13:12+00:00" + "time": "2025-07-22T15:41:55+00:00" }, { "name": "laravel/horizon", - "version": "v5.31.2", + "version": "v5.33.1", "source": { "type": "git", "url": "https://github.com/laravel/horizon.git", - "reference": "e6068c65be6c02a01e34531abf3143fab91f0de0" + "reference": "50057bca1f1dcc9fbd5ff6d65143833babd784b3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/horizon/zipball/e6068c65be6c02a01e34531abf3143fab91f0de0", - "reference": "e6068c65be6c02a01e34531abf3143fab91f0de0", + "url": "https://api.github.com/repos/laravel/horizon/zipball/50057bca1f1dcc9fbd5ff6d65143833babd784b3", + "reference": "50057bca1f1dcc9fbd5ff6d65143833babd784b3", "shasum": "" }, "require": { @@ -1550,7 +1550,7 @@ ] }, "branch-alias": { - "dev-master": "5.x-dev" + "dev-master": "6.x-dev" } }, "autoload": { @@ -1575,22 +1575,22 @@ ], "support": { "issues": "https://github.com/laravel/horizon/issues", - "source": "https://github.com/laravel/horizon/tree/v5.31.2" + "source": "https://github.com/laravel/horizon/tree/v5.33.1" }, - "time": "2025-04-18T12:57:39+00:00" + "time": "2025-06-16T13:48:30+00:00" }, { "name": "laravel/prompts", - "version": "v0.3.5", + "version": "v0.3.6", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "57b8f7efe40333cdb925700891c7d7465325d3b1" + "reference": "86a8b692e8661d0fb308cec64f3d176821323077" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/57b8f7efe40333cdb925700891c7d7465325d3b1", - "reference": "57b8f7efe40333cdb925700891c7d7465325d3b1", + "url": "https://api.github.com/repos/laravel/prompts/zipball/86a8b692e8661d0fb308cec64f3d176821323077", + "reference": "86a8b692e8661d0fb308cec64f3d176821323077", "shasum": "" }, "require": { @@ -1634,9 +1634,9 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.3.5" + "source": "https://github.com/laravel/prompts/tree/v0.3.6" }, - "time": "2025-02-11T13:34:40+00:00" + "time": "2025-07-07T14:17:42+00:00" }, { "name": "laravel/serializable-closure", @@ -1701,16 +1701,16 @@ }, { "name": "laravel/socialite", - "version": "v5.21.0", + "version": "v5.23.0", "source": { "type": "git", "url": "https://github.com/laravel/socialite.git", - "reference": "d83639499ad14985c9a6a9713b70073300ce998d" + "reference": "e9e0fc83b9d8d71c8385a5da20e5b95ca6234cf5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/socialite/zipball/d83639499ad14985c9a6a9713b70073300ce998d", - "reference": "d83639499ad14985c9a6a9713b70073300ce998d", + "url": "https://api.github.com/repos/laravel/socialite/zipball/e9e0fc83b9d8d71c8385a5da20e5b95ca6234cf5", + "reference": "e9e0fc83b9d8d71c8385a5da20e5b95ca6234cf5", "shasum": "" }, "require": { @@ -1769,7 +1769,7 @@ "issues": "https://github.com/laravel/socialite/issues", "source": "https://github.com/laravel/socialite" }, - "time": "2025-05-19T12:56:37+00:00" + "time": "2025-07-23T14:16:08+00:00" }, { "name": "laravel/tinker", @@ -2040,16 +2040,16 @@ }, { "name": "league/commonmark", - "version": "2.7.0", + "version": "2.7.1", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "6fbb36d44824ed4091adbcf4c7d4a3923cdb3405" + "reference": "10732241927d3971d28e7ea7b5712721fa2296ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/6fbb36d44824ed4091adbcf4c7d4a3923cdb3405", - "reference": "6fbb36d44824ed4091adbcf4c7d4a3923cdb3405", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/10732241927d3971d28e7ea7b5712721fa2296ca", + "reference": "10732241927d3971d28e7ea7b5712721fa2296ca", "shasum": "" }, "require": { @@ -2078,7 +2078,7 @@ "symfony/process": "^5.4 | ^6.0 | ^7.0", "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0", "unleashedtech/php-coding-standard": "^3.1.1", - "vimeo/psalm": "^4.24.0 || ^5.0.0" + "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" }, "suggest": { "symfony/yaml": "v2.3+ required if using the Front Matter extension" @@ -2143,7 +2143,7 @@ "type": "tidelift" } ], - "time": "2025-05-05T12:20:28+00:00" + "time": "2025-07-20T12:47:49+00:00" }, { "name": "league/config", @@ -2229,16 +2229,16 @@ }, { "name": "league/flysystem", - "version": "3.29.1", + "version": "3.30.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "edc1bb7c86fab0776c3287dbd19b5fa278347319" + "reference": "2203e3151755d874bb2943649dae1eb8533ac93e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/edc1bb7c86fab0776c3287dbd19b5fa278347319", - "reference": "edc1bb7c86fab0776c3287dbd19b5fa278347319", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/2203e3151755d874bb2943649dae1eb8533ac93e", + "reference": "2203e3151755d874bb2943649dae1eb8533ac93e", "shasum": "" }, "require": { @@ -2262,13 +2262,13 @@ "composer/semver": "^3.0", "ext-fileinfo": "*", "ext-ftp": "*", - "ext-mongodb": "^1.3", + "ext-mongodb": "^1.3|^2", "ext-zip": "*", "friendsofphp/php-cs-fixer": "^3.5", "google/cloud-storage": "^1.23", "guzzlehttp/psr7": "^2.6", "microsoft/azure-storage-blob": "^1.1", - "mongodb/mongodb": "^1.2", + "mongodb/mongodb": "^1.2|^2", "phpseclib/phpseclib": "^3.0.36", "phpstan/phpstan": "^1.10", "phpunit/phpunit": "^9.5.11|^10.0", @@ -2306,22 +2306,22 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.29.1" + "source": "https://github.com/thephpleague/flysystem/tree/3.30.0" }, - "time": "2024-10-08T08:58:34+00:00" + "time": "2025-06-25T13:29:59+00:00" }, { "name": "league/flysystem-local", - "version": "3.29.0", + "version": "3.30.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-local.git", - "reference": "e0e8d52ce4b2ed154148453d321e97c8e931bd27" + "reference": "6691915f77c7fb69adfb87dcd550052dc184ee10" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/e0e8d52ce4b2ed154148453d321e97c8e931bd27", - "reference": "e0e8d52ce4b2ed154148453d321e97c8e931bd27", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/6691915f77c7fb69adfb87dcd550052dc184ee10", + "reference": "6691915f77c7fb69adfb87dcd550052dc184ee10", "shasum": "" }, "require": { @@ -2355,9 +2355,9 @@ "local" ], "support": { - "source": "https://github.com/thephpleague/flysystem-local/tree/3.29.0" + "source": "https://github.com/thephpleague/flysystem-local/tree/3.30.0" }, - "time": "2024-08-09T21:24:39+00:00" + "time": "2025-05-21T10:34:19+00:00" }, { "name": "league/mime-type-detection", @@ -2667,16 +2667,16 @@ }, { "name": "livewire/livewire", - "version": "v3.6.3", + "version": "v3.6.4", "source": { "type": "git", "url": "https://github.com/livewire/livewire.git", - "reference": "56aa1bb63a46e06181c56fa64717a7287e19115e" + "reference": "ef04be759da41b14d2d129e670533180a44987dc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/livewire/zipball/56aa1bb63a46e06181c56fa64717a7287e19115e", - "reference": "56aa1bb63a46e06181c56fa64717a7287e19115e", + "url": "https://api.github.com/repos/livewire/livewire/zipball/ef04be759da41b14d2d129e670533180a44987dc", + "reference": "ef04be759da41b14d2d129e670533180a44987dc", "shasum": "" }, "require": { @@ -2731,7 +2731,7 @@ "description": "A front-end framework for Laravel.", "support": { "issues": "https://github.com/livewire/livewire/issues", - "source": "https://github.com/livewire/livewire/tree/v3.6.3" + "source": "https://github.com/livewire/livewire/tree/v3.6.4" }, "funding": [ { @@ -2739,7 +2739,7 @@ "type": "github" } ], - "time": "2025-04-12T22:26:52+00:00" + "time": "2025-07-17T05:12:15+00:00" }, { "name": "monolog/monolog", @@ -2846,16 +2846,16 @@ }, { "name": "nesbot/carbon", - "version": "3.9.1", + "version": "3.10.1", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "ced71f79398ece168e24f7f7710462f462310d4d" + "reference": "1fd1935b2d90aef2f093c5e35f7ae1257c448d00" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/ced71f79398ece168e24f7f7710462f462310d4d", - "reference": "ced71f79398ece168e24f7f7710462f462310d4d", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/1fd1935b2d90aef2f093c5e35f7ae1257c448d00", + "reference": "1fd1935b2d90aef2f093c5e35f7ae1257c448d00", "shasum": "" }, "require": { @@ -2863,9 +2863,9 @@ "ext-json": "*", "php": "^8.1", "psr/clock": "^1.0", - "symfony/clock": "^6.3 || ^7.0", + "symfony/clock": "^6.3.12 || ^7.0", "symfony/polyfill-mbstring": "^1.0", - "symfony/translation": "^4.4.18 || ^5.2.1|| ^6.0 || ^7.0" + "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0" }, "provide": { "psr/clock-implementation": "1.0" @@ -2873,14 +2873,13 @@ "require-dev": { "doctrine/dbal": "^3.6.3 || ^4.0", "doctrine/orm": "^2.15.2 || ^3.0", - "friendsofphp/php-cs-fixer": "^3.57.2", + "friendsofphp/php-cs-fixer": "^3.75.0", "kylekatarnls/multi-tester": "^2.5.3", - "ondrejmirtes/better-reflection": "^6.25.0.4", "phpmd/phpmd": "^2.15.0", - "phpstan/extension-installer": "^1.3.1", - "phpstan/phpstan": "^1.11.2", - "phpunit/phpunit": "^10.5.20", - "squizlabs/php_codesniffer": "^3.9.0" + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.17", + "phpunit/phpunit": "^10.5.46", + "squizlabs/php_codesniffer": "^3.13.0" }, "bin": [ "bin/carbon" @@ -2948,7 +2947,7 @@ "type": "tidelift" } ], - "time": "2025-05-01T19:51:51+00:00" + "time": "2025-06-21T15:19:35+00:00" }, { "name": "nette/schema", @@ -3014,16 +3013,16 @@ }, { "name": "nette/utils", - "version": "v4.0.6", + "version": "v4.0.7", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "ce708655043c7050eb050df361c5e313cf708309" + "reference": "e67c4061eb40b9c113b218214e42cb5a0dda28f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/ce708655043c7050eb050df361c5e313cf708309", - "reference": "ce708655043c7050eb050df361c5e313cf708309", + "url": "https://api.github.com/repos/nette/utils/zipball/e67c4061eb40b9c113b218214e42cb5a0dda28f2", + "reference": "e67c4061eb40b9c113b218214e42cb5a0dda28f2", "shasum": "" }, "require": { @@ -3094,22 +3093,22 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.0.6" + "source": "https://github.com/nette/utils/tree/v4.0.7" }, - "time": "2025-03-30T21:06:30+00:00" + "time": "2025-06-03T04:55:08+00:00" }, { "name": "nikic/php-parser", - "version": "v5.4.0", + "version": "v5.5.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "447a020a1f875a434d62f2a401f53b82a396e494" + "reference": "ae59794362fe85e051a58ad36b289443f57be7a9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/447a020a1f875a434d62f2a401f53b82a396e494", - "reference": "447a020a1f875a434d62f2a401f53b82a396e494", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/ae59794362fe85e051a58ad36b289443f57be7a9", + "reference": "ae59794362fe85e051a58ad36b289443f57be7a9", "shasum": "" }, "require": { @@ -3152,37 +3151,37 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.4.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.5.0" }, - "time": "2024-12-30T11:07:19+00:00" + "time": "2025-05-31T08:24:38+00:00" }, { "name": "nunomaduro/termwind", - "version": "v2.3.0", + "version": "v2.3.1", "source": { "type": "git", "url": "https://github.com/nunomaduro/termwind.git", - "reference": "52915afe6a1044e8b9cee1bcff836fb63acf9cda" + "reference": "dfa08f390e509967a15c22493dc0bac5733d9123" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/52915afe6a1044e8b9cee1bcff836fb63acf9cda", - "reference": "52915afe6a1044e8b9cee1bcff836fb63acf9cda", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/dfa08f390e509967a15c22493dc0bac5733d9123", + "reference": "dfa08f390e509967a15c22493dc0bac5733d9123", "shasum": "" }, "require": { "ext-mbstring": "*", "php": "^8.2", - "symfony/console": "^7.1.8" + "symfony/console": "^7.2.6" }, "require-dev": { - "illuminate/console": "^11.33.2", - "laravel/pint": "^1.18.2", + "illuminate/console": "^11.44.7", + "laravel/pint": "^1.22.0", "mockery/mockery": "^1.6.12", - "pestphp/pest": "^2.36.0", - "phpstan/phpstan": "^1.12.11", - "phpstan/phpstan-strict-rules": "^1.6.1", - "symfony/var-dumper": "^7.1.8", + "pestphp/pest": "^2.36.0 || ^3.8.2", + "phpstan/phpstan": "^1.12.25", + "phpstan/phpstan-strict-rules": "^1.6.2", + "symfony/var-dumper": "^7.2.6", "thecodingmachine/phpstan-strict-rules": "^1.0.0" }, "type": "library", @@ -3225,7 +3224,7 @@ ], "support": { "issues": "https://github.com/nunomaduro/termwind/issues", - "source": "https://github.com/nunomaduro/termwind/tree/v2.3.0" + "source": "https://github.com/nunomaduro/termwind/tree/v2.3.1" }, "funding": [ { @@ -3241,7 +3240,7 @@ "type": "github" } ], - "time": "2024-11-21T10:39:51+00:00" + "time": "2025-05-08T08:14:37+00:00" }, { "name": "paragonie/constant_time_encoding", @@ -3437,16 +3436,16 @@ }, { "name": "phpseclib/phpseclib", - "version": "3.0.43", + "version": "3.0.46", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "709ec107af3cb2f385b9617be72af8cf62441d02" + "reference": "56483a7de62a6c2a6635e42e93b8a9e25d4f0ec6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/709ec107af3cb2f385b9617be72af8cf62441d02", - "reference": "709ec107af3cb2f385b9617be72af8cf62441d02", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/56483a7de62a6c2a6635e42e93b8a9e25d4f0ec6", + "reference": "56483a7de62a6c2a6635e42e93b8a9e25d4f0ec6", "shasum": "" }, "require": { @@ -3527,7 +3526,7 @@ ], "support": { "issues": "https://github.com/phpseclib/phpseclib/issues", - "source": "https://github.com/phpseclib/phpseclib/tree/3.0.43" + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.46" }, "funding": [ { @@ -3543,7 +3542,7 @@ "type": "tidelift" } ], - "time": "2024-12-14T21:12:59+00:00" + "time": "2025-06-26T16:29:55+00:00" }, { "name": "psr/clock", @@ -3959,16 +3958,16 @@ }, { "name": "psy/psysh", - "version": "v0.12.8", + "version": "v0.12.9", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "85057ceedee50c49d4f6ecaff73ee96adb3b3625" + "reference": "1b801844becfe648985372cb4b12ad6840245ace" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/85057ceedee50c49d4f6ecaff73ee96adb3b3625", - "reference": "85057ceedee50c49d4f6ecaff73ee96adb3b3625", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/1b801844becfe648985372cb4b12ad6840245ace", + "reference": "1b801844becfe648985372cb4b12ad6840245ace", "shasum": "" }, "require": { @@ -4032,9 +4031,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.8" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.9" }, - "time": "2025-03-16T03:05:19+00:00" + "time": "2025-06-23T02:35:06+00:00" }, { "name": "ralouphie/getallheaders", @@ -4158,21 +4157,20 @@ }, { "name": "ramsey/uuid", - "version": "4.7.6", + "version": "4.9.0", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "91039bc1faa45ba123c4328958e620d382ec7088" + "reference": "4e0e23cc785f0724a0e838279a9eb03f28b092a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/91039bc1faa45ba123c4328958e620d382ec7088", - "reference": "91039bc1faa45ba123c4328958e620d382ec7088", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/4e0e23cc785f0724a0e838279a9eb03f28b092a0", + "reference": "4e0e23cc785f0724a0e838279a9eb03f28b092a0", "shasum": "" }, "require": { - "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12", - "ext-json": "*", + "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" }, @@ -4180,26 +4178,23 @@ "rhumsaa/uuid": "self.version" }, "require-dev": { - "captainhook/captainhook": "^5.10", + "captainhook/captainhook": "^5.25", "captainhook/plugin-composer": "^5.3", - "dealerdirect/phpcodesniffer-composer-installer": "^0.7.0", - "doctrine/annotations": "^1.8", - "ergebnis/composer-normalize": "^2.15", - "mockery/mockery": "^1.3", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "ergebnis/composer-normalize": "^2.47", + "mockery/mockery": "^1.6", "paragonie/random-lib": "^2", - "php-mock/php-mock": "^2.2", - "php-mock/php-mock-mockery": "^1.3", - "php-parallel-lint/php-parallel-lint": "^1.1", - "phpbench/phpbench": "^1.0", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-mockery": "^1.1", - "phpstan/phpstan-phpunit": "^1.1", - "phpunit/phpunit": "^8.5 || ^9", - "ramsey/composer-repl": "^1.4", - "slevomat/coding-standard": "^8.4", - "squizlabs/php_codesniffer": "^3.5", - "vimeo/psalm": "^4.9" + "php-mock/php-mock": "^2.6", + "php-mock/php-mock-mockery": "^1.5", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpbench/phpbench": "^1.2.14", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6", + "slevomat/coding-standard": "^8.18", + "squizlabs/php_codesniffer": "^3.13" }, "suggest": { "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", @@ -4234,19 +4229,9 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.7.6" + "source": "https://github.com/ramsey/uuid/tree/4.9.0" }, - "funding": [ - { - "url": "https://github.com/ramsey", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/ramsey/uuid", - "type": "tidelift" - } - ], - "time": "2024-04-27T21:32:50+00:00" + "time": "2025-06-25T14:20:11+00:00" }, { "name": "ratchet/pawl", @@ -4990,16 +4975,16 @@ }, { "name": "spatie/laravel-permission", - "version": "6.17.0", + "version": "6.21.0", "source": { "type": "git", "url": "https://github.com/spatie/laravel-permission.git", - "reference": "02ada8f638b643713fa2fb543384738e27346ddb" + "reference": "6a118e8855dfffcd90403aab77bbf35a03db51b3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-permission/zipball/02ada8f638b643713fa2fb543384738e27346ddb", - "reference": "02ada8f638b643713fa2fb543384738e27346ddb", + "url": "https://api.github.com/repos/spatie/laravel-permission/zipball/6a118e8855dfffcd90403aab77bbf35a03db51b3", + "reference": "6a118e8855dfffcd90403aab77bbf35a03db51b3", "shasum": "" }, "require": { @@ -5061,7 +5046,7 @@ ], "support": { "issues": "https://github.com/spatie/laravel-permission/issues", - "source": "https://github.com/spatie/laravel-permission/tree/6.17.0" + "source": "https://github.com/spatie/laravel-permission/tree/6.21.0" }, "funding": [ { @@ -5069,11 +5054,11 @@ "type": "github" } ], - "time": "2025-04-08T15:06:14+00:00" + "time": "2025-07-23T16:08:05+00:00" }, { "name": "symfony/clock", - "version": "v7.2.0", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/clock.git", @@ -5127,7 +5112,7 @@ "time" ], "support": { - "source": "https://github.com/symfony/clock/tree/v7.2.0" + "source": "https://github.com/symfony/clock/tree/v7.3.0" }, "funding": [ { @@ -5147,23 +5132,24 @@ }, { "name": "symfony/console", - "version": "v7.2.6", + "version": "v7.3.1", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "0e2e3f38c192e93e622e41ec37f4ca70cfedf218" + "reference": "9e27aecde8f506ba0fd1d9989620c04a87697101" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/0e2e3f38c192e93e622e41ec37f4ca70cfedf218", - "reference": "0e2e3f38c192e93e622e41ec37f4ca70cfedf218", + "url": "https://api.github.com/repos/symfony/console/zipball/9e27aecde8f506ba0fd1d9989620c04a87697101", + "reference": "9e27aecde8f506ba0fd1d9989620c04a87697101", "shasum": "" }, "require": { "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0", "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^6.4|^7.0" + "symfony/string": "^7.2" }, "conflict": { "symfony/dependency-injection": "<6.4", @@ -5220,7 +5206,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.2.6" + "source": "https://github.com/symfony/console/tree/v7.3.1" }, "funding": [ { @@ -5236,11 +5222,11 @@ "type": "tidelift" } ], - "time": "2025-04-07T19:09:28+00:00" + "time": "2025-06-27T19:55:54+00:00" }, { "name": "symfony/css-selector", - "version": "v7.2.0", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", @@ -5285,7 +5271,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v7.2.0" + "source": "https://github.com/symfony/css-selector/tree/v7.3.0" }, "funding": [ { @@ -5305,16 +5291,16 @@ }, { "name": "symfony/deprecation-contracts", - "version": "v3.5.1", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6" + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6", - "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", "shasum": "" }, "require": { @@ -5327,7 +5313,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.5-dev" + "dev-main": "3.6-dev" } }, "autoload": { @@ -5352,7 +5338,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.5.1" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" }, "funding": [ { @@ -5368,20 +5354,20 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2024-09-25T14:21:43+00:00" }, { "name": "symfony/error-handler", - "version": "v7.2.5", + "version": "v7.3.1", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "102be5e6a8e4f4f3eb3149bcbfa33a80d1ee374b" + "reference": "35b55b166f6752d6aaf21aa042fc5ed280fce235" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/102be5e6a8e4f4f3eb3149bcbfa33a80d1ee374b", - "reference": "102be5e6a8e4f4f3eb3149bcbfa33a80d1ee374b", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/35b55b166f6752d6aaf21aa042fc5ed280fce235", + "reference": "35b55b166f6752d6aaf21aa042fc5ed280fce235", "shasum": "" }, "require": { @@ -5394,9 +5380,11 @@ "symfony/http-kernel": "<6.4" }, "require-dev": { + "symfony/console": "^6.4|^7.0", "symfony/deprecation-contracts": "^2.5|^3", "symfony/http-kernel": "^6.4|^7.0", - "symfony/serializer": "^6.4|^7.0" + "symfony/serializer": "^6.4|^7.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" }, "bin": [ "Resources/bin/patch-type-declarations" @@ -5427,7 +5415,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.2.5" + "source": "https://github.com/symfony/error-handler/tree/v7.3.1" }, "funding": [ { @@ -5443,20 +5431,20 @@ "type": "tidelift" } ], - "time": "2025-03-03T07:12:39+00:00" + "time": "2025-06-13T07:48:40+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v7.2.0", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "910c5db85a5356d0fea57680defec4e99eb9c8c1" + "reference": "497f73ac996a598c92409b44ac43b6690c4f666d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/910c5db85a5356d0fea57680defec4e99eb9c8c1", - "reference": "910c5db85a5356d0fea57680defec4e99eb9c8c1", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/497f73ac996a598c92409b44ac43b6690c4f666d", + "reference": "497f73ac996a598c92409b44ac43b6690c4f666d", "shasum": "" }, "require": { @@ -5507,7 +5495,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v7.2.0" + "source": "https://github.com/symfony/event-dispatcher/tree/v7.3.0" }, "funding": [ { @@ -5523,20 +5511,20 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2025-04-22T09:11:45+00:00" }, { "name": "symfony/event-dispatcher-contracts", - "version": "v3.5.1", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "7642f5e970b672283b7823222ae8ef8bbc160b9f" + "reference": "59eb412e93815df44f05f342958efa9f46b1e586" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/7642f5e970b672283b7823222ae8ef8bbc160b9f", - "reference": "7642f5e970b672283b7823222ae8ef8bbc160b9f", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586", + "reference": "59eb412e93815df44f05f342958efa9f46b1e586", "shasum": "" }, "require": { @@ -5550,7 +5538,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.5-dev" + "dev-main": "3.6-dev" } }, "autoload": { @@ -5583,7 +5571,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.5.1" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" }, "funding": [ { @@ -5599,20 +5587,20 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2024-09-25T14:21:43+00:00" }, { "name": "symfony/finder", - "version": "v7.2.2", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "87a71856f2f56e4100373e92529eed3171695cfb" + "reference": "ec2344cf77a48253bbca6939aa3d2477773ea63d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/87a71856f2f56e4100373e92529eed3171695cfb", - "reference": "87a71856f2f56e4100373e92529eed3171695cfb", + "url": "https://api.github.com/repos/symfony/finder/zipball/ec2344cf77a48253bbca6939aa3d2477773ea63d", + "reference": "ec2344cf77a48253bbca6939aa3d2477773ea63d", "shasum": "" }, "require": { @@ -5647,7 +5635,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.2.2" + "source": "https://github.com/symfony/finder/tree/v7.3.0" }, "funding": [ { @@ -5663,20 +5651,20 @@ "type": "tidelift" } ], - "time": "2024-12-30T19:00:17+00:00" + "time": "2024-12-30T19:00:26+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.2.6", + "version": "v7.3.1", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "6023ec7607254c87c5e69fb3558255aca440d72b" + "reference": "23dd60256610c86a3414575b70c596e5deff6ed9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/6023ec7607254c87c5e69fb3558255aca440d72b", - "reference": "6023ec7607254c87c5e69fb3558255aca440d72b", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/23dd60256610c86a3414575b70c596e5deff6ed9", + "reference": "23dd60256610c86a3414575b70c596e5deff6ed9", "shasum": "" }, "require": { @@ -5693,6 +5681,7 @@ "doctrine/dbal": "^3.6|^4", "predis/predis": "^1.1|^2.0", "symfony/cache": "^6.4.12|^7.1.5", + "symfony/clock": "^6.4|^7.0", "symfony/dependency-injection": "^6.4|^7.0", "symfony/expression-language": "^6.4|^7.0", "symfony/http-kernel": "^6.4|^7.0", @@ -5725,7 +5714,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.2.6" + "source": "https://github.com/symfony/http-foundation/tree/v7.3.1" }, "funding": [ { @@ -5741,20 +5730,20 @@ "type": "tidelift" } ], - "time": "2025-04-09T08:14:01+00:00" + "time": "2025-06-23T15:07:14+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.2.6", + "version": "v7.3.1", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "f9dec01e6094a063e738f8945ef69c0cfcf792ec" + "reference": "1644879a66e4aa29c36fe33dfa6c54b450ce1831" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/f9dec01e6094a063e738f8945ef69c0cfcf792ec", - "reference": "f9dec01e6094a063e738f8945ef69c0cfcf792ec", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/1644879a66e4aa29c36fe33dfa6c54b450ce1831", + "reference": "1644879a66e4aa29c36fe33dfa6c54b450ce1831", "shasum": "" }, "require": { @@ -5762,8 +5751,8 @@ "psr/log": "^1|^2|^3", "symfony/deprecation-contracts": "^2.5|^3", "symfony/error-handler": "^6.4|^7.0", - "symfony/event-dispatcher": "^6.4|^7.0", - "symfony/http-foundation": "^6.4|^7.0", + "symfony/event-dispatcher": "^7.3", + "symfony/http-foundation": "^7.3", "symfony/polyfill-ctype": "^1.8" }, "conflict": { @@ -5839,7 +5828,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.2.6" + "source": "https://github.com/symfony/http-kernel/tree/v7.3.1" }, "funding": [ { @@ -5855,20 +5844,20 @@ "type": "tidelift" } ], - "time": "2025-05-02T09:04:03+00:00" + "time": "2025-06-28T08:24:55+00:00" }, { "name": "symfony/mailer", - "version": "v7.2.6", + "version": "v7.3.1", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "998692469d6e698c6eadc7ef37a6530a9eabb356" + "reference": "b5db5105b290bdbea5ab27b89c69effcf1cb3368" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/998692469d6e698c6eadc7ef37a6530a9eabb356", - "reference": "998692469d6e698c6eadc7ef37a6530a9eabb356", + "url": "https://api.github.com/repos/symfony/mailer/zipball/b5db5105b290bdbea5ab27b89c69effcf1cb3368", + "reference": "b5db5105b290bdbea5ab27b89c69effcf1cb3368", "shasum": "" }, "require": { @@ -5919,7 +5908,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.2.6" + "source": "https://github.com/symfony/mailer/tree/v7.3.1" }, "funding": [ { @@ -5935,20 +5924,20 @@ "type": "tidelift" } ], - "time": "2025-04-04T09:50:51+00:00" + "time": "2025-06-27T19:55:54+00:00" }, { "name": "symfony/mime", - "version": "v7.2.6", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "706e65c72d402539a072d0d6ad105fff6c161ef1" + "reference": "0e7b19b2f399c31df0cdbe5d8cbf53f02f6cfcd9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/706e65c72d402539a072d0d6ad105fff6c161ef1", - "reference": "706e65c72d402539a072d0d6ad105fff6c161ef1", + "url": "https://api.github.com/repos/symfony/mime/zipball/0e7b19b2f399c31df0cdbe5d8cbf53f02f6cfcd9", + "reference": "0e7b19b2f399c31df0cdbe5d8cbf53f02f6cfcd9", "shasum": "" }, "require": { @@ -6003,7 +5992,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.2.6" + "source": "https://github.com/symfony/mime/tree/v7.3.0" }, "funding": [ { @@ -6019,7 +6008,7 @@ "type": "tidelift" } ], - "time": "2025-04-27T13:34:41+00:00" + "time": "2025-02-19T08:51:26+00:00" }, { "name": "symfony/polyfill-ctype", @@ -6660,16 +6649,16 @@ }, { "name": "symfony/process", - "version": "v7.2.5", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "87b7c93e57df9d8e39a093d32587702380ff045d" + "reference": "40c295f2deb408d5e9d2d32b8ba1dd61e36f05af" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/87b7c93e57df9d8e39a093d32587702380ff045d", - "reference": "87b7c93e57df9d8e39a093d32587702380ff045d", + "url": "https://api.github.com/repos/symfony/process/zipball/40c295f2deb408d5e9d2d32b8ba1dd61e36f05af", + "reference": "40c295f2deb408d5e9d2d32b8ba1dd61e36f05af", "shasum": "" }, "require": { @@ -6701,7 +6690,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.2.5" + "source": "https://github.com/symfony/process/tree/v7.3.0" }, "funding": [ { @@ -6717,20 +6706,20 @@ "type": "tidelift" } ], - "time": "2025-03-13T12:21:46+00:00" + "time": "2025-04-17T09:11:12+00:00" }, { "name": "symfony/routing", - "version": "v7.2.3", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "ee9a67edc6baa33e5fae662f94f91fd262930996" + "reference": "8e213820c5fea844ecea29203d2a308019007c15" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/ee9a67edc6baa33e5fae662f94f91fd262930996", - "reference": "ee9a67edc6baa33e5fae662f94f91fd262930996", + "url": "https://api.github.com/repos/symfony/routing/zipball/8e213820c5fea844ecea29203d2a308019007c15", + "reference": "8e213820c5fea844ecea29203d2a308019007c15", "shasum": "" }, "require": { @@ -6782,7 +6771,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.2.3" + "source": "https://github.com/symfony/routing/tree/v7.3.0" }, "funding": [ { @@ -6798,20 +6787,20 @@ "type": "tidelift" } ], - "time": "2025-01-17T10:56:55+00:00" + "time": "2025-05-24T20:43:28+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.5.1", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "e53260aabf78fb3d63f8d79d69ece59f80d5eda0" + "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/e53260aabf78fb3d63f8d79d69ece59f80d5eda0", - "reference": "e53260aabf78fb3d63f8d79d69ece59f80d5eda0", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f021b05a130d35510bd6b25fe9053c2a8a15d5d4", + "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4", "shasum": "" }, "require": { @@ -6829,7 +6818,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.5-dev" + "dev-main": "3.6-dev" } }, "autoload": { @@ -6865,7 +6854,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.5.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.6.0" }, "funding": [ { @@ -6881,20 +6870,20 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2025-04-25T09:37:31+00:00" }, { "name": "symfony/string", - "version": "v7.2.6", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "a214fe7d62bd4df2a76447c67c6b26e1d5e74931" + "reference": "f3570b8c61ca887a9e2938e85cb6458515d2b125" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/a214fe7d62bd4df2a76447c67c6b26e1d5e74931", - "reference": "a214fe7d62bd4df2a76447c67c6b26e1d5e74931", + "url": "https://api.github.com/repos/symfony/string/zipball/f3570b8c61ca887a9e2938e85cb6458515d2b125", + "reference": "f3570b8c61ca887a9e2938e85cb6458515d2b125", "shasum": "" }, "require": { @@ -6952,7 +6941,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.2.6" + "source": "https://github.com/symfony/string/tree/v7.3.0" }, "funding": [ { @@ -6968,20 +6957,20 @@ "type": "tidelift" } ], - "time": "2025-04-20T20:18:16+00:00" + "time": "2025-04-20T20:19:01+00:00" }, { "name": "symfony/translation", - "version": "v7.2.6", + "version": "v7.3.1", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "e7fd8e2a4239b79a0fd9fb1fef3e0e7f969c6dc6" + "reference": "241d5ac4910d256660238a7ecf250deba4c73063" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/e7fd8e2a4239b79a0fd9fb1fef3e0e7f969c6dc6", - "reference": "e7fd8e2a4239b79a0fd9fb1fef3e0e7f969c6dc6", + "url": "https://api.github.com/repos/symfony/translation/zipball/241d5ac4910d256660238a7ecf250deba4c73063", + "reference": "241d5ac4910d256660238a7ecf250deba4c73063", "shasum": "" }, "require": { @@ -6991,6 +6980,7 @@ "symfony/translation-contracts": "^2.5|^3.0" }, "conflict": { + "nikic/php-parser": "<5.0", "symfony/config": "<6.4", "symfony/console": "<6.4", "symfony/dependency-injection": "<6.4", @@ -7004,7 +6994,7 @@ "symfony/translation-implementation": "2.3|3.0" }, "require-dev": { - "nikic/php-parser": "^4.18|^5.0", + "nikic/php-parser": "^5.0", "psr/log": "^1|^2|^3", "symfony/config": "^6.4|^7.0", "symfony/console": "^6.4|^7.0", @@ -7047,7 +7037,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v7.2.6" + "source": "https://github.com/symfony/translation/tree/v7.3.1" }, "funding": [ { @@ -7063,20 +7053,20 @@ "type": "tidelift" } ], - "time": "2025-04-07T19:09:28+00:00" + "time": "2025-06-27T19:55:54+00:00" }, { "name": "symfony/translation-contracts", - "version": "v3.5.1", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/symfony/translation-contracts.git", - "reference": "4667ff3bd513750603a09c8dedbea942487fb07c" + "reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/4667ff3bd513750603a09c8dedbea942487fb07c", - "reference": "4667ff3bd513750603a09c8dedbea942487fb07c", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/df210c7a2573f1913b2d17cc95f90f53a73d8f7d", + "reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d", "shasum": "" }, "require": { @@ -7089,7 +7079,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.5-dev" + "dev-main": "3.6-dev" } }, "autoload": { @@ -7125,7 +7115,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/translation-contracts/tree/v3.5.1" + "source": "https://github.com/symfony/translation-contracts/tree/v3.6.0" }, "funding": [ { @@ -7141,20 +7131,20 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2024-09-27T08:32:26+00:00" }, { "name": "symfony/uid", - "version": "v7.2.0", + "version": "v7.3.1", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "2d294d0c48df244c71c105a169d0190bfb080426" + "reference": "a69f69f3159b852651a6bf45a9fdd149520525bb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/2d294d0c48df244c71c105a169d0190bfb080426", - "reference": "2d294d0c48df244c71c105a169d0190bfb080426", + "url": "https://api.github.com/repos/symfony/uid/zipball/a69f69f3159b852651a6bf45a9fdd149520525bb", + "reference": "a69f69f3159b852651a6bf45a9fdd149520525bb", "shasum": "" }, "require": { @@ -7199,7 +7189,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v7.2.0" + "source": "https://github.com/symfony/uid/tree/v7.3.1" }, "funding": [ { @@ -7215,24 +7205,25 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2025-06-27T19:55:54+00:00" }, { "name": "symfony/var-dumper", - "version": "v7.2.6", + "version": "v7.3.1", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "9c46038cd4ed68952166cf7001b54eb539184ccb" + "reference": "6e209fbe5f5a7b6043baba46fe5735a4b85d0d42" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9c46038cd4ed68952166cf7001b54eb539184ccb", - "reference": "9c46038cd4ed68952166cf7001b54eb539184ccb", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/6e209fbe5f5a7b6043baba46fe5735a4b85d0d42", + "reference": "6e209fbe5f5a7b6043baba46fe5735a4b85d0d42", "shasum": "" }, "require": { "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0" }, "conflict": { @@ -7282,7 +7273,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.2.6" + "source": "https://github.com/symfony/var-dumper/tree/v7.3.1" }, "funding": [ { @@ -7298,20 +7289,20 @@ "type": "tidelift" } ], - "time": "2025-04-09T08:14:01+00:00" + "time": "2025-06-27T19:55:54+00:00" }, { "name": "symfony/yaml", - "version": "v7.2.6", + "version": "v7.3.1", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "0feafffb843860624ddfd13478f481f4c3cd8b23" + "reference": "0c3555045a46ab3cd4cc5a69d161225195230edb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/0feafffb843860624ddfd13478f481f4c3cd8b23", - "reference": "0feafffb843860624ddfd13478f481f4c3cd8b23", + "url": "https://api.github.com/repos/symfony/yaml/zipball/0c3555045a46ab3cd4cc5a69d161225195230edb", + "reference": "0c3555045a46ab3cd4cc5a69d161225195230edb", "shasum": "" }, "require": { @@ -7354,7 +7345,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.2.6" + "source": "https://github.com/symfony/yaml/tree/v7.3.1" }, "funding": [ { @@ -7370,7 +7361,7 @@ "type": "tidelift" } ], - "time": "2025-04-04T10:10:11+00:00" + "time": "2025-06-03T06:57:57+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -8008,16 +7999,16 @@ }, { "name": "filp/whoops", - "version": "2.18.0", + "version": "2.18.3", "source": { "type": "git", "url": "https://github.com/filp/whoops.git", - "reference": "a7de6c3c6c3c022f5cfc337f8ede6a14460cf77e" + "reference": "59a123a3d459c5a23055802237cb317f609867e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filp/whoops/zipball/a7de6c3c6c3c022f5cfc337f8ede6a14460cf77e", - "reference": "a7de6c3c6c3c022f5cfc337f8ede6a14460cf77e", + "url": "https://api.github.com/repos/filp/whoops/zipball/59a123a3d459c5a23055802237cb317f609867e5", + "reference": "59a123a3d459c5a23055802237cb317f609867e5", "shasum": "" }, "require": { @@ -8067,7 +8058,7 @@ ], "support": { "issues": "https://github.com/filp/whoops/issues", - "source": "https://github.com/filp/whoops/tree/2.18.0" + "source": "https://github.com/filp/whoops/tree/2.18.3" }, "funding": [ { @@ -8075,7 +8066,7 @@ "type": "github" } ], - "time": "2025-03-15T12:00:00+00:00" + "time": "2025-06-16T00:02:10+00:00" }, { "name": "hamcrest/hamcrest-php", @@ -8130,16 +8121,16 @@ }, { "name": "laravel/pail", - "version": "v1.2.2", + "version": "v1.2.3", "source": { "type": "git", "url": "https://github.com/laravel/pail.git", - "reference": "f31f4980f52be17c4667f3eafe034e6826787db2" + "reference": "8cc3d575c1f0e57eeb923f366a37528c50d2385a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pail/zipball/f31f4980f52be17c4667f3eafe034e6826787db2", - "reference": "f31f4980f52be17c4667f3eafe034e6826787db2", + "url": "https://api.github.com/repos/laravel/pail/zipball/8cc3d575c1f0e57eeb923f366a37528c50d2385a", + "reference": "8cc3d575c1f0e57eeb923f366a37528c50d2385a", "shasum": "" }, "require": { @@ -8159,7 +8150,7 @@ "orchestra/testbench-core": "^8.13|^9.0|^10.0", "pestphp/pest": "^2.20|^3.0", "pestphp/pest-plugin-type-coverage": "^2.3|^3.0", - "phpstan/phpstan": "^1.10", + "phpstan/phpstan": "^1.12.27", "symfony/var-dumper": "^6.3|^7.0" }, "type": "library", @@ -8195,6 +8186,7 @@ "description": "Easily delve into your Laravel application's log files directly from the command line.", "homepage": "https://github.com/laravel/pail", "keywords": [ + "dev", "laravel", "logs", "php", @@ -8204,20 +8196,20 @@ "issues": "https://github.com/laravel/pail/issues", "source": "https://github.com/laravel/pail" }, - "time": "2025-01-28T15:15:15+00:00" + "time": "2025-06-05T13:55:57+00:00" }, { "name": "laravel/pint", - "version": "v1.22.0", + "version": "v1.24.0", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "7ddfaa6523a675fae5c4123ee38fc6bfb8ee4f36" + "reference": "0345f3b05f136801af8c339f9d16ef29e6b4df8a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/7ddfaa6523a675fae5c4123ee38fc6bfb8ee4f36", - "reference": "7ddfaa6523a675fae5c4123ee38fc6bfb8ee4f36", + "url": "https://api.github.com/repos/laravel/pint/zipball/0345f3b05f136801af8c339f9d16ef29e6b4df8a", + "reference": "0345f3b05f136801af8c339f9d16ef29e6b4df8a", "shasum": "" }, "require": { @@ -8228,12 +8220,12 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.75.0", - "illuminate/view": "^11.44.2", - "larastan/larastan": "^3.3.1", - "laravel-zero/framework": "^11.36.1", + "friendsofphp/php-cs-fixer": "^3.82.2", + "illuminate/view": "^11.45.1", + "larastan/larastan": "^3.5.0", + "laravel-zero/framework": "^11.45.0", "mockery/mockery": "^1.6.12", - "nunomaduro/termwind": "^2.3", + "nunomaduro/termwind": "^2.3.1", "pestphp/pest": "^2.36.0" }, "bin": [ @@ -8241,6 +8233,9 @@ ], "type": "project", "autoload": { + "files": [ + "overrides/Runner/Parallel/ProcessFactory.php" + ], "psr-4": { "App\\": "app/", "Database\\Seeders\\": "database/seeders/", @@ -8270,20 +8265,20 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2025-04-08T22:11:45+00:00" + "time": "2025-07-10T18:09:32+00:00" }, { "name": "laravel/sail", - "version": "v1.42.0", + "version": "v1.44.0", "source": { "type": "git", "url": "https://github.com/laravel/sail.git", - "reference": "2edaaf77f3c07a4099965bb3d7dfee16e801c0f6" + "reference": "a09097bd2a8a38e23ac472fa6a6cf5b0d1c1d3fe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sail/zipball/2edaaf77f3c07a4099965bb3d7dfee16e801c0f6", - "reference": "2edaaf77f3c07a4099965bb3d7dfee16e801c0f6", + "url": "https://api.github.com/repos/laravel/sail/zipball/a09097bd2a8a38e23ac472fa6a6cf5b0d1c1d3fe", + "reference": "a09097bd2a8a38e23ac472fa6a6cf5b0d1c1d3fe", "shasum": "" }, "require": { @@ -8333,7 +8328,7 @@ "issues": "https://github.com/laravel/sail/issues", "source": "https://github.com/laravel/sail" }, - "time": "2025-04-29T14:26:46+00:00" + "time": "2025-07-04T16:17:06+00:00" }, { "name": "mockery/mockery", @@ -8420,16 +8415,16 @@ }, { "name": "myclabs/deep-copy", - "version": "1.13.1", + "version": "1.13.3", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "1720ddd719e16cf0db4eb1c6eca108031636d46c" + "reference": "faed855a7b5f4d4637717c2b3863e277116beb36" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/1720ddd719e16cf0db4eb1c6eca108031636d46c", - "reference": "1720ddd719e16cf0db4eb1c6eca108031636d46c", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/faed855a7b5f4d4637717c2b3863e277116beb36", + "reference": "faed855a7b5f4d4637717c2b3863e277116beb36", "shasum": "" }, "require": { @@ -8468,7 +8463,7 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.1" + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.3" }, "funding": [ { @@ -8476,27 +8471,27 @@ "type": "tidelift" } ], - "time": "2025-04-29T12:36:36+00:00" + "time": "2025-07-05T12:25:42+00:00" }, { "name": "nunomaduro/collision", - "version": "v8.8.0", + "version": "v8.8.2", "source": { "type": "git", "url": "https://github.com/nunomaduro/collision.git", - "reference": "4cf9f3b47afff38b139fb79ce54fc71799022ce8" + "reference": "60207965f9b7b7a4ce15a0f75d57f9dadb105bdb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/collision/zipball/4cf9f3b47afff38b139fb79ce54fc71799022ce8", - "reference": "4cf9f3b47afff38b139fb79ce54fc71799022ce8", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/60207965f9b7b7a4ce15a0f75d57f9dadb105bdb", + "reference": "60207965f9b7b7a4ce15a0f75d57f9dadb105bdb", "shasum": "" }, "require": { - "filp/whoops": "^2.18.0", - "nunomaduro/termwind": "^2.3.0", + "filp/whoops": "^2.18.1", + "nunomaduro/termwind": "^2.3.1", "php": "^8.2.0", - "symfony/console": "^7.2.5" + "symfony/console": "^7.3.0" }, "conflict": { "laravel/framework": "<11.44.2 || >=13.0.0", @@ -8504,15 +8499,15 @@ }, "require-dev": { "brianium/paratest": "^7.8.3", - "larastan/larastan": "^3.2", - "laravel/framework": "^11.44.2 || ^12.6", - "laravel/pint": "^1.21.2", - "laravel/sail": "^1.41.0", - "laravel/sanctum": "^4.0.8", + "larastan/larastan": "^3.4.2", + "laravel/framework": "^11.44.2 || ^12.18", + "laravel/pint": "^1.22.1", + "laravel/sail": "^1.43.1", + "laravel/sanctum": "^4.1.1", "laravel/tinker": "^2.10.1", - "orchestra/testbench-core": "^9.12.0 || ^10.1", - "pestphp/pest": "^3.8.0", - "sebastian/environment": "^7.2.0 || ^8.0" + "orchestra/testbench-core": "^9.12.0 || ^10.4", + "pestphp/pest": "^3.8.2", + "sebastian/environment": "^7.2.1 || ^8.0" }, "type": "library", "extra": { @@ -8575,7 +8570,7 @@ "type": "patreon" } ], - "time": "2025-04-03T14:33:09+00:00" + "time": "2025-06-25T02:12:12+00:00" }, { "name": "phar-io/manifest", @@ -8697,16 +8692,16 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.14", + "version": "2.1.19", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "8f2e03099cac24ff3b379864d171c5acbfc6b9a2" + "reference": "473a8c30e450d87099f76313edcbb90852f9afdf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/8f2e03099cac24ff3b379864d171c5acbfc6b9a2", - "reference": "8f2e03099cac24ff3b379864d171c5acbfc6b9a2", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/473a8c30e450d87099f76313edcbb90852f9afdf", + "reference": "473a8c30e450d87099f76313edcbb90852f9afdf", "shasum": "" }, "require": { @@ -8751,20 +8746,20 @@ "type": "github" } ], - "time": "2025-05-02T15:32:28+00:00" + "time": "2025-07-21T19:58:24+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "11.0.9", + "version": "11.0.10", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "14d63fbcca18457e49c6f8bebaa91a87e8e188d7" + "reference": "1a800a7446add2d79cc6b3c01c45381810367d76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/14d63fbcca18457e49c6f8bebaa91a87e8e188d7", - "reference": "14d63fbcca18457e49c6f8bebaa91a87e8e188d7", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/1a800a7446add2d79cc6b3c01c45381810367d76", + "reference": "1a800a7446add2d79cc6b3c01c45381810367d76", "shasum": "" }, "require": { @@ -8821,15 +8816,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.9" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/show" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" } ], - "time": "2025-02-25T13:26:39+00:00" + "time": "2025-06-18T08:56:18+00:00" }, { "name": "phpunit/php-file-iterator", @@ -9078,16 +9085,16 @@ }, { "name": "phpunit/phpunit", - "version": "11.5.19", + "version": "11.5.27", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "0da1ebcdbc4d5bd2d189cfe02846a89936d8dda5" + "reference": "446d43867314781df7e9adf79c3ec7464956fd8f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0da1ebcdbc4d5bd2d189cfe02846a89936d8dda5", - "reference": "0da1ebcdbc4d5bd2d189cfe02846a89936d8dda5", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/446d43867314781df7e9adf79c3ec7464956fd8f", + "reference": "446d43867314781df7e9adf79c3ec7464956fd8f", "shasum": "" }, "require": { @@ -9097,11 +9104,11 @@ "ext-mbstring": "*", "ext-xml": "*", "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.13.1", + "myclabs/deep-copy": "^1.13.3", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.2", - "phpunit/php-code-coverage": "^11.0.9", + "phpunit/php-code-coverage": "^11.0.10", "phpunit/php-file-iterator": "^5.1.0", "phpunit/php-invoker": "^5.0.1", "phpunit/php-text-template": "^4.0.1", @@ -9110,7 +9117,7 @@ "sebastian/code-unit": "^3.0.3", "sebastian/comparator": "^6.3.1", "sebastian/diff": "^6.0.2", - "sebastian/environment": "^7.2.0", + "sebastian/environment": "^7.2.1", "sebastian/exporter": "^6.3.0", "sebastian/global-state": "^7.0.2", "sebastian/object-enumerator": "^6.0.1", @@ -9159,7 +9166,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.19" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.27" }, "funding": [ { @@ -9183,7 +9190,7 @@ "type": "tidelift" } ], - "time": "2025-05-02T06:56:52+00:00" + "time": "2025-07-11T04:10:06+00:00" }, { "name": "psr/cache", @@ -9611,23 +9618,23 @@ }, { "name": "sebastian/environment", - "version": "7.2.0", + "version": "7.2.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "855f3ae0ab316bbafe1ba4e16e9f3c078d24a0c5" + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/855f3ae0ab316bbafe1ba4e16e9f3c078d24a0c5", - "reference": "855f3ae0ab316bbafe1ba4e16e9f3c078d24a0c5", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^11.3" }, "suggest": { "ext-posix": "*" @@ -9663,15 +9670,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/7.2.0" + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" } ], - "time": "2024-07-03T04:54:44+00:00" + "time": "2025-05-21T11:55:47+00:00" }, { "name": "sebastian/exporter", @@ -10214,16 +10233,16 @@ }, { "name": "swagger-api/swagger-ui", - "version": "v5.21.0", + "version": "v5.27.0", "source": { "type": "git", "url": "https://github.com/swagger-api/swagger-ui.git", - "reference": "fceaec605072fbc717a04895bd19814d9a1c8e6d" + "reference": "7b86721ad6494216d8bad0540c737efe1885688c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/swagger-api/swagger-ui/zipball/fceaec605072fbc717a04895bd19814d9a1c8e6d", - "reference": "fceaec605072fbc717a04895bd19814d9a1c8e6d", + "url": "https://api.github.com/repos/swagger-api/swagger-ui/zipball/7b86721ad6494216d8bad0540c737efe1885688c", + "reference": "7b86721ad6494216d8bad0540c737efe1885688c", "shasum": "" }, "type": "library", @@ -10269,9 +10288,9 @@ ], "support": { "issues": "https://github.com/swagger-api/swagger-ui/issues", - "source": "https://github.com/swagger-api/swagger-ui/tree/v5.21.0" + "source": "https://github.com/swagger-api/swagger-ui/tree/v5.27.0" }, - "time": "2025-04-13T19:37:38+00:00" + "time": "2025-07-16T12:18:52+00:00" }, { "name": "theseer/tokenizer", @@ -10325,16 +10344,16 @@ }, { "name": "zircote/swagger-php", - "version": "5.1.1", + "version": "5.1.4", "source": { "type": "git", "url": "https://github.com/zircote/swagger-php.git", - "reference": "7a6544c60441ddb5959b91266b3a290dc28537ba" + "reference": "471f2e7c24c9508a2ee08df245cab64b62dbf721" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/zircote/swagger-php/zipball/7a6544c60441ddb5959b91266b3a290dc28537ba", - "reference": "7a6544c60441ddb5959b91266b3a290dc28537ba", + "url": "https://api.github.com/repos/zircote/swagger-php/zipball/471f2e7c24c9508a2ee08df245cab64b62dbf721", + "reference": "471f2e7c24c9508a2ee08df245cab64b62dbf721", "shasum": "" }, "require": { @@ -10405,9 +10424,9 @@ ], "support": { "issues": "https://github.com/zircote/swagger-php/issues", - "source": "https://github.com/zircote/swagger-php/tree/5.1.1" + "source": "https://github.com/zircote/swagger-php/tree/5.1.4" }, - "time": "2025-04-27T10:02:08+00:00" + "time": "2025-07-15T23:54:13+00:00" } ], "aliases": [], diff --git a/config/horizon.php b/config/horizon.php index 6098dda..0424829 100644 --- a/config/horizon.php +++ b/config/horizon.php @@ -246,6 +246,18 @@ 'timeout' => 1800, 'nice' => 0, ], + 'supervisor-6' => [ + 'connection' => 'redis', + 'queue' => ['cluster'], + 'balance' => 'auto', + 'maxProcesses' => 4, + 'maxTime' => 0, + 'maxJobs' => 0, + 'memory' => 256, + 'tries' => 1, + 'timeout' => 900, + 'nice' => 0, + ], ], 'environments' => [ @@ -275,6 +287,11 @@ 'balanceMaxShift' => 1, 'balanceCooldown' => 3, ], + 'supervisor-6' => [ + 'maxProcesses' => 20, + 'balanceMaxShift' => 1, + 'balanceCooldown' => 3, + ], ], 'local' => [], diff --git a/config/s3server.php b/config/s3server.php index 7a08ae1..f1a9d8a 100644 --- a/config/s3server.php +++ b/config/s3server.php @@ -10,7 +10,8 @@ * @author Marcel Menk */ return [ - 'auth' => true, - 'auth_driver' => LaravelS3Server\Drivers\DatabaseAuthenticationDriver::class, - 'storage_driver' => LaravelS3Server\Drivers\FileStorageDriver::class, + 'auth' => env('S3SERVER_AUTH', true), + 'auth_driver' => env('S3SERVER_AUTH_DRIVER', LaravelS3Server\Drivers\DatabaseAuthenticationDriver::class), + 'storage_driver' => env('S3SERVER_STORAGE_DRIVER', LaravelS3Server\Drivers\FileStorageDriver::class), + 'storage_path' => env('S3SERVER_STORAGE_PATH', 'kops/'), ]; diff --git a/routes/console.php b/routes/console.php index 9c01ac0..978ebf0 100644 --- a/routes/console.php +++ b/routes/console.php @@ -2,6 +2,9 @@ declare(strict_types=1); +use App\Jobs\Cluster\Dispatchers\ClusterCreation as ClusterCreation; +use App\Jobs\Cluster\Dispatchers\ClusterDeletion as ClusterDeletion; +use App\Jobs\Cluster\Dispatchers\ClusterUpdate as ClusterUpdate; use App\Jobs\Cluster\Dispatchers\LimitMonitoring as ClusterLimitMonitoring; use App\Jobs\Cluster\Dispatchers\StatusMonitoring as ClusterStatusMonitoring; use App\Jobs\Flux\Actions\StatusMonitoring as FluxDeploymentStatusMonitoring; @@ -13,6 +16,9 @@ Schedule::command('horizon:snapshot')->everyFiveMinutes(); +Schedule::job(new ClusterCreation(), 'dispatchers')->everyMinute(); +Schedule::job(new ClusterDeletion(), 'dispatchers')->everyMinute(); +Schedule::job(new ClusterUpdate(), 'dispatchers')->everyMinute(); Schedule::job(new ClusterLimitMonitoring(), 'dispatchers')->hourly(); Schedule::job(new ClusterStatusMonitoring(), 'dispatchers')->everyTenMinutes(); Schedule::job(new FluxDeploymentCreation(), 'dispatchers')->everyMinute(); From 6e42316cd3da630cd1cfae982651f08f697aa820 Mon Sep 17 00:00:00 2001 From: Marcel Menk Date: Thu, 24 Jul 2025 19:50:59 +0200 Subject: [PATCH 4/6] feat: kops cluster state management --- .env.example | 2 +- app/Helpers/Kops/KopsDeployment.php | 131 ++++++++++++++---- .../Controllers/API/TemplateController.php | 4 + app/Http/Controllers/TemplateController.php | 4 + .../Projects/Templates/TemplateFile.php | 3 + config/s3server.php | 2 +- ...25_07_23_000001_update_template_tables.php | 8 ++ resources/sass/app.scss | 11 ++ resources/views/template/add-file.blade.php | 16 +++ .../views/template/file-tree-file.blade.php | 3 + .../views/template/update-file.blade.php | 16 +++ 11 files changed, 171 insertions(+), 29 deletions(-) diff --git a/.env.example b/.env.example index e88002d..2cc5c3c 100644 --- a/.env.example +++ b/.env.example @@ -92,4 +92,4 @@ AI_CHAT_COMPLETIONS_ENDPOINT=/v1/chat/completions AI_EMBEDDING_ENDPOINT=/v1/embeddings AI_REMOTE_EMBEDDING=true -S3SERVER_STORAGE_PATH=kops/ +S3SERVER_STORAGE_PATH=state/ \ No newline at end of file diff --git a/app/Helpers/Kops/KopsDeployment.php b/app/Helpers/Kops/KopsDeployment.php index e16030c..1f9cd0f 100644 --- a/app/Helpers/Kops/KopsDeployment.php +++ b/app/Helpers/Kops/KopsDeployment.php @@ -7,10 +7,15 @@ use App\Exceptions\KopsException; use App\Helpers\Kubernetes\YamlFormatter; use App\Models\Kubernetes\Clusters\Cluster; +use App\Models\Kubernetes\Clusters\ClusterData; +use App\Models\Kubernetes\Clusters\ClusterSecretData; use Exception; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Process; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; +use LaravelS3Server\Models\S3AccessCredential; use Symfony\Component\Yaml\Yaml; /** @@ -38,6 +43,15 @@ public static function generate(Cluster $cluster, array $data = [], array $secre if ($replaceExisting) { Storage::disk('local')->deleteDirectory($cluster->kopsPath); + + $s3Credentials = S3AccessCredential::where('access_key_id', $cluster->id)->first(); + } else { + $s3Credentials = S3AccessCredential::create([ + 'access_key_id' => $cluster->id, + 'secret_access_key' => Str::random(32), + 'description' => 'Kops state store for cluster ' . $cluster->id, + 'bucket' => $cluster->id, + ]); } $clusterDirectoryStatus = Storage::disk('local')->makeDirectory($cluster->kopsPath); @@ -46,26 +60,39 @@ public static function generate(Cluster $cluster, array $data = [], array $secre throw new KopsException('Server Error', 500); } - $cluster->template->fullTree->each(function ($item) use ($cluster, $data, $secretData) { + $files = collect(); + + $cluster->template->fullTree->each(function ($item) use ($cluster, $data, $secretData, &$files) { if ($item->type === 'file') { - self::createFile($item, $cluster, $data, $secretData); + $files->push(self::createFile($item, $cluster, $data, $secretData)); } elseif ($item->type === 'folder') { - self::createFolder($item, $cluster, $data, $secretData); + $files->concat(self::createFolder($item, $cluster, $data, $secretData)); } }); - // TODO: Properly order files and apply every file separately - // TODO: Set proper s3 state storage backend - $cmd = ['kops', 'create', '-f', $cluster->kopsPath]; - $result = Process::run($cmd); + $files->filter()->sortBy('object.sort')->each(function ($file) use ($cluster, $replaceExisting, $s3Credentials) { + if ($replaceExisting) { + $cmd = ['kops', 'replace', '-f', $cluster->kopsPath . $file->object->path, '--force']; + } else { + $cmd = ['kops', 'create', '-f', $cluster->kopsPath . $file->object->path]; + } - if (!$result->successful()) { - throw new KopsException('Failed to create cluster', 500); - } + $result = Process::env([ + 'S3_ENDPOINT' => config('app.url') . '/s3', + 'S3_FORCE_PATH_STYLE' => 'true', + 'KOPS_STATE_STORE' => 's3://' . $cluster->id, + ...($s3Credentials ? [ + 'AWS_ACCESS_KEY_ID' => $s3Credentials->access_key_id, + 'AWS_SECRET_ACCESS_KEY' => $s3Credentials->secret_access_key, + ] : []), + ])->run($cmd); + + if (!$result->successful()) { + throw new KopsException('Failed to create cluster', 500); + } + }); } - // TODO: Add a method to update a cluster - /** * Delete a deployment. * @@ -77,14 +104,55 @@ public static function delete(Cluster $cluster) throw new KopsException('Not Found', 404); } - // TODO: Properly order files and apply every file separately - // TODO: Set proper s3 state storage backend - $cmd = ['kops', 'delete', '-f', $cluster->kopsPath, '--yes']; - $result = Process::run($cmd); + $s3Credentials = S3AccessCredential::where('access_key_id', $cluster->id)->first(); + $files = collect(); - if (!$result->successful()) { - throw new KopsException('Failed to delete cluster', 500); - } + $cluster->template->fullTree->each(function ($item) use ($cluster, &$files) { + if ($item->type === 'file') { + $files->push( + self::createFile( + $item, + $cluster, + $cluster->clusterData->mapWithKeys(function (ClusterData $data) { + return [$data->key => $data->value]; + })->toArray(), + $cluster->clusterSecretData->mapWithKeys(function (ClusterSecretData $data) { + return [$data->key => $data->value]; + })->toArray() + ) + ); + } elseif ($item->type === 'folder') { + $files->concat( + self::createFolder( + $item, + $cluster, + $cluster->clusterData->mapWithKeys(function (ClusterData $data) { + return [$data->key => $data->value]; + })->toArray(), + $cluster->clusterSecretData->mapWithKeys(function (ClusterSecretData $data) { + return [$data->key => $data->value]; + })->toArray() + ) + ); + } + }); + + $files->filter()->sortBy('object.sort')->each(function ($file) use ($cluster, $s3Credentials) { + $cmd = ['kops', 'delete', '-f', $cluster->kopsPath . $file->object->path, '--yes']; + $result = Process::env([ + 'S3_ENDPOINT' => config('app.url') . '/s3', + 'S3_FORCE_PATH_STYLE' => 'true', + 'KOPS_STATE_STORE' => 's3://' . $cluster->id, + ...($s3Credentials ? [ + 'AWS_ACCESS_KEY_ID' => $s3Credentials->access_key_id, + 'AWS_SECRET_ACCESS_KEY' => $s3Credentials->secret_access_key, + ] : []), + ])->run($cmd); + + if (!$result->successful()) { + throw new KopsException('Failed to delete cluster', 500); + } + }); if (!Storage::disk('local')->deleteDirectory($cluster->kopsPath)) { throw new KopsException('Server Error', 500); @@ -98,21 +166,25 @@ public static function delete(Cluster $cluster) * @param Cluster $cluster * @param array $data * @param array $secretData - * @param array $portClaims + * + * @return Collection */ - private static function createFolder(object $item, Cluster $cluster, array $data = [], array $secretData = [], array $portClaims = []) + private static function createFolder(object $item, Cluster $cluster, array $data = [], array $secretData = []): Collection { - $path = $cluster->kopsPath . $item->object->path; + $files = collect(); + $path = $cluster->kopsPath . $item->object->path; Storage::disk('local')->makeDirectory($path); - $item->children?->each(function ($child) use ($cluster, $data, $secretData, $portClaims) { + $item->children?->each(function ($child) use ($cluster, $data, $secretData, &$files) { if ($child->type === 'file') { - self::createFile($child, $cluster, $data, $secretData, $portClaims); + $files->push(self::createFile($child, $cluster, $data, $secretData)); } elseif ($child->type === 'folder') { - self::createFolder($child, $cluster, $data, $secretData, $portClaims); + $files->concat(self::createFolder($child, $cluster, $data, $secretData)); } }); + + return $files; } /** @@ -122,9 +194,10 @@ private static function createFolder(object $item, Cluster $cluster, array $data * @param Cluster $cluster * @param array $data * @param array $secretData - * @param array $portClaims + * + * @return object|null */ - private static function createFile(object $item, Cluster $cluster, array $data = [], array $secretData = [], array $portClaims = []) + private static function createFile(object $item, Cluster $cluster, array $data = [], array $secretData = []) { $path = $cluster->kopsPath . $item->object->path; @@ -147,8 +220,12 @@ private static function createFile(object $item, Cluster $cluster, array $data = } Storage::disk('local')->put($path, YamlFormatter::format(Yaml::dump($templateContent, 10, 2))); + + return $item; } catch (Exception $e) { throw new KopsException('Server Error', 500); } + + return null; } } diff --git a/app/Http/Controllers/API/TemplateController.php b/app/Http/Controllers/API/TemplateController.php index e7222d9..227f265 100644 --- a/app/Http/Controllers/API/TemplateController.php +++ b/app/Http/Controllers/API/TemplateController.php @@ -1120,6 +1120,7 @@ public function action_add_file(string $template_id, Request $request) 'name' => ['required', 'string', 'max:255'], 'template_directory_id' => ['nullable', 'string', 'max:255'], 'mime_type' => ['required', 'string', 'max:255'], + 'sort' => ['nullable', 'integer'], ]); if ($validator->fails()) { @@ -1133,6 +1134,7 @@ public function action_add_file(string $template_id, Request $request) 'name' => $request->name, 'mime_type' => $request->mime_type, 'content' => '', + 'sort' => $request->sort, ]) ) { Deployment::where('delete', '=', false) @@ -1208,6 +1210,7 @@ public function action_update_file(string $template_id, string $file_id, Request 'template_directory_id' => ['nullable', 'string', 'max:255'], 'mime_type' => ['required', 'string', 'max:255'], 'content' => ['nullable', 'string'], + 'sort' => ['nullable', 'integer'], ]); if ($validator->fails()) { @@ -1224,6 +1227,7 @@ public function action_update_file(string $template_id, string $file_id, Request 'template_directory_id' => $request->template_directory_id, 'mime_type' => $request->mime_type, ...($request->content ? ['content' => $request->content] : []), + 'sort' => $request->sort, ]); Deployment::where('delete', '=', false) diff --git a/app/Http/Controllers/TemplateController.php b/app/Http/Controllers/TemplateController.php index f73abf7..6ffbd64 100644 --- a/app/Http/Controllers/TemplateController.php +++ b/app/Http/Controllers/TemplateController.php @@ -318,6 +318,7 @@ public function action_add_file(string $template_id, Request $request) 'name' => ['required', 'string', 'max:255'], 'template_directory_id' => ['nullable', 'string', 'max:255'], 'mime_type' => ['required', 'string', 'max:255'], + 'sort' => ['nullable', 'integer'], ])->validate(); if ( @@ -327,6 +328,7 @@ public function action_add_file(string $template_id, Request $request) 'name' => $request->name, 'mime_type' => $request->mime_type, 'content' => '', + 'sort' => $request->sort, ]) ) { Deployment::where('delete', '=', false) @@ -377,6 +379,7 @@ public function action_update_file(string $template_id, string $file_id, Request 'template_directory_id' => ['nullable', 'string', 'max:255'], 'mime_type' => ['required', 'string', 'max:255'], 'content' => ['nullable', 'string'], + 'sort' => ['nullable', 'integer'], ])->validate(); if ( @@ -389,6 +392,7 @@ public function action_update_file(string $template_id, string $file_id, Request 'template_directory_id' => $request->template_directory_id, 'mime_type' => $request->mime_type, ...($request->content ? ['content' => $request->content] : []), + 'sort' => $request->sort, ]); Deployment::where('delete', '=', false) diff --git a/app/Models/Projects/Templates/TemplateFile.php b/app/Models/Projects/Templates/TemplateFile.php index f0ad18b..56fecb5 100644 --- a/app/Models/Projects/Templates/TemplateFile.php +++ b/app/Models/Projects/Templates/TemplateFile.php @@ -30,6 +30,7 @@ * @OA\Property(property="name", type="string", example="File 1"), * @OA\Property(property="mime_type", type="string", example="text/plain"), * @OA\Property(property="content", type="string", example="Content of the file"), + * @OA\Property(property="sort", type="integer", example=0), * @OA\Property(property="created_at", type="string", format="date-time", example="2021-01-01 00:00:00", nullable=true), * @OA\Property(property="updated_at", type="string", format="date-time", example="2021-01-01 00:00:00", nullable=true), * @OA\Property(property="deleted_at", type="string", format="date-time", example="2021-01-01 00:00:00", nullable=true), @@ -42,6 +43,7 @@ * @property string $template_directory_id * @property string $name * @property string $mime_type + * @property int $sort * @property Carbon $created_at * @property Carbon $updated_at * @property Carbon $deleted_at @@ -101,6 +103,7 @@ public function getTreeAttribute(): object 'id' => $this->id, 'name' => $this->name, 'mime_type' => $this->mime_type, + 'sort' => $this->sort, ]; } diff --git a/config/s3server.php b/config/s3server.php index f1a9d8a..a3ca682 100644 --- a/config/s3server.php +++ b/config/s3server.php @@ -13,5 +13,5 @@ 'auth' => env('S3SERVER_AUTH', true), 'auth_driver' => env('S3SERVER_AUTH_DRIVER', LaravelS3Server\Drivers\DatabaseAuthenticationDriver::class), 'storage_driver' => env('S3SERVER_STORAGE_DRIVER', LaravelS3Server\Drivers\FileStorageDriver::class), - 'storage_path' => env('S3SERVER_STORAGE_PATH', 'kops/'), + 'storage_path' => env('S3SERVER_STORAGE_PATH', 's3/'), ]; diff --git a/database/migrations/2025_07_23_000001_update_template_tables.php b/database/migrations/2025_07_23_000001_update_template_tables.php index 186f2ba..756f6b0 100644 --- a/database/migrations/2025_07_23_000001_update_template_tables.php +++ b/database/migrations/2025_07_23_000001_update_template_tables.php @@ -16,6 +16,10 @@ public function up() $table->enum('type', ['application', 'cluster'])->default('application')->after('user_id'); }); + Schema::table('template_files', function (Blueprint $table) { + $table->integer('sort')->nullable()->after('content'); + }); + Schema::table('clusters', function (Blueprint $table) { $table->foreignUuid('template_id')->nullable()->after('project_id')->references('id')->on('templates'); $table->boolean('update')->default(false)->after('name'); @@ -70,6 +74,10 @@ public function down() $table->dropColumn('template_id'); }); + Schema::table('template_files', function (Blueprint $table) { + $table->dropColumn('sort'); + }); + Schema::table('templates', function (Blueprint $table) { $table->dropColumn('type'); }); diff --git a/resources/sass/app.scss b/resources/sass/app.scss index ac81594..578e444 100644 --- a/resources/sass/app.scss +++ b/resources/sass/app.scss @@ -542,3 +542,14 @@ main { overflow-wrap: break-word; word-break: break-word; } + +.badge-count { + width: 1.125rem; + height: 1.125rem; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + font-weight: 600; + color: var(--bs-white); +} diff --git a/resources/views/template/add-file.blade.php b/resources/views/template/add-file.blade.php index a8ba968..f56d465 100644 --- a/resources/views/template/add-file.blade.php +++ b/resources/views/template/add-file.blade.php @@ -65,6 +65,22 @@
+ @if ($template->type === 'cluster') +
+ + +
+ + + @error('sort') + + {{ $message }} + + @enderror +
+
+ @endif +
+ @if ($template->type === 'cluster') +
+ + +
+ + + @error('sort') + + {{ $message }} + + @enderror +
+
+ @endif +
@foreach ($templates as $template) - @if ($template->groupedFields->on_create->default->count() > 0 || $template->groupedFields->on_create->advanced->count() > 0) + @if ($template->groupedFields->on_create->default->count() > 0 || $template->groupedFields->on_create->advanced->count() > 0 || $template->environmentVariables->count() > 0) @endif + @if ($template->type === 'cluster') + @foreach ($template->environmentVariables as $environmentVariable) +
+ +
+ +
+
+ @endforeach + @endif
@endif diff --git a/resources/views/cluster/update.blade.php b/resources/views/cluster/update.blade.php index b856bc8..a6e4acc 100644 --- a/resources/views/cluster/update.blade.php +++ b/resources/views/cluster/update.blade.php @@ -48,7 +48,7 @@
- @if ($cluster->template->groupedFields->on_update->default->count() > 0 || $cluster->template->groupedFields->on_update->advanced->count() > 0) + @if ($cluster->template->groupedFields->on_update->default->count() > 0 || $cluster->template->groupedFields->on_update->advanced->count() > 0 || $cluster->template->environmentVariables->count() > 0)
@if ($cluster->template->groupedFields->on_update->default->count() > 0) @foreach ($cluster->template->groupedFields->on_update->default as $field) @@ -232,6 +232,22 @@ @endforeach
@endif + @if ($cluster->template->type === 'cluster') + @foreach ($cluster->template->environmentVariables as $environmentVariable) + @php + $environmentVariableValue = $cluster->environmentVariables->where('template_env_variable_id', $environmentVariable->id)->first()?->value; + @endphp +
+ +
+ +
+
+ @endforeach + @endif
@endif diff --git a/resources/views/template/add-env-variable.blade.php b/resources/views/template/add-env-variable.blade.php new file mode 100644 index 0000000..7aebcac --- /dev/null +++ b/resources/views/template/add-env-variable.blade.php @@ -0,0 +1,65 @@ +@extends('layouts.app') + +@section('content') +
+ + @if ($template->gitCredentials) +
+
+
+ + {{ __('This template is synced from a Git repository. Changing the template manually may result in unexpected behavior!') }} +
+
+
+ @endif +
+
+
+
{{ __('Add environment variable') }}
+ +
+ + @csrf + + +
+ + +
+ +
+
+ +
+ + +
+ + + @error('key') + {{ $message }} + @enderror +
+
+ +
+
+ +
+
+ +
+
+
+
+
+@endsection diff --git a/resources/views/template/env-variable-tree.blade.php b/resources/views/template/env-variable-tree.blade.php new file mode 100644 index 0000000..7025e72 --- /dev/null +++ b/resources/views/template/env-variable-tree.blade.php @@ -0,0 +1,27 @@ +@if ($template->environmentVariables->isEmpty()) +
+ + {{ __('No environment variables defined') }} +
+@else +
    + @foreach ($template->environmentVariables as $environmentVariable) +
  • + + +
    + {{ $environmentVariable->key }} +
    +
    + +
  • + @endforeach +
+@endif diff --git a/resources/views/template/index.blade.php b/resources/views/template/index.blade.php index cb624d9..d5fd496 100644 --- a/resources/views/template/index.blade.php +++ b/resources/views/template/index.blade.php @@ -56,6 +56,19 @@
@endif + @if ($template->type == 'cluster') +
+
+ {{ __('Environment Variables') }} + + + +
+
+ @include('template.env-variable-tree', ['template' => $template]) +
+
+ @endif
@endif
diff --git a/resources/views/template/update-env-variable.blade.php b/resources/views/template/update-env-variable.blade.php new file mode 100644 index 0000000..30473e2 --- /dev/null +++ b/resources/views/template/update-env-variable.blade.php @@ -0,0 +1,65 @@ +@extends('layouts.app') + +@section('content') +
+
+
+ + + +
+
+ @if ($template->gitCredentials) +
+
+
+ + {{ __('This template is synced from a Git repository. Changing the template manually may result in unexpected behavior!') }} +
+
+
+ @endif +
+
+
+
{{ __('Update environment variable') }}
+ +
+
+ @csrf + + +
+ + +
+ +
+
+ +
+ + +
+ + + @error('key') + {{ $message }} + @enderror +
+
+ +
+
+ +
+
+
+
+
+
+
+
+@endsection diff --git a/routes/api.php b/routes/api.php index b04ccfa..8851a22 100644 --- a/routes/api.php +++ b/routes/api.php @@ -67,6 +67,12 @@ Route::patch('/templates/{template_id}/ports/{port_id}', [App\Http\Controllers\API\TemplateController::class, 'action_update_port'])->name('api.template.port.update')->middleware('api.permission.guard:templates.ports.update'); Route::delete('/templates/{template_id}/ports/{port_id}', [App\Http\Controllers\API\TemplateController::class, 'action_delete_port'])->name('api.template.port.delete')->middleware('api.permission.guard:templates.ports.delete'); + Route::get('/templates/{template_id}/env-variables', [App\Http\Controllers\API\TemplateController::class, 'action_list_env_variable'])->name('api.template.env-variable.list')->middleware('api.permission.guard:templates.env-variables.view'); + Route::post('/templates/{template_id}/env-variables', [App\Http\Controllers\API\TemplateController::class, 'action_add_env_variable'])->name('api.template.env-variable.add')->middleware('api.permission.guard:templates.env-variables.add'); + Route::get('/templates/{template_id}/env-variables/{env_variable_id}', [App\Http\Controllers\API\TemplateController::class, 'action_get_env_variable'])->name('api.template.env-variable.get')->middleware('api.permission.guard:templates.env-variables.view'); + Route::patch('/templates/{template_id}/env-variables/{env_variable_id}', [App\Http\Controllers\API\TemplateController::class, 'action_update_env_variable'])->name('api.template.env-variable.update')->middleware('api.permission.guard:templates.env-variables.update'); + Route::delete('/templates/{template_id}/env-variables/{env_variable_id}', [App\Http\Controllers\API\TemplateController::class, 'action_delete_env_variable'])->name('api.template.env-variable.delete')->middleware('api.permission.guard:templates.env-variables.delete'); + Route::get('/projects/{project_id}/clusters', [App\Http\Controllers\API\ClusterController::class, 'action_list'])->name('api.cluster.list')->middleware('api.permission.guard:projects.clusters.view'); Route::post('/projects/{project_id}/clusters', [App\Http\Controllers\API\ClusterController::class, 'action_add'])->name('api.cluster.add')->middleware('api.permission.guard:projects.clusters.add'); Route::get('/projects/{project_id}/clusters/{cluster_id}', [App\Http\Controllers\API\ClusterController::class, 'action_get'])->name('api.cluster.get')->middleware('api.permission.guard:projects.clusters.view'); diff --git a/routes/web.php b/routes/web.php index 6447ec7..2d6ef02 100644 --- a/routes/web.php +++ b/routes/web.php @@ -77,6 +77,12 @@ Route::post('/templates/{template_id}/port/{port_id}/update', [App\Http\Controllers\TemplateController::class, 'action_update_port'])->name('template.port.update.action')->middleware('ui.permission.guard:templates.ports.update'); Route::get('/templates/{template_id}/port/{port_id}/delete', [App\Http\Controllers\TemplateController::class, 'action_delete_port'])->name('template.port.delete.action')->middleware('ui.permission.guard:templates.ports.delete'); + Route::get('/templates/{template_id}/env-variable/add', [App\Http\Controllers\TemplateController::class, 'page_add_env_variable'])->name('template.env-variable.add')->middleware('ui.permission.guard:templates.env-variables.add'); + Route::post('/templates/{template_id}/env-variable/add', [App\Http\Controllers\TemplateController::class, 'action_add_env_variable'])->name('template.env-variable.add.action')->middleware('ui.permission.guard:templates.env-variables.add'); + Route::get('/templates/{template_id}/env-variable/{env_variable_id}/update', [App\Http\Controllers\TemplateController::class, 'page_update_env_variable'])->name('template.env-variable.update')->middleware('ui.permission.guard:templates.env-variables.update'); + Route::post('/templates/{template_id}/env-variable/{env_variable_id}/update', [App\Http\Controllers\TemplateController::class, 'action_update_env_variable'])->name('template.env-variable.update.action')->middleware('ui.permission.guard:templates.env-variables.update'); + Route::get('/templates/{template_id}/env-variable/{env_variable_id}/delete', [App\Http\Controllers\TemplateController::class, 'action_delete_env_variable'])->name('template.env-variable.delete.action')->middleware('ui.permission.guard:templates.env-variables.delete'); + Route::get('/projects/{project_id}/clusters', [App\Http\Controllers\ClusterController::class, 'page_index'])->name('cluster.index')->middleware('ui.permission.guard:projects.clusters.view'); Route::get('/projects/{project_id}/clusters/add', [App\Http\Controllers\ClusterController::class, 'page_add'])->name('cluster.add')->middleware('ui.permission.guard:projects.clusters.add'); Route::post('/projects/{project_id}/clusters/add', [App\Http\Controllers\ClusterController::class, 'action_add'])->name('cluster.add.action')->middleware('ui.permission.guard:projects.clusters.add'); diff --git a/tests/Unit/Helpers/Kubernetes/HelmManifestsTest.php b/tests/Unit/Helpers/Kubernetes/HelmManifestsTest.php index ff39aa5..7f14afd 100644 --- a/tests/Unit/Helpers/Kubernetes/HelmManifestsTest.php +++ b/tests/Unit/Helpers/Kubernetes/HelmManifestsTest.php @@ -141,6 +141,12 @@ public function __construct($dirHolder) $this->dirHolder = $dirHolder; } + public function timeout($timeout) + { + // Return self to allow method chaining + return $this; + } + public function run($command) { // Check if this is a helm pull command