Merge remote-tracking branch 'origin/next' into next
This commit is contained in:
commit
2ba62ffe02
14 changed files with 802 additions and 15 deletions
|
|
@ -18,6 +18,7 @@
|
|||
use App\Rules\ValidGitBranch;
|
||||
use App\Rules\ValidGitRepositoryUrl;
|
||||
use App\Services\DockerImageParser;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
|
@ -3919,4 +3920,260 @@ private function validateDataApplications(Request $request, Server $server)
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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: ['id', 'type'],
|
||||
properties: [
|
||||
'id' => ['type' => 'integer', 'description' => 'The ID of the storage.'],
|
||||
'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 \Illuminate\Http\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(), [
|
||||
'id' => 'required|integer',
|
||||
'type' => 'required|string|in:persistent,file',
|
||||
'is_preview_suffix_enabled' => 'boolean',
|
||||
'name' => 'string',
|
||||
'mount_path' => 'string',
|
||||
'host_path' => 'string|nullable',
|
||||
'content' => 'string|nullable',
|
||||
]);
|
||||
|
||||
$allAllowedFields = ['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);
|
||||
}
|
||||
|
||||
if ($request->type === 'persistent') {
|
||||
$storage = $application->persistentStorages->where('id', $request->id)->first();
|
||||
} else {
|
||||
$storage = $application->fileStorages->where('id', $request->id)->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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2745,7 +2745,8 @@ private function generate_local_persistent_volumes()
|
|||
} else {
|
||||
$volume_name = $persistentStorage->name;
|
||||
}
|
||||
if ($this->pull_request_id !== 0) {
|
||||
$isPreviewSuffixEnabled = (bool) data_get($persistentStorage, 'is_preview_suffix_enabled', true);
|
||||
if ($this->pull_request_id !== 0 && $isPreviewSuffixEnabled) {
|
||||
$volume_name = addPreviewDeploymentSuffix($volume_name, $this->pull_request_id);
|
||||
}
|
||||
$local_persistent_volumes[] = $volume_name.':'.$persistentStorage->mount_path;
|
||||
|
|
@ -2763,7 +2764,8 @@ private function generate_local_persistent_volumes_only_volume_names()
|
|||
}
|
||||
$name = $persistentStorage->name;
|
||||
|
||||
if ($this->pull_request_id !== 0) {
|
||||
$isPreviewSuffixEnabled = (bool) data_get($persistentStorage, 'is_preview_suffix_enabled', true);
|
||||
if ($this->pull_request_id !== 0 && $isPreviewSuffixEnabled) {
|
||||
$name = addPreviewDeploymentSuffix($name, $this->pull_request_id);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -40,12 +40,16 @@ class FileStorage extends Component
|
|||
#[Validate(['required', 'boolean'])]
|
||||
public bool $isBasedOnGit = false;
|
||||
|
||||
#[Validate(['required', 'boolean'])]
|
||||
public bool $isPreviewSuffixEnabled = true;
|
||||
|
||||
protected $rules = [
|
||||
'fileStorage.is_directory' => 'required',
|
||||
'fileStorage.fs_path' => 'required',
|
||||
'fileStorage.mount_path' => 'required',
|
||||
'content' => 'nullable',
|
||||
'isBasedOnGit' => 'required|boolean',
|
||||
'isPreviewSuffixEnabled' => 'required|boolean',
|
||||
];
|
||||
|
||||
public function mount()
|
||||
|
|
@ -71,12 +75,14 @@ public function syncData(bool $toModel = false): void
|
|||
// Sync to model
|
||||
$this->fileStorage->content = $this->content;
|
||||
$this->fileStorage->is_based_on_git = $this->isBasedOnGit;
|
||||
$this->fileStorage->is_preview_suffix_enabled = $this->isPreviewSuffixEnabled;
|
||||
|
||||
$this->fileStorage->save();
|
||||
} else {
|
||||
// Sync from model
|
||||
$this->content = $this->fileStorage->content;
|
||||
$this->isBasedOnGit = $this->fileStorage->is_based_on_git;
|
||||
$this->isPreviewSuffixEnabled = $this->fileStorage->is_preview_suffix_enabled ?? true;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -175,6 +181,7 @@ public function submit()
|
|||
// Sync component properties to model
|
||||
$this->fileStorage->content = $this->content;
|
||||
$this->fileStorage->is_based_on_git = $this->isBasedOnGit;
|
||||
$this->fileStorage->is_preview_suffix_enabled = $this->isPreviewSuffixEnabled;
|
||||
$this->fileStorage->save();
|
||||
$this->fileStorage->saveStorageOnServer();
|
||||
$this->dispatch('success', 'File updated.');
|
||||
|
|
@ -187,9 +194,11 @@ public function submit()
|
|||
}
|
||||
}
|
||||
|
||||
public function instantSave()
|
||||
public function instantSave(): void
|
||||
{
|
||||
$this->submit();
|
||||
$this->authorize('update', $this->resource);
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'File updated.');
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
|
|
@ -29,10 +29,13 @@ class Show extends Component
|
|||
|
||||
public ?string $hostPath = null;
|
||||
|
||||
public bool $isPreviewSuffixEnabled = true;
|
||||
|
||||
protected $rules = [
|
||||
'name' => 'required|string',
|
||||
'mountPath' => 'required|string',
|
||||
'hostPath' => 'string|nullable',
|
||||
'isPreviewSuffixEnabled' => 'required|boolean',
|
||||
];
|
||||
|
||||
protected $validationAttributes = [
|
||||
|
|
@ -53,11 +56,13 @@ private function syncData(bool $toModel = false): void
|
|||
$this->storage->name = $this->name;
|
||||
$this->storage->mount_path = $this->mountPath;
|
||||
$this->storage->host_path = $this->hostPath;
|
||||
$this->storage->is_preview_suffix_enabled = $this->isPreviewSuffixEnabled;
|
||||
} else {
|
||||
// Sync FROM model (on load/refresh)
|
||||
$this->name = $this->storage->name;
|
||||
$this->mountPath = $this->storage->mount_path;
|
||||
$this->hostPath = $this->storage->host_path;
|
||||
$this->isPreviewSuffixEnabled = $this->storage->is_preview_suffix_enabled ?? true;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -67,6 +72,16 @@ public function mount()
|
|||
$this->isReadOnly = $this->storage->shouldBeReadOnlyInUI();
|
||||
}
|
||||
|
||||
public function instantSave(): void
|
||||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
$this->validate();
|
||||
|
||||
$this->syncData(true);
|
||||
$this->storage->save();
|
||||
$this->dispatch('success', 'Storage updated successfully');
|
||||
}
|
||||
|
||||
public function submit()
|
||||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ class LocalFileVolume extends BaseModel
|
|||
// 'mount_path' => 'encrypted',
|
||||
'content' => 'encrypted',
|
||||
'is_directory' => 'boolean',
|
||||
'is_preview_suffix_enabled' => 'boolean',
|
||||
];
|
||||
|
||||
use HasFactory;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@ class LocalPersistentVolume extends Model
|
|||
{
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'is_preview_suffix_enabled' => 'boolean',
|
||||
];
|
||||
|
||||
public function resource()
|
||||
{
|
||||
return $this->morphTo('resource');
|
||||
|
|
|
|||
|
|
@ -789,7 +789,10 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
$mainDirectory = str(base_configuration_dir().'/applications/'.$uuid);
|
||||
}
|
||||
$source = replaceLocalSource($source, $mainDirectory);
|
||||
if ($isPullRequest) {
|
||||
$isPreviewSuffixEnabled = $foundConfig
|
||||
? (bool) data_get($foundConfig, 'is_preview_suffix_enabled', true)
|
||||
: true;
|
||||
if ($isPullRequest && $isPreviewSuffixEnabled) {
|
||||
$source = addPreviewDeploymentSuffix($source, $pull_request_id);
|
||||
}
|
||||
LocalFileVolume::updateOrCreate(
|
||||
|
|
@ -1315,19 +1318,19 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
}
|
||||
if (! $isDatabase && $fqdns instanceof Collection && $fqdns->count() > 0) {
|
||||
$shouldGenerateLabelsExactly = $resource->destination->server->settings->generate_exact_labels;
|
||||
$uuid = $resource->uuid;
|
||||
$network = data_get($resource, 'destination.network');
|
||||
$labelUuid = $resource->uuid;
|
||||
$labelNetwork = data_get($resource, 'destination.network');
|
||||
if ($isPullRequest) {
|
||||
$uuid = "{$resource->uuid}-{$pullRequestId}";
|
||||
$labelUuid = "{$resource->uuid}-{$pullRequestId}";
|
||||
}
|
||||
if ($isPullRequest) {
|
||||
$network = "{$resource->destination->network}-{$pullRequestId}";
|
||||
$labelNetwork = "{$resource->destination->network}-{$pullRequestId}";
|
||||
}
|
||||
if ($shouldGenerateLabelsExactly) {
|
||||
switch ($server->proxyType()) {
|
||||
case ProxyTypes::TRAEFIK->value:
|
||||
$serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
|
||||
uuid: $uuid,
|
||||
uuid: $labelUuid,
|
||||
domains: $fqdns,
|
||||
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
|
||||
serviceLabels: $serviceLabels,
|
||||
|
|
@ -1339,8 +1342,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
break;
|
||||
case ProxyTypes::CADDY->value:
|
||||
$serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
|
||||
network: $network,
|
||||
uuid: $uuid,
|
||||
network: $labelNetwork,
|
||||
uuid: $labelUuid,
|
||||
domains: $fqdns,
|
||||
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
|
||||
serviceLabels: $serviceLabels,
|
||||
|
|
@ -1354,7 +1357,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
}
|
||||
} else {
|
||||
$serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
|
||||
uuid: $uuid,
|
||||
uuid: $labelUuid,
|
||||
domains: $fqdns,
|
||||
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
|
||||
serviceLabels: $serviceLabels,
|
||||
|
|
@ -1364,8 +1367,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
image: $image
|
||||
));
|
||||
$serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
|
||||
network: $network,
|
||||
uuid: $uuid,
|
||||
network: $labelNetwork,
|
||||
uuid: $labelUuid,
|
||||
domains: $fqdns,
|
||||
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
|
||||
serviceLabels: $serviceLabels,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('local_file_volumes', function (Blueprint $table) {
|
||||
$table->boolean('is_preview_suffix_enabled')->default(true)->after('is_based_on_git');
|
||||
});
|
||||
|
||||
Schema::table('local_persistent_volumes', function (Blueprint $table) {
|
||||
$table->boolean('is_preview_suffix_enabled')->default(true)->after('host_path');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('local_file_volumes', function (Blueprint $table) {
|
||||
$table->dropColumn('is_preview_suffix_enabled');
|
||||
});
|
||||
|
||||
Schema::table('local_persistent_volumes', function (Blueprint $table) {
|
||||
$table->dropColumn('is_preview_suffix_enabled');
|
||||
});
|
||||
}
|
||||
};
|
||||
161
openapi.json
161
openapi.json
|
|
@ -3442,6 +3442,167 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"\/applications\/{uuid}\/storages": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Applications"
|
||||
],
|
||||
"summary": "List Storages",
|
||||
"description": "List all persistent storages and file storages by application UUID.",
|
||||
"operationId": "list-storages-by-application-uuid",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "uuid",
|
||||
"in": "path",
|
||||
"description": "UUID of the application.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "All storages by application UUID.",
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"persistent_storages": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"file_storages": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#\/components\/responses\/400"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"patch": {
|
||||
"tags": [
|
||||
"Applications"
|
||||
],
|
||||
"summary": "Update Storage",
|
||||
"description": "Update a persistent storage or file storage by application UUID.",
|
||||
"operationId": "update-storage-by-application-uuid",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "uuid",
|
||||
"in": "path",
|
||||
"description": "UUID of the application.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"description": "Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.",
|
||||
"required": true,
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"required": [
|
||||
"id",
|
||||
"type"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"description": "The ID of the storage."
|
||||
},
|
||||
"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)."
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Storage updated.",
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#\/components\/responses\/400"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
},
|
||||
"422": {
|
||||
"$ref": "#\/components\/responses\/422"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/cloud-tokens": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
|
|
|||
102
openapi.yaml
102
openapi.yaml
|
|
@ -2170,6 +2170,108 @@ paths:
|
|||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
'/applications/{uuid}/storages':
|
||||
get:
|
||||
tags:
|
||||
- Applications
|
||||
summary: 'List Storages'
|
||||
description: 'List all persistent storages and file storages by application UUID.'
|
||||
operationId: list-storages-by-application-uuid
|
||||
parameters:
|
||||
-
|
||||
name: uuid
|
||||
in: path
|
||||
description: 'UUID of the application.'
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: 'All storages by application UUID.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
persistent_storages: { type: array, items: { type: object } }
|
||||
file_storages: { type: array, items: { type: object } }
|
||||
type: object
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'400':
|
||||
$ref: '#/components/responses/400'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
patch:
|
||||
tags:
|
||||
- Applications
|
||||
summary: 'Update Storage'
|
||||
description: 'Update a persistent storage or file storage by application UUID.'
|
||||
operationId: update-storage-by-application-uuid
|
||||
parameters:
|
||||
-
|
||||
name: uuid
|
||||
in: path
|
||||
description: 'UUID of the application.'
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
description: 'Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.'
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
required:
|
||||
- id
|
||||
- type
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
description: 'The ID of the storage.'
|
||||
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).'
|
||||
type: object
|
||||
additionalProperties: false
|
||||
responses:
|
||||
'200':
|
||||
description: 'Storage updated.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'400':
|
||||
$ref: '#/components/responses/400'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
'422':
|
||||
$ref: '#/components/responses/422'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
/cloud-tokens:
|
||||
get:
|
||||
tags:
|
||||
|
|
|
|||
|
|
@ -15,6 +15,15 @@
|
|||
<x-forms.input label="Destination Path" :value="$fileStorage->mount_path" readonly />
|
||||
</div>
|
||||
</div>
|
||||
@if ($resource instanceof \App\Models\Application)
|
||||
@can('update', $resource)
|
||||
<div class="w-96">
|
||||
<x-forms.checkbox instantSave canGate="update" :canResource="$resource" label="Add suffix for PR deployments"
|
||||
id="isPreviewSuffixEnabled"
|
||||
helper="When enabled, a -pr-N suffix is added to this volume's path for preview deployments (e.g. ./scripts becomes ./scripts-pr-1). Disable this for volumes that contain shared config or scripts from your repository."></x-forms.checkbox>
|
||||
</div>
|
||||
@endcan
|
||||
@endif
|
||||
<form wire:submit='submit' class="flex flex-col gap-2">
|
||||
@if (!$isReadOnly)
|
||||
@can('update', $resource)
|
||||
|
|
|
|||
|
|
@ -38,6 +38,15 @@
|
|||
<x-forms.input id="mountPath" required readonly />
|
||||
</div>
|
||||
@endif
|
||||
@if (!$isService)
|
||||
@can('update', $resource)
|
||||
<div class="w-96">
|
||||
<x-forms.checkbox instantSave canGate="update" :canResource="$resource" label="Add suffix for PR deployments"
|
||||
id="isPreviewSuffixEnabled"
|
||||
helper="When enabled, a -pr-N suffix is added to this volume's name for preview deployments (e.g. myvolume becomes myvolume-pr-1). Disable this for volumes that should be shared between the main and preview deployments."></x-forms.checkbox>
|
||||
</div>
|
||||
@endcan
|
||||
@endif
|
||||
@else
|
||||
@can('update', $resource)
|
||||
@if ($isFirst)
|
||||
|
|
@ -54,6 +63,13 @@
|
|||
<x-forms.input id="mountPath" required />
|
||||
</div>
|
||||
@endif
|
||||
@if (!$isService)
|
||||
<div class="w-96">
|
||||
<x-forms.checkbox instantSave canGate="update" :canResource="$resource" label="Add suffix for PR deployments"
|
||||
id="isPreviewSuffixEnabled"
|
||||
helper="When enabled, a -pr-N suffix is added to this volume's name for preview deployments (e.g. myvolume becomes myvolume-pr-1). Disable this for volumes that should be shared between the main and preview deployments."></x-forms.checkbox>
|
||||
</div>
|
||||
@endif
|
||||
<div class="flex gap-2">
|
||||
<x-forms.button type="submit">
|
||||
Update
|
||||
|
|
|
|||
|
|
@ -120,6 +120,8 @@
|
|||
Route::patch('/applications/{uuid}/envs', [ApplicationsController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']);
|
||||
Route::delete('/applications/{uuid}/envs/{env_uuid}', [ApplicationsController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']);
|
||||
Route::get('/applications/{uuid}/logs', [ApplicationsController::class, 'logs_by_uuid'])->middleware(['api.ability:read']);
|
||||
Route::get('/applications/{uuid}/storages', [ApplicationsController::class, 'storages'])->middleware(['api.ability:read']);
|
||||
Route::patch('/applications/{uuid}/storages', [ApplicationsController::class, 'update_storage'])->middleware(['api.ability:write']);
|
||||
|
||||
Route::match(['get', 'post'], '/applications/{uuid}/start', [ApplicationsController::class, 'action_deploy'])->middleware(['api.ability:deploy']);
|
||||
Route::match(['get', 'post'], '/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:deploy']);
|
||||
|
|
|
|||
176
tests/Unit/PreviewDeploymentBindMountTest.php
Normal file
176
tests/Unit/PreviewDeploymentBindMountTest.php
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Tests for GitHub issue #7802: volume mappings from repo content in Preview Deployments.
|
||||
*
|
||||
* Behavioral tests for addPreviewDeploymentSuffix and related helper functions.
|
||||
*
|
||||
* Note: The parser functions (applicationParser, serviceParser) and
|
||||
* ApplicationDeploymentJob methods require database-persisted models with
|
||||
* relationships (Application->destination->server, etc.), making them
|
||||
* unsuitable for unit tests. Integration tests for those paths belong
|
||||
* in tests/Feature/.
|
||||
*/
|
||||
describe('addPreviewDeploymentSuffix', function () {
|
||||
it('appends -pr-N suffix for non-zero pull request id', function () {
|
||||
expect(addPreviewDeploymentSuffix('myvolume', 3))->toBe('myvolume-pr-3');
|
||||
});
|
||||
|
||||
it('returns name unchanged when pull request id is zero', function () {
|
||||
expect(addPreviewDeploymentSuffix('myvolume', 0))->toBe('myvolume');
|
||||
});
|
||||
|
||||
it('handles pull request id of 1', function () {
|
||||
expect(addPreviewDeploymentSuffix('scripts', 1))->toBe('scripts-pr-1');
|
||||
});
|
||||
|
||||
it('handles large pull request ids', function () {
|
||||
expect(addPreviewDeploymentSuffix('data', 9999))->toBe('data-pr-9999');
|
||||
});
|
||||
|
||||
it('handles names with dots and slashes', function () {
|
||||
expect(addPreviewDeploymentSuffix('./scripts', 2))->toBe('./scripts-pr-2');
|
||||
});
|
||||
|
||||
it('handles names with existing hyphens', function () {
|
||||
expect(addPreviewDeploymentSuffix('my-volume-name', 5))->toBe('my-volume-name-pr-5');
|
||||
});
|
||||
|
||||
it('handles empty name with non-zero pr id', function () {
|
||||
expect(addPreviewDeploymentSuffix('', 1))->toBe('-pr-1');
|
||||
});
|
||||
|
||||
it('handles uuid-prefixed volume names', function () {
|
||||
$uuid = 'abc123_my-volume';
|
||||
expect(addPreviewDeploymentSuffix($uuid, 7))->toBe('abc123_my-volume-pr-7');
|
||||
});
|
||||
|
||||
it('defaults pull_request_id to 0', function () {
|
||||
expect(addPreviewDeploymentSuffix('myvolume'))->toBe('myvolume');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sourceIsLocal', function () {
|
||||
it('detects relative paths starting with dot-slash', function () {
|
||||
expect(sourceIsLocal(str('./scripts')))->toBeTrue();
|
||||
});
|
||||
|
||||
it('detects absolute paths starting with slash', function () {
|
||||
expect(sourceIsLocal(str('/var/data')))->toBeTrue();
|
||||
});
|
||||
|
||||
it('detects tilde paths', function () {
|
||||
expect(sourceIsLocal(str('~/data')))->toBeTrue();
|
||||
});
|
||||
|
||||
it('detects parent directory paths', function () {
|
||||
expect(sourceIsLocal(str('../config')))->toBeTrue();
|
||||
});
|
||||
|
||||
it('returns false for named volumes', function () {
|
||||
expect(sourceIsLocal(str('myvolume')))->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceLocalSource', function () {
|
||||
it('replaces dot-slash prefix with target path', function () {
|
||||
$result = replaceLocalSource(str('./scripts'), str('/app'));
|
||||
expect((string) $result)->toBe('/app/scripts');
|
||||
});
|
||||
|
||||
it('replaces dot-dot-slash prefix with target path', function () {
|
||||
$result = replaceLocalSource(str('../config'), str('/app'));
|
||||
expect((string) $result)->toBe('/app./config');
|
||||
});
|
||||
|
||||
it('replaces tilde prefix with target path', function () {
|
||||
$result = replaceLocalSource(str('~/data'), str('/app'));
|
||||
expect((string) $result)->toBe('/app/data');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Source-code structure tests for parser and deployment job.
|
||||
*
|
||||
* These verify that key code patterns exist in the parser and deployment job.
|
||||
* They are intentionally text-based because the parser/deployment functions
|
||||
* require database-persisted models with deep relationships, making behavioral
|
||||
* unit tests impractical. Full behavioral coverage should be done via Feature tests.
|
||||
*/
|
||||
describe('parser structure: bind mount handling', function () {
|
||||
it('checks is_preview_suffix_enabled before applying suffix', function () {
|
||||
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
|
||||
|
||||
$bindBlockStart = strpos($parsersFile, "if (\$type->value() === 'bind')");
|
||||
$volumeBlockStart = strpos($parsersFile, "} elseif (\$type->value() === 'volume')");
|
||||
$bindBlock = substr($parsersFile, $bindBlockStart, $volumeBlockStart - $bindBlockStart);
|
||||
|
||||
expect($bindBlock)
|
||||
->toContain('$isPreviewSuffixEnabled')
|
||||
->toContain('is_preview_suffix_enabled')
|
||||
->toContain('addPreviewDeploymentSuffix');
|
||||
});
|
||||
|
||||
it('applies preview suffix to named volumes', function () {
|
||||
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
|
||||
|
||||
$volumeBlockStart = strpos($parsersFile, "} elseif (\$type->value() === 'volume')");
|
||||
$volumeBlock = substr($parsersFile, $volumeBlockStart, 1000);
|
||||
|
||||
expect($volumeBlock)->toContain('addPreviewDeploymentSuffix');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parser structure: label generation uuid isolation', function () {
|
||||
it('uses labelUuid instead of mutating shared uuid', function () {
|
||||
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
|
||||
|
||||
$labelBlockStart = strpos($parsersFile, '$shouldGenerateLabelsExactly = $resource->destination->server->settings->generate_exact_labels;');
|
||||
$labelBlock = substr($parsersFile, $labelBlockStart, 300);
|
||||
|
||||
expect($labelBlock)
|
||||
->toContain('$labelUuid = $resource->uuid')
|
||||
->not->toContain('$uuid = $resource->uuid')
|
||||
->not->toContain('$uuid = "{$resource->uuid}');
|
||||
});
|
||||
|
||||
it('uses labelUuid in all proxy label generation calls', function () {
|
||||
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
|
||||
|
||||
$labelBlockStart = strpos($parsersFile, '$shouldGenerateLabelsExactly');
|
||||
$labelBlockEnd = strpos($parsersFile, "data_forget(\$service, 'volumes.*.content')");
|
||||
$labelBlock = substr($parsersFile, $labelBlockStart, $labelBlockEnd - $labelBlockStart);
|
||||
|
||||
expect($labelBlock)
|
||||
->toContain('uuid: $labelUuid')
|
||||
->not->toContain('uuid: $uuid');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deployment job structure: is_preview_suffix_enabled', function () {
|
||||
it('checks setting in generate_local_persistent_volumes', function () {
|
||||
$deploymentJobFile = file_get_contents(__DIR__.'/../../app/Jobs/ApplicationDeploymentJob.php');
|
||||
|
||||
$methodStart = strpos($deploymentJobFile, 'function generate_local_persistent_volumes()');
|
||||
$methodEnd = strpos($deploymentJobFile, 'function generate_local_persistent_volumes_only_volume_names()');
|
||||
$methodBlock = substr($deploymentJobFile, $methodStart, $methodEnd - $methodStart);
|
||||
|
||||
expect($methodBlock)
|
||||
->toContain('is_preview_suffix_enabled')
|
||||
->toContain('$isPreviewSuffixEnabled')
|
||||
->toContain('addPreviewDeploymentSuffix');
|
||||
});
|
||||
|
||||
it('checks setting in generate_local_persistent_volumes_only_volume_names', function () {
|
||||
$deploymentJobFile = file_get_contents(__DIR__.'/../../app/Jobs/ApplicationDeploymentJob.php');
|
||||
|
||||
$methodStart = strpos($deploymentJobFile, 'function generate_local_persistent_volumes_only_volume_names()');
|
||||
$methodEnd = strpos($deploymentJobFile, 'function generate_healthcheck_commands()');
|
||||
$methodBlock = substr($deploymentJobFile, $methodStart, $methodEnd - $methodStart);
|
||||
|
||||
expect($methodBlock)
|
||||
->toContain('is_preview_suffix_enabled')
|
||||
->toContain('$isPreviewSuffixEnabled')
|
||||
->toContain('addPreviewDeploymentSuffix');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue