Skip to content

Commit b93f109

Browse files
authored
feat(api): add POST /move endpoints to relocate resources between environments (#8968)
2 parents 5699b78 + 6872f63 commit b93f109

9 files changed

Lines changed: 1071 additions & 0 deletions

File tree

app/Http/Controllers/Api/ApplicationsController.php

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3950,6 +3950,99 @@ public function action_restart(Request $request)
39503950
);
39513951
}
39523952

3953+
#[OA\Post(
3954+
summary: 'Move',
3955+
description: 'Move application to another project/environment. This is a purely organizational change — running containers are not affected. Note: after moving, the application will pick up shared environment variables from the new environment on the next deployment.',
3956+
path: '/applications/{uuid}/move',
3957+
operationId: 'move-application-by-uuid',
3958+
security: [
3959+
['bearerAuth' => []],
3960+
],
3961+
tags: ['Applications'],
3962+
parameters: [
3963+
new OA\Parameter(
3964+
name: 'uuid',
3965+
in: 'path',
3966+
description: 'UUID of the application.',
3967+
required: true,
3968+
schema: new OA\Schema(
3969+
type: 'string',
3970+
)
3971+
),
3972+
],
3973+
requestBody: new OA\RequestBody(
3974+
description: 'Target environment to move the application to.',
3975+
required: true,
3976+
content: [
3977+
new OA\MediaType(
3978+
mediaType: 'application/json',
3979+
schema: new OA\Schema(
3980+
type: 'object',
3981+
properties: [
3982+
'environment_uuid' => ['type' => 'string', 'description' => 'UUID of the target environment.'],
3983+
],
3984+
required: ['environment_uuid'],
3985+
)
3986+
),
3987+
]
3988+
),
3989+
responses: [
3990+
new OA\Response(
3991+
response: 200,
3992+
description: 'Application moved successfully.',
3993+
content: [
3994+
new OA\MediaType(
3995+
mediaType: 'application/json',
3996+
schema: new OA\Schema(
3997+
type: 'object',
3998+
properties: [
3999+
'message' => ['type' => 'string', 'example' => 'Application moved successfully.'],
4000+
'uuid' => ['type' => 'string'],
4001+
'project_uuid' => ['type' => 'string'],
4002+
'environment_uuid' => ['type' => 'string'],
4003+
]
4004+
)
4005+
),
4006+
]
4007+
),
4008+
new OA\Response(
4009+
response: 401,
4010+
ref: '#/components/responses/401',
4011+
),
4012+
new OA\Response(
4013+
response: 400,
4014+
ref: '#/components/responses/400',
4015+
),
4016+
new OA\Response(
4017+
response: 404,
4018+
ref: '#/components/responses/404',
4019+
),
4020+
new OA\Response(
4021+
response: 422,
4022+
ref: '#/components/responses/422',
4023+
),
4024+
]
4025+
)]
4026+
public function move_by_uuid(Request $request): \Illuminate\Http\JsonResponse
4027+
{
4028+
$teamId = getTeamIdFromToken();
4029+
if (is_null($teamId)) {
4030+
return invalidTokenResponse();
4031+
}
4032+
$uuid = $request->route('uuid');
4033+
if (! $uuid) {
4034+
return response()->json(['message' => 'UUID is required.'], 400);
4035+
}
4036+
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
4037+
if (! $application) {
4038+
return response()->json(['message' => 'Application not found.'], 404);
4039+
}
4040+
4041+
$this->authorize('update', $application);
4042+
4043+
return moveResourceToEnvironment($request, $application, 'Application', $teamId);
4044+
}
4045+
39534046
private function validateDataApplications(Request $request, Server $server)
39544047
{
39554048
$teamId = getTeamIdFromToken();

app/Http/Controllers/Api/DatabasesController.php

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2943,6 +2943,99 @@ public function list_backup_executions(Request $request)
29432943
]);
29442944
}
29452945

2946+
#[OA\Post(
2947+
summary: 'Move',
2948+
description: 'Move database to another project/environment. This is a purely organizational change — running containers are not affected. Note: after moving, the database will pick up shared environment variables from the new environment on the next deployment.',
2949+
path: '/databases/{uuid}/move',
2950+
operationId: 'move-database-by-uuid',
2951+
security: [
2952+
['bearerAuth' => []],
2953+
],
2954+
tags: ['Databases'],
2955+
parameters: [
2956+
new OA\Parameter(
2957+
name: 'uuid',
2958+
in: 'path',
2959+
description: 'UUID of the database.',
2960+
required: true,
2961+
schema: new OA\Schema(
2962+
type: 'string',
2963+
)
2964+
),
2965+
],
2966+
requestBody: new OA\RequestBody(
2967+
description: 'Target environment to move the database to.',
2968+
required: true,
2969+
content: [
2970+
new OA\MediaType(
2971+
mediaType: 'application/json',
2972+
schema: new OA\Schema(
2973+
type: 'object',
2974+
properties: [
2975+
'environment_uuid' => ['type' => 'string', 'description' => 'UUID of the target environment.'],
2976+
],
2977+
required: ['environment_uuid'],
2978+
)
2979+
),
2980+
]
2981+
),
2982+
responses: [
2983+
new OA\Response(
2984+
response: 200,
2985+
description: 'Database moved successfully.',
2986+
content: [
2987+
new OA\MediaType(
2988+
mediaType: 'application/json',
2989+
schema: new OA\Schema(
2990+
type: 'object',
2991+
properties: [
2992+
'message' => ['type' => 'string', 'example' => 'Database moved successfully.'],
2993+
'uuid' => ['type' => 'string'],
2994+
'project_uuid' => ['type' => 'string'],
2995+
'environment_uuid' => ['type' => 'string'],
2996+
]
2997+
)
2998+
),
2999+
]
3000+
),
3001+
new OA\Response(
3002+
response: 401,
3003+
ref: '#/components/responses/401',
3004+
),
3005+
new OA\Response(
3006+
response: 400,
3007+
ref: '#/components/responses/400',
3008+
),
3009+
new OA\Response(
3010+
response: 404,
3011+
ref: '#/components/responses/404',
3012+
),
3013+
new OA\Response(
3014+
response: 422,
3015+
ref: '#/components/responses/422',
3016+
),
3017+
]
3018+
)]
3019+
public function move_by_uuid(Request $request): \Illuminate\Http\JsonResponse
3020+
{
3021+
$teamId = getTeamIdFromToken();
3022+
if (is_null($teamId)) {
3023+
return invalidTokenResponse();
3024+
}
3025+
$uuid = $request->route('uuid');
3026+
if (! $uuid) {
3027+
return response()->json(['message' => 'UUID is required.'], 400);
3028+
}
3029+
$database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId);
3030+
if (! $database) {
3031+
return response()->json(['message' => 'Database not found.'], 404);
3032+
}
3033+
3034+
$this->authorize('update', $database);
3035+
3036+
return moveResourceToEnvironment($request, $database, 'Database', $teamId);
3037+
}
3038+
29463039
#[OA\Get(
29473040
summary: 'Start',
29483041
description: 'Start database. `Post` request is also accepted.',

app/Http/Controllers/Api/ServicesController.php

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1869,6 +1869,99 @@ public function delete_env_by_uuid(Request $request)
18691869
return response()->json(['message' => 'Environment variable deleted.']);
18701870
}
18711871

1872+
#[OA\Post(
1873+
summary: 'Move',
1874+
description: 'Move service to another project/environment. This is a purely organizational change — running containers are not affected. Note: after moving, the service will pick up shared environment variables from the new environment on the next deployment.',
1875+
path: '/services/{uuid}/move',
1876+
operationId: 'move-service-by-uuid',
1877+
security: [
1878+
['bearerAuth' => []],
1879+
],
1880+
tags: ['Services'],
1881+
parameters: [
1882+
new OA\Parameter(
1883+
name: 'uuid',
1884+
in: 'path',
1885+
description: 'UUID of the service.',
1886+
required: true,
1887+
schema: new OA\Schema(
1888+
type: 'string',
1889+
)
1890+
),
1891+
],
1892+
requestBody: new OA\RequestBody(
1893+
description: 'Target environment to move the service to.',
1894+
required: true,
1895+
content: [
1896+
new OA\MediaType(
1897+
mediaType: 'application/json',
1898+
schema: new OA\Schema(
1899+
type: 'object',
1900+
properties: [
1901+
'environment_uuid' => ['type' => 'string', 'description' => 'UUID of the target environment.'],
1902+
],
1903+
required: ['environment_uuid'],
1904+
)
1905+
),
1906+
]
1907+
),
1908+
responses: [
1909+
new OA\Response(
1910+
response: 200,
1911+
description: 'Service moved successfully.',
1912+
content: [
1913+
new OA\MediaType(
1914+
mediaType: 'application/json',
1915+
schema: new OA\Schema(
1916+
type: 'object',
1917+
properties: [
1918+
'message' => ['type' => 'string', 'example' => 'Service moved successfully.'],
1919+
'uuid' => ['type' => 'string'],
1920+
'project_uuid' => ['type' => 'string'],
1921+
'environment_uuid' => ['type' => 'string'],
1922+
]
1923+
)
1924+
),
1925+
]
1926+
),
1927+
new OA\Response(
1928+
response: 401,
1929+
ref: '#/components/responses/401',
1930+
),
1931+
new OA\Response(
1932+
response: 400,
1933+
ref: '#/components/responses/400',
1934+
),
1935+
new OA\Response(
1936+
response: 404,
1937+
ref: '#/components/responses/404',
1938+
),
1939+
new OA\Response(
1940+
response: 422,
1941+
ref: '#/components/responses/422',
1942+
),
1943+
]
1944+
)]
1945+
public function move_by_uuid(Request $request): \Illuminate\Http\JsonResponse
1946+
{
1947+
$teamId = getTeamIdFromToken();
1948+
if (is_null($teamId)) {
1949+
return invalidTokenResponse();
1950+
}
1951+
$uuid = $request->route('uuid');
1952+
if (! $uuid) {
1953+
return response()->json(['message' => 'UUID is required.'], 400);
1954+
}
1955+
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first();
1956+
if (! $service) {
1957+
return response()->json(['message' => 'Service not found.'], 404);
1958+
}
1959+
1960+
$this->authorize('update', $service);
1961+
1962+
return moveResourceToEnvironment($request, $service, 'Service', $teamId);
1963+
}
1964+
18721965
#[OA\Get(
18731966
summary: 'Start',
18741967
description: 'Start service. `Post` request is also accepted.',

app/Models/Environment.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ public static function ownedByCurrentTeam()
4747
return Environment::whereRelation('project.team', 'id', currentTeam()->id)->orderBy('name');
4848
}
4949

50+
public static function ownedByCurrentTeamAPI(int $teamId)
51+
{
52+
return Environment::whereRelation('project.team', 'id', $teamId)->orderBy('name');
53+
}
54+
5055
public function isEmpty()
5156
{
5257
return $this->applications()->count() == 0 &&

bootstrap/helpers/api.php

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,15 @@
33
use App\Enums\BuildPackTypes;
44
use App\Enums\RedirectTypes;
55
use App\Enums\StaticImageTypes;
6+
use App\Models\Environment;
67
use App\Rules\ValidGitBranch;
78
use App\Support\ValidationPatterns;
89
use Illuminate\Database\Eloquent\Collection;
910
use Illuminate\Database\Eloquent\Model;
11+
use Illuminate\Http\JsonResponse;
1012
use Illuminate\Http\Request;
13+
use Illuminate\Support\Facades\Gate;
14+
use Illuminate\Support\Facades\Validator;
1115
use Illuminate\Validation\Rule;
1216

1317
function getTeamIdFromToken()
@@ -172,6 +176,64 @@ function sharedDataApplications()
172176
];
173177
}
174178

179+
function moveResourceToEnvironment(Request $request, $resource, string $resourceType, int $teamId): JsonResponse
180+
{
181+
182+
$validator = Validator::make($request->all(), [
183+
'environment_uuid' => 'required|string',
184+
]);
185+
186+
if ($validator->fails()) {
187+
return response()->json([
188+
'message' => 'Validation failed.',
189+
'errors' => $validator->errors(),
190+
], 422);
191+
}
192+
193+
$extraFields = array_diff(array_keys($request->all()), ['environment_uuid']);
194+
if (! empty($extraFields)) {
195+
return response()->json([
196+
'message' => 'Validation failed.',
197+
'errors' => collect($extraFields)->mapWithKeys(fn ($field) => [$field => 'This field is not allowed.'])->toArray(),
198+
], 422);
199+
}
200+
201+
$newEnvironment = Environment::ownedByCurrentTeamAPI($teamId)
202+
->whereUuid($request->environment_uuid)
203+
->first();
204+
205+
if (! $newEnvironment) {
206+
return response()->json(['message' => 'Target environment not found or not owned by your team.'], 404);
207+
}
208+
209+
Gate::authorize('update', $newEnvironment);
210+
211+
if ($resource->environment_id === $newEnvironment->id) {
212+
return response()->json(['message' => "$resourceType is already in this environment."], 400);
213+
}
214+
215+
$oldEnvironment = $resource->environment()->with('project')->first();
216+
217+
$resource->update(['environment_id' => $newEnvironment->id]);
218+
219+
auditLog('api.'.str($resourceType)->lower()->value().'.moved', [
220+
'team_id' => $teamId,
221+
'resource_uuid' => $resource->uuid,
222+
'resource_type' => str($resourceType)->lower()->value(),
223+
'from_project_uuid' => $oldEnvironment?->project?->uuid,
224+
'from_environment_uuid' => $oldEnvironment?->uuid,
225+
'to_project_uuid' => $newEnvironment->project->uuid,
226+
'to_environment_uuid' => $newEnvironment->uuid,
227+
]);
228+
229+
return response()->json([
230+
'message' => "$resourceType moved successfully.",
231+
'uuid' => $resource->uuid,
232+
'project_uuid' => $newEnvironment->project->uuid,
233+
'environment_uuid' => $newEnvironment->uuid,
234+
]);
235+
}
236+
175237
function validateIncomingRequest(Request $request)
176238
{
177239
// check if request is json

0 commit comments

Comments
 (0)