Merge remote-tracking branch 'origin/next' into pr-8371-v4.x

This commit is contained in:
Andras Bacsai
2026-03-29 23:52:37 +02:00
622 changed files with 52885 additions and 5894 deletions
@@ -11,6 +11,8 @@ use App\Jobs\DeleteResourceJob;
use App\Models\Application;
use App\Models\EnvironmentVariable;
use App\Models\GithubApp;
use App\Models\LocalFileVolume;
use App\Models\LocalPersistentVolume;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
@@ -18,9 +20,11 @@ use App\Models\Service;
use App\Rules\ValidGitBranch;
use App\Rules\ValidGitRepositoryUrl;
use App\Services\DockerImageParser;
use App\Support\ValidationPatterns;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use OpenApi\Attributes as OA;
use Spatie\Url\Url;
@@ -1002,10 +1006,10 @@ class ApplicationsController extends Controller
$this->authorize('create', Application::class);
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled'];
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled'];
$validator = customApiValidator($request->all(), [
'name' => 'string|max:255',
@@ -1099,13 +1103,23 @@ class ApplicationsController extends Controller
return response()->json(['message' => 'Server has multiple destinations and you do not set destination_uuid.'], 400);
}
$destination = $destinations->first();
if ($destinations->count() > 1 && $request->has('destination_uuid')) {
$destination = $destinations->where('uuid', $request->destination_uuid)->first();
if (! $destination) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'destination_uuid' => 'Provided destination_uuid does not belong to the specified server.',
],
], 422);
}
}
if ($type === 'public') {
$validationRules = [
'git_repository' => ['string', 'required', new ValidGitRepositoryUrl],
'git_branch' => ['string', 'required', new ValidGitBranch],
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
'docker_compose_location' => 'string',
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required',
@@ -1141,14 +1155,14 @@ class ApplicationsController extends Controller
$request->offsetSet('name', generate_application_name($request->git_repository, $request->git_branch));
}
$return = $this->validateDataApplications($request, $server);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
$application = new Application;
removeUnnecessaryFieldsFromRequest($request);
$application->fill($request->all());
$application->fill($request->only($allowedFields));
$dockerComposeDomainsJson = collect();
if ($request->has('docker_compose_domains')) {
$dockerComposeDomains = collect($request->docker_compose_domains);
@@ -1305,7 +1319,6 @@ class ApplicationsController extends Controller
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
'github_app_uuid' => 'string|required',
'watch_paths' => 'string|nullable',
'docker_compose_location' => 'string',
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required',
@@ -1341,7 +1354,7 @@ class ApplicationsController extends Controller
}
$return = $this->validateDataApplications($request, $server);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
$githubApp = GithubApp::whereTeamId($teamId)->where('uuid', $githubAppUuid)->first();
@@ -1380,7 +1393,7 @@ class ApplicationsController extends Controller
$application = new Application;
removeUnnecessaryFieldsFromRequest($request);
$application->fill($request->all());
$application->fill($request->only($allowedFields));
$dockerComposeDomainsJson = collect();
if ($request->has('docker_compose_domains')) {
@@ -1537,7 +1550,6 @@ class ApplicationsController extends Controller
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
'private_key_uuid' => 'string|required',
'watch_paths' => 'string|nullable',
'docker_compose_location' => 'string',
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required',
@@ -1574,7 +1586,7 @@ class ApplicationsController extends Controller
}
$return = $this->validateDataApplications($request, $server);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
$privateKey = PrivateKey::whereTeamId($teamId)->where('uuid', $request->private_key_uuid)->first();
@@ -1585,7 +1597,7 @@ class ApplicationsController extends Controller
$application = new Application;
removeUnnecessaryFieldsFromRequest($request);
$application->fill($request->all());
$application->fill($request->only($allowedFields));
$dockerComposeDomainsJson = collect();
if ($request->has('docker_compose_domains')) {
@@ -1747,7 +1759,7 @@ class ApplicationsController extends Controller
}
$return = $this->validateDataApplications($request, $server);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
if (! isBase64Encoded($request->dockerfile)) {
@@ -1776,7 +1788,7 @@ class ApplicationsController extends Controller
}
$application = new Application;
$application->fill($request->all());
$application->fill($request->only($allowedFields));
$application->fqdn = $fqdn;
$application->ports_exposes = $port;
$application->build_pack = 'dockerfile';
@@ -1855,7 +1867,7 @@ class ApplicationsController extends Controller
$request->offsetSet('name', 'docker-image-'.new Cuid2);
}
$return = $this->validateDataApplications($request, $server);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
// Process docker image name and tag using DockerImageParser
@@ -1888,7 +1900,7 @@ class ApplicationsController extends Controller
$application = new Application;
removeUnnecessaryFieldsFromRequest($request);
$application->fill($request->all());
$application->fill($request->only($allowedFields));
$application->fqdn = $fqdn;
$application->build_pack = 'dockerimage';
$application->destination_id = $destination->id;
@@ -1979,7 +1991,7 @@ class ApplicationsController extends Controller
], 422);
}
$return = $this->validateDataApplications($request, $server);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
if (! isBase64Encoded($request->docker_compose_raw)) {
@@ -2004,7 +2016,7 @@ class ApplicationsController extends Controller
$service = new Service;
removeUnnecessaryFieldsFromRequest($request);
$service->fill($request->all());
$service->fill($request->only($allowedFields));
$service->docker_compose_raw = $dockerComposeRaw;
$service->environment_id = $environment->id;
@@ -2466,7 +2478,7 @@ class ApplicationsController extends Controller
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
@@ -2480,20 +2492,17 @@ class ApplicationsController extends Controller
$this->authorize('update', $application);
$server = $application->destination->server;
$allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled'];
$allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled'];
$validationRules = [
'name' => 'string|max:255',
'description' => 'string|nullable',
'static_image' => 'string',
'watch_paths' => 'string|nullable',
'docker_compose_location' => 'string',
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required',
'docker_compose_domains.*.domain' => 'string|nullable',
'docker_compose_custom_start_command' => 'string|nullable',
'docker_compose_custom_build_command' => 'string|nullable',
'custom_nginx_configuration' => 'string|nullable',
'is_http_basic_auth_enabled' => 'boolean|nullable',
'http_basic_auth_username' => 'string',
@@ -2539,7 +2548,7 @@ class ApplicationsController extends Controller
}
}
$return = $this->validateDataApplications($request, $server);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
@@ -2771,7 +2780,7 @@ class ApplicationsController extends Controller
}
removeUnnecessaryFieldsFromRequest($request);
$data = $request->all();
$data = $request->only($allowedFields);
if ($requestHasDomains && $server->isProxyShouldRun()) {
data_set($data, 'fqdn', $domains);
}
@@ -2939,10 +2948,7 @@ class ApplicationsController extends Controller
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'Environment variable updated.'],
]
ref: '#/components/schemas/EnvironmentVariable'
)
),
]
@@ -2963,7 +2969,7 @@ class ApplicationsController extends Controller
)]
public function update_env_by_uuid(Request $request)
{
$allowedFields = ['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime'];
$allowedFields = ['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime', 'comment'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -2971,10 +2977,10 @@ class ApplicationsController extends Controller
}
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first();
if (! $application) {
return response()->json([
@@ -2993,6 +2999,7 @@ class ApplicationsController extends Controller
'is_shown_once' => 'boolean',
'is_runtime' => 'boolean',
'is_buildtime' => 'boolean',
'comment' => 'string|nullable|max:256',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
@@ -3034,6 +3041,9 @@ class ApplicationsController extends Controller
if ($request->has('is_buildtime') && $env->is_buildtime != $request->is_buildtime) {
$env->is_buildtime = $request->is_buildtime;
}
if ($request->has('comment') && $env->comment != $request->comment) {
$env->comment = $request->comment;
}
$env->save();
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
@@ -3064,6 +3074,9 @@ class ApplicationsController extends Controller
if ($request->has('is_buildtime') && $env->is_buildtime != $request->is_buildtime) {
$env->is_buildtime = $request->is_buildtime;
}
if ($request->has('comment') && $env->comment != $request->comment) {
$env->comment = $request->comment;
}
$env->save();
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
@@ -3165,10 +3178,10 @@ class ApplicationsController extends Controller
}
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first();
if (! $application) {
return response()->json([
@@ -3185,7 +3198,7 @@ class ApplicationsController extends Controller
], 400);
}
$bulk_data = collect($bulk_data)->map(function ($item) {
return collect($item)->only(['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime']);
return collect($item)->only(['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime', 'comment']);
});
$returnedEnvs = collect();
foreach ($bulk_data as $item) {
@@ -3198,6 +3211,7 @@ class ApplicationsController extends Controller
'is_shown_once' => 'boolean',
'is_runtime' => 'boolean',
'is_buildtime' => 'boolean',
'comment' => 'string|nullable|max:256',
]);
if ($validator->fails()) {
return response()->json([
@@ -3230,6 +3244,9 @@ class ApplicationsController extends Controller
if ($item->has('is_buildtime') && $env->is_buildtime != $item->get('is_buildtime')) {
$env->is_buildtime = $item->get('is_buildtime');
}
if ($item->has('comment') && $env->comment != $item->get('comment')) {
$env->comment = $item->get('comment');
}
$env->save();
} else {
$env = $application->environment_variables()->create([
@@ -3241,6 +3258,7 @@ class ApplicationsController extends Controller
'is_shown_once' => $is_shown_once,
'is_runtime' => $item->get('is_runtime', true),
'is_buildtime' => $item->get('is_buildtime', true),
'comment' => $item->get('comment'),
'resourceable_type' => get_class($application),
'resourceable_id' => $application->id,
]);
@@ -3264,6 +3282,9 @@ class ApplicationsController extends Controller
if ($item->has('is_buildtime') && $env->is_buildtime != $item->get('is_buildtime')) {
$env->is_buildtime = $item->get('is_buildtime');
}
if ($item->has('comment') && $env->comment != $item->get('comment')) {
$env->comment = $item->get('comment');
}
$env->save();
} else {
$env = $application->environment_variables()->create([
@@ -3275,6 +3296,7 @@ class ApplicationsController extends Controller
'is_shown_once' => $is_shown_once,
'is_runtime' => $item->get('is_runtime', true),
'is_buildtime' => $item->get('is_buildtime', true),
'comment' => $item->get('comment'),
'resourceable_type' => get_class($application),
'resourceable_id' => $application->id,
]);
@@ -3356,13 +3378,13 @@ class ApplicationsController extends Controller
)]
public function create_env(Request $request)
{
$allowedFields = ['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime'];
$allowedFields = ['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime', 'comment'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first();
if (! $application) {
return response()->json([
@@ -3381,6 +3403,7 @@ class ApplicationsController extends Controller
'is_shown_once' => 'boolean',
'is_runtime' => 'boolean',
'is_buildtime' => 'boolean',
'comment' => 'string|nullable|max:256',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
@@ -3416,6 +3439,7 @@ class ApplicationsController extends Controller
'is_shown_once' => $request->is_shown_once ?? false,
'is_runtime' => $request->is_runtime ?? true,
'is_buildtime' => $request->is_buildtime ?? true,
'comment' => $request->comment ?? null,
'resourceable_type' => get_class($application),
'resourceable_id' => $application->id,
]);
@@ -3440,6 +3464,7 @@ class ApplicationsController extends Controller
'is_shown_once' => $request->is_shown_once ?? false,
'is_runtime' => $request->is_runtime ?? true,
'is_buildtime' => $request->is_buildtime ?? true,
'comment' => $request->comment ?? null,
'resourceable_type' => get_class($application),
'resourceable_id' => $application->id,
]);
@@ -3516,7 +3541,7 @@ class ApplicationsController extends Controller
if (is_null($teamId)) {
return invalidTokenResponse();
}
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first();
if (! $application) {
return response()->json([
@@ -3526,7 +3551,7 @@ class ApplicationsController extends Controller
$this->authorize('manageEnvironment', $application);
$found_env = EnvironmentVariable::where('uuid', $request->env_uuid)
$found_env = EnvironmentVariable::where('uuid', $request->route('env_uuid'))
->where('resourceable_type', Application::class)
->where('resourceable_id', $application->id)
->first();
@@ -3676,6 +3701,15 @@ class ApplicationsController extends Controller
type: 'string',
)
),
new OA\Parameter(
name: 'docker_cleanup',
in: 'query',
description: 'Perform docker cleanup (prune networks, volumes, etc.).',
schema: new OA\Schema(
type: 'boolean',
default: true,
)
),
],
responses: [
new OA\Response(
@@ -3724,7 +3758,8 @@ class ApplicationsController extends Controller
$this->authorize('deploy', $application);
StopApplication::dispatch($application);
$dockerCleanup = $request->boolean('docker_cleanup', true);
StopApplication::dispatch($application, false, $dockerCleanup);
return response()->json(
[
@@ -3915,4 +3950,528 @@ class ApplicationsController extends Controller
}
}
}
#[OA\Get(
summary: 'List Storages',
description: 'List all persistent storages and file storages by application UUID.',
path: '/applications/{uuid}/storages',
operationId: 'list-storages-by-application-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Applications'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the application.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
responses: [
new OA\Response(
response: 200,
description: 'All storages by application UUID.',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'persistent_storages', type: 'array', items: new OA\Items(type: 'object')),
new OA\Property(property: 'file_storages', type: 'array', items: new OA\Items(type: 'object')),
],
),
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function storages(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
if (! $application) {
return response()->json([
'message' => 'Application not found',
], 404);
}
$this->authorize('view', $application);
$persistentStorages = $application->persistentStorages->sortBy('id')->values();
$fileStorages = $application->fileStorages->sortBy('id')->values();
return response()->json([
'persistent_storages' => $persistentStorages,
'file_storages' => $fileStorages,
]);
}
#[OA\Patch(
summary: 'Update Storage',
description: 'Update a persistent storage or file storage by application UUID.',
path: '/applications/{uuid}/storages',
operationId: 'update-storage-by-application-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Applications'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the application.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
requestBody: new OA\RequestBody(
description: 'Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.',
required: true,
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['type'],
properties: [
'uuid' => ['type' => 'string', 'description' => 'The UUID of the storage (preferred).'],
'id' => ['type' => 'integer', 'description' => 'The ID of the storage (deprecated, use uuid instead).'],
'type' => ['type' => 'string', 'enum' => ['persistent', 'file'], 'description' => 'The type of storage: persistent or file.'],
'is_preview_suffix_enabled' => ['type' => 'boolean', 'description' => 'Whether to add -pr-N suffix for preview deployments.'],
'name' => ['type' => 'string', 'description' => 'The volume name (persistent only, not allowed for read-only storages).'],
'mount_path' => ['type' => 'string', 'description' => 'The container mount path (not allowed for read-only storages).'],
'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path (persistent only, not allowed for read-only storages).'],
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages).'],
],
additionalProperties: false,
),
),
],
),
responses: [
new OA\Response(
response: 200,
description: 'Storage updated.',
content: new OA\JsonContent(type: 'object'),
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function update_storage(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first();
if (! $application) {
return response()->json([
'message' => 'Application not found',
], 404);
}
$this->authorize('update', $application);
$validator = customApiValidator($request->all(), [
'uuid' => 'string',
'id' => 'integer',
'type' => 'required|string|in:persistent,file',
'is_preview_suffix_enabled' => 'boolean',
'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN],
'mount_path' => 'string',
'host_path' => 'string|nullable',
'content' => 'string|nullable',
]);
$allAllowedFields = ['uuid', 'id', 'type', 'is_preview_suffix_enabled', 'name', 'mount_path', 'host_path', 'content'];
$extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
if (! empty($extraFields)) {
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
$storageUuid = $request->input('uuid');
$storageId = $request->input('id');
if (! $storageUuid && ! $storageId) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['uuid' => 'Either uuid or id is required.'],
], 422);
}
$lookupField = $storageUuid ? 'uuid' : 'id';
$lookupValue = $storageUuid ?? $storageId;
if ($request->type === 'persistent') {
$storage = $application->persistentStorages->where($lookupField, $lookupValue)->first();
} else {
$storage = $application->fileStorages->where($lookupField, $lookupValue)->first();
}
if (! $storage) {
return response()->json([
'message' => 'Storage not found.',
], 404);
}
$isReadOnly = $storage->shouldBeReadOnlyInUI();
$editableOnlyFields = ['name', 'mount_path', 'host_path', 'content'];
$requestedEditableFields = array_intersect($editableOnlyFields, array_keys($request->all()));
if ($isReadOnly && ! empty($requestedEditableFields)) {
return response()->json([
'message' => 'This storage is read-only (managed by docker-compose or service definition). Only is_preview_suffix_enabled can be updated.',
'read_only_fields' => array_values($requestedEditableFields),
], 422);
}
// Reject fields that don't apply to the given storage type
if (! $isReadOnly) {
$typeSpecificInvalidFields = $request->type === 'persistent'
? array_intersect(['content'], array_keys($request->all()))
: array_intersect(['name', 'host_path'], array_keys($request->all()));
if (! empty($typeSpecificInvalidFields)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => collect($typeSpecificInvalidFields)
->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type '{$request->type}'."]),
], 422);
}
}
// Always allowed
if ($request->has('is_preview_suffix_enabled')) {
$storage->is_preview_suffix_enabled = $request->is_preview_suffix_enabled;
}
// Only for editable storages
if (! $isReadOnly) {
if ($request->type === 'persistent') {
if ($request->has('name')) {
$storage->name = $request->name;
}
if ($request->has('mount_path')) {
$storage->mount_path = $request->mount_path;
}
if ($request->has('host_path')) {
$storage->host_path = $request->host_path;
}
} else {
if ($request->has('mount_path')) {
$storage->mount_path = $request->mount_path;
}
if ($request->has('content')) {
$storage->content = $request->content;
}
}
}
$storage->save();
return response()->json($storage);
}
#[OA\Post(
summary: 'Create Storage',
description: 'Create a persistent storage or file storage for an application.',
path: '/applications/{uuid}/storages',
operationId: 'create-storage-by-application-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Applications'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the application.',
required: true,
schema: new OA\Schema(type: 'string')
),
],
requestBody: new OA\RequestBody(
required: true,
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['type', 'mount_path'],
properties: [
'type' => ['type' => 'string', 'enum' => ['persistent', 'file'], 'description' => 'The type of storage.'],
'name' => ['type' => 'string', 'description' => 'Volume name (persistent only, required for persistent).'],
'mount_path' => ['type' => 'string', 'description' => 'The container mount path.'],
'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path (persistent only, optional).'],
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional).'],
'is_directory' => ['type' => 'boolean', 'description' => 'Whether this is a directory mount (file only, default false).'],
'fs_path' => ['type' => 'string', 'description' => 'Host directory path (required when is_directory is true).'],
],
additionalProperties: false,
),
),
],
),
responses: [
new OA\Response(
response: 201,
description: 'Storage created.',
content: new OA\JsonContent(type: 'object'),
),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
]
)]
public function create_storage(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
if (! $application) {
return response()->json(['message' => 'Application not found.'], 404);
}
$this->authorize('update', $application);
$validator = customApiValidator($request->all(), [
'type' => 'required|string|in:persistent,file',
'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN],
'mount_path' => 'required|string',
'host_path' => 'string|nullable',
'content' => 'string|nullable',
'is_directory' => 'boolean',
'fs_path' => 'string',
]);
$allAllowedFields = ['type', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'fs_path'];
$extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
if (! empty($extraFields)) {
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
if ($request->type === 'persistent') {
if (! $request->name) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['name' => 'The name field is required for persistent storages.'],
], 422);
}
$typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'fs_path'], array_keys($request->all()));
if (! empty($typeSpecificInvalidFields)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => collect($typeSpecificInvalidFields)
->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type 'persistent'."]),
], 422);
}
$storage = LocalPersistentVolume::create([
'name' => $application->uuid.'-'.$request->name,
'mount_path' => $request->mount_path,
'host_path' => $request->host_path,
'resource_id' => $application->id,
'resource_type' => $application->getMorphClass(),
]);
return response()->json($storage, 201);
}
// File storage
$typeSpecificInvalidFields = array_intersect(['name', 'host_path'], array_keys($request->all()));
if (! empty($typeSpecificInvalidFields)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => collect($typeSpecificInvalidFields)
->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type 'file'."]),
], 422);
}
$isDirectory = $request->boolean('is_directory', false);
if ($isDirectory) {
if (! $request->fs_path) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['fs_path' => 'The fs_path field is required for directory mounts.'],
], 422);
}
$fsPath = str($request->fs_path)->trim()->start('/')->value();
$mountPath = str($request->mount_path)->trim()->start('/')->value();
validateShellSafePath($fsPath, 'storage source path');
validateShellSafePath($mountPath, 'storage destination path');
$storage = LocalFileVolume::create([
'fs_path' => $fsPath,
'mount_path' => $mountPath,
'is_directory' => true,
'resource_id' => $application->id,
'resource_type' => get_class($application),
]);
} else {
$mountPath = str($request->mount_path)->trim()->start('/')->value();
validateShellSafePath($mountPath, 'file storage path');
$fsPath = application_configuration_dir().'/'.$application->uuid.$mountPath;
$storage = LocalFileVolume::create([
'fs_path' => $fsPath,
'mount_path' => $mountPath,
'content' => $request->content,
'is_directory' => false,
'resource_id' => $application->id,
'resource_type' => get_class($application),
]);
}
return response()->json($storage, 201);
}
#[OA\Delete(
summary: 'Delete Storage',
description: 'Delete a persistent storage or file storage by application UUID.',
path: '/applications/{uuid}/storages/{storage_uuid}',
operationId: 'delete-storage-by-application-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Applications'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the application.',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'storage_uuid',
in: 'path',
description: 'UUID of the storage.',
required: true,
schema: new OA\Schema(type: 'string')
),
],
responses: [
new OA\Response(response: 200, description: 'Storage deleted.', content: new OA\JsonContent(
properties: [new OA\Property(property: 'message', type: 'string')],
)),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
]
)]
public function delete_storage(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
if (! $application) {
return response()->json(['message' => 'Application not found.'], 404);
}
$this->authorize('update', $application);
$storageUuid = $request->route('storage_uuid');
$storage = $application->persistentStorages->where('uuid', $storageUuid)->first();
if (! $storage) {
$storage = $application->fileStorages->where('uuid', $storageUuid)->first();
}
if (! $storage) {
return response()->json(['message' => 'Storage not found.'], 404);
}
if ($storage->shouldBeReadOnlyInUI()) {
return response()->json([
'message' => 'This storage is read-only (managed by docker-compose or service definition) and cannot be deleted.',
], 422);
}
if ($storage instanceof LocalFileVolume) {
$storage->deleteStorageOnServer();
}
$storage->delete();
return response()->json(['message' => 'Storage deleted.']);
}
}