mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
Traefik and Caddy routes of applications now retry a failed connection on another container, and the rolling update waits a configurable drain time (default 5 seconds) after the new container is healthy before it stops the old containers. The wait is skipped when no old container runs or when the proxy labels changed, because the proxy drops a route that two containers define differently. Fixes #8627
514 lines
21 KiB
PHP
514 lines
21 KiB
PHP
<?php
|
|
|
|
use App\Actions\Shared\MigrateResourceToDestination;
|
|
use App\Enums\BuildPackTypes;
|
|
use App\Enums\HttpBasicAuthHashAlgorithm;
|
|
use App\Enums\RedirectTypes;
|
|
use App\Enums\StaticImageTypes;
|
|
use App\Models\Application;
|
|
use App\Models\ApplicationSetting;
|
|
use App\Models\Environment;
|
|
use App\Models\EnvironmentVariable;
|
|
use App\Models\ServerSetting;
|
|
use App\Models\StandaloneDocker;
|
|
use App\Models\SwarmDocker;
|
|
use App\Rules\ManualWebhookSecret;
|
|
use App\Rules\ValidGitBranch;
|
|
use App\Support\ValidationPatterns;
|
|
use Illuminate\Database\Eloquent\Collection;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Collection as SupportCollection;
|
|
use Illuminate\Support\Facades\Gate;
|
|
use Illuminate\Support\Facades\Validator;
|
|
use Illuminate\Validation\Rule;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
function getTeamIdFromToken()
|
|
{
|
|
$user = auth()->user();
|
|
$token = $user?->currentAccessToken();
|
|
$teamId = data_get($token, 'team_id');
|
|
|
|
if (! $user || is_null($teamId) || ! $user->teams()->where('teams.id', $teamId)->exists()) {
|
|
return null;
|
|
}
|
|
|
|
return $teamId;
|
|
}
|
|
function invalidTokenResponse()
|
|
{
|
|
return response()->json(['message' => 'Invalid token.', 'docs' => 'https://coolify.io/docs/api-reference/authorization'], 400);
|
|
}
|
|
|
|
function serializeApiResponse($data)
|
|
{
|
|
if ($data instanceof Collection) {
|
|
return $data->map(function ($d) {
|
|
$d = collect(apiEnvironmentVariableDisplayValue($d))->sortKeys();
|
|
$created_at = data_get($d, 'created_at');
|
|
$updated_at = data_get($d, 'updated_at');
|
|
if ($created_at) {
|
|
unset($d['created_at']);
|
|
$d['created_at'] = $created_at;
|
|
}
|
|
if ($updated_at) {
|
|
unset($d['updated_at']);
|
|
$d['updated_at'] = $updated_at;
|
|
}
|
|
if (data_get($d, 'name')) {
|
|
$d = $d->prepend($d['name'], 'name');
|
|
}
|
|
if (data_get($d, 'description')) {
|
|
$d = $d->prepend($d['description'], 'description');
|
|
}
|
|
if (data_get($d, 'uuid')) {
|
|
$d = $d->prepend($d['uuid'], 'uuid');
|
|
}
|
|
|
|
if (! is_null(data_get($d, 'id'))) {
|
|
$d = $d->prepend($d['id'], 'id');
|
|
}
|
|
|
|
return removeServerProxySecretsFromApiPayload($d);
|
|
});
|
|
} else {
|
|
$d = collect(apiEnvironmentVariableDisplayValue($data))->sortKeys();
|
|
$created_at = data_get($d, 'created_at');
|
|
$updated_at = data_get($d, 'updated_at');
|
|
if ($created_at) {
|
|
unset($d['created_at']);
|
|
$d['created_at'] = $created_at;
|
|
}
|
|
if ($updated_at) {
|
|
unset($d['updated_at']);
|
|
$d['updated_at'] = $updated_at;
|
|
}
|
|
if (data_get($d, 'name')) {
|
|
$d = $d->prepend($d['name'], 'name');
|
|
}
|
|
if (data_get($d, 'description')) {
|
|
$d = $d->prepend($d['description'], 'description');
|
|
}
|
|
if (data_get($d, 'uuid')) {
|
|
$d = $d->prepend($d['uuid'], 'uuid');
|
|
}
|
|
|
|
if (! is_null(data_get($d, 'id'))) {
|
|
$d = $d->prepend($d['id'], 'id');
|
|
}
|
|
|
|
return removeServerProxySecretsFromApiPayload($d);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Serialize an environment variable with its display value, so a reference to a
|
|
* locked shared variable keeps its reference text instead of the locked value.
|
|
*/
|
|
function apiEnvironmentVariableDisplayValue(mixed $data): mixed
|
|
{
|
|
if (! $data instanceof EnvironmentVariable || in_array('real_value', $data->getHidden(), true)) {
|
|
return $data;
|
|
}
|
|
|
|
return [...$data->toArray(), 'real_value' => $data->displayRealValue()];
|
|
}
|
|
|
|
/**
|
|
* Remove the saved proxy configuration and validation logs from every
|
|
* serialized server in an API payload, including nested ones such as
|
|
* `destination.server`, unless the token can read sensitive data.
|
|
* The saved proxy configuration often contains DNS provider API tokens.
|
|
*
|
|
* @template T
|
|
*
|
|
* @param T $payload
|
|
* @return T
|
|
*/
|
|
function removeServerProxySecretsFromApiPayload(mixed $payload): mixed
|
|
{
|
|
if (request()->attributes->get('can_read_sensitive', false) === true) {
|
|
return $payload;
|
|
}
|
|
|
|
return removeServerProxySecrets($payload);
|
|
}
|
|
|
|
/**
|
|
* @template T
|
|
*
|
|
* @param T $value
|
|
* @return T
|
|
*/
|
|
function removeServerProxySecrets(mixed $value): mixed
|
|
{
|
|
if (! is_array($value) && ! $value instanceof SupportCollection) {
|
|
return $value;
|
|
}
|
|
|
|
$proxy = $value['proxy'] ?? null;
|
|
if (is_array($proxy)) {
|
|
unset($proxy['last_saved_proxy_configuration']);
|
|
$value['proxy'] = $proxy;
|
|
unset($value['validation_logs']);
|
|
}
|
|
|
|
foreach ($value as $key => $item) {
|
|
if (is_array($item) || $item instanceof SupportCollection) {
|
|
$value[$key] = removeServerProxySecrets($item);
|
|
}
|
|
}
|
|
|
|
return $value;
|
|
}
|
|
|
|
/**
|
|
* Re-expose a model's `$hidden` sensitive fields when the current API request
|
|
* carries the `read:sensitive` or `root` token ability (set by the
|
|
* ApiSensitiveData middleware).
|
|
*/
|
|
function exposeSensitiveFields(Model $model): Model
|
|
{
|
|
if (request()->attributes->get('can_read_sensitive', false) === true && filled($model->getHidden())) {
|
|
$model->makeVisible($model->getHidden());
|
|
}
|
|
|
|
return $model;
|
|
}
|
|
|
|
/**
|
|
* Shared validation rules for application create and update API endpoints.
|
|
*
|
|
* Pass the existing application on update so unchanged manual webhook secrets
|
|
* are accepted even when they predate the minimum length.
|
|
*/
|
|
function sharedDataApplications(?Application $application = null): array
|
|
{
|
|
return [
|
|
'git_repository' => 'string',
|
|
'git_branch' => ['string', new ValidGitBranch],
|
|
'build_pack' => Rule::enum(BuildPackTypes::class),
|
|
'is_static' => 'boolean',
|
|
'is_spa' => 'boolean',
|
|
'is_auto_deploy_enabled' => 'boolean',
|
|
'is_force_https_enabled' => 'boolean',
|
|
'is_preview_deployments_enabled' => 'boolean',
|
|
'use_build_secrets' => 'boolean',
|
|
'is_git_submodules_enabled' => 'boolean',
|
|
'is_git_lfs_enabled' => 'boolean',
|
|
'is_git_shallow_clone_enabled' => 'boolean',
|
|
'disable_build_cache' => 'boolean',
|
|
'inject_build_args_to_dockerfile' => 'boolean',
|
|
'include_source_commit_in_build' => 'boolean',
|
|
'is_env_sorting_enabled' => 'boolean',
|
|
'redact_all_env_values_in_logs' => 'boolean',
|
|
'include_coolify_default_variables' => 'boolean',
|
|
'is_pr_deployments_public_enabled' => 'boolean',
|
|
'is_gzip_enabled' => 'boolean',
|
|
'is_stripprefix_enabled' => 'boolean',
|
|
'is_permanent_redirect_enabled' => 'boolean',
|
|
'traefik_certificate_resolver' => ['nullable', 'string', 'max:255', 'regex:'.ServerSetting::TRAEFIK_CERTIFICATE_RESOLVER_PATTERN],
|
|
'is_container_label_readonly_enabled' => 'boolean',
|
|
'is_raw_compose_deployment_enabled' => 'boolean',
|
|
'is_log_drain_enabled' => 'boolean',
|
|
'is_gpu_enabled' => 'boolean',
|
|
'gpu_driver' => 'string|nullable',
|
|
'gpu_count' => ['string', 'nullable', 'regex:'.ApplicationSetting::GPU_COUNT_PATTERN],
|
|
'gpu_device_ids' => 'string|nullable',
|
|
'gpu_options' => ['string', 'nullable', function (string $attribute, mixed $value, Closure $fail): void {
|
|
if (ApplicationSetting::parseGpuOptions($value) === null) {
|
|
$fail('The gpu_options must be key=value pairs or a YAML mapping.');
|
|
}
|
|
}],
|
|
'is_consistent_container_name_enabled' => 'boolean',
|
|
'custom_internal_name' => ['nullable', ...ValidationPatterns::containerNameRules()],
|
|
'custom_container_name_prefix' => 'string|nullable|max:'.ApplicationSetting::MAX_CONTAINER_NAME_PREFIX_LENGTH,
|
|
'preview_url_template' => 'string',
|
|
'max_restart_count' => 'integer|min:0',
|
|
'stop_grace_period' => 'nullable|integer|min:'.MIN_STOP_GRACE_PERIOD_SECONDS.'|max:'.MAX_STOP_GRACE_PERIOD_SECONDS,
|
|
'rolling_update_drain_seconds' => 'integer|min:0|max:'.MAX_ROLLING_UPDATE_DRAIN_SECONDS,
|
|
'docker_images_to_keep' => 'integer|min:0|max:100',
|
|
'static_image' => Rule::enum(StaticImageTypes::class),
|
|
'domains' => ValidationPatterns::applicationDomainRules(),
|
|
'noindex_domains' => 'array|nullable',
|
|
'noindex_domains.*' => 'string',
|
|
'redirect' => Rule::enum(RedirectTypes::class),
|
|
'git_commit_sha' => ['string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'],
|
|
'docker_registry_image_name' => ValidationPatterns::dockerImageNameRules(),
|
|
'docker_registry_image_tag' => ValidationPatterns::dockerImageTagRules(),
|
|
'install_command' => ValidationPatterns::shellSafeCommandRules(),
|
|
'build_command' => ValidationPatterns::shellSafeCommandRules(),
|
|
'start_command' => ValidationPatterns::shellSafeCommandRules(),
|
|
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/',
|
|
'ports_mappings' => 'string|regex:/^(\d+:\d+)(,\d+:\d+)*$/|nullable',
|
|
'custom_network_aliases' => 'string|nullable',
|
|
'base_directory' => ValidationPatterns::directoryPathRules(),
|
|
'publish_directory' => ValidationPatterns::directoryPathRules(),
|
|
'health_check_enabled' => 'boolean',
|
|
'health_check_type' => 'string|in:http,cmd',
|
|
'health_check_command' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'],
|
|
'health_check_path' => ['string', 'regex:#^[a-zA-Z0-9/\-_.~%,;]+$#'],
|
|
'health_check_port' => 'integer|nullable|min:1|max:65535',
|
|
'health_check_host' => ['string', function (string $attribute, mixed $value, Closure $fail): void {
|
|
if (! ValidationPatterns::isValidHealthCheckHost($value)) {
|
|
$fail('The health check host must be a hostname or an IP address.');
|
|
}
|
|
}],
|
|
'health_check_method' => 'string|in:GET,HEAD,POST,OPTIONS',
|
|
'health_check_return_code' => 'numeric',
|
|
'health_check_scheme' => 'string|in:http,https',
|
|
'health_check_response_text' => 'string|nullable',
|
|
'health_check_interval' => 'numeric',
|
|
'health_check_timeout' => 'numeric',
|
|
'health_check_retries' => 'numeric',
|
|
'health_check_start_period' => 'numeric',
|
|
'limits_memory' => 'string',
|
|
'limits_memory_swap' => 'string',
|
|
'limits_memory_swappiness' => 'numeric',
|
|
'limits_memory_reservation' => 'string',
|
|
'limits_cpus' => 'string',
|
|
'limits_cpuset' => 'string|nullable',
|
|
'limits_cpu_shares' => 'numeric',
|
|
'custom_labels' => 'string|nullable',
|
|
'custom_docker_run_options' => ValidationPatterns::shellSafeCommandRules(2000),
|
|
// Security: deployment commands are intentionally arbitrary shell (e.g. "php artisan migrate").
|
|
// Access is gated by API token authentication. Commands run inside the app container, not the host.
|
|
'post_deployment_command' => 'string|nullable',
|
|
'post_deployment_command_container' => ValidationPatterns::containerNameRules(),
|
|
'pre_deployment_command' => 'string|nullable',
|
|
'pre_deployment_command_container' => ValidationPatterns::containerNameRules(),
|
|
'manual_webhook_secret_github' => ['nullable', 'string', new ManualWebhookSecret($application?->manual_webhook_secret_github)],
|
|
'manual_webhook_secret_gitlab' => ['nullable', 'string', new ManualWebhookSecret($application?->manual_webhook_secret_gitlab)],
|
|
'manual_webhook_secret_bitbucket' => ['nullable', 'string', new ManualWebhookSecret($application?->manual_webhook_secret_bitbucket)],
|
|
'manual_webhook_secret_gitea' => ['nullable', 'string', new ManualWebhookSecret($application?->manual_webhook_secret_gitea)],
|
|
'dockerfile_location' => ValidationPatterns::filePathRules(),
|
|
'dockerfile_target_build' => ValidationPatterns::dockerTargetRules(),
|
|
'docker_compose_location' => ValidationPatterns::filePathRules(),
|
|
'docker_compose' => 'string|nullable',
|
|
'docker_compose_domains' => 'array|nullable',
|
|
'docker_compose_custom_start_command' => ValidationPatterns::shellSafeCommandRules(),
|
|
'docker_compose_custom_build_command' => ValidationPatterns::shellSafeCommandRules(),
|
|
'is_container_label_escape_enabled' => 'boolean',
|
|
'is_preserve_repository_enabled' => 'boolean',
|
|
'http_basic_auth_hash_algorithm' => Rule::enum(HttpBasicAuthHashAlgorithm::class),
|
|
'http_basic_auth_bcrypt_cost' => 'integer|min:4|max:14',
|
|
'http_basic_auth_argon2id_memory_cost' => 'integer|min:8192|max:262144',
|
|
'http_basic_auth_argon2id_time_cost' => 'integer|min:1|max:12',
|
|
];
|
|
}
|
|
|
|
function moveResourceToEnvironment(Request $request, $resource, string $resourceType, int $teamId): JsonResponse
|
|
{
|
|
|
|
$validator = Validator::make($request->all(), [
|
|
'environment_uuid' => 'required|string',
|
|
]);
|
|
|
|
if ($validator->fails()) {
|
|
return response()->json([
|
|
'message' => 'Validation failed.',
|
|
'errors' => $validator->errors(),
|
|
], 422);
|
|
}
|
|
|
|
$extraFields = array_diff(array_keys($request->all()), ['environment_uuid']);
|
|
if (! empty($extraFields)) {
|
|
return response()->json([
|
|
'message' => 'Validation failed.',
|
|
'errors' => collect($extraFields)->mapWithKeys(fn ($field) => [$field => 'This field is not allowed.'])->toArray(),
|
|
], 422);
|
|
}
|
|
|
|
$newEnvironment = Environment::ownedByCurrentTeamAPI($teamId)
|
|
->whereUuid($request->environment_uuid)
|
|
->first();
|
|
|
|
if (! $newEnvironment) {
|
|
return response()->json(['message' => 'Target environment not found or not owned by your team.'], 404);
|
|
}
|
|
|
|
Gate::authorize('update', $newEnvironment);
|
|
|
|
if ($resource->environment_id === $newEnvironment->id) {
|
|
return response()->json(['message' => "$resourceType is already in this environment."], 400);
|
|
}
|
|
|
|
$oldEnvironment = $resource->environment()->with('project')->first();
|
|
|
|
$resource->update(['environment_id' => $newEnvironment->id]);
|
|
|
|
auditLog('api.'.str($resourceType)->lower()->value().'.moved', [
|
|
'team_id' => $teamId,
|
|
'resource_uuid' => $resource->uuid,
|
|
'resource_type' => str($resourceType)->lower()->value(),
|
|
'from_project_uuid' => $oldEnvironment?->project?->uuid,
|
|
'from_environment_uuid' => $oldEnvironment?->uuid,
|
|
'to_project_uuid' => $newEnvironment->project->uuid,
|
|
'to_environment_uuid' => $newEnvironment->uuid,
|
|
]);
|
|
|
|
return response()->json([
|
|
'message' => "$resourceType moved successfully.",
|
|
'uuid' => $resource->uuid,
|
|
'project_uuid' => $newEnvironment->project->uuid,
|
|
'environment_uuid' => $newEnvironment->uuid,
|
|
]);
|
|
}
|
|
|
|
function migrateResourceToDestination(Request $request, $resource, string $resourceType, int $teamId): JsonResponse
|
|
{
|
|
if (! isDev()) {
|
|
abort(404);
|
|
}
|
|
|
|
$validator = Validator::make($request->all(), [
|
|
'destination_uuid' => 'required|string',
|
|
'migrate_volumes' => 'boolean',
|
|
]);
|
|
|
|
if ($validator->fails()) {
|
|
return response()->json([
|
|
'message' => 'Validation failed.',
|
|
'errors' => $validator->errors(),
|
|
], 422);
|
|
}
|
|
|
|
$allowedFields = ['destination_uuid', 'migrate_volumes'];
|
|
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
|
if (! empty($extraFields)) {
|
|
return response()->json([
|
|
'message' => 'Validation failed.',
|
|
'errors' => collect($extraFields)->mapWithKeys(fn ($field) => [$field => 'This field is not allowed.'])->toArray(),
|
|
], 422);
|
|
}
|
|
|
|
Gate::authorize('update', $resource);
|
|
|
|
$destination = StandaloneDocker::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->destination_uuid)->first()
|
|
?? SwarmDocker::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->destination_uuid)->first();
|
|
|
|
if (! $destination || ! $destination->server?->canHostResources()) {
|
|
return response()->json(['message' => 'Destination not found.'], 404);
|
|
}
|
|
|
|
$sourceDestination = $resource->destination;
|
|
$migrateVolumes = $request->boolean('migrate_volumes', true);
|
|
|
|
try {
|
|
$result = MigrateResourceToDestination::run(
|
|
$resource,
|
|
$destination,
|
|
$migrateVolumes,
|
|
);
|
|
} catch (ValidationException $e) {
|
|
return response()->json([
|
|
'message' => collect($e->errors())->flatten()->first() ?? $e->getMessage(),
|
|
'errors' => $e->errors(),
|
|
], 422);
|
|
}
|
|
|
|
auditLog('api.'.str($resourceType)->lower()->value().'.migrated', [
|
|
'team_id' => $teamId,
|
|
'resource_uuid' => $resource->uuid,
|
|
'resource_type' => str($resourceType)->lower()->value(),
|
|
'from_destination_uuid' => $sourceDestination?->uuid,
|
|
'to_destination_uuid' => $destination->uuid,
|
|
'from_server_id' => $sourceDestination?->server_id,
|
|
'to_server_id' => $destination->server_id,
|
|
'migrate_volumes' => $migrateVolumes,
|
|
'async' => $result['async'],
|
|
'volume_jobs' => $result['volume_jobs'],
|
|
]);
|
|
|
|
return response()->json([
|
|
'message' => $result['message'],
|
|
'uuid' => $resource->uuid,
|
|
'destination_uuid' => $destination->uuid,
|
|
'async' => $result['async'],
|
|
'volume_jobs' => $result['volume_jobs'],
|
|
]);
|
|
}
|
|
|
|
function validateIncomingRequest(Request $request)
|
|
{
|
|
// check if request is json
|
|
if (! $request->isJson()) {
|
|
return response()->json([
|
|
'message' => 'Invalid request.',
|
|
'error' => 'Content-Type must be application/json.',
|
|
], 400);
|
|
}
|
|
// check if request is valid json
|
|
if (! json_decode($request->getContent())) {
|
|
return response()->json([
|
|
'message' => 'Invalid request.',
|
|
'error' => 'Invalid JSON.',
|
|
], 400);
|
|
}
|
|
// check if valid json is empty
|
|
if (empty($request->json()->all())) {
|
|
return response()->json([
|
|
'message' => 'Invalid request.',
|
|
'error' => 'Empty JSON.',
|
|
], 400);
|
|
}
|
|
}
|
|
|
|
function removeUnnecessaryFieldsFromRequest(Request $request)
|
|
{
|
|
$request->offsetUnset('project_uuid');
|
|
$request->offsetUnset('environment_name');
|
|
$request->offsetUnset('environment_uuid');
|
|
$request->offsetUnset('destination_uuid');
|
|
$request->offsetUnset('server_uuid');
|
|
$request->offsetUnset('type');
|
|
$request->offsetUnset('domains');
|
|
$request->offsetUnset('instant_deploy');
|
|
$request->offsetUnset('github_app_uuid');
|
|
$request->offsetUnset('private_key_uuid');
|
|
$request->offsetUnset('use_build_server');
|
|
$request->offsetUnset('use_build_secrets');
|
|
$request->offsetUnset('is_static');
|
|
$request->offsetUnset('is_spa');
|
|
$request->offsetUnset('is_auto_deploy_enabled');
|
|
$request->offsetUnset('is_force_https_enabled');
|
|
$request->offsetUnset('is_preview_deployments_enabled');
|
|
$request->offsetUnset('connect_to_docker_network');
|
|
$request->offsetUnset('force_domain_override');
|
|
$request->offsetUnset('autogenerate_domain');
|
|
$request->offsetUnset('is_container_label_escape_enabled');
|
|
$request->offsetUnset('is_preserve_repository_enabled');
|
|
$request->offsetUnset('include_source_commit_in_build');
|
|
$request->offsetUnset('is_git_submodules_enabled');
|
|
$request->offsetUnset('is_git_lfs_enabled');
|
|
$request->offsetUnset('is_git_shallow_clone_enabled');
|
|
$request->offsetUnset('disable_build_cache');
|
|
$request->offsetUnset('inject_build_args_to_dockerfile');
|
|
$request->offsetUnset('is_env_sorting_enabled');
|
|
$request->offsetUnset('redact_all_env_values_in_logs');
|
|
$request->offsetUnset('include_coolify_default_variables');
|
|
$request->offsetUnset('is_pr_deployments_public_enabled');
|
|
$request->offsetUnset('stop_grace_period');
|
|
$request->offsetUnset('rolling_update_drain_seconds');
|
|
$request->offsetUnset('docker_images_to_keep');
|
|
$request->offsetUnset('is_gzip_enabled');
|
|
$request->offsetUnset('is_stripprefix_enabled');
|
|
$request->offsetUnset('is_permanent_redirect_enabled');
|
|
$request->offsetUnset('traefik_certificate_resolver');
|
|
$request->offsetUnset('is_container_label_readonly_enabled');
|
|
$request->offsetUnset('is_raw_compose_deployment_enabled');
|
|
$request->offsetUnset('is_log_drain_enabled');
|
|
$request->offsetUnset('is_gpu_enabled');
|
|
$request->offsetUnset('gpu_driver');
|
|
$request->offsetUnset('gpu_count');
|
|
$request->offsetUnset('gpu_device_ids');
|
|
$request->offsetUnset('gpu_options');
|
|
$request->offsetUnset('is_consistent_container_name_enabled');
|
|
$request->offsetUnset('custom_internal_name');
|
|
$request->offsetUnset('custom_container_name_prefix');
|
|
$request->offsetUnset('docker_compose_raw');
|
|
$request->offsetUnset('tags');
|
|
}
|