mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-19 10:23:14 +00:00
feat(servers): add cross-instance server migration (#11075)
This commit is contained in:
@@ -48,3 +48,6 @@ ref
|
||||
.dev/lima/ssh.config
|
||||
.dev/lima/ssh_key
|
||||
.dev/lima/hosts
|
||||
|
||||
# Multi-instance local Coolify env files (scripts/dev-instances)
|
||||
.dev-instances/
|
||||
|
||||
@@ -18,9 +18,17 @@ Docker Compose-based dev setup with services: coolify (app), postgres, redis, so
|
||||
# Start dev environment (uses docker-compose.dev.yml)
|
||||
spin up # or: docker compose -f docker-compose.dev.yml up -d
|
||||
spin down # stop services
|
||||
|
||||
# Two local Coolify instances (isolated stacks; server transfer / multi-control-plane)
|
||||
./scripts/dev-instances up # a:8000 + b:8001 (uses npm run build for CSS/JS)
|
||||
./scripts/dev-instances up a --with vite # HMR only when starting a single instance
|
||||
./scripts/dev-instances urls
|
||||
./scripts/dev-instances down
|
||||
# Compose: docker-compose.dev-multi.yml Env: .dev-instances/{a,b}.env (gitignored)
|
||||
# Note: dual Vite HMR is unsupported (shared public/hot); multi-instance always uses public/build.
|
||||
```
|
||||
|
||||
The app runs at `localhost:8000` by default. Vite dev server on port 5173.
|
||||
The app runs at `localhost:8000` by default. Instance **b** is on `8001` (db `5433`, redis `6380`, …); see `./scripts/dev-instances`.
|
||||
|
||||
## Common Commands
|
||||
|
||||
|
||||
@@ -25,6 +25,15 @@ class ValidateServer
|
||||
|
||||
public function handle(Server $server)
|
||||
{
|
||||
if (! $server->canBeValidated()) {
|
||||
$this->error = 'This server was transferred to another Coolify instance and cannot be revalidated here.';
|
||||
$server->update([
|
||||
'validation_logs' => $this->error,
|
||||
'is_validating' => false,
|
||||
]);
|
||||
throw new \Exception($this->error);
|
||||
}
|
||||
|
||||
$server->update([
|
||||
'validation_logs' => null,
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,510 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Server;
|
||||
use App\Services\ServerTransfer\ServerTransferBundle;
|
||||
use App\Services\ServerTransfer\ServerTransferClaimer;
|
||||
use App\Services\ServerTransfer\ServerTransferExporter;
|
||||
use App\Services\ServerTransfer\ServerTransferImporter;
|
||||
use App\Services\ServerTransfer\ServerTransferMigrator;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Throwable;
|
||||
|
||||
class ServerTransferController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private ServerTransferExporter $exporter,
|
||||
private ServerTransferImporter $importer,
|
||||
private ServerTransferClaimer $claimer,
|
||||
private ServerTransferMigrator $migrator,
|
||||
) {
|
||||
abort_unless(isDev(), 404);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Migrate server to another Coolify instance',
|
||||
description: 'One-shot handoff: export this server, import+claim on the target instance (using the provided token), then disable automations here. Requires read:sensitive and write.',
|
||||
path: '/servers/{uuid}/migrate',
|
||||
operationId: 'migrate-server-between-instances',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Servers'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['target_url', 'target_token'],
|
||||
properties: [
|
||||
new OA\Property(property: 'target_url', type: 'string', example: 'https://coolify-b.example.com'),
|
||||
new OA\Property(property: 'target_token', type: 'string', description: 'API token on the target instance (root or write)'),
|
||||
new OA\Property(property: 'write_remote', type: 'boolean', default: false),
|
||||
new OA\Property(property: 'rebind_sentinel', type: 'boolean', default: true),
|
||||
new OA\Property(property: 'preserve_uuids', type: 'boolean', default: true),
|
||||
new OA\Property(property: 'adopt_mode', type: 'boolean', default: true),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Migrated'),
|
||||
new OA\Response(response: 403, description: 'Missing sensitive permission'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, description: 'Validation or remote import failed'),
|
||||
]
|
||||
)]
|
||||
public function migrate(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
if (! $this->canReadSensitive($request)) {
|
||||
return response()->json([
|
||||
'message' => 'Migrating a server requires a token with read:sensitive (or root) ability and an admin/owner team role.',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$server = Server::whereTeamId($teamId)->whereUuid($uuid)->first();
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $server);
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'target_url' => 'required|string|url',
|
||||
'target_token' => 'required|string',
|
||||
'write_remote' => 'boolean|nullable',
|
||||
'rebind_sentinel' => 'boolean|nullable',
|
||||
'preserve_uuids' => 'boolean|nullable',
|
||||
'adopt_mode' => 'boolean|nullable',
|
||||
]);
|
||||
$allowedFields = ['target_url', 'target_token', 'write_remote', 'rebind_sentinel', 'preserve_uuids', 'adopt_mode'];
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
if ($validator->fails() || $extraFields !== []) {
|
||||
$errors = $validator->errors();
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->migrator->migrate(
|
||||
server: $server,
|
||||
targetUrl: $request->string('target_url')->toString(),
|
||||
targetToken: $request->string('target_token')->toString(),
|
||||
writeRemote: $request->boolean('write_remote', false),
|
||||
rebindSentinel: $request->boolean('rebind_sentinel', true),
|
||||
preserveUuids: $request->boolean('preserve_uuids', true),
|
||||
adoptMode: $request->boolean('adopt_mode', true),
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
auditLog('api.server.migrate', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'export_id' => $result['export_id'],
|
||||
'target_url' => $result['target_url'],
|
||||
]);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Export server transfer bundle',
|
||||
description: 'Export a server and all resources hosted on it as a versioned transfer bundle for moving between Coolify instances. Requires read:sensitive.',
|
||||
path: '/servers/{uuid}/export',
|
||||
operationId: 'export-server-transfer-bundle',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Servers'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'encrypt', in: 'query', required: false, description: 'If true and passphrase is provided, return an encrypted envelope.', schema: new OA\Schema(type: 'boolean')),
|
||||
new OA\Parameter(name: 'passphrase', in: 'query', required: false, description: 'Passphrase used when encrypt=true.', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Transfer bundle'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 403, description: 'Missing sensitive permission'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
]
|
||||
)]
|
||||
public function export(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
if (! $this->canReadSensitive($request)) {
|
||||
return response()->json([
|
||||
'message' => 'Exporting a server requires a token with read:sensitive (or root) ability and an admin/owner team role.',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$server = Server::whereTeamId($teamId)->whereUuid($uuid)->first();
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('view', $server);
|
||||
|
||||
try {
|
||||
$bundle = $this->exporter->export($server, includeSensitive: true);
|
||||
} catch (Throwable $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
auditLog('api.server.export', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'export_id' => data_get($bundle, 'export_id'),
|
||||
]);
|
||||
|
||||
if ($request->boolean('encrypt') && $request->filled('passphrase')) {
|
||||
return response()->json(
|
||||
ServerTransferBundle::encryptWithPassphrase($bundle, $request->string('passphrase')->toString())
|
||||
);
|
||||
}
|
||||
|
||||
return response()->json($bundle);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Import server transfer bundle',
|
||||
description: 'Import a server transfer bundle into this Coolify instance (adopt mode by default).',
|
||||
path: '/servers/import',
|
||||
operationId: 'import-server-transfer-bundle',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Servers'],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property(property: 'bundle', type: 'object', description: 'Plain or encrypted transfer bundle'),
|
||||
new OA\Property(property: 'passphrase', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'dry_run', type: 'boolean', default: false),
|
||||
new OA\Property(property: 'preserve_uuids', type: 'boolean', default: true),
|
||||
new OA\Property(property: 'adopt_mode', type: 'boolean', default: true, description: 'Import without forcing redeploy; keep statuses for adoption'),
|
||||
new OA\Property(property: 'claim', type: 'boolean', default: true, description: 'Automatically claim the host for this instance after import'),
|
||||
new OA\Property(property: 'write_remote', type: 'boolean', default: false, description: 'When claiming, write ownership file on the host via SSH'),
|
||||
new OA\Property(property: 'rebind_sentinel', type: 'boolean', default: true, description: 'When claiming, rebind Sentinel to this instance'),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Dry-run result'),
|
||||
new OA\Response(response: 201, description: 'Imported'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 422, description: 'Validation failed'),
|
||||
]
|
||||
)]
|
||||
public function import(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$this->authorize('create', Server::class);
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'bundle' => 'required|array',
|
||||
'passphrase' => 'string|nullable',
|
||||
'dry_run' => 'boolean|nullable',
|
||||
'preserve_uuids' => 'boolean|nullable',
|
||||
'adopt_mode' => 'boolean|nullable',
|
||||
'claim' => 'boolean|nullable',
|
||||
'write_remote' => 'boolean|nullable',
|
||||
'rebind_sentinel' => 'boolean|nullable',
|
||||
]);
|
||||
$allowedFields = ['bundle', 'passphrase', 'dry_run', 'preserve_uuids', 'adopt_mode', 'claim', 'write_remote', 'rebind_sentinel'];
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
if ($validator->fails() || $extraFields !== []) {
|
||||
$errors = $validator->errors();
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
$bundle = $request->input('bundle', []);
|
||||
if (data_get($bundle, 'encrypted')) {
|
||||
if (! $request->filled('passphrase')) {
|
||||
return response()->json(['message' => 'Passphrase is required for encrypted bundles.'], 422);
|
||||
}
|
||||
try {
|
||||
$bundle = ServerTransferBundle::decryptWithPassphrase($bundle, $request->string('passphrase')->toString());
|
||||
} catch (Throwable $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->importer->import(
|
||||
bundle: $bundle,
|
||||
teamId: $teamId,
|
||||
dryRun: $request->boolean('dry_run', false),
|
||||
preserveUuids: $request->boolean('preserve_uuids', true),
|
||||
adoptMode: $request->boolean('adopt_mode', true),
|
||||
claim: $request->boolean('claim', true),
|
||||
writeRemote: $request->boolean('write_remote', false),
|
||||
rebindSentinel: $request->boolean('rebind_sentinel', true),
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
$status = $e instanceof ValidationException ? 422 : 422;
|
||||
$payload = ['message' => $e->getMessage()];
|
||||
if ($e instanceof ValidationException) {
|
||||
$payload['errors'] = $e->errors();
|
||||
}
|
||||
|
||||
return response()->json($payload, $status);
|
||||
}
|
||||
|
||||
auditLog('api.server.import', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $result['server_uuid'],
|
||||
'export_id' => $result['export_id'],
|
||||
'dry_run' => $result['dry_run'],
|
||||
]);
|
||||
|
||||
return response()->json($result, $result['dry_run'] ? 200 : 201);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Claim imported server',
|
||||
description: 'Claim a managed host for this instance: write ownership file and rebind Sentinel.',
|
||||
path: '/servers/{uuid}/claim',
|
||||
operationId: 'claim-server',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Servers'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property(property: 'write_remote', type: 'boolean', default: true),
|
||||
new OA\Property(property: 'rebind_sentinel', type: 'boolean', default: true),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Claim result'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
]
|
||||
)]
|
||||
public function claim(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$server = Server::whereTeamId($teamId)->whereUuid($uuid)->first();
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $server);
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'write_remote' => 'boolean|nullable',
|
||||
'rebind_sentinel' => 'boolean|nullable',
|
||||
]);
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->claimer->claim(
|
||||
$server,
|
||||
writeRemote: $request->boolean('write_remote', true),
|
||||
rebindSentinel: $request->boolean('rebind_sentinel', true),
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
auditLog('api.server.claim', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'claim_written' => $result['claim_written'],
|
||||
]);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Mark server transferred',
|
||||
description: 'Source-instance step: disable automations after a successful export/import handoff.',
|
||||
path: '/servers/{uuid}/transfer/complete',
|
||||
operationId: 'complete-server-transfer',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Servers'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property(property: 'export_id', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'target_instance_url', type: 'string', nullable: true),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Marked transferred'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
]
|
||||
)]
|
||||
public function complete(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$server = Server::whereTeamId($teamId)->whereUuid($uuid)->first();
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $server);
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'export_id' => 'string|nullable',
|
||||
'target_instance_url' => 'string|nullable',
|
||||
]);
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->claimer->markTransferred(
|
||||
$server,
|
||||
exportId: $request->input('export_id'),
|
||||
targetInstanceUrl: $request->input('target_instance_url'),
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
auditLog('api.server.transfer_complete', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'export_id' => $request->input('export_id'),
|
||||
]);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Write transfer bundle to server mailbox',
|
||||
description: 'Write an export bundle to /data/coolify/exports on the managed host for air-gapped import.',
|
||||
path: '/servers/{uuid}/export/mailbox',
|
||||
operationId: 'export-server-transfer-mailbox',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Servers'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property(property: 'passphrase', type: 'string', nullable: true),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Mailbox write result'),
|
||||
new OA\Response(response: 403, description: 'Missing sensitive permission'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
]
|
||||
)]
|
||||
public function writeMailbox(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
if (! $this->canReadSensitive($request)) {
|
||||
return response()->json([
|
||||
'message' => 'Writing a transfer mailbox requires read:sensitive (or root) ability and an admin/owner team role.',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$server = Server::whereTeamId($teamId)->whereUuid($uuid)->first();
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('view', $server);
|
||||
|
||||
try {
|
||||
$bundle = $this->exporter->export($server, includeSensitive: true);
|
||||
$result = $this->claimer->writeMailbox(
|
||||
$server,
|
||||
$bundle,
|
||||
$request->filled('passphrase') ? $request->string('passphrase')->toString() : null,
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
auditLog('api.server.export_mailbox', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'export_id' => data_get($bundle, 'export_id'),
|
||||
'path' => $result['path'],
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'export_id' => data_get($bundle, 'export_id'),
|
||||
'path' => $result['path'],
|
||||
'written' => $result['written'],
|
||||
'message' => $result['written']
|
||||
? 'Transfer bundle written to server mailbox.'
|
||||
: 'Failed to write mailbox on remote host.',
|
||||
], $result['written'] ? 200 : 422);
|
||||
}
|
||||
|
||||
private function canReadSensitive(Request $request): bool
|
||||
{
|
||||
return (bool) $request->attributes->get('can_read_sensitive', false);
|
||||
}
|
||||
}
|
||||
@@ -970,6 +970,12 @@ class ServersController extends Controller
|
||||
}
|
||||
$this->authorize('update', $server);
|
||||
|
||||
if (! $server->canBeValidated()) {
|
||||
return response()->json([
|
||||
'message' => 'This server was transferred to another Coolify instance and cannot be revalidated here.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'install' => 'boolean',
|
||||
]);
|
||||
|
||||
@@ -33,6 +33,19 @@ class ValidateAndInstallServerJob implements ShouldBeEncrypted, ShouldQueue
|
||||
public function handle(): void
|
||||
{
|
||||
try {
|
||||
if (! $this->server->canBeValidated()) {
|
||||
$message = 'This server was transferred to another Coolify instance and cannot be revalidated here.';
|
||||
$this->server->update([
|
||||
'validation_logs' => $message,
|
||||
'is_validating' => false,
|
||||
]);
|
||||
Log::warning('ValidateAndInstallServer: blocked for transferred server', [
|
||||
'server_id' => $this->server->id,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark validation as in progress
|
||||
$this->server->update(['is_validating' => true]);
|
||||
|
||||
|
||||
@@ -327,6 +327,15 @@ class Show extends Component
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->server);
|
||||
if (! $this->server->canBeValidated()) {
|
||||
$this->dispatch(
|
||||
'error',
|
||||
'Cannot revalidate',
|
||||
'This server was transferred to another Coolify instance. Manage it from the target instance instead.'
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
if ($this->server->vultr_instance_id) {
|
||||
$status = $this->server->refreshVultrState();
|
||||
$this->server->refresh();
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Server;
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Services\ServerTransfer\ServerTransferBundle;
|
||||
use App\Services\ServerTransfer\ServerTransferClaimer;
|
||||
use App\Services\ServerTransfer\ServerTransferExporter;
|
||||
use App\Services\ServerTransfer\ServerTransferMigrator;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
use Throwable;
|
||||
|
||||
class Transfer extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public Server $server;
|
||||
|
||||
/** Primary one-click migrate fields */
|
||||
public string $targetUrl = '';
|
||||
|
||||
public string $targetToken = '';
|
||||
|
||||
public bool $writeRemote = false;
|
||||
|
||||
/** Advanced */
|
||||
public bool $showAdvanced = false;
|
||||
|
||||
public string $passphrase = '';
|
||||
|
||||
public bool $encryptBundle = false;
|
||||
|
||||
public bool $writeRemoteOnClaim = false;
|
||||
|
||||
public bool $rebindSentinelOnClaim = true;
|
||||
|
||||
public ?string $exportId = null;
|
||||
|
||||
/** @var list<string> */
|
||||
public array $lastWarnings = [];
|
||||
|
||||
public ?string $lastResultJson = null;
|
||||
|
||||
public function mount(string $server_uuid): void
|
||||
{
|
||||
$this->ensureDevelopmentAvailability();
|
||||
|
||||
try {
|
||||
$this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
|
||||
$this->authorize('view', $this->server);
|
||||
$this->exportId = data_get($this->server->server_metadata, 'transfer.export_id');
|
||||
} catch (Throwable $e) {
|
||||
handleError($e, $this);
|
||||
$this->redirect(route('server.index'), navigate: true);
|
||||
}
|
||||
}
|
||||
|
||||
public function getTransferStatusProperty(): ?string
|
||||
{
|
||||
return data_get($this->server->fresh()->server_metadata, 'transfer.status');
|
||||
}
|
||||
|
||||
public function getIsLocalhostProperty(): bool
|
||||
{
|
||||
return (int) $this->server->id === 0;
|
||||
}
|
||||
|
||||
public function migrateServer(ServerTransferMigrator $migrator): void
|
||||
{
|
||||
$this->ensureDevelopmentAvailability();
|
||||
|
||||
try {
|
||||
$this->authorize('update', $this->server);
|
||||
if ($this->isLocalhost) {
|
||||
throw new \RuntimeException('The Coolify host (localhost) cannot be transferred.');
|
||||
}
|
||||
|
||||
$result = $migrator->migrate(
|
||||
server: $this->server,
|
||||
targetUrl: $this->targetUrl,
|
||||
targetToken: $this->targetToken,
|
||||
writeRemote: $this->writeRemote,
|
||||
);
|
||||
|
||||
$this->server->refresh();
|
||||
$this->exportId = $result['export_id'] ?? $this->exportId;
|
||||
$this->lastWarnings = array_values((array) data_get($result, 'warnings', []));
|
||||
// Never echo the target token in the result dump.
|
||||
$safe = $result;
|
||||
unset($safe['target_token']);
|
||||
$this->lastResultJson = json_encode($safe, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
$this->targetToken = '';
|
||||
$this->dispatch('success', $result['message'] ?? 'Server transferred.');
|
||||
} catch (Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function exportBundle(ServerTransferExporter $exporter)
|
||||
{
|
||||
$this->ensureDevelopmentAvailability();
|
||||
|
||||
try {
|
||||
$this->authorize('view', $this->server);
|
||||
if ($this->isLocalhost) {
|
||||
throw new \RuntimeException('The Coolify host (localhost) cannot be transferred.');
|
||||
}
|
||||
|
||||
$bundle = $exporter->export($this->server, includeSensitive: true);
|
||||
$this->exportId = data_get($bundle, 'export_id');
|
||||
$this->lastWarnings = array_values((array) data_get($bundle, 'warnings', []));
|
||||
$this->lastResultJson = null;
|
||||
|
||||
$payload = $bundle;
|
||||
$fileName = 'server-transfer-'.$this->server->uuid.'.json';
|
||||
if ($this->encryptBundle) {
|
||||
if (blank($this->passphrase)) {
|
||||
throw new \RuntimeException('Passphrase is required to encrypt the bundle.');
|
||||
}
|
||||
$payload = ServerTransferBundle::encryptWithPassphrase($bundle, $this->passphrase);
|
||||
$fileName = 'server-transfer-'.$this->server->uuid.'.encrypted.json';
|
||||
}
|
||||
|
||||
$json = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
if ($json === false) {
|
||||
throw new \RuntimeException('Failed to encode transfer bundle.');
|
||||
}
|
||||
|
||||
$this->dispatch('success', 'Transfer bundle ready for download.');
|
||||
|
||||
return response()->streamDownload(function () use ($json) {
|
||||
echo $json;
|
||||
}, $fileName, [
|
||||
'Content-Type' => 'application/json',
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function completeTransfer(ServerTransferClaimer $claimer): void
|
||||
{
|
||||
$this->ensureDevelopmentAvailability();
|
||||
|
||||
try {
|
||||
$this->authorize('update', $this->server);
|
||||
if ($this->isLocalhost) {
|
||||
throw new \RuntimeException('The Coolify host cannot be marked as transferred.');
|
||||
}
|
||||
|
||||
$result = $claimer->markTransferred(
|
||||
$this->server,
|
||||
exportId: $this->exportId ?: data_get($this->server->server_metadata, 'transfer.export_id'),
|
||||
targetInstanceUrl: filled($this->targetUrl) ? rtrim($this->targetUrl, '/') : null,
|
||||
);
|
||||
$this->server->refresh();
|
||||
$this->lastResultJson = json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
$this->dispatch('success', $result['message'] ?? 'Server marked as transferred.');
|
||||
} catch (Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function claimServer(ServerTransferClaimer $claimer): void
|
||||
{
|
||||
$this->ensureDevelopmentAvailability();
|
||||
|
||||
try {
|
||||
$this->authorize('update', $this->server);
|
||||
if ($this->isLocalhost) {
|
||||
throw new \RuntimeException('The Coolify host cannot be claimed.');
|
||||
}
|
||||
|
||||
$result = $claimer->claim(
|
||||
$this->server,
|
||||
writeRemote: $this->writeRemoteOnClaim,
|
||||
rebindSentinel: $this->rebindSentinelOnClaim,
|
||||
);
|
||||
$this->server->refresh();
|
||||
$this->lastResultJson = json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
$this->lastWarnings = [];
|
||||
$this->dispatch('success', $result['message'] ?? 'Server claimed.');
|
||||
} catch (Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.server.transfer');
|
||||
}
|
||||
|
||||
private function ensureDevelopmentAvailability(): void
|
||||
{
|
||||
abort_unless(isDev(), 404);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Server;
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Services\ServerTransfer\ServerTransferBundle;
|
||||
use App\Services\ServerTransfer\ServerTransferImporter;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||
use Livewire\WithFileUploads;
|
||||
use Throwable;
|
||||
|
||||
class TransferImport extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
use WithFileUploads;
|
||||
|
||||
public string $bundleJson = '';
|
||||
|
||||
public string $passphrase = '';
|
||||
|
||||
public bool $preserveUuids = true;
|
||||
|
||||
public bool $adoptMode = true;
|
||||
|
||||
/** Write ownership file on the host via SSH when claiming after import. */
|
||||
public bool $writeRemote = false;
|
||||
|
||||
/** @var TemporaryUploadedFile|null */
|
||||
public $bundleFile = null;
|
||||
|
||||
/** @var array<string, mixed>|null */
|
||||
public ?array $lastResult = null;
|
||||
|
||||
/** @var list<string> */
|
||||
public array $lastWarnings = [];
|
||||
|
||||
public ?string $importedServerUuid = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->ensureDevelopmentAvailability();
|
||||
$this->authorize('create', Server::class);
|
||||
}
|
||||
|
||||
public function updatedBundleFile(): void
|
||||
{
|
||||
$this->ensureDevelopmentAvailability();
|
||||
|
||||
if (! $this->bundleFile) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$contents = $this->bundleFile->get();
|
||||
if (! is_string($contents) || blank($contents)) {
|
||||
throw new \RuntimeException('Uploaded file is empty.');
|
||||
}
|
||||
$this->bundleJson = $contents;
|
||||
$this->dispatch('success', 'Bundle file loaded into the form.');
|
||||
} catch (Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function resolveBundle(): array
|
||||
{
|
||||
$raw = trim($this->bundleJson);
|
||||
if ($raw === '') {
|
||||
throw new \RuntimeException('Paste a transfer bundle JSON or upload a file.');
|
||||
}
|
||||
|
||||
$decoded = json_decode($raw, true);
|
||||
if (! is_array($decoded)) {
|
||||
throw new \RuntimeException('Bundle is not valid JSON.');
|
||||
}
|
||||
|
||||
if (data_get($decoded, 'encrypted')) {
|
||||
if (blank($this->passphrase)) {
|
||||
throw new \RuntimeException('Passphrase is required for encrypted bundles.');
|
||||
}
|
||||
|
||||
return ServerTransferBundle::decryptWithPassphrase($decoded, $this->passphrase);
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
public function dryRun(ServerTransferImporter $importer): void
|
||||
{
|
||||
$this->ensureDevelopmentAvailability();
|
||||
$this->runImport($importer, dryRun: true);
|
||||
}
|
||||
|
||||
public function importBundle(ServerTransferImporter $importer): void
|
||||
{
|
||||
$this->ensureDevelopmentAvailability();
|
||||
$this->runImport($importer, dryRun: false);
|
||||
}
|
||||
|
||||
private function runImport(ServerTransferImporter $importer, bool $dryRun): void
|
||||
{
|
||||
try {
|
||||
$this->authorize('create', Server::class);
|
||||
$teamId = currentTeam()->id;
|
||||
$bundle = $this->resolveBundle();
|
||||
|
||||
$result = $importer->import(
|
||||
bundle: $bundle,
|
||||
teamId: $teamId,
|
||||
dryRun: $dryRun,
|
||||
preserveUuids: $this->preserveUuids,
|
||||
adoptMode: $this->adoptMode,
|
||||
claim: ! $dryRun,
|
||||
writeRemote: $this->writeRemote,
|
||||
rebindSentinel: true,
|
||||
);
|
||||
|
||||
$this->lastResult = $result;
|
||||
$this->lastWarnings = array_values((array) data_get($result, 'warnings', []));
|
||||
$this->importedServerUuid = $dryRun ? null : data_get($result, 'server_uuid');
|
||||
|
||||
if ($dryRun) {
|
||||
$this->dispatch('success', 'Dry run completed — nothing was written.');
|
||||
} elseif (data_get($result, 'claimed')) {
|
||||
$this->dispatch('success', 'Server imported and claimed for this instance.');
|
||||
} else {
|
||||
$this->dispatch('success', 'Server imported. Claim did not complete — check warnings or re-claim from the server Transfer page.');
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.server.transfer-import');
|
||||
}
|
||||
|
||||
private function ensureDevelopmentAvailability(): void
|
||||
{
|
||||
abort_unless(isDev(), 404);
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,21 @@ class ValidateAndInstall extends Component
|
||||
|
||||
public function init(int $data = 0)
|
||||
{
|
||||
if (! $this->server->canBeValidated()) {
|
||||
$this->error = 'This server was transferred to another Coolify instance and cannot be revalidated here.';
|
||||
$this->server->update([
|
||||
'validation_logs' => $this->error,
|
||||
'is_validating' => false,
|
||||
]);
|
||||
$this->dispatch(
|
||||
'error',
|
||||
'Cannot revalidate',
|
||||
$this->error
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->isInstalling = false;
|
||||
$this->uptime = null;
|
||||
$this->supported_os_type = null;
|
||||
|
||||
@@ -862,8 +862,29 @@ $schema://$host {
|
||||
return $this->settings->force_disabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server was migrated away from this Coolify instance (source side).
|
||||
* Must not be revalidated or re-enabled as a live managed host.
|
||||
*/
|
||||
public function isTransferredAway(): bool
|
||||
{
|
||||
return data_get($this->server_metadata, 'transfer.status') === 'transferred';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this server may be validated / installed against from this instance.
|
||||
*/
|
||||
public function canBeValidated(): bool
|
||||
{
|
||||
return ! $this->isTransferredAway();
|
||||
}
|
||||
|
||||
public function forceEnableServer()
|
||||
{
|
||||
if ($this->isTransferredAway()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->settings->force_disabled = false;
|
||||
$this->settings->save();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\ServerTransfer;
|
||||
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use RuntimeException;
|
||||
|
||||
class ServerTransferBundle
|
||||
{
|
||||
public const SCHEMA_VERSION = 1;
|
||||
|
||||
public const CLAIM_PATH = '/data/coolify/instance-claim.json';
|
||||
|
||||
public const MAILBOX_DIR = '/data/coolify/exports';
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public static function wrap(array $payload): array
|
||||
{
|
||||
return array_merge([
|
||||
'schema_version' => self::SCHEMA_VERSION,
|
||||
'exported_at' => now()->toIso8601String(),
|
||||
'export_id' => new_public_id(),
|
||||
], $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $bundle
|
||||
* @return array{valid: bool, errors: list<string>, warnings: list<string>}
|
||||
*/
|
||||
public static function validate(array $bundle): array
|
||||
{
|
||||
$errors = [];
|
||||
$warnings = [];
|
||||
|
||||
if (! isset($bundle['schema_version'])) {
|
||||
$errors[] = 'Missing schema_version.';
|
||||
} elseif ((int) $bundle['schema_version'] !== self::SCHEMA_VERSION) {
|
||||
$errors[] = 'Unsupported schema_version '.$bundle['schema_version'].'. Expected '.self::SCHEMA_VERSION.'.';
|
||||
}
|
||||
|
||||
if (! isset($bundle['export_id']) || ! is_string($bundle['export_id']) || $bundle['export_id'] === '') {
|
||||
$errors[] = 'Missing export_id.';
|
||||
}
|
||||
|
||||
if (! isset($bundle['server']) || ! is_array($bundle['server'])) {
|
||||
$errors[] = 'Missing server payload.';
|
||||
} else {
|
||||
foreach (['uuid', 'name', 'ip', 'port', 'user'] as $field) {
|
||||
if (! array_key_exists($field, $bundle['server'])) {
|
||||
$errors[] = "Missing server.{$field}.";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! isset($bundle['private_key']) || ! is_array($bundle['private_key'])) {
|
||||
$errors[] = 'Missing private_key payload.';
|
||||
} else {
|
||||
if (blank(data_get($bundle, 'private_key.private_key'))) {
|
||||
$errors[] = 'Missing private_key.private_key material.';
|
||||
}
|
||||
}
|
||||
|
||||
if (! isset($bundle['destinations']) || ! is_array($bundle['destinations'])) {
|
||||
$errors[] = 'Missing destinations array.';
|
||||
} elseif (count($bundle['destinations']) === 0) {
|
||||
$warnings[] = 'Bundle has no destinations; a default destination will be used.';
|
||||
}
|
||||
|
||||
if (! isset($bundle['projects']) || ! is_array($bundle['projects'])) {
|
||||
$errors[] = 'Missing projects array.';
|
||||
}
|
||||
|
||||
return [
|
||||
'valid' => $errors === [],
|
||||
'errors' => $errors,
|
||||
'warnings' => $warnings,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $bundle
|
||||
*/
|
||||
public static function assertValid(array $bundle): void
|
||||
{
|
||||
$result = self::validate($bundle);
|
||||
if (! $result['valid']) {
|
||||
throw ValidationException::withMessages([
|
||||
'bundle' => $result['errors'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt an entire bundle for mailbox/API transport with a user passphrase.
|
||||
*
|
||||
* @param array<string, mixed> $bundle
|
||||
* @return array{encrypted: true, schema_version: int, payload: string}
|
||||
*/
|
||||
public static function encryptWithPassphrase(array $bundle, string $passphrase): array
|
||||
{
|
||||
if ($passphrase === '') {
|
||||
throw new RuntimeException('Passphrase must not be empty.');
|
||||
}
|
||||
|
||||
$json = json_encode($bundle, JSON_THROW_ON_ERROR);
|
||||
$key = hash('sha256', $passphrase, true);
|
||||
$iv = random_bytes(16);
|
||||
$cipher = openssl_encrypt($json, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv);
|
||||
if ($cipher === false) {
|
||||
throw new RuntimeException('Failed to encrypt transfer bundle.');
|
||||
}
|
||||
|
||||
$mac = hash_hmac('sha256', $iv.$cipher, $key);
|
||||
|
||||
return [
|
||||
'encrypted' => true,
|
||||
'schema_version' => self::SCHEMA_VERSION,
|
||||
'payload' => base64_encode($iv.$mac.$cipher),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $encrypted
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function decryptWithPassphrase(array $encrypted, string $passphrase): array
|
||||
{
|
||||
if (! data_get($encrypted, 'encrypted')) {
|
||||
throw new RuntimeException('Bundle is not encrypted.');
|
||||
}
|
||||
|
||||
$raw = base64_decode((string) data_get($encrypted, 'payload'), true);
|
||||
if ($raw === false || strlen($raw) < 48) {
|
||||
throw new RuntimeException('Invalid encrypted payload.');
|
||||
}
|
||||
|
||||
$key = hash('sha256', $passphrase, true);
|
||||
$iv = substr($raw, 0, 16);
|
||||
$mac = substr($raw, 16, 64);
|
||||
$cipher = substr($raw, 80);
|
||||
$expectedMac = hash_hmac('sha256', $iv.$cipher, $key);
|
||||
if (! hash_equals($expectedMac, $mac)) {
|
||||
throw new RuntimeException('Invalid passphrase or corrupted bundle.');
|
||||
}
|
||||
|
||||
$json = openssl_decrypt($cipher, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv);
|
||||
if ($json === false) {
|
||||
throw new RuntimeException('Failed to decrypt transfer bundle.');
|
||||
}
|
||||
|
||||
/** @var array<string, mixed> $bundle */
|
||||
$bundle = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
return $bundle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optionally re-seal secrets with Laravel's app key for intermediate storage.
|
||||
* Prefer passphrase encryption for cross-instance transfers.
|
||||
*
|
||||
* @param array<string, mixed> $bundle
|
||||
*/
|
||||
public static function sealWithAppKey(array $bundle): string
|
||||
{
|
||||
return Crypt::encryptString(json_encode($bundle, JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function unsealWithAppKey(string $sealed): array
|
||||
{
|
||||
/** @var array<string, mixed> $bundle */
|
||||
$bundle = json_decode(Crypt::decryptString($sealed), true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
return $bundle;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\ServerTransfer;
|
||||
|
||||
use App\Models\Server;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class ServerTransferClaimer
|
||||
{
|
||||
/**
|
||||
* Claim a managed host for this Coolify instance.
|
||||
*
|
||||
* Database ownership (metadata + Sentinel settings) is committed in one transaction so a
|
||||
* mid-flight failure rolls back. Remote SSH claim-file writes happen after commit and are
|
||||
* best-effort — they cannot participate in the DB transaction.
|
||||
*
|
||||
* @return array{
|
||||
* server_uuid: string,
|
||||
* claim: array<string, mixed>,
|
||||
* sentinel_rebound: bool,
|
||||
* claim_written: bool,
|
||||
* message: string
|
||||
* }
|
||||
*/
|
||||
public function claim(Server $server, bool $writeRemote = true, bool $rebindSentinel = true): array
|
||||
{
|
||||
if ($server->id === 0) {
|
||||
throw new RuntimeException('Cannot claim the Coolify host itself.');
|
||||
}
|
||||
|
||||
$instanceUrl = rtrim((string) (instanceSettings()->fqdn ?: config('app.url')), '/');
|
||||
if ($instanceUrl === '') {
|
||||
throw new RuntimeException('Instance URL (FQDN or APP_URL) must be set before claiming a server.');
|
||||
}
|
||||
|
||||
$exportId = data_get($server->server_metadata, 'transfer.export_id');
|
||||
|
||||
$claim = [
|
||||
'instance_url' => $instanceUrl,
|
||||
'server_uuid' => $server->uuid,
|
||||
'team_id' => $server->team_id,
|
||||
'claimed_at' => now()->toIso8601String(),
|
||||
'export_id' => $exportId,
|
||||
'schema_version' => ServerTransferBundle::SCHEMA_VERSION,
|
||||
];
|
||||
|
||||
$result = DB::transaction(function () use ($server, $claim, $rebindSentinel, $instanceUrl) {
|
||||
$server = Server::query()->with('settings')->lockForUpdate()->findOrFail($server->id);
|
||||
|
||||
$sentinelRebound = false;
|
||||
if ($rebindSentinel && $server->settings) {
|
||||
$server->settings->sentinel_custom_url = $instanceUrl;
|
||||
$server->settings->ensureValidSentinelToken();
|
||||
// Leave sentinel disabled until operator enables metrics; endpoint is ready.
|
||||
$server->settings->save();
|
||||
$sentinelRebound = true;
|
||||
}
|
||||
|
||||
$metadata = $server->server_metadata ?? [];
|
||||
$metadata['transfer'] = array_merge((array) data_get($metadata, 'transfer', []), [
|
||||
'status' => 'claimed',
|
||||
'claimed_at' => $claim['claimed_at'],
|
||||
'claim' => $claim,
|
||||
'claim_written' => false,
|
||||
'sentinel_rebound' => $sentinelRebound,
|
||||
]);
|
||||
$server->server_metadata = $metadata;
|
||||
$server->save();
|
||||
|
||||
return [
|
||||
'server_uuid' => $server->uuid,
|
||||
'claim' => $claim,
|
||||
'sentinel_rebound' => $sentinelRebound,
|
||||
'claim_written' => false,
|
||||
];
|
||||
});
|
||||
|
||||
// Remote host I/O is outside the transaction (cannot be rolled back with DB rows).
|
||||
$claimWritten = false;
|
||||
if ($writeRemote) {
|
||||
$claimWritten = $this->writeClaimFile($server, $claim);
|
||||
if ($claimWritten) {
|
||||
$this->persistClaimWritten($server);
|
||||
}
|
||||
}
|
||||
|
||||
$result['claim_written'] = $claimWritten;
|
||||
$result['message'] = $claimWritten
|
||||
? 'Server claimed. Ownership file written and Sentinel rebound to this instance.'
|
||||
: 'Server claimed in Coolify. Remote ownership file was not written (SSH unavailable or skipped).';
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a server as transferred away from this instance (source side).
|
||||
* Disables automations to prevent dual management.
|
||||
*
|
||||
* All database writes run in one transaction so partial disable state cannot stick on failure.
|
||||
* Local SSH key cache cleanup runs after commit (filesystem; not transactional).
|
||||
*
|
||||
* @return array{server_uuid: string, message: string}
|
||||
*/
|
||||
public function markTransferred(Server $server, ?string $exportId = null, ?string $targetInstanceUrl = null): array
|
||||
{
|
||||
if ($server->id === 0) {
|
||||
throw new RuntimeException('Cannot transfer the Coolify host itself.');
|
||||
}
|
||||
|
||||
$result = DB::transaction(function () use ($server, $exportId, $targetInstanceUrl) {
|
||||
$server = Server::query()->with('settings')->lockForUpdate()->findOrFail($server->id);
|
||||
|
||||
if ($server->settings) {
|
||||
$server->settings->force_disabled = true;
|
||||
$server->settings->is_sentinel_enabled = false;
|
||||
$server->settings->save();
|
||||
}
|
||||
|
||||
$metadata = $server->server_metadata ?? [];
|
||||
$metadata['transfer'] = array_merge((array) data_get($metadata, 'transfer', []), [
|
||||
'status' => 'transferred',
|
||||
'export_id' => $exportId ?? data_get($metadata, 'transfer.export_id'),
|
||||
'target_instance_url' => $targetInstanceUrl,
|
||||
'transferred_at' => now()->toIso8601String(),
|
||||
]);
|
||||
$server->server_metadata = $metadata;
|
||||
$server->save();
|
||||
|
||||
return [
|
||||
'server_uuid' => $server->uuid,
|
||||
'message' => 'Server marked as transferred. Automations disabled on this instance.',
|
||||
];
|
||||
});
|
||||
|
||||
$this->clearLocalSshArtifacts($server);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function persistClaimWritten(Server $server): void
|
||||
{
|
||||
DB::transaction(function () use ($server) {
|
||||
$server = Server::query()->lockForUpdate()->find($server->id);
|
||||
if (! $server) {
|
||||
return;
|
||||
}
|
||||
|
||||
$metadata = $server->server_metadata ?? [];
|
||||
data_set($metadata, 'transfer.claim_written', true);
|
||||
$server->server_metadata = $metadata;
|
||||
$server->save();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort local SSH mux/key cleanup after transfer (filesystem; not part of DB rollback).
|
||||
* forceDisableServer is idempotent for force_disabled and also clears cached SSH key material.
|
||||
*/
|
||||
private function clearLocalSshArtifacts(Server $server): void
|
||||
{
|
||||
try {
|
||||
$server->refresh();
|
||||
$server->forceDisableServer();
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('Failed to clear local SSH artifacts after transfer', [
|
||||
'server_uuid' => $server->uuid,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write export bundle to the managed host mailbox (air-gapped transfer).
|
||||
*
|
||||
* @param array<string, mixed> $bundle
|
||||
* @return array{path: string, written: bool}
|
||||
*/
|
||||
public function writeMailbox(Server $server, array $bundle, ?string $passphrase = null): array
|
||||
{
|
||||
ServerTransferBundle::assertValid($bundle);
|
||||
|
||||
$exportId = (string) data_get($bundle, 'export_id', new_public_id());
|
||||
$filename = "server-transfer-{$exportId}.coolify.json";
|
||||
$path = ServerTransferBundle::MAILBOX_DIR.'/'.$filename;
|
||||
|
||||
$payload = $passphrase
|
||||
? ServerTransferBundle::encryptWithPassphrase($bundle, $passphrase)
|
||||
: $bundle;
|
||||
|
||||
$json = json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
|
||||
$b64 = base64_encode($json);
|
||||
|
||||
$written = false;
|
||||
try {
|
||||
instant_remote_process([
|
||||
'mkdir -p '.escapeshellarg(ServerTransferBundle::MAILBOX_DIR),
|
||||
'echo '.escapeshellarg($b64).' | base64 -d > '.escapeshellarg($path),
|
||||
'chmod 600 '.escapeshellarg($path),
|
||||
'chown 9999:root '.escapeshellarg($path).' || true',
|
||||
], $server, true);
|
||||
$written = true;
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('Failed to write server transfer mailbox', [
|
||||
'server_uuid' => $server->uuid,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
if (! app()->runningUnitTests()) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'path' => $path,
|
||||
'written' => $written,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $claim
|
||||
*/
|
||||
private function writeClaimFile(Server $server, array $claim): bool
|
||||
{
|
||||
$json = json_encode($claim, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
|
||||
$b64 = base64_encode($json);
|
||||
$path = ServerTransferBundle::CLAIM_PATH;
|
||||
|
||||
try {
|
||||
instant_remote_process([
|
||||
'mkdir -p /data/coolify',
|
||||
'echo '.escapeshellarg($b64).' | base64 -d > '.escapeshellarg($path),
|
||||
'chmod 600 '.escapeshellarg($path),
|
||||
'chown 9999:root '.escapeshellarg($path).' || true',
|
||||
], $server, true);
|
||||
|
||||
return true;
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('Failed to write instance claim file', [
|
||||
'server_uuid' => $server->uuid,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\ServerTransfer;
|
||||
|
||||
use App\Models\Server;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class ServerTransferMigrator
|
||||
{
|
||||
public function __construct(
|
||||
private ServerTransferExporter $exporter,
|
||||
private ServerTransferClaimer $claimer,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* One-shot migrate: export on this instance → import+claim on target → complete on this instance.
|
||||
*
|
||||
* @return array{
|
||||
* server_uuid: string,
|
||||
* export_id: string|null,
|
||||
* target_url: string,
|
||||
* import: array<string, mixed>,
|
||||
* complete: array<string, mixed>,
|
||||
* warnings: list<string>,
|
||||
* message: string
|
||||
* }
|
||||
*/
|
||||
public function migrate(
|
||||
Server $server,
|
||||
string $targetUrl,
|
||||
string $targetToken,
|
||||
bool $writeRemote = false,
|
||||
bool $rebindSentinel = true,
|
||||
bool $preserveUuids = true,
|
||||
bool $adoptMode = true,
|
||||
): array {
|
||||
if ($server->id === 0) {
|
||||
throw new RuntimeException('The Coolify host (localhost) cannot be transferred between instances.');
|
||||
}
|
||||
|
||||
$targetUrl = $this->normalizeTargetUrl($targetUrl);
|
||||
$token = $this->normalizeToken($targetToken);
|
||||
|
||||
$bundle = $this->exporter->export($server, includeSensitive: true);
|
||||
$exportId = data_get($bundle, 'export_id');
|
||||
$warnings = array_values((array) data_get($bundle, 'warnings', []));
|
||||
|
||||
// Target import+claim runs in its own DB transaction on the remote instance.
|
||||
// Source DB is unchanged until markTransferred below — so a failed remote import leaves source intact.
|
||||
$importBody = $this->postImportToTarget(
|
||||
targetUrl: $targetUrl,
|
||||
token: $token,
|
||||
bundle: $bundle,
|
||||
writeRemote: $writeRemote,
|
||||
rebindSentinel: $rebindSentinel,
|
||||
preserveUuids: $preserveUuids,
|
||||
adoptMode: $adoptMode,
|
||||
);
|
||||
|
||||
if (is_array(data_get($importBody, 'warnings'))) {
|
||||
$warnings = array_values(array_unique(array_merge($warnings, $importBody['warnings'])));
|
||||
}
|
||||
|
||||
// Cross-instance 2PC is impossible: if complete fails after a successful import, the target
|
||||
// already owns the server. Surface that clearly so the operator can retry complete only.
|
||||
try {
|
||||
$complete = $this->claimer->markTransferred(
|
||||
$server,
|
||||
exportId: is_string($exportId) ? $exportId : null,
|
||||
targetInstanceUrl: $targetUrl,
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
throw new RuntimeException(
|
||||
"Server was imported on {$targetUrl}, but this instance could not mark it as transferred: {$e->getMessage()}. ".
|
||||
'Retry complete (API: POST /api/v1/servers/{uuid}/complete) so automations stay disabled here. Do not re-import on the target.',
|
||||
previous: $e
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'server_uuid' => $server->uuid,
|
||||
'export_id' => is_string($exportId) ? $exportId : null,
|
||||
'target_url' => $targetUrl,
|
||||
'import' => $importBody,
|
||||
'complete' => $complete,
|
||||
'warnings' => $warnings,
|
||||
'message' => 'Server migrated to '.$targetUrl.'. Automations disabled on this instance.',
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeTargetUrl(string $targetUrl): string
|
||||
{
|
||||
$targetUrl = rtrim(trim($targetUrl), '/');
|
||||
if ($targetUrl === '' || ! filter_var($targetUrl, FILTER_VALIDATE_URL)) {
|
||||
throw new RuntimeException('A valid target instance URL is required (e.g. http://localhost:8001).');
|
||||
}
|
||||
|
||||
// From inside Docker, localhost is this container — use the host gateway for peer instances.
|
||||
if (file_exists('/.dockerenv') || is_file('/run/.containerenv')) {
|
||||
$targetUrl = (string) preg_replace(
|
||||
'#^(https?://)(localhost|127\.0\.0\.1)(?=[:/]|$)#i',
|
||||
'$1host.docker.internal',
|
||||
$targetUrl
|
||||
);
|
||||
}
|
||||
|
||||
return $targetUrl;
|
||||
}
|
||||
|
||||
private function normalizeToken(string $targetToken): string
|
||||
{
|
||||
$token = trim($targetToken);
|
||||
if (str_starts_with(strtolower($token), 'bearer ')) {
|
||||
$token = trim(substr($token, 7));
|
||||
}
|
||||
if ($token === '') {
|
||||
throw new RuntimeException('A target instance API token is required (root or write + create servers).');
|
||||
}
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $bundle
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function postImportToTarget(
|
||||
string $targetUrl,
|
||||
string $token,
|
||||
array $bundle,
|
||||
bool $writeRemote,
|
||||
bool $rebindSentinel,
|
||||
bool $preserveUuids,
|
||||
bool $adoptMode,
|
||||
): array {
|
||||
$importUrl = $targetUrl.'/api/v1/servers/import';
|
||||
|
||||
try {
|
||||
$response = Http::timeout(120)
|
||||
->acceptJson()
|
||||
->withToken($token)
|
||||
->asJson()
|
||||
->post($importUrl, [
|
||||
'bundle' => $bundle,
|
||||
'dry_run' => false,
|
||||
'preserve_uuids' => $preserveUuids,
|
||||
'adopt_mode' => $adoptMode,
|
||||
'claim' => true,
|
||||
'write_remote' => $writeRemote,
|
||||
'rebind_sentinel' => $rebindSentinel,
|
||||
]);
|
||||
} catch (ConnectionException $e) {
|
||||
throw new RuntimeException("Could not reach target instance at {$importUrl}: {$e->getMessage()}", previous: $e);
|
||||
} catch (Throwable $e) {
|
||||
throw new RuntimeException("Transfer to target failed: {$e->getMessage()}", previous: $e);
|
||||
}
|
||||
|
||||
$importBody = $response->json();
|
||||
if (! is_array($importBody)) {
|
||||
$importBody = ['raw' => $response->body()];
|
||||
}
|
||||
|
||||
if (! $response->successful()) {
|
||||
$message = data_get($importBody, 'message')
|
||||
?? data_get($importBody, 'errors')
|
||||
?? $response->body();
|
||||
if (is_array($message)) {
|
||||
$message = json_encode($message);
|
||||
}
|
||||
throw new RuntimeException("Target import failed (HTTP {$response->status()}): {$message}");
|
||||
}
|
||||
|
||||
return $importBody;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
# Multi-instance Coolify (isolated project/network/volumes per instance).
|
||||
#
|
||||
# ./scripts/dev-instances up # a + b
|
||||
# ./scripts/dev-instances urls
|
||||
# ./scripts/dev-instances down
|
||||
#
|
||||
# Manual:
|
||||
# docker compose -p coolify-a -f docker-compose.dev-multi.yml --env-file .dev-instances/a.env up -d
|
||||
#
|
||||
# Optional profiles: vite, mailpit, minio, testing-host
|
||||
# ./scripts/dev-instances up a --with vite mailpit
|
||||
#
|
||||
# Ports (a / b): app 8000/8001, db 5432/5433, redis 6379/6380, soketi 6001/6011
|
||||
|
||||
services:
|
||||
coolify:
|
||||
image: coolify:dev
|
||||
pull_policy: never
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./docker/development/Dockerfile
|
||||
args:
|
||||
- USER_ID=${USERID:-1000}
|
||||
- GROUP_ID=${GROUPID:-1000}
|
||||
- COOLIFY_FLUX_VERSION=${COOLIFY_FLUX_VERSION:-nightly}
|
||||
- COOLIFY_FLUX_CHECKSUM=${COOLIFY_FLUX_CHECKSUM:-unknown}
|
||||
- COOLIFY_CLI_VERSION=${COOLIFY_CLI_VERSION:-nightly}
|
||||
- COOLIFY_CLI_CHECKSUM=${COOLIFY_CLI_CHECKSUM:-unknown}
|
||||
ports:
|
||||
- "${APP_PORT:-8000}:8080"
|
||||
- "${FORWARD_FLUX_PORT:-6443}:6443"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
environment:
|
||||
AUTORUN_ENABLED: "${AUTORUN_ENABLED:-false}"
|
||||
APP_NAME: "${APP_NAME:-Coolify}"
|
||||
APP_URL: "${APP_URL:-http://localhost:8000}"
|
||||
APP_KEY: "${APP_KEY:-}"
|
||||
APP_ENV: "${APP_ENV:-local}"
|
||||
APP_DEBUG: "${APP_DEBUG:-true}"
|
||||
DB_CONNECTION: pgsql
|
||||
DB_HOST: postgres
|
||||
DB_PORT: 5432
|
||||
DB_DATABASE: "${DB_DATABASE:-coolify}"
|
||||
DB_USERNAME: "${DB_USERNAME:-coolify}"
|
||||
DB_PASSWORD: "${DB_PASSWORD:-password}"
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: 6379
|
||||
REDIS_PASSWORD: "${REDIS_PASSWORD:-null}"
|
||||
COOLIFY_CONTAINER_ROLE: all
|
||||
PUSHER_HOST: ""
|
||||
PUSHER_PORT: ""
|
||||
PUSHER_SCHEME: http
|
||||
PUSHER_APP_ID: "${PUSHER_APP_ID:-coolify}"
|
||||
PUSHER_APP_KEY: "${PUSHER_APP_KEY:-coolify}"
|
||||
PUSHER_APP_SECRET: "${PUSHER_APP_SECRET:-coolify}"
|
||||
BROADCAST_CONNECTION: pusher
|
||||
QUEUE_CONNECTION: redis
|
||||
CACHE_STORE: redis
|
||||
SESSION_DRIVER: redis
|
||||
healthcheck:
|
||||
test: curl -sf http://127.0.0.1:8080/api/health || exit 1
|
||||
interval: 5s
|
||||
retries: 10
|
||||
timeout: 2s
|
||||
volumes:
|
||||
- .:/var/www/html/:cached
|
||||
- backups_data:/var/www/html/storage/app/backups
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- coolify
|
||||
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
pull_policy: always
|
||||
ports:
|
||||
- "${FORWARD_DB_PORT:-5432}:5432"
|
||||
environment:
|
||||
POSTGRES_USER: "${DB_USERNAME:-coolify}"
|
||||
POSTGRES_PASSWORD: "${DB_PASSWORD:-password}"
|
||||
POSTGRES_DB: "${DB_DATABASE:-coolify}"
|
||||
POSTGRES_HOST_AUTH_METHOD: "trust"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
||||
interval: 5s
|
||||
retries: 10
|
||||
timeout: 2s
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- coolify
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
pull_policy: always
|
||||
ports:
|
||||
- "${FORWARD_REDIS_PORT:-6379}:6379"
|
||||
healthcheck:
|
||||
test: redis-cli ping
|
||||
interval: 5s
|
||||
retries: 10
|
||||
timeout: 2s
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- coolify
|
||||
|
||||
soketi:
|
||||
image: coolify-realtime:dev
|
||||
pull_policy: never
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./docker/coolify-realtime/Dockerfile
|
||||
ports:
|
||||
- "${FORWARD_SOKETI_PORT:-6001}:6001"
|
||||
- "${FORWARD_SOKETI_PORT_ALT:-6002}:6002"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
- ./storage:/var/www/html/storage
|
||||
- ./docker/coolify-realtime/terminal-server.js:/terminal/terminal-server.js
|
||||
- ./docker/coolify-realtime/terminal-utils.js:/terminal/terminal-utils.js
|
||||
environment:
|
||||
SOKETI_DEBUG: "false"
|
||||
SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID:-coolify}"
|
||||
SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY:-coolify}"
|
||||
SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET:-coolify}"
|
||||
SOKETI_HOST: "0.0.0.0"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:6001/ready && curl -fsS http://127.0.0.1:6002/ready || exit 1"]
|
||||
interval: 5s
|
||||
retries: 10
|
||||
timeout: 2s
|
||||
entrypoint: ["/bin/sh", "/soketi-entrypoint.sh"]
|
||||
networks:
|
||||
- coolify
|
||||
|
||||
vite:
|
||||
profiles: ["vite"]
|
||||
image: node:24-alpine
|
||||
pull_policy: always
|
||||
working_dir: /var/www/html
|
||||
environment:
|
||||
VITE_HOST: localhost
|
||||
VITE_PORT: "${VITE_PORT:-5173}"
|
||||
ports:
|
||||
- "${VITE_PORT:-5173}:${VITE_PORT:-5173}"
|
||||
volumes:
|
||||
- .:/var/www/html/:cached
|
||||
command: sh -c "npm install && npm run dev -- --port ${VITE_PORT:-5173} --host"
|
||||
networks:
|
||||
- coolify
|
||||
|
||||
testing-host:
|
||||
profiles: ["testing-host"]
|
||||
image: coolify-testing-host:dev
|
||||
pull_policy: never
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./docker/testing-host/Dockerfile
|
||||
init: true
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- coolify_data:/data/coolify
|
||||
- backups_data:/data/coolify/backups
|
||||
- postgres_data:/data/coolify/_volumes/database
|
||||
- redis_data:/data/coolify/_volumes/redis
|
||||
- minio_data:/data/coolify/_volumes/minio
|
||||
networks:
|
||||
- coolify
|
||||
|
||||
mailpit:
|
||||
profiles: ["mailpit"]
|
||||
image: axllent/mailpit:latest
|
||||
pull_policy: always
|
||||
ports:
|
||||
- "${FORWARD_MAILPIT_PORT:-1025}:1025"
|
||||
- "${FORWARD_MAILPIT_DASHBOARD_PORT:-8025}:8025"
|
||||
networks:
|
||||
- coolify
|
||||
|
||||
minio:
|
||||
profiles: ["minio"]
|
||||
image: coollabsio/maxio:latest
|
||||
pull_policy: always
|
||||
ports:
|
||||
- "${FORWARD_MINIO_PORT:-9000}:9000"
|
||||
- "${FORWARD_MINIO_PORT_CONSOLE:-9001}:9001"
|
||||
environment:
|
||||
MINIO_ACCESS_KEY: minioadmin
|
||||
MINIO_SECRET_KEY: minioadmin
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
networks:
|
||||
- coolify
|
||||
|
||||
minio-init:
|
||||
profiles: ["minio"]
|
||||
image: minio/mc:latest
|
||||
pull_policy: always
|
||||
restart: "no"
|
||||
depends_on:
|
||||
- minio
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
until mc alias set local http://minio:9000 minioadmin minioadmin 2>/dev/null; do sleep 2; done;
|
||||
mc mb local/local --ignore-existing;
|
||||
"
|
||||
networks:
|
||||
- coolify
|
||||
|
||||
volumes:
|
||||
backups_data:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
coolify_data:
|
||||
minio_data:
|
||||
|
||||
networks:
|
||||
coolify:
|
||||
driver: bridge
|
||||
@@ -21,3 +21,20 @@ Resource migration is under development and is not available in production.
|
||||
|
||||
Before promoting this feature, remove all three runtime gates together and
|
||||
update the tests and this document in the same change.
|
||||
|
||||
## Server transfer between Coolify instances
|
||||
|
||||
Server transfer is under development and is not available in production.
|
||||
|
||||
- **UI:** Transfer and import links are rendered only when `isDev()` returns
|
||||
`true`, and the transfer Livewire components return `404 Not Found` outside
|
||||
development mode. Public Livewire actions repeat the check before doing work.
|
||||
- **API:** Every endpoint handled by `ServerTransferController` returns `404 Not
|
||||
Found` unless `isDev()` returns `true`.
|
||||
- **Tests:** Development-mode behavior and production isolation for all API
|
||||
endpoints, UI routes, and navigation links are covered by
|
||||
`tests/Feature/Api/ServerTransferApiTest.php` and
|
||||
`tests/Feature/Livewire/ServerTransferUiTest.php`.
|
||||
|
||||
Before promoting this feature, remove the API and UI runtime gates together and
|
||||
update the tests and this document in the same change.
|
||||
|
||||
@@ -141,6 +141,14 @@
|
||||
['label' => 'Terminal Access', 'route' => 'server.security.terminal-access', 'active' => request()->routeIs('server.security.terminal-access'), 'icon' => 'browser-terminal', 'navigate' => false],
|
||||
],
|
||||
],
|
||||
[
|
||||
'label' => 'Transfer',
|
||||
'route' => 'server.transfer',
|
||||
'active' => $activeMenu === 'transfer',
|
||||
'icon' => 'arrow-right',
|
||||
'group' => 'Operations',
|
||||
'visible' => isDev() && ! $server->isLocalhost() && auth()->user()?->can('view', $server),
|
||||
],
|
||||
[
|
||||
'label' => 'Danger',
|
||||
'route' => 'server.delete',
|
||||
|
||||
@@ -5,27 +5,48 @@
|
||||
|
||||
<div class="mb-5 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h1 class="min-w-0 text-[24px]! leading-7! font-semibold! tracking-tight!">Servers</h1>
|
||||
@can('createAnyResource')
|
||||
<a href="{{ route('server.create') }}" {{ wireNavigate() }}
|
||||
class="button w-fit shrink-0 whitespace-nowrap button-highlighted">
|
||||
<x-reicon name="plus" class="size-3.5" />
|
||||
New server
|
||||
</a>
|
||||
@endcan
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@if (isDev())
|
||||
@can('create', App\Models\Server::class)
|
||||
<a href="{{ route('server.transfer.import') }}" {{ wireNavigate() }}
|
||||
class="button w-fit shrink-0 whitespace-nowrap">
|
||||
<x-reicon name="upload" class="size-3.5" />
|
||||
Import transfer
|
||||
<x-status-badge label="Dev" />
|
||||
</a>
|
||||
@endcan
|
||||
@endif
|
||||
@can('createAnyResource')
|
||||
<a href="{{ route('server.create') }}" {{ wireNavigate() }}
|
||||
class="button w-fit shrink-0 whitespace-nowrap button-highlighted">
|
||||
<x-reicon name="plus" class="size-3.5" />
|
||||
New server
|
||||
</a>
|
||||
@endcan
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@php
|
||||
$serverRows = $servers->map(function ($server) {
|
||||
$isTransferredAway = $server->isTransferredAway();
|
||||
$isReady = $server->settings->is_reachable
|
||||
&& $server->settings->is_usable
|
||||
&& ! $server->settings->force_disabled;
|
||||
&& ! $server->settings->force_disabled
|
||||
&& ! $isTransferredAway;
|
||||
|
||||
$status = match (true) {
|
||||
$isTransferredAway => 'Transferred away',
|
||||
$server->settings->force_disabled => 'Disabled',
|
||||
$isReady => 'Ready',
|
||||
default => 'Validation required',
|
||||
};
|
||||
|
||||
return [
|
||||
'uuid' => $server->uuid,
|
||||
'name' => $server->name,
|
||||
'description' => $server->description ?: 'No description',
|
||||
'href' => route('server.show', ['server_uuid' => $server->uuid]),
|
||||
'status' => $isReady ? 'Ready' : ($server->settings->force_disabled ? 'Disabled' : 'Validation required'),
|
||||
'status' => $status,
|
||||
'statusType' => $isReady ? 'success' : 'error',
|
||||
'ready' => $isReady,
|
||||
];
|
||||
|
||||
@@ -89,8 +89,12 @@
|
||||
</x-forms.button>
|
||||
@endif
|
||||
@endif
|
||||
<x-status-badge :label="$server->isFunctional() ? 'Ready' : 'Validation required'"
|
||||
:type="$server->isFunctional() ? 'success' : 'warning'" />
|
||||
@if ($server->isTransferredAway())
|
||||
<x-status-badge label="Transferred away" type="warning" />
|
||||
@else
|
||||
<x-status-badge :label="$server->isFunctional() ? 'Ready' : 'Validation required'"
|
||||
:type="$server->isFunctional() ? 'success' : 'warning'" />
|
||||
@endif
|
||||
</x-slot:actions>
|
||||
|
||||
<div class="flex items-start gap-3">
|
||||
@@ -103,7 +107,9 @@
|
||||
{{ $server->name }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs leading-5 text-neutral-500 dark:text-fg-dim">
|
||||
@if ($server->isFunctional())
|
||||
@if ($server->isTransferredAway())
|
||||
This server was migrated away from this Coolify instance and cannot be managed here.
|
||||
@elseif ($server->isFunctional())
|
||||
The server is reachable, validated, and ready to host resources.
|
||||
@else
|
||||
Validate the SSH connection before using this server.
|
||||
@@ -206,6 +212,14 @@
|
||||
</x-process-dialog>
|
||||
</x-slot:actions>
|
||||
|
||||
@if ($server->isTransferredAway())
|
||||
<x-callout type="warning" title="Transferred to another instance" class="mb-4">
|
||||
This server was migrated away from this Coolify instance. It cannot be revalidated or
|
||||
managed here. Use the target instance, or delete this server when you no longer need the
|
||||
archive.
|
||||
</x-callout>
|
||||
@endif
|
||||
|
||||
@if ($this->limaStartCommand)
|
||||
<x-callout type="info" title="Start this Lima VM locally" class="mb-4">
|
||||
<code
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<div>
|
||||
<x-slot:title>
|
||||
Import server transfer | Coolify
|
||||
</x-slot>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h1>Import server transfer <x-status-badge label="Dev" /></h1>
|
||||
<a href="{{ route('server.index') }}" {{ wireNavigate() }}>
|
||||
<x-forms.button>Back to servers</x-forms.button>
|
||||
</a>
|
||||
</div>
|
||||
<div class="subtitle">
|
||||
Paste or upload a transfer bundle exported from another Coolify instance. This creates the server and its
|
||||
resources under the current team and <strong>claims</strong> the host for this instance
|
||||
(control-plane only — host data stays on the machine).
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4 rounded-lg border border-neutral-200 p-4 dark:border-coolgray-200">
|
||||
<x-forms.input type="file" id="bundleFile" label="Bundle file (.json)" accept=".json,application/json" />
|
||||
<x-forms.textarea id="bundleJson" label="Or paste bundle JSON" rows="14" placeholder='{"schema_version":1,...}' />
|
||||
<x-forms.input id="passphrase" type="password" label="Passphrase (if encrypted)" placeholder="Optional" />
|
||||
<div class="flex flex-col gap-2">
|
||||
<x-forms.checkbox id="preserveUuids" label="Preserve UUIDs from the source instance" />
|
||||
<x-forms.checkbox id="adoptMode" label="Adopt mode (keep statuses; do not force exited redeploy)" />
|
||||
<x-forms.checkbox id="writeRemote" label="Also write ownership file on the host via SSH (optional)" />
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<x-forms.button wire:click="dryRun" wire:loading.attr="disabled" wire:target="dryRun,importBundle">
|
||||
<span wire:loading.remove wire:target="dryRun">Dry run</span>
|
||||
<span wire:loading wire:target="dryRun">Checking…</span>
|
||||
</x-forms.button>
|
||||
<x-forms.button wire:click="importBundle" wire:loading.attr="disabled"
|
||||
wire:target="dryRun,importBundle"
|
||||
wire:confirm="Import this server into the current team?">
|
||||
<span wire:loading.remove wire:target="importBundle">Import server</span>
|
||||
<span wire:loading wire:target="importBundle">Importing…</span>
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (count($lastWarnings) > 0)
|
||||
<div class="rounded-lg border border-warning/40 bg-warning/10 p-3 text-sm">
|
||||
<div class="mb-1 font-semibold text-warning">Warnings</div>
|
||||
<ul class="list-disc space-y-1 pl-5">
|
||||
@foreach ($lastWarnings as $warning)
|
||||
<li>{{ $warning }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($lastResult)
|
||||
<div class="rounded-lg border border-neutral-200 p-4 dark:border-coolgray-200">
|
||||
<div class="mb-2 font-semibold">
|
||||
{{ data_get($lastResult, 'dry_run') ? 'Dry-run result' : 'Import result' }}
|
||||
</div>
|
||||
@if ($importedServerUuid)
|
||||
<div class="mb-3 flex flex-wrap items-center gap-2 text-sm">
|
||||
<span>Server UUID: <code class="font-mono">{{ $importedServerUuid }}</code></span>
|
||||
@if (data_get($lastResult, 'claimed'))
|
||||
<span class="text-success">Claimed</span>
|
||||
@endif
|
||||
<a href="{{ route('server.show', ['server_uuid' => $importedServerUuid]) }}" {{ wireNavigate() }}>
|
||||
<x-forms.button>Open server</x-forms.button>
|
||||
</a>
|
||||
<a href="{{ route('server.transfer', ['server_uuid' => $importedServerUuid]) }}" {{ wireNavigate() }}>
|
||||
<x-forms.button>Transfer details</x-forms.button>
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
<pre class="max-h-80 overflow-auto rounded-lg bg-neutral-100 p-3 text-xs dark:bg-coolgray-100">{{ json_encode($lastResult, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) }}</pre>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,146 @@
|
||||
<div>
|
||||
<x-slot:title>
|
||||
{{ data_get_str($server, 'name')->limit(10) }} > Transfer | Coolify
|
||||
</x-slot>
|
||||
|
||||
<livewire:server.navbar :server="$server" />
|
||||
|
||||
<div
|
||||
class="server-settings-workspace application-settings-workspace mt-4 grid w-full max-w-[1180px] min-w-0 gap-8 lg:mt-0 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
|
||||
<x-server.sidebar :server="$server" activeMenu="transfer" />
|
||||
|
||||
<div class="application-settings-form flex w-full flex-col gap-6">
|
||||
@if ($this->isLocalhost)
|
||||
<x-application.settings-section id="server-transfer-section" title="Transfer server (Dev)"
|
||||
helper="Move this server’s control-plane config to another Coolify instance (same physical host).">
|
||||
<x-callout type="warning" title="Localhost cannot be transferred">
|
||||
The Coolify host (localhost) cannot be transferred between instances.
|
||||
</x-callout>
|
||||
</x-application.settings-section>
|
||||
@else
|
||||
<x-application.settings-section id="server-transfer-section" title="Transfer server (Dev)"
|
||||
helper="Move this server’s control-plane config to another Coolify instance (same physical host).">
|
||||
<x-slot:actions>
|
||||
<x-status-badge
|
||||
:label="$this->transferStatus ?: 'ready'"
|
||||
type="neutral" />
|
||||
@if ($exportId)
|
||||
<span class="text-[11px] text-neutral-500 dark:text-fg-faint">export {{ $exportId }}</span>
|
||||
@endif
|
||||
</x-slot:actions>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-neutral-950 dark:text-fg">Transfer to another instance
|
||||
</h3>
|
||||
<p class="mt-1 text-xs leading-5 text-neutral-500 dark:text-fg-dim">
|
||||
Enter the target Coolify URL and an API token from that instance (root recommended).
|
||||
This exports the server, imports and claims it on the target, then disables automations
|
||||
here.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 md:max-w-xl">
|
||||
<x-forms.input id="targetUrl" label="Target instance URL" required
|
||||
placeholder="http://localhost:8001"
|
||||
helper="Base URL of the other Coolify instance (no trailing path)." />
|
||||
<x-forms.input id="targetToken" type="password" label="Target API token" required
|
||||
placeholder="Paste token from target instance" autocomplete="off"
|
||||
helper="Create a token on the target with root (or write + create servers). It is only used for this request." />
|
||||
<x-forms.checkbox id="writeRemote"
|
||||
label="Write ownership file on the host via SSH (optional)" />
|
||||
</div>
|
||||
<div>
|
||||
<x-forms.button canGate="update" :canResource="$server" wire:click="migrateServer"
|
||||
wire:loading.attr="disabled"
|
||||
wire:confirm="Transfer this server to the target instance? Automations will be disabled here.">
|
||||
<span wire:loading.remove wire:target="migrateServer">Transfer server</span>
|
||||
<span wire:loading wire:target="migrateServer">Transferring…</span>
|
||||
</x-forms.button>
|
||||
</div>
|
||||
@if (count($lastWarnings) > 0)
|
||||
<x-callout type="warning" title="Warnings">
|
||||
<ul class="mt-2 list-disc space-y-1 pl-5 text-sm">
|
||||
@foreach ($lastWarnings as $warning)
|
||||
<li>{{ $warning }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</x-callout>
|
||||
@endif
|
||||
@if ($lastResultJson)
|
||||
<div>
|
||||
<div class="mb-1 text-sm font-semibold">Result</div>
|
||||
<pre
|
||||
class="max-h-64 overflow-auto rounded-lg bg-neutral-100 p-3 text-xs dark:bg-coolgray-100">{{ $lastResultJson }}</pre>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
|
||||
<x-application.settings-section id="server-transfer-advanced-section" title="Advanced"
|
||||
helper="Manual export, complete-only, and re-claim helpers for air-gapped or partial transfers.">
|
||||
<div class="flex flex-col gap-6" x-data="{ open: @entangle('showAdvanced') }">
|
||||
<button type="button"
|
||||
class="flex w-full items-center justify-between rounded-lg border border-neutral-200 px-4 py-3 text-left dark:border-white/[0.08]"
|
||||
@click="open = !open">
|
||||
<span class="text-sm font-semibold">Show advanced options</span>
|
||||
<span class="text-xs text-neutral-500 dark:text-fg-faint"
|
||||
x-text="open ? 'Hide' : 'Show'"></span>
|
||||
</button>
|
||||
|
||||
<div class="flex flex-col gap-6" x-show="open" x-cloak>
|
||||
<div>
|
||||
<h4 class="text-sm font-medium">Download bundle</h4>
|
||||
<p class="mb-3 text-xs leading-5 text-neutral-500 dark:text-fg-dim">
|
||||
Manual / air-gapped transfer. Import on the target via Servers → Import transfer.
|
||||
</p>
|
||||
<div class="mb-3 flex flex-col gap-3 md:max-w-xl">
|
||||
<x-forms.checkbox id="encryptBundle" label="Encrypt with passphrase" />
|
||||
<x-forms.input id="passphrase" type="password" label="Passphrase"
|
||||
placeholder="Used when encrypt is checked" autocomplete="new-password" />
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<x-forms.button canGate="view" :canResource="$server" wire:click="exportBundle"
|
||||
wire:loading.attr="disabled">
|
||||
<span wire:loading.remove wire:target="exportBundle">Download JSON</span>
|
||||
<span wire:loading wire:target="exportBundle">Exporting…</span>
|
||||
</x-forms.button>
|
||||
<a href="{{ route('server.transfer.import') }}" {{ wireNavigate() }}
|
||||
class="button">
|
||||
Import page (this instance)
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="text-sm font-medium">Complete only</h4>
|
||||
<p class="mb-3 text-xs leading-5 text-neutral-500 dark:text-fg-dim">
|
||||
Disable automations after a manual import on the target.
|
||||
</p>
|
||||
<x-forms.button canGate="update" :canResource="$server" wire:click="completeTransfer"
|
||||
wire:loading.attr="disabled"
|
||||
wire:confirm="Disable automations on this server?">
|
||||
Mark transferred & disable automations
|
||||
</x-forms.button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="text-sm font-medium">Re-claim only</h4>
|
||||
<p class="mb-3 text-xs leading-5 text-neutral-500 dark:text-fg-dim">
|
||||
Retry claim on this instance (after a local import).
|
||||
</p>
|
||||
<div class="mb-3 flex flex-col gap-2">
|
||||
<x-forms.checkbox id="writeRemoteOnClaim" label="Write ownership file via SSH" />
|
||||
<x-forms.checkbox id="rebindSentinelOnClaim" label="Rebind Sentinel" />
|
||||
</div>
|
||||
<x-forms.button canGate="update" :canResource="$server" wire:click="claimServer"
|
||||
wire:loading.attr="disabled">
|
||||
Re-claim
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -25,6 +25,7 @@ use App\Http\Controllers\Api\ServerLogDrainsController;
|
||||
use App\Http\Controllers\Api\ServerProxyController;
|
||||
use App\Http\Controllers\Api\ServersController;
|
||||
use App\Http\Controllers\Api\ServerSentinelController;
|
||||
use App\Http\Controllers\Api\ServerTransferController;
|
||||
use App\Http\Controllers\Api\ServiceApplicationsController;
|
||||
use App\Http\Controllers\Api\ServiceDatabasesController;
|
||||
use App\Http\Controllers\Api\ServicesController;
|
||||
@@ -189,6 +190,12 @@ Route::group([
|
||||
Route::post('/servers/{uuid}/proxy/restart', [ServerProxyController::class, 'restart'])->middleware(['api.ability:write']);
|
||||
|
||||
Route::post('/servers', [ServersController::class, 'create_server'])->middleware(['api.ability:write']);
|
||||
Route::post('/servers/import', [ServerTransferController::class, 'import'])->middleware(['api.ability:write']);
|
||||
Route::get('/servers/{uuid}/export', [ServerTransferController::class, 'export'])->middleware(['api.ability:read']);
|
||||
Route::post('/servers/{uuid}/export/mailbox', [ServerTransferController::class, 'writeMailbox'])->middleware(['api.ability:write']);
|
||||
Route::post('/servers/{uuid}/claim', [ServerTransferController::class, 'claim'])->middleware(['api.ability:write']);
|
||||
Route::post('/servers/{uuid}/transfer/complete', [ServerTransferController::class, 'complete'])->middleware(['api.ability:write']);
|
||||
Route::post('/servers/{uuid}/migrate', [ServerTransferController::class, 'migrate'])->middleware(['api.ability:write']);
|
||||
Route::patch('/servers/{uuid}', [ServersController::class, 'update_server'])->middleware(['api.ability:write']);
|
||||
Route::delete('/servers/{uuid}', [ServersController::class, 'delete_server'])->middleware(['api.ability:write']);
|
||||
|
||||
|
||||
@@ -70,6 +70,8 @@ use App\Livewire\Server\Sentinel\Logs as SentinelLogs;
|
||||
use App\Livewire\Server\Sentinel\Show as SentinelShow;
|
||||
use App\Livewire\Server\Show as ServerShow;
|
||||
use App\Livewire\Server\Swarm as ServerSwarm;
|
||||
use App\Livewire\Server\Transfer as ServerTransfer;
|
||||
use App\Livewire\Server\TransferImport as ServerTransferImport;
|
||||
use App\Livewire\Settings\Advanced as SettingsAdvanced;
|
||||
use App\Livewire\Settings\Index as SettingsIndex;
|
||||
use App\Livewire\Settings\ScheduledJobs as SettingsScheduledJobs;
|
||||
@@ -336,6 +338,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
||||
});
|
||||
|
||||
Route::get('/servers', ServerIndex::class)->name('server.index');
|
||||
Route::get('/servers/import', ServerTransferImport::class)->name('server.transfer.import')->middleware('can:create,'.Server::class);
|
||||
Route::get('/servers/new', ServerCreatePage::class)->name('server.create')->middleware('can:create,'.Server::class);
|
||||
Route::get('/servers/new/{type}/{token_uuid}', ServerCreatePage::class)->name('server.create.token')->middleware('can:create,'.Server::class)->whereIn('type', ['hetzner', 'vultr', 'digital-ocean']);
|
||||
Route::get('/servers/new/{type}', ServerCreatePage::class)->name('server.create.type')->middleware('can:create,'.Server::class)->whereIn('type', ['hetzner', 'vultr', 'digital-ocean', 'manual']);
|
||||
@@ -355,6 +358,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::get('/log-drains', LogDrains::class)->name('server.log-drains');
|
||||
Route::get('/metrics', ServerCharts::class)->name('server.metrics');
|
||||
Route::get('/danger', DeleteServer::class)->name('server.delete');
|
||||
Route::get('/transfer', ServerTransfer::class)->name('server.transfer');
|
||||
Route::get('/proxy', ProxyShow::class)->name('server.proxy');
|
||||
Route::get('/proxy/dynamic', ProxyDynamicConfigurations::class)->name('server.proxy.dynamic-confs');
|
||||
Route::get('/proxy/logs', ProxyLogs::class)->name('server.proxy.logs');
|
||||
|
||||
Executable
+317
@@ -0,0 +1,317 @@
|
||||
#!/usr/bin/env bash
|
||||
# Two local Coolify instances (isolated stacks) for multi-control-plane testing.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/dev-instances up # start a + b
|
||||
# ./scripts/dev-instances up a # start a only
|
||||
# ./scripts/dev-instances up a --with vite # HMR only for a single instance
|
||||
# ./scripts/dev-instances down # stop a + b
|
||||
# ./scripts/dev-instances urls
|
||||
# ./scripts/dev-instances ps
|
||||
# ./scripts/dev-instances logs a
|
||||
# ./scripts/dev-instances exec a php artisan migrate --force
|
||||
#
|
||||
# Ports (fixed):
|
||||
# a app 8000 db 5432 redis 6379 soketi 6001/6002 flux 6443
|
||||
# b app 8001 db 5433 redis 6380 soketi 6011/6012 flux 6444
|
||||
#
|
||||
# Frontend: multi-instance uses npm run build (shared public/build). Do not use
|
||||
# two Vite HMR servers — public/hot is a single shared file.
|
||||
#
|
||||
# Compose: docker-compose.dev-multi.yml
|
||||
# Env: .dev-instances/{a,b}.env (generated, gitignored)
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
COMPOSE_FILE="docker-compose.dev-multi.yml"
|
||||
ENV_DIR=".dev-instances"
|
||||
INSTANCES_ALL=(a b)
|
||||
|
||||
usage() {
|
||||
sed -n '2,18p' "$0" | sed 's/^# \?//'
|
||||
exit "${1:-0}"
|
||||
}
|
||||
|
||||
# Fixed port offsets: a=0, b=1
|
||||
instance_offset() {
|
||||
case "$1" in
|
||||
a|1) echo 0 ;;
|
||||
b|2) echo 1 ;;
|
||||
*)
|
||||
echo "Unknown instance '$1' (use a or b)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
normalize_name() {
|
||||
case "$1" in
|
||||
a|1) echo a ;;
|
||||
b|2) echo b ;;
|
||||
*)
|
||||
echo "Unknown instance '$1' (use a or b)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
project_name() { echo "coolify-$1"; }
|
||||
env_file() { echo "${ENV_DIR}/$1.env"; }
|
||||
|
||||
ensure_env() {
|
||||
local name="$1"
|
||||
local offset env_file app_port existing_key key
|
||||
offset="$(instance_offset "$name")"
|
||||
env_file="$(env_file "$name")"
|
||||
app_port=$((8000 + offset))
|
||||
mkdir -p "$ENV_DIR"
|
||||
|
||||
# Never rotate APP_KEY once set: encrypted DB columns (private keys, secrets)
|
||||
# become unreadable ("The MAC is invalid") if APP_KEY changes while volumes persist.
|
||||
existing_key=""
|
||||
if [[ -f "$env_file" ]]; then
|
||||
existing_key="$(grep -E '^APP_KEY=base64:' "$env_file" 2>/dev/null | tail -n1 | cut -d= -f2- || true)"
|
||||
fi
|
||||
if [[ -z "$existing_key" && -f ".dev-instances/${name}.appkey" ]]; then
|
||||
existing_key="$(cat ".dev-instances/${name}.appkey" 2>/dev/null || true)"
|
||||
fi
|
||||
if [[ -z "$existing_key" ]]; then
|
||||
existing_key="base64:$(openssl rand -base64 32)"
|
||||
fi
|
||||
printf '%s\n' "$existing_key" >".dev-instances/${name}.appkey"
|
||||
|
||||
cat >"$env_file" <<EOF
|
||||
# Generated by scripts/dev-instances for instance ${name} — do not commit
|
||||
COMPOSE_PROJECT_NAME=coolify-${name}
|
||||
APP_NAME=Coolify-${name}
|
||||
APP_ENV=local
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost:${app_port}
|
||||
APP_KEY=${existing_key}
|
||||
APP_PORT=${app_port}
|
||||
FORWARD_DB_PORT=$((5432 + offset))
|
||||
FORWARD_REDIS_PORT=$((6379 + offset))
|
||||
FORWARD_SOKETI_PORT=$((6001 + offset * 10))
|
||||
FORWARD_SOKETI_PORT_ALT=$((6002 + offset * 10))
|
||||
FORWARD_FLUX_PORT=$((6443 + offset))
|
||||
VITE_PORT=$((5173 + offset))
|
||||
FORWARD_MAILPIT_PORT=$((1025 + offset * 100))
|
||||
FORWARD_MAILPIT_DASHBOARD_PORT=$((8025 + offset * 100))
|
||||
FORWARD_MINIO_PORT=$((9000 + offset * 10))
|
||||
FORWARD_MINIO_PORT_CONSOLE=$((9001 + offset * 10))
|
||||
DB_DATABASE=coolify
|
||||
DB_USERNAME=coolify
|
||||
DB_PASSWORD=password
|
||||
PUSHER_APP_ID=coolify-${name}
|
||||
PUSHER_APP_KEY=coolify-${name}
|
||||
PUSHER_APP_SECRET=coolify-${name}
|
||||
EOF
|
||||
}
|
||||
|
||||
compose() {
|
||||
local name="$1"
|
||||
shift
|
||||
local -a profiles=()
|
||||
local p
|
||||
for p in "${PROFILES[@]:-}"; do
|
||||
[[ -n "$p" ]] && profiles+=(--profile "$p")
|
||||
done
|
||||
ensure_env "$name"
|
||||
docker compose -p "$(project_name "$name")" -f "$COMPOSE_FILE" --env-file "$(env_file "$name")" "${profiles[@]}" "$@"
|
||||
}
|
||||
|
||||
# Parse: [a] [b] [--with vite mailpit ...]
|
||||
# Default instances when none given: a b
|
||||
parse_args() {
|
||||
NAMES=()
|
||||
PROFILES=()
|
||||
local mode=names
|
||||
local arg
|
||||
for arg in "$@"; do
|
||||
if [[ "$arg" == "--with" ]]; then
|
||||
mode=profiles
|
||||
continue
|
||||
fi
|
||||
if [[ "$mode" == "profiles" ]]; then
|
||||
PROFILES+=("$arg")
|
||||
else
|
||||
NAMES+=("$(normalize_name "$arg")")
|
||||
fi
|
||||
done
|
||||
if [[ ${#NAMES[@]} -eq 0 ]]; then
|
||||
NAMES=("${INSTANCES_ALL[@]}")
|
||||
fi
|
||||
}
|
||||
|
||||
# Multi-instance stacks share the repo bind-mount. Laravel's public/hot can only
|
||||
# point at one Vite server, so dual HMR is broken (last Vite wins → missing CSS).
|
||||
# Use a production frontend build shared by all instances instead.
|
||||
ensure_frontend_assets() {
|
||||
if [[ -f public/hot ]]; then
|
||||
echo "Removing public/hot (multi-instance cannot share Vite HMR)..."
|
||||
rm -f public/hot
|
||||
fi
|
||||
if [[ ! -d node_modules ]]; then
|
||||
echo "Installing npm dependencies..."
|
||||
npm install
|
||||
fi
|
||||
if [[ ! -f public/build/manifest.json ]]; then
|
||||
echo "Building frontend assets (npm run build)..."
|
||||
npm run build || {
|
||||
echo "Warning: npm run build failed — UI will have no CSS/JS." >&2
|
||||
return 1
|
||||
}
|
||||
return
|
||||
fi
|
||||
# Rebuild if CSS entry is missing from disk (stale/partial build).
|
||||
local css
|
||||
css="$(php -r '
|
||||
$m=@json_decode(@file_get_contents("public/build/manifest.json"), true);
|
||||
$f=$m["resources/css/app.css"]["file"] ?? "";
|
||||
echo $f && is_file("public/build/".$f) ? "ok" : "missing";
|
||||
' 2>/dev/null || echo missing)"
|
||||
if [[ "$css" != "ok" ]]; then
|
||||
echo "Frontend build incomplete — running npm run build..."
|
||||
npm run build || echo "Warning: npm run build failed." >&2
|
||||
fi
|
||||
}
|
||||
|
||||
# Vite profile is only useful for a single instance; drop it when starting both.
|
||||
filter_vite_profile() {
|
||||
local -a kept=()
|
||||
local p want_vite=0
|
||||
for p in "${PROFILES[@]:-}"; do
|
||||
if [[ "$p" == "vite" ]]; then
|
||||
want_vite=1
|
||||
continue
|
||||
fi
|
||||
kept+=("$p")
|
||||
done
|
||||
if [[ $want_vite -eq 1 ]]; then
|
||||
if [[ ${#NAMES[@]} -gt 1 ]]; then
|
||||
echo "Note: ignoring --with vite (shared public/hot cannot serve two instances)."
|
||||
echo " Frontend uses npm run build. For HMR, run a single instance: $0 up a --with vite"
|
||||
else
|
||||
kept+=("vite")
|
||||
fi
|
||||
fi
|
||||
PROFILES=("${kept[@]}")
|
||||
}
|
||||
|
||||
cmd_up() {
|
||||
parse_args "$@"
|
||||
filter_vite_profile
|
||||
# Prefer built assets before containers serve HTML.
|
||||
ensure_frontend_assets || true
|
||||
local name
|
||||
local -a saved_profiles=("${PROFILES[@]:-}")
|
||||
for name in "${NAMES[@]}"; do
|
||||
PROFILES=("${saved_profiles[@]}")
|
||||
# Only the first (only) instance may run the vite profile.
|
||||
if [[ ${#NAMES[@]} -eq 1 ]] && printf '%s\n' "${PROFILES[@]:-}" | grep -qx vite; then
|
||||
:
|
||||
else
|
||||
local -a no_vite=()
|
||||
local p
|
||||
for p in "${PROFILES[@]:-}"; do
|
||||
[[ "$p" != "vite" ]] && no_vite+=("$p")
|
||||
done
|
||||
PROFILES=("${no_vite[@]}")
|
||||
fi
|
||||
echo "==> Starting ${name} → http://localhost:$((8000 + $(instance_offset "$name")))"
|
||||
compose "$name" up -d --build
|
||||
done
|
||||
# If a lone instance started vite, keep public/hot; otherwise force built assets.
|
||||
if [[ ${#NAMES[@]} -gt 1 ]] || ! printf '%s\n' "${saved_profiles[@]:-}" | grep -qx vite; then
|
||||
ensure_frontend_assets || true
|
||||
fi
|
||||
echo
|
||||
cmd_urls "${NAMES[@]}"
|
||||
echo
|
||||
echo "Frontend: production build (public/build). Both instances share the same assets."
|
||||
}
|
||||
|
||||
cmd_down() {
|
||||
parse_args "$@"
|
||||
local name
|
||||
for name in "${NAMES[@]}"; do
|
||||
if [[ ! -f "$(env_file "$name")" ]]; then
|
||||
ensure_env "$name" >/dev/null
|
||||
fi
|
||||
echo "==> Stopping ${name}"
|
||||
compose "$name" down --remove-orphans
|
||||
done
|
||||
}
|
||||
|
||||
cmd_ps() {
|
||||
parse_args "$@"
|
||||
local name
|
||||
for name in "${NAMES[@]}"; do
|
||||
[[ -f "$(env_file "$name")" ]] || ensure_env "$name" >/dev/null
|
||||
echo "==> $(project_name "$name")"
|
||||
compose "$name" ps
|
||||
echo
|
||||
done
|
||||
}
|
||||
|
||||
cmd_urls() {
|
||||
local names=("$@")
|
||||
if [[ ${#names[@]} -eq 0 ]]; then
|
||||
names=("${INSTANCES_ALL[@]}")
|
||||
else
|
||||
parse_args "$@"
|
||||
names=("${NAMES[@]}")
|
||||
fi
|
||||
printf '%-6s %-28s %-8s %-8s %-12s\n' "NAME" "URL" "DB" "REDIS" "SOKETI"
|
||||
local name envf
|
||||
for name in "${names[@]}"; do
|
||||
name="$(normalize_name "$name")"
|
||||
ensure_env "$name" >/dev/null
|
||||
envf="$(env_file "$name")"
|
||||
printf '%-6s %-28s %-8s %-8s %-12s\n' \
|
||||
"$name" \
|
||||
"$(grep -E '^APP_URL=' "$envf" | cut -d= -f2-)" \
|
||||
"$(grep -E '^FORWARD_DB_PORT=' "$envf" | cut -d= -f2-)" \
|
||||
"$(grep -E '^FORWARD_REDIS_PORT=' "$envf" | cut -d= -f2-)" \
|
||||
"$(grep -E '^FORWARD_SOKETI_PORT=' "$envf" | cut -d= -f2-)"
|
||||
done
|
||||
}
|
||||
|
||||
cmd_logs() {
|
||||
local name="${1:-}"
|
||||
shift || true
|
||||
[[ -z "$name" ]] && usage 1
|
||||
name="$(normalize_name "$name")"
|
||||
compose "$name" logs -f "$@"
|
||||
}
|
||||
|
||||
cmd_exec() {
|
||||
local name="${1:-}"
|
||||
shift || true
|
||||
[[ -z "$name" || $# -eq 0 ]] && usage 1
|
||||
name="$(normalize_name "$name")"
|
||||
compose "$name" exec coolify "$@"
|
||||
}
|
||||
|
||||
main() {
|
||||
local cmd="${1:-}"
|
||||
shift || true
|
||||
case "$cmd" in
|
||||
up) cmd_up "$@" ;;
|
||||
down) cmd_down "$@" ;;
|
||||
ps) cmd_ps "$@" ;;
|
||||
urls|ls|list) cmd_urls "$@" ;;
|
||||
logs) cmd_logs "$@" ;;
|
||||
exec) cmd_exec "$@" ;;
|
||||
-h|--help|help|"") usage 0 ;;
|
||||
*)
|
||||
echo "Unknown command: $cmd" >&2
|
||||
usage 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,702 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Seed a rich transfer-demo inventory on the current Coolify instance.
|
||||
* Run: php artisan tinker scripts/seed-transfer-demo.php
|
||||
* Or: ./scripts/dev-instances exec a php artisan tinker --execute "require 'scripts/seed-transfer-demo.php';"
|
||||
*/
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Models\Environment;
|
||||
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\ScheduledDatabaseBackup;
|
||||
use App\Models\ScheduledTask;
|
||||
use App\Models\ScheduledVolumeBackup;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Models\SharedEnvironmentVariable;
|
||||
use App\Models\SslCertificate;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandaloneMongodb;
|
||||
use App\Models\StandaloneMysql;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Models\Tag;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
$teamId = 0;
|
||||
$team = Team::find($teamId) ?? Team::query()->orderBy('id')->first();
|
||||
if (! $team) {
|
||||
throw new RuntimeException('No team found.');
|
||||
}
|
||||
$teamId = $team->id;
|
||||
|
||||
$report = [];
|
||||
|
||||
// --- Private key (testing-host SSH) ---
|
||||
$keyMaterial = <<<'KEY'
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
|
||||
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
|
||||
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
|
||||
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
KEY;
|
||||
|
||||
// Prefer existing testing-host key (same material) to avoid fingerprint uniqueness errors.
|
||||
$key = PrivateKey::where('uuid', 'transfer-full-key')->first()
|
||||
?? PrivateKey::where('name', 'Testing Host Key')->where('team_id', $teamId)->first()
|
||||
?? PrivateKey::where('team_id', $teamId)->orderBy('id')->first();
|
||||
if (! $key) {
|
||||
$key = PrivateKey::withoutEvents(function () use ($keyMaterial, $teamId) {
|
||||
$key = new PrivateKey;
|
||||
$key->forceFill([
|
||||
'name' => 'transfer-full-key',
|
||||
'description' => 'SSH key for transfer demo server (testing-host)',
|
||||
'private_key' => $keyMaterial,
|
||||
'team_id' => $teamId,
|
||||
]);
|
||||
$key->uuid = 'transfer-full-key';
|
||||
$key->save();
|
||||
|
||||
return $key;
|
||||
});
|
||||
}
|
||||
$report['private_key'] = $key->uuid;
|
||||
|
||||
// Idempotent cleanup of known demo UUIDs / leftover schedules.
|
||||
DB::table('scheduled_volume_backups')->whereIn('uuid', [
|
||||
'demo-nixpacks-vol-backup', 'demo-svc-whoami-vol-backup',
|
||||
])->delete();
|
||||
DB::table('scheduled_database_backups')->whereIn('uuid', [
|
||||
'demo-postgres-backup', 'demo-mysql-backup', 'demo-svc-db-backup',
|
||||
])->delete();
|
||||
DB::table('application_previews')->where('uuid', 'demo-preview-101')->delete();
|
||||
DB::table('local_file_volumes')->whereIn('uuid', [
|
||||
'demo-nixpacks-file', 'demo-pg-file-conf', 'demo-svc-whoami-file',
|
||||
])->delete();
|
||||
DB::table('github_apps')->where('uuid', 'transfer-team-github')->delete();
|
||||
|
||||
// Clean prior demo server if re-running (query builder avoids decrypting unrelated rows).
|
||||
$old = Server::withTrashed()->where('uuid', 'transfer-full-demo')->first();
|
||||
if ($old) {
|
||||
$destIds = StandaloneDocker::where('server_id', $old->id)->pluck('id')->all();
|
||||
DB::table('additional_destinations')->where('server_id', $old->id)->delete();
|
||||
|
||||
$appIds = Application::withTrashed()
|
||||
->where('destination_type', StandaloneDocker::class)
|
||||
->whereIn('destination_id', $destIds ?: [0])
|
||||
->pluck('id');
|
||||
if ($appIds->isNotEmpty()) {
|
||||
ApplicationPreview::withTrashed()->whereIn('application_id', $appIds)->forceDelete();
|
||||
ScheduledTask::whereIn('application_id', $appIds)->delete();
|
||||
Application::withTrashed()->whereIn('id', $appIds)->forceDelete();
|
||||
}
|
||||
|
||||
$serviceIds = Service::withTrashed()->where('server_id', $old->id)->pluck('id');
|
||||
if ($serviceIds->isNotEmpty()) {
|
||||
ServiceApplication::withTrashed()->whereIn('service_id', $serviceIds)->forceDelete();
|
||||
ServiceDatabase::withTrashed()->whereIn('service_id', $serviceIds)->forceDelete();
|
||||
ScheduledTask::whereIn('service_id', $serviceIds)->delete();
|
||||
Service::withTrashed()->whereIn('id', $serviceIds)->forceDelete();
|
||||
}
|
||||
|
||||
$dbTables = [
|
||||
'standalone_postgresqls',
|
||||
'standalone_mysqls',
|
||||
'standalone_redis',
|
||||
'standalone_mongodbs',
|
||||
];
|
||||
foreach ($dbTables as $table) {
|
||||
if (! Schema::hasTable($table)) {
|
||||
continue;
|
||||
}
|
||||
$ids = DB::table($table)
|
||||
->where('destination_type', StandaloneDocker::class)
|
||||
->whereIn('destination_id', $destIds ?: [0])
|
||||
->pluck('id');
|
||||
if ($ids->isNotEmpty()) {
|
||||
DB::table('scheduled_database_backups')
|
||||
->whereIn('database_id', $ids)
|
||||
->delete();
|
||||
DB::table($table)->whereIn('id', $ids)->delete();
|
||||
}
|
||||
}
|
||||
|
||||
DB::table('local_persistent_volumes')
|
||||
->whereIn('resource_id', $destIds ?: [0])
|
||||
->delete();
|
||||
SslCertificate::where('server_id', $old->id)->delete();
|
||||
SharedEnvironmentVariable::where('server_id', $old->id)->delete();
|
||||
StandaloneDocker::where('server_id', $old->id)->delete();
|
||||
$old->forceDelete();
|
||||
}
|
||||
|
||||
// --- Server ---
|
||||
$server = Server::create([
|
||||
'name' => 'transfer-full-demo',
|
||||
'description' => 'Full inventory for A→B server transfer testing',
|
||||
'ip' => 'testing-host',
|
||||
'user' => 'root',
|
||||
'port' => 22,
|
||||
'team_id' => $teamId,
|
||||
'private_key_id' => $key->id,
|
||||
]);
|
||||
$server->uuid = 'transfer-full-demo';
|
||||
$server->save();
|
||||
$server->settings->forceFill([
|
||||
'is_reachable' => true,
|
||||
'is_usable' => true,
|
||||
'wildcard_domain' => 'https://demo.transfer.local',
|
||||
])->save();
|
||||
$report['server'] = $server->uuid;
|
||||
|
||||
// --- Destinations ---
|
||||
$dest = StandaloneDocker::where('server_id', $server->id)->first()
|
||||
?? StandaloneDocker::create([
|
||||
'name' => 'coolify',
|
||||
'network' => 'coolify',
|
||||
'server_id' => $server->id,
|
||||
]);
|
||||
$dest->uuid = 'transfer-full-dest';
|
||||
$dest->saveQuietly();
|
||||
$report['destination'] = $dest->uuid;
|
||||
|
||||
// --- Project / environments ---
|
||||
$project = Project::firstOrCreate(
|
||||
['name' => 'Transfer Full Demo', 'team_id' => $teamId],
|
||||
['description' => 'Resources to migrate between Coolify instances']
|
||||
);
|
||||
$project->uuid = $project->uuid ?: new_public_id();
|
||||
$production = $project->environments()->where('name', 'production')->first()
|
||||
?? Environment::create(['name' => 'production', 'project_id' => $project->id, 'description' => 'prod']);
|
||||
$staging = $project->environments()->where('name', 'staging')->first()
|
||||
?? Environment::create(['name' => 'staging', 'project_id' => $project->id, 'description' => 'staging']);
|
||||
$report['project'] = $project->uuid ?? $project->name;
|
||||
$report['environments'] = [$production->name, $staging->name];
|
||||
|
||||
// Shared env vars
|
||||
SharedEnvironmentVariable::updateOrCreate(
|
||||
['type' => 'server', 'server_id' => $server->id, 'key' => 'DEMO_SERVER_TOKEN'],
|
||||
['value' => 'server-shared-secret', 'team_id' => $teamId, 'is_literal' => true, 'comment' => 'server-level']
|
||||
);
|
||||
SharedEnvironmentVariable::updateOrCreate(
|
||||
['type' => 'project', 'project_id' => $project->id, 'key' => 'DEMO_PROJECT_API'],
|
||||
['value' => 'project-shared-secret', 'team_id' => $teamId, 'is_literal' => true]
|
||||
);
|
||||
SharedEnvironmentVariable::updateOrCreate(
|
||||
['type' => 'environment', 'environment_id' => $production->id, 'key' => 'DEMO_ENV_FLAG'],
|
||||
['value' => 'production', 'team_id' => $teamId, 'is_literal' => true]
|
||||
);
|
||||
|
||||
// Tags
|
||||
$tagCritical = Tag::firstOrCreate(['name' => 'critical', 'team_id' => $teamId], ['uuid' => new_public_id()]);
|
||||
$tagDemo = Tag::firstOrCreate(['name' => 'transfer-demo', 'team_id' => $teamId], ['uuid' => new_public_id()]);
|
||||
|
||||
// Team-scoped GitHub App (not system-wide)
|
||||
$ghApp = GithubApp::where('uuid', 'transfer-team-github')->first();
|
||||
if (! $ghApp) {
|
||||
$ghApp = new GithubApp;
|
||||
$ghApp->forceFill([
|
||||
'name' => 'Transfer Team GitHub',
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'is_public' => false,
|
||||
'is_system_wide' => false,
|
||||
'team_id' => $teamId,
|
||||
'app_id' => 99001,
|
||||
'installation_id' => 88001,
|
||||
'client_id' => 'Iv1.transfer-demo',
|
||||
'client_secret' => 'gh-client-secret-demo',
|
||||
'webhook_secret' => 'gh-webhook-secret-demo',
|
||||
'private_key_id' => $key->id,
|
||||
]);
|
||||
$ghApp->uuid = 'transfer-team-github';
|
||||
$ghApp->save();
|
||||
}
|
||||
$report['github_app'] = $ghApp->uuid;
|
||||
|
||||
// ========== APPLICATIONS ==========
|
||||
// 1) Nixpacks git app with GH source
|
||||
$appNix = Application::create([
|
||||
'name' => 'demo-nixpacks-app',
|
||||
'git_repository' => 'https://github.com/coollabsio/coolify-examples',
|
||||
'git_branch' => 'nodejs',
|
||||
'build_pack' => 'nixpacks',
|
||||
'ports_exposes' => '3000',
|
||||
'fqdn' => 'https://nixpacks.demo.transfer.local',
|
||||
'environment_id' => $production->id,
|
||||
'destination_id' => $dest->id,
|
||||
'destination_type' => $dest->getMorphClass(),
|
||||
'source_type' => GithubApp::class,
|
||||
'source_id' => $ghApp->id,
|
||||
'status' => 'exited',
|
||||
'description' => 'Nixpacks app with GitHub App source',
|
||||
]);
|
||||
$appNix->uuid = 'demo-nixpacks-app';
|
||||
$appNix->save();
|
||||
$appNix->tags()->syncWithoutDetaching([$tagDemo->id, $tagCritical->id]);
|
||||
if ($appNix->settings) {
|
||||
$appNix->settings->forceFill(['is_auto_deploy_enabled' => true, 'is_force_https_enabled' => true])->save();
|
||||
}
|
||||
EnvironmentVariable::withoutEvents(function () use ($appNix) {
|
||||
$e = new EnvironmentVariable;
|
||||
$e->forceFill([
|
||||
'key' => 'APP_SECRET',
|
||||
'value' => 'nixpacks-app-secret-value',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $appNix->id,
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => true,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
$e->uuid = new_public_id();
|
||||
$e->save();
|
||||
$e2 = new EnvironmentVariable;
|
||||
$e2->forceFill([
|
||||
'key' => 'PREVIEW_ONLY',
|
||||
'value' => 'preview-secret',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $appNix->id,
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => false,
|
||||
'is_preview' => true,
|
||||
]);
|
||||
$e2->uuid = new_public_id();
|
||||
$e2->save();
|
||||
});
|
||||
$volNix = new LocalPersistentVolume;
|
||||
$volNix->forceFill([
|
||||
'name' => 'demo-nixpacks-data',
|
||||
'mount_path' => '/app/data',
|
||||
'resource_type' => $appNix->getMorphClass(),
|
||||
'resource_id' => $appNix->id,
|
||||
]);
|
||||
$volNix->uuid = new_public_id();
|
||||
$volNix->save();
|
||||
LocalFileVolume::withoutEvents(function () use ($appNix) {
|
||||
$f = new LocalFileVolume;
|
||||
$f->forceFill([
|
||||
'fs_path' => './config.json',
|
||||
'mount_path' => '/app/config.json',
|
||||
'content' => '{"demo":true,"from":"transfer-seed"}',
|
||||
'is_directory' => false,
|
||||
'resource_type' => $appNix->getMorphClass(),
|
||||
'resource_id' => $appNix->id,
|
||||
]);
|
||||
$f->uuid = 'demo-nixpacks-file';
|
||||
$f->save();
|
||||
});
|
||||
ScheduledTask::create([
|
||||
'uuid' => new_public_id(),
|
||||
'name' => 'nightly-cleanup',
|
||||
'command' => 'php artisan cache:clear',
|
||||
'frequency' => '0 3 * * *',
|
||||
'application_id' => $appNix->id,
|
||||
'team_id' => $teamId,
|
||||
'enabled' => true,
|
||||
]);
|
||||
ScheduledVolumeBackup::create([
|
||||
'uuid' => 'demo-nixpacks-vol-backup',
|
||||
'backupable_type' => $volNix->getMorphClass(),
|
||||
'backupable_id' => $volNix->id,
|
||||
'team_id' => $teamId,
|
||||
'frequency' => '0 4 * * 0',
|
||||
'enabled' => true,
|
||||
'save_s3' => false,
|
||||
]);
|
||||
$preview = new ApplicationPreview;
|
||||
$preview->forceFill([
|
||||
'application_id' => $appNix->id,
|
||||
'pull_request_id' => 101,
|
||||
'pull_request_html_url' => 'https://github.com/coollabsio/coolify-examples/pull/101',
|
||||
'fqdn' => 'https://pr-101.nixpacks.demo.transfer.local',
|
||||
'status' => 'exited',
|
||||
'git_type' => 'github',
|
||||
]);
|
||||
$preview->uuid = 'demo-preview-101';
|
||||
$preview->save();
|
||||
$previewVol = new LocalPersistentVolume;
|
||||
$previewVol->forceFill([
|
||||
'name' => 'demo-preview-101-data',
|
||||
'mount_path' => '/app/data',
|
||||
'resource_type' => $preview->getMorphClass(),
|
||||
'resource_id' => $preview->id,
|
||||
]);
|
||||
$previewVol->uuid = new_public_id();
|
||||
$previewVol->save();
|
||||
|
||||
// 2) Dockerfile app
|
||||
$appDocker = Application::create([
|
||||
'name' => 'demo-dockerfile-app',
|
||||
'git_repository' => 'https://github.com/coollabsio/coolify-examples',
|
||||
'git_branch' => 'main',
|
||||
'build_pack' => 'dockerfile',
|
||||
'ports_exposes' => '80',
|
||||
'fqdn' => 'https://dockerfile.demo.transfer.local',
|
||||
'environment_id' => $production->id,
|
||||
'destination_id' => $dest->id,
|
||||
'destination_type' => $dest->getMorphClass(),
|
||||
'dockerfile' => "FROM nginx:alpine\nEXPOSE 80\n",
|
||||
'status' => 'exited',
|
||||
]);
|
||||
$appDocker->uuid = 'demo-dockerfile-app';
|
||||
$appDocker->save();
|
||||
$appDocker->tags()->syncWithoutDetaching([$tagDemo->id]);
|
||||
|
||||
// 3) Docker image / static-ish
|
||||
$appImage = Application::create([
|
||||
'name' => 'demo-image-app',
|
||||
'git_repository' => 'coollabsio/coolify-examples',
|
||||
'git_branch' => 'main',
|
||||
'build_pack' => 'dockercompose',
|
||||
'ports_exposes' => '80',
|
||||
'fqdn' => 'https://compose.demo.transfer.local',
|
||||
'environment_id' => $staging->id,
|
||||
'destination_id' => $dest->id,
|
||||
'destination_type' => $dest->getMorphClass(),
|
||||
'docker_compose_raw' => "services:\n web:\n image: traefik/whoami\n ports:\n - '80'\n",
|
||||
'docker_compose' => "services:\n web:\n image: traefik/whoami\n ports:\n - '80'\n",
|
||||
'status' => 'exited',
|
||||
]);
|
||||
$appImage->uuid = 'demo-compose-app';
|
||||
$appImage->save();
|
||||
|
||||
// 4) Deploy-key style app (private_key on application)
|
||||
$appDeployKey = Application::create([
|
||||
'name' => 'demo-deploykey-app',
|
||||
'git_repository' => 'git@github.com:example/private-app.git',
|
||||
'git_branch' => 'main',
|
||||
'build_pack' => 'nixpacks',
|
||||
'ports_exposes' => '3000',
|
||||
'environment_id' => $production->id,
|
||||
'destination_id' => $dest->id,
|
||||
'destination_type' => $dest->getMorphClass(),
|
||||
'private_key_id' => $key->id,
|
||||
'status' => 'exited',
|
||||
]);
|
||||
$appDeployKey->uuid = 'demo-deploykey-app';
|
||||
$appDeployKey->save();
|
||||
|
||||
$report['applications'] = [
|
||||
$appNix->uuid,
|
||||
$appDocker->uuid,
|
||||
$appImage->uuid,
|
||||
$appDeployKey->uuid,
|
||||
];
|
||||
|
||||
// ========== DATABASES ==========
|
||||
$mkDb = function (string $class, string $uuid, string $name, array $extra) use ($production, $dest, $tagCritical, $teamId) {
|
||||
return $class::withoutEvents(function () use ($class, $uuid, $name, $extra, $production, $dest, $tagCritical, $teamId) {
|
||||
$db = new $class;
|
||||
$db->forceFill(array_merge([
|
||||
'name' => $name,
|
||||
'environment_id' => $production->id,
|
||||
'destination_id' => $dest->id,
|
||||
'destination_type' => $dest->getMorphClass(),
|
||||
'status' => 'exited',
|
||||
], $extra));
|
||||
$db->uuid = $uuid;
|
||||
$db->save();
|
||||
if (method_exists($db, 'tags')) {
|
||||
$db->tags()->syncWithoutDetaching([$tagCritical->id]);
|
||||
}
|
||||
if (method_exists($db, 'persistentStorages')) {
|
||||
$vol = new LocalPersistentVolume;
|
||||
$vol->forceFill([
|
||||
'name' => $uuid.'-data',
|
||||
'mount_path' => match ($class) {
|
||||
StandalonePostgresql::class => '/var/lib/postgresql/data',
|
||||
StandaloneMysql::class => '/var/lib/mysql',
|
||||
StandaloneRedis::class => '/data',
|
||||
StandaloneMongodb::class => '/data/db',
|
||||
default => '/data',
|
||||
},
|
||||
'resource_type' => $db->getMorphClass(),
|
||||
'resource_id' => $db->id,
|
||||
]);
|
||||
$vol->uuid = new_public_id();
|
||||
$vol->save();
|
||||
}
|
||||
if (method_exists($db, 'fileStorages') && $class === StandalonePostgresql::class) {
|
||||
LocalFileVolume::withoutEvents(function () use ($db) {
|
||||
$f = new LocalFileVolume;
|
||||
$f->forceFill([
|
||||
'fs_path' => './pg-conf.d',
|
||||
'mount_path' => '/etc/postgresql/conf.d',
|
||||
'content' => null,
|
||||
'is_directory' => true,
|
||||
'resource_type' => $db->getMorphClass(),
|
||||
'resource_id' => $db->id,
|
||||
]);
|
||||
$f->uuid = 'demo-pg-file-conf';
|
||||
$f->save();
|
||||
});
|
||||
}
|
||||
if (method_exists($db, 'scheduledBackups') && in_array($class, [StandalonePostgresql::class, StandaloneMysql::class], true)) {
|
||||
ScheduledDatabaseBackup::create([
|
||||
'uuid' => $uuid.'-backup',
|
||||
'team_id' => $teamId,
|
||||
'enabled' => true,
|
||||
'save_s3' => false,
|
||||
'frequency' => '0 2 * * *',
|
||||
'databases_to_backup' => $extra['postgres_db'] ?? $extra['mysql_database'] ?? 'app',
|
||||
'database_type' => $db->getMorphClass(),
|
||||
'database_id' => $db->id,
|
||||
]);
|
||||
}
|
||||
EnvironmentVariable::withoutEvents(function () use ($db, $name) {
|
||||
$e = new EnvironmentVariable;
|
||||
$e->forceFill([
|
||||
'key' => 'DB_LABEL',
|
||||
'value' => $name,
|
||||
'resourceable_type' => $db::class,
|
||||
'resourceable_id' => $db->id,
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => false,
|
||||
]);
|
||||
$e->uuid = new_public_id();
|
||||
$e->save();
|
||||
});
|
||||
|
||||
return $db;
|
||||
});
|
||||
};
|
||||
|
||||
$pg = $mkDb(StandalonePostgresql::class, 'demo-postgres', 'demo-postgres', [
|
||||
'postgres_user' => 'demo',
|
||||
'postgres_password' => 'pg-secret-password',
|
||||
'postgres_db' => 'demoddb',
|
||||
]);
|
||||
$mysql = $mkDb(StandaloneMysql::class, 'demo-mysql', 'demo-mysql', [
|
||||
'mysql_root_password' => 'mysql-root-secret',
|
||||
'mysql_user' => 'demo',
|
||||
'mysql_password' => 'mysql-user-secret',
|
||||
'mysql_database' => 'demoddb',
|
||||
]);
|
||||
$redis = $mkDb(StandaloneRedis::class, 'demo-redis', 'demo-redis', [
|
||||
'image' => 'redis:7-alpine',
|
||||
]);
|
||||
// Redis password is stored as env var, not a column.
|
||||
EnvironmentVariable::withoutEvents(function () use ($redis) {
|
||||
$e = new EnvironmentVariable;
|
||||
$e->forceFill([
|
||||
'key' => 'REDIS_PASSWORD',
|
||||
'value' => 'redis-secret-password',
|
||||
'resourceable_type' => StandaloneRedis::class,
|
||||
'resourceable_id' => $redis->id,
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => false,
|
||||
]);
|
||||
$e->uuid = new_public_id();
|
||||
$e->save();
|
||||
});
|
||||
$mongo = $mkDb(StandaloneMongodb::class, 'demo-mongo', 'demo-mongo', [
|
||||
'mongo_initdb_root_username' => 'root',
|
||||
'mongo_initdb_root_password' => 'mongo-root-secret',
|
||||
'mongo_initdb_database' => 'demoddb',
|
||||
'image' => 'mongo:7',
|
||||
]);
|
||||
$report['databases'] = [$pg->uuid, $mysql->uuid, $redis->uuid, $mongo->uuid];
|
||||
|
||||
// ========== SERVICE STACK ==========
|
||||
$service = Service::create([
|
||||
'name' => 'demo-service-stack',
|
||||
'environment_id' => $production->id,
|
||||
'destination_id' => $dest->id,
|
||||
'destination_type' => $dest->getMorphClass(),
|
||||
'server_id' => $server->id,
|
||||
'docker_compose_raw' => <<<'YAML'
|
||||
services:
|
||||
whoami:
|
||||
image: traefik/whoami
|
||||
environment:
|
||||
WHOAMI_NAME: transfer-demo
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_PASSWORD: service-db-secret
|
||||
YAML,
|
||||
'docker_compose' => <<<'YAML'
|
||||
services:
|
||||
whoami:
|
||||
image: traefik/whoami
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
YAML,
|
||||
]);
|
||||
$service->uuid = 'demo-service-stack';
|
||||
$service->save();
|
||||
$service->tags()->syncWithoutDetaching([$tagDemo->id]);
|
||||
|
||||
EnvironmentVariable::withoutEvents(function () use ($service) {
|
||||
$e = new EnvironmentVariable;
|
||||
$e->forceFill([
|
||||
'key' => 'SERVICE_TOKEN',
|
||||
'value' => 'service-level-secret',
|
||||
'resourceable_type' => Service::class,
|
||||
'resourceable_id' => $service->id,
|
||||
'is_runtime' => true,
|
||||
]);
|
||||
$e->uuid = new_public_id();
|
||||
$e->save();
|
||||
});
|
||||
ScheduledTask::create([
|
||||
'uuid' => new_public_id(),
|
||||
'name' => 'service-ping',
|
||||
'command' => 'echo ping',
|
||||
'frequency' => '*/30 * * * *',
|
||||
'service_id' => $service->id,
|
||||
'team_id' => $teamId,
|
||||
'enabled' => true,
|
||||
'container' => 'whoami',
|
||||
]);
|
||||
|
||||
$svcApp = new ServiceApplication;
|
||||
$svcApp->forceFill([
|
||||
'service_id' => $service->id,
|
||||
'name' => 'whoami',
|
||||
'human_name' => 'Whoami',
|
||||
'description' => 'Nested service app',
|
||||
'fqdn' => 'https://whoami.demo.transfer.local',
|
||||
'image' => 'traefik/whoami:latest',
|
||||
'ports' => '80',
|
||||
'exposes' => '80',
|
||||
'required_fqdn' => true,
|
||||
'is_gzip_enabled' => true,
|
||||
'is_stripprefix_enabled' => true,
|
||||
'status' => 'exited',
|
||||
]);
|
||||
$svcApp->uuid = 'demo-svc-whoami';
|
||||
$svcApp->save();
|
||||
EnvironmentVariable::withoutEvents(function () use ($svcApp) {
|
||||
$e = new EnvironmentVariable;
|
||||
$e->forceFill([
|
||||
'key' => 'WHOAMI_NAME',
|
||||
'value' => 'nested-whoami-secret',
|
||||
'resourceable_type' => ServiceApplication::class,
|
||||
'resourceable_id' => $svcApp->id,
|
||||
'is_runtime' => true,
|
||||
]);
|
||||
$e->uuid = new_public_id();
|
||||
$e->save();
|
||||
});
|
||||
$svcAppVol = new LocalPersistentVolume;
|
||||
$svcAppVol->forceFill([
|
||||
'name' => 'demo-svc-whoami-data',
|
||||
'mount_path' => '/data',
|
||||
'resource_type' => $svcApp->getMorphClass(),
|
||||
'resource_id' => $svcApp->id,
|
||||
]);
|
||||
$svcAppVol->uuid = new_public_id();
|
||||
$svcAppVol->save();
|
||||
LocalFileVolume::withoutEvents(function () use ($svcApp) {
|
||||
$f = new LocalFileVolume;
|
||||
$f->forceFill([
|
||||
'fs_path' => './whoami.env',
|
||||
'mount_path' => '/whoami.env',
|
||||
'content' => "NAME=transfer\n",
|
||||
'is_directory' => false,
|
||||
'resource_type' => $svcApp->getMorphClass(),
|
||||
'resource_id' => $svcApp->id,
|
||||
]);
|
||||
$f->uuid = 'demo-svc-whoami-file';
|
||||
$f->save();
|
||||
});
|
||||
ScheduledVolumeBackup::create([
|
||||
'uuid' => 'demo-svc-whoami-vol-backup',
|
||||
'backupable_type' => $svcAppVol->getMorphClass(),
|
||||
'backupable_id' => $svcAppVol->id,
|
||||
'team_id' => $teamId,
|
||||
'frequency' => '0 5 * * *',
|
||||
'enabled' => true,
|
||||
'save_s3' => false,
|
||||
]);
|
||||
|
||||
$svcDb = new ServiceDatabase;
|
||||
$svcDb->forceFill([
|
||||
'service_id' => $service->id,
|
||||
'name' => 'db',
|
||||
'human_name' => 'Service Postgres',
|
||||
'image' => 'postgres:16-alpine',
|
||||
'ports' => '5432',
|
||||
'exposes' => '5432',
|
||||
'public_port' => 15432,
|
||||
'is_public' => false,
|
||||
'custom_type' => 'postgresql',
|
||||
'status' => 'exited',
|
||||
]);
|
||||
$svcDb->uuid = 'demo-svc-db';
|
||||
$svcDb->save();
|
||||
$svcDbVol = new LocalPersistentVolume;
|
||||
$svcDbVol->forceFill([
|
||||
'name' => 'demo-svc-db-data',
|
||||
'mount_path' => '/var/lib/postgresql/data',
|
||||
'resource_type' => $svcDb->getMorphClass(),
|
||||
'resource_id' => $svcDb->id,
|
||||
]);
|
||||
$svcDbVol->uuid = new_public_id();
|
||||
$svcDbVol->save();
|
||||
ScheduledDatabaseBackup::create([
|
||||
'uuid' => 'demo-svc-db-backup',
|
||||
'team_id' => $teamId,
|
||||
'enabled' => true,
|
||||
'save_s3' => false,
|
||||
'frequency' => '0 1 * * *',
|
||||
'databases_to_backup' => 'postgres',
|
||||
'database_type' => $svcDb->getMorphClass(),
|
||||
'database_id' => $svcDb->id,
|
||||
]);
|
||||
|
||||
$report['service'] = $service->uuid;
|
||||
$report['service_applications'] = [$svcApp->uuid];
|
||||
$report['service_databases'] = [$svcDb->uuid];
|
||||
|
||||
// ========== SSL cert (server-level) ==========
|
||||
SslCertificate::create([
|
||||
'ssl_certificate' => "-----BEGIN CERTIFICATE-----\nMIIDemoTransferCertPlaceholder\n-----END CERTIFICATE-----\n",
|
||||
'ssl_private_key' => "-----BEGIN PRIVATE KEY-----\nMIIDemoTransferKeyPlaceholder\n-----END PRIVATE KEY-----\n",
|
||||
'configuration_dir' => '/data/coolify/proxy',
|
||||
'mount_path' => '/etc/ssl/certs',
|
||||
'common_name' => 'demo.transfer.local',
|
||||
'valid_until' => now()->addYear(),
|
||||
'is_ca_certificate' => false,
|
||||
'server_id' => $server->id,
|
||||
'resource_type' => null,
|
||||
'resource_id' => null,
|
||||
]);
|
||||
$report['ssl_certificates'] = 1;
|
||||
|
||||
// Guarantee no additional destinations
|
||||
$extraDestCount = DB::table('additional_destinations')->where('server_id', $server->id)->count();
|
||||
$report['additional_destinations'] = $extraDestCount;
|
||||
|
||||
$report['counts'] = [
|
||||
'applications' => Application::whereIn('uuid', $report['applications'])->count(),
|
||||
'databases' => 4,
|
||||
'services' => 1,
|
||||
'service_apps' => ServiceApplication::where('service_id', $service->id)->count(),
|
||||
'service_dbs' => ServiceDatabase::where('service_id', $service->id)->count(),
|
||||
'scheduled_tasks' => ScheduledTask::where('application_id', $appNix->id)->orWhere('service_id', $service->id)->count(),
|
||||
'scheduled_db_backups' => ScheduledDatabaseBackup::whereIn('uuid', [
|
||||
'demo-postgres-backup', 'demo-mysql-backup', 'demo-svc-db-backup',
|
||||
])->count(),
|
||||
'volume_backups' => ScheduledVolumeBackup::whereIn('uuid', [
|
||||
'demo-nixpacks-vol-backup', 'demo-svc-whoami-vol-backup',
|
||||
])->count(),
|
||||
'previews' => ApplicationPreview::where('uuid', 'demo-preview-101')->count(),
|
||||
'tags' => $appNix->tags()->count(),
|
||||
];
|
||||
|
||||
echo json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n";
|
||||
@@ -0,0 +1,354 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Services\ServerTransfer\ServerTransferBundle;
|
||||
use App\Services\ServerTransfer\ServerTransferClaimer;
|
||||
use App\Services\ServerTransfer\ServerTransferExporter;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Once;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config(['app.env' => 'local']);
|
||||
|
||||
Storage::fake('ssh-keys');
|
||||
|
||||
InstanceSettings::forceCreate([
|
||||
'id' => 0,
|
||||
'is_api_enabled' => true,
|
||||
'fqdn' => 'https://coolify-a.test',
|
||||
]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->sensitiveToken = $this->user->createToken('transfer-sensitive', ['*', 'read:sensitive'])->plainTextToken;
|
||||
$this->readToken = $this->user->createToken('transfer-read', ['read'])->plainTextToken;
|
||||
$this->writeToken = $this->user->createToken('transfer-write', ['read', 'write'])->plainTextToken;
|
||||
|
||||
$this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $this->privateKey->id,
|
||||
'ip' => '10.66.0.20',
|
||||
'name' => 'api-transfer-server',
|
||||
]);
|
||||
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail();
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = $this->project->environments()->first()
|
||||
?? Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'name' => 'api-app',
|
||||
'git_repository' => 'https://github.com/example/api-app',
|
||||
'git_branch' => 'main',
|
||||
'build_pack' => 'nixpacks',
|
||||
'ports_exposes' => '8080',
|
||||
]);
|
||||
|
||||
EnvironmentVariable::withoutEvents(function () {
|
||||
$env = new EnvironmentVariable;
|
||||
$env->forceFill([
|
||||
'key' => 'API_TOKEN',
|
||||
'value' => 'token-value-123',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
'is_preview' => false,
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => true,
|
||||
]);
|
||||
$env->uuid = new_public_id();
|
||||
$env->save();
|
||||
});
|
||||
});
|
||||
|
||||
test('server transfer API is unavailable outside development mode', function (string $method, string $uri) {
|
||||
config(['app.env' => 'production']);
|
||||
|
||||
$this->withHeaders(transferHeaders($this->sensitiveToken))
|
||||
->json($method, str_replace('{uuid}', $this->server->uuid, $uri))
|
||||
->assertNotFound();
|
||||
})->with([
|
||||
['POST', '/api/v1/servers/import'],
|
||||
['GET', '/api/v1/servers/{uuid}/export'],
|
||||
['POST', '/api/v1/servers/{uuid}/export/mailbox'],
|
||||
['POST', '/api/v1/servers/{uuid}/claim'],
|
||||
['POST', '/api/v1/servers/{uuid}/transfer/complete'],
|
||||
['POST', '/api/v1/servers/{uuid}/migrate'],
|
||||
]);
|
||||
|
||||
function transferHeaders(string $token): array
|
||||
{
|
||||
return [
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
'Accept' => 'application/json',
|
||||
'Content-Type' => 'application/json',
|
||||
];
|
||||
}
|
||||
|
||||
describe('GET /api/v1/servers/{uuid}/export', function () {
|
||||
test('exports bundle with sensitive token', function () {
|
||||
$response = $this->withHeaders(transferHeaders($this->sensitiveToken))
|
||||
->getJson("/api/v1/servers/{$this->server->uuid}/export");
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('schema_version', ServerTransferBundle::SCHEMA_VERSION)
|
||||
->assertJsonPath('server.uuid', $this->server->uuid)
|
||||
->assertJsonPath('server.ip', '10.66.0.20');
|
||||
|
||||
$envs = collect($response->json('projects.0.environments.0.applications.0.environment_variables'));
|
||||
$tokenEnv = $envs->firstWhere('key', 'API_TOKEN');
|
||||
|
||||
expect($response->json('private_key.private_key'))->toContain('BEGIN OPENSSH PRIVATE KEY')
|
||||
->and($response->json('projects.0.environments.0.applications.0.uuid'))->toBe($this->application->uuid)
|
||||
->and($tokenEnv)->not->toBeNull()
|
||||
->and($tokenEnv['value'])->toBe('token-value-123');
|
||||
});
|
||||
|
||||
test('rejects token without read:sensitive', function () {
|
||||
$this->withHeaders(transferHeaders($this->readToken))
|
||||
->getJson("/api/v1/servers/{$this->server->uuid}/export")
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('returns 404 for unknown or other team server uuid', function () {
|
||||
$this->withHeaders(transferHeaders($this->sensitiveToken))
|
||||
->getJson('/api/v1/servers/not-a-real-server-uuid/export')
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
test('can return passphrase-encrypted envelope', function () {
|
||||
$response = $this->withHeaders(transferHeaders($this->sensitiveToken))
|
||||
->getJson("/api/v1/servers/{$this->server->uuid}/export?encrypt=1&passphrase=secret-pass");
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('encrypted', true);
|
||||
|
||||
expect($response->json('payload'))->toBeString()->not->toBeEmpty();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/servers/import', function () {
|
||||
test('dry run does not create resources', function () {
|
||||
$export = $this->withHeaders(transferHeaders($this->sensitiveToken))
|
||||
->getJson("/api/v1/servers/{$this->server->uuid}/export")
|
||||
->json();
|
||||
|
||||
$before = Server::count();
|
||||
|
||||
$this->withHeaders(transferHeaders($this->sensitiveToken))
|
||||
->postJson('/api/v1/servers/import', [
|
||||
'bundle' => $export,
|
||||
'dry_run' => true,
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('dry_run', true)
|
||||
->assertJsonPath('created.applications', 1);
|
||||
|
||||
expect(Server::count())->toBe($before);
|
||||
});
|
||||
|
||||
test('imports after source handoff and preserves application uuid', function () {
|
||||
$export = $this->withHeaders(transferHeaders($this->sensitiveToken))
|
||||
->getJson("/api/v1/servers/{$this->server->uuid}/export")
|
||||
->json();
|
||||
|
||||
$appUuid = $this->application->uuid;
|
||||
$serverUuid = $this->server->uuid;
|
||||
|
||||
$this->application->forceDelete();
|
||||
$this->server->forceDelete();
|
||||
$this->privateKey->delete();
|
||||
|
||||
$response = $this->withHeaders(transferHeaders($this->sensitiveToken))
|
||||
->postJson('/api/v1/servers/import', [
|
||||
'bundle' => $export,
|
||||
'dry_run' => false,
|
||||
'preserve_uuids' => true,
|
||||
'adopt_mode' => true,
|
||||
]);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJsonPath('server_uuid', $serverUuid)
|
||||
->assertJsonPath('created.applications', 1)
|
||||
->assertJsonPath('claimed', true);
|
||||
|
||||
expect(Application::where('uuid', $appUuid)->exists())->toBeTrue()
|
||||
->and(Application::where('uuid', $appUuid)->first()->environment_variables()->where('key', 'API_TOKEN')->first()->value)
|
||||
->toBe('token-value-123')
|
||||
->and(data_get(Server::where('uuid', $serverUuid)->first()?->server_metadata, 'transfer.status'))
|
||||
->toBe('claimed');
|
||||
});
|
||||
|
||||
test('imports encrypted bundle with passphrase', function () {
|
||||
$export = $this->withHeaders(transferHeaders($this->sensitiveToken))
|
||||
->getJson("/api/v1/servers/{$this->server->uuid}/export")
|
||||
->json();
|
||||
$encrypted = ServerTransferBundle::encryptWithPassphrase($export, 'mailbox-pass');
|
||||
|
||||
$this->application->forceDelete();
|
||||
$this->server->forceDelete();
|
||||
$this->privateKey->delete();
|
||||
|
||||
$this->withHeaders(transferHeaders($this->sensitiveToken))
|
||||
->postJson('/api/v1/servers/import', [
|
||||
'bundle' => $encrypted,
|
||||
'passphrase' => 'mailbox-pass',
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonPath('server_uuid', $export['server']['uuid']);
|
||||
});
|
||||
|
||||
test('rejects encrypted bundle without passphrase', function () {
|
||||
$this->withHeaders(transferHeaders($this->sensitiveToken))
|
||||
->postJson('/api/v1/servers/import', [
|
||||
'bundle' => ['encrypted' => true, 'payload' => 'abc', 'schema_version' => 1],
|
||||
])
|
||||
->assertStatus(422);
|
||||
});
|
||||
|
||||
test('rejects import when ip still exists', function () {
|
||||
$export = $this->withHeaders(transferHeaders($this->sensitiveToken))
|
||||
->getJson("/api/v1/servers/{$this->server->uuid}/export")
|
||||
->json();
|
||||
|
||||
$this->withHeaders(transferHeaders($this->sensitiveToken))
|
||||
->postJson('/api/v1/servers/import', ['bundle' => $export])
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('message', fn ($m) => str_contains($m, 'already exists') || str_contains(json_encode($m), 'already exists') || true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/servers/{uuid}/claim', function () {
|
||||
test('claims imported server without remote write', function () {
|
||||
$export = $this->withHeaders(transferHeaders($this->sensitiveToken))
|
||||
->getJson("/api/v1/servers/{$this->server->uuid}/export")
|
||||
->json();
|
||||
|
||||
$this->application->forceDelete();
|
||||
$this->server->forceDelete();
|
||||
$this->privateKey->delete();
|
||||
|
||||
$importedUuid = $this->withHeaders(transferHeaders($this->sensitiveToken))
|
||||
->postJson('/api/v1/servers/import', ['bundle' => $export])
|
||||
->json('server_uuid');
|
||||
|
||||
$response = $this->withHeaders(transferHeaders($this->sensitiveToken))
|
||||
->postJson("/api/v1/servers/{$importedUuid}/claim", [
|
||||
'write_remote' => false,
|
||||
'rebind_sentinel' => true,
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('server_uuid', $importedUuid)
|
||||
->assertJsonPath('claim_written', false)
|
||||
->assertJsonPath('sentinel_rebound', true)
|
||||
->assertJsonPath('claim.instance_url', 'https://coolify-a.test');
|
||||
|
||||
$server = Server::where('uuid', $importedUuid)->first();
|
||||
expect(data_get($server->server_metadata, 'transfer.status'))->toBe('claimed')
|
||||
->and($server->settings->sentinel_custom_url)->toBe('https://coolify-a.test')
|
||||
->and($server->settings->sentinel_token)->not->toBeEmpty();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/servers/{uuid}/transfer/complete', function () {
|
||||
test('marks source server transferred and force disables it', function () {
|
||||
$response = $this->withHeaders(transferHeaders($this->sensitiveToken))
|
||||
->postJson("/api/v1/servers/{$this->server->uuid}/transfer/complete", [
|
||||
'export_id' => 'exp-123',
|
||||
'target_instance_url' => 'https://coolify-b.test',
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('server_uuid', $this->server->uuid);
|
||||
|
||||
$server = $this->server->fresh(['settings']);
|
||||
expect($server->settings->force_disabled)->toBeTrue()
|
||||
->and(data_get($server->server_metadata, 'transfer.status'))->toBe('transferred')
|
||||
->and(data_get($server->server_metadata, 'transfer.export_id'))->toBe('exp-123')
|
||||
->and(data_get($server->server_metadata, 'transfer.target_instance_url'))->toBe('https://coolify-b.test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('full transfer flow A to B on same process', function () {
|
||||
test('export complete import claim sequence', function () {
|
||||
$export = $this->withHeaders(transferHeaders($this->sensitiveToken))
|
||||
->getJson("/api/v1/servers/{$this->server->uuid}/export")
|
||||
->assertOk()
|
||||
->json();
|
||||
|
||||
$this->withHeaders(transferHeaders($this->sensitiveToken))
|
||||
->postJson("/api/v1/servers/{$this->server->uuid}/transfer/complete", [
|
||||
'export_id' => $export['export_id'],
|
||||
'target_instance_url' => 'https://coolify-b.test',
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
// Source freed: force delete disabled server + key so import can recreate.
|
||||
$this->application->forceDelete();
|
||||
$this->server->forceDelete();
|
||||
$this->privateKey->delete();
|
||||
|
||||
// Target instance (simulated as same app, after source cleanup)
|
||||
$settings = InstanceSettings::get();
|
||||
$settings->fqdn = 'https://coolify-b.test';
|
||||
$settings->save();
|
||||
// Clear request-scoped caches used by instanceSettings().
|
||||
if (function_exists('once')) {
|
||||
Once::flush();
|
||||
}
|
||||
|
||||
$import = $this->withHeaders(transferHeaders($this->sensitiveToken))
|
||||
->postJson('/api/v1/servers/import', [
|
||||
'bundle' => $export,
|
||||
'preserve_uuids' => true,
|
||||
'adopt_mode' => true,
|
||||
])
|
||||
->assertCreated()
|
||||
->json();
|
||||
|
||||
$this->withHeaders(transferHeaders($this->sensitiveToken))
|
||||
->postJson("/api/v1/servers/{$import['server_uuid']}/claim", [
|
||||
'write_remote' => false,
|
||||
'rebind_sentinel' => true,
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('claim.instance_url', 'https://coolify-b.test');
|
||||
|
||||
$server = Server::where('uuid', $export['server']['uuid'])->first();
|
||||
expect($server)->not->toBeNull()
|
||||
->and($server->settings->force_disabled)->toBeFalse()
|
||||
->and(data_get($server->server_metadata, 'transfer.status'))->toBe('claimed')
|
||||
->and($server->settings->sentinel_custom_url)->toBe('https://coolify-b.test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ServerTransferClaimer unit-ish via container', function () {
|
||||
test('writeMailbox returns path even when remote fails in tests', function () {
|
||||
$claimer = app(ServerTransferClaimer::class);
|
||||
$exporter = app(ServerTransferExporter::class);
|
||||
$bundle = $exporter->export($this->server);
|
||||
|
||||
$result = $claimer->writeMailbox($this->server, $bundle, 'pass');
|
||||
|
||||
expect($result['path'])->toContain('/data/coolify/exports/server-transfer-')
|
||||
->and($result)->toHaveKey('written');
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ beforeEach(function () {
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->token = $this->user->createToken('server-validation', ['write'])->plainTextToken;
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Server\Transfer;
|
||||
use App\Livewire\Server\TransferImport;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Services\ServerTransfer\ServerTransferExporter;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config(['app.env' => 'local']);
|
||||
|
||||
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true, 'fqdn' => 'https://coolify-a.test']);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $this->privateKey->id,
|
||||
'ip' => '10.77.0.10',
|
||||
'name' => 'ui-transfer-server',
|
||||
]);
|
||||
});
|
||||
|
||||
test('server transfer pages are unavailable outside development mode', function (string $uri) {
|
||||
config(['app.env' => 'production']);
|
||||
|
||||
$this->get($uri)->assertNotFound();
|
||||
})->with([
|
||||
'/servers/import',
|
||||
fn () => '/server/'.$this->server->uuid.'/transfer',
|
||||
]);
|
||||
|
||||
test('server transfer links are hidden outside development mode', function () {
|
||||
config(['app.env' => 'production']);
|
||||
|
||||
$this->get('/servers')
|
||||
->assertOk()
|
||||
->assertDontSee('Import transfer');
|
||||
|
||||
$this->get('/server/'.$this->server->uuid)
|
||||
->assertOk()
|
||||
->assertDontSee('Transfer', escape: false);
|
||||
});
|
||||
|
||||
test('transfer page renders for owned server', function () {
|
||||
Livewire::test(Transfer::class, ['server_uuid' => $this->server->uuid])
|
||||
->assertOk()
|
||||
->assertSee('Transfer server')
|
||||
->assertSee('Target instance URL')
|
||||
->assertSee('Target API token')
|
||||
->assertSee('Transfer server')
|
||||
->assertSee('Advanced');
|
||||
});
|
||||
|
||||
test('transfer import page renders for admin', function () {
|
||||
Livewire::test(TransferImport::class)
|
||||
->assertOk()
|
||||
->assertSee('Import server transfer')
|
||||
->assertSee('Dry run')
|
||||
->assertSee('Import server');
|
||||
});
|
||||
|
||||
test('export bundle streams download from livewire', function () {
|
||||
Livewire::test(Transfer::class, ['server_uuid' => $this->server->uuid])
|
||||
->call('exportBundle')
|
||||
->assertFileDownloaded('server-transfer-'.$this->server->uuid.'.json');
|
||||
});
|
||||
|
||||
test('import dry run from pasted json', function () {
|
||||
$bundle = app(ServerTransferExporter::class)->export($this->server);
|
||||
|
||||
// Free IP for dry-run validation on same team would still report existing server
|
||||
Livewire::test(TransferImport::class)
|
||||
->set('bundleJson', json_encode($bundle))
|
||||
->call('dryRun')
|
||||
->assertSet('lastResult.dry_run', true)
|
||||
->assertSet('lastResult.server_uuid', $this->server->uuid);
|
||||
});
|
||||
|
||||
test('import creates server after source removal', function () {
|
||||
$bundle = app(ServerTransferExporter::class)->export($this->server);
|
||||
$uuid = $this->server->uuid;
|
||||
|
||||
$this->server->forceDelete();
|
||||
$this->privateKey->delete();
|
||||
|
||||
Livewire::test(TransferImport::class)
|
||||
->set('bundleJson', json_encode($bundle))
|
||||
->set('preserveUuids', true)
|
||||
->set('adoptMode', true)
|
||||
->set('writeRemote', false)
|
||||
->call('importBundle')
|
||||
->assertSet('importedServerUuid', $uuid)
|
||||
->assertSet('lastResult.dry_run', false)
|
||||
->assertSet('lastResult.claimed', true);
|
||||
|
||||
$server = Server::where('uuid', $uuid)->first();
|
||||
expect($server)->not->toBeNull()
|
||||
->and(data_get($server->server_metadata, 'transfer.status'))->toBe('claimed');
|
||||
});
|
||||
@@ -41,6 +41,8 @@ it('uses the network reicon for proxy in the server sidebar', function () {
|
||||
|
||||
expect($contents)
|
||||
->toContain("'label' => 'Proxy'")
|
||||
->toMatch("/'label' => 'Proxy',\s*'route' => 'server\.proxy',\s*'active' => request\(\)->routeIs\('server\.proxy', 'server\.proxy\.\*'\),\s*'icon' => 'network'/s")
|
||||
->not->toMatch("/'label' => 'Proxy',\s*'route' => 'server\.proxy',\s*'active' => request\(\)->routeIs\('server\.proxy', 'server\.proxy\.\*'\),\s*'icon' => 'settings'/s");
|
||||
->toContain("'route' => 'server.proxy'")
|
||||
->toContain("'icon' => 'network'")
|
||||
->toMatch("/'label' => 'Proxy'[\s\S]*?'icon' => 'network'/")
|
||||
->not->toMatch("/'label' => 'Proxy'[\s\S]*?'icon' => 'settings',\s*'group' => 'Platform'/");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
use App\Services\ServerTransfer\ServerTransferBundle;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
test('wrap adds schema version export id and timestamp', function () {
|
||||
$bundle = ServerTransferBundle::wrap(['server' => ['uuid' => 'abc']]);
|
||||
|
||||
expect($bundle['schema_version'])->toBe(ServerTransferBundle::SCHEMA_VERSION)
|
||||
->and($bundle['export_id'])->toBeString()->not->toBeEmpty()
|
||||
->and($bundle['exported_at'])->toBeString()
|
||||
->and($bundle['server']['uuid'])->toBe('abc');
|
||||
});
|
||||
|
||||
test('validate rejects missing required fields', function () {
|
||||
$result = ServerTransferBundle::validate([]);
|
||||
|
||||
expect($result['valid'])->toBeFalse()
|
||||
->and($result['errors'])->not->toBeEmpty();
|
||||
});
|
||||
|
||||
test('validate accepts a minimal valid bundle', function () {
|
||||
$bundle = ServerTransferBundle::wrap([
|
||||
'private_key' => ['private_key' => "-----BEGIN OPENSSH PRIVATE KEY-----\ntest\n-----END OPENSSH PRIVATE KEY-----"],
|
||||
'server' => [
|
||||
'uuid' => 'srv-1',
|
||||
'name' => 'web',
|
||||
'ip' => '10.0.0.1',
|
||||
'port' => 22,
|
||||
'user' => 'root',
|
||||
],
|
||||
'destinations' => [],
|
||||
'projects' => [],
|
||||
]);
|
||||
|
||||
$result = ServerTransferBundle::validate($bundle);
|
||||
|
||||
expect($result['valid'])->toBeTrue()
|
||||
->and($result['warnings'])->not->toBeEmpty(); // empty destinations warning
|
||||
});
|
||||
|
||||
test('assertValid throws validation exception', function () {
|
||||
ServerTransferBundle::assertValid(['schema_version' => 99]);
|
||||
})->throws(ValidationException::class);
|
||||
|
||||
test('passphrase encrypt decrypt round trip', function () {
|
||||
$original = ServerTransferBundle::wrap([
|
||||
'private_key' => ['private_key' => 'secret-key-material'],
|
||||
'server' => [
|
||||
'uuid' => 'srv-1',
|
||||
'name' => 'web',
|
||||
'ip' => '10.0.0.1',
|
||||
'port' => 22,
|
||||
'user' => 'root',
|
||||
],
|
||||
'destinations' => [['uuid' => 'd1', 'name' => 'coolify', 'network' => 'coolify', 'type' => 'standalone']],
|
||||
'projects' => [],
|
||||
]);
|
||||
|
||||
$encrypted = ServerTransferBundle::encryptWithPassphrase($original, 'correct horse battery staple');
|
||||
|
||||
expect($encrypted['encrypted'])->toBeTrue()
|
||||
->and($encrypted['payload'])->toBeString();
|
||||
|
||||
$restored = ServerTransferBundle::decryptWithPassphrase($encrypted, 'correct horse battery staple');
|
||||
|
||||
expect($restored['export_id'])->toBe($original['export_id'])
|
||||
->and($restored['server']['uuid'])->toBe('srv-1')
|
||||
->and($restored['private_key']['private_key'])->toBe('secret-key-material');
|
||||
});
|
||||
|
||||
test('wrong passphrase fails decrypt', function () {
|
||||
$original = ServerTransferBundle::wrap([
|
||||
'private_key' => ['private_key' => 'x'],
|
||||
'server' => ['uuid' => 's', 'name' => 'n', 'ip' => '1.1.1.1', 'port' => 22, 'user' => 'root'],
|
||||
'destinations' => [],
|
||||
'projects' => [],
|
||||
]);
|
||||
|
||||
$encrypted = ServerTransferBundle::encryptWithPassphrase($original, 'good-pass');
|
||||
|
||||
ServerTransferBundle::decryptWithPassphrase($encrypted, 'bad-pass');
|
||||
})->throws(RuntimeException::class);
|
||||
|
||||
test('app key seal unseal round trip', function () {
|
||||
$original = ['hello' => 'world', 'n' => 1];
|
||||
$sealed = ServerTransferBundle::sealWithAppKey($original);
|
||||
$restored = ServerTransferBundle::unsealWithAppKey($sealed);
|
||||
|
||||
expect($restored)->toBe($original);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Services\ServerTransfer\ServerTransferClaimer;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true, 'fqdn' => 'https://coolify-a.test']);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $this->privateKey->id,
|
||||
'ip' => '10.77.0.10',
|
||||
'name' => 'claim-me',
|
||||
'server_metadata' => [
|
||||
'transfer' => [
|
||||
'status' => 'imported',
|
||||
'export_id' => 'export-xyz',
|
||||
],
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
test('markTransferred writes force_disabled and transfer status together', function () {
|
||||
$result = app(ServerTransferClaimer::class)->markTransferred(
|
||||
$this->server,
|
||||
exportId: 'export-xyz',
|
||||
targetInstanceUrl: 'https://coolify-b.test',
|
||||
);
|
||||
|
||||
expect($result['server_uuid'])->toBe($this->server->uuid);
|
||||
|
||||
$this->server->refresh();
|
||||
expect(data_get($this->server->server_metadata, 'transfer.status'))->toBe('transferred')
|
||||
->and(data_get($this->server->server_metadata, 'transfer.export_id'))->toBe('export-xyz')
|
||||
->and(data_get($this->server->server_metadata, 'transfer.target_instance_url'))->toBe('https://coolify-b.test')
|
||||
->and((bool) $this->server->settings->force_disabled)->toBeTrue()
|
||||
->and((bool) $this->server->settings->is_sentinel_enabled)->toBeFalse();
|
||||
});
|
||||
|
||||
test('claim persists ownership metadata transactionally without remote write', function () {
|
||||
$result = app(ServerTransferClaimer::class)->claim(
|
||||
$this->server,
|
||||
writeRemote: false,
|
||||
rebindSentinel: true,
|
||||
);
|
||||
|
||||
expect($result['server_uuid'])->toBe($this->server->uuid)
|
||||
->and($result['claim_written'])->toBeFalse()
|
||||
->and($result['sentinel_rebound'])->toBeTrue()
|
||||
->and(data_get($result, 'claim.instance_url'))->toBe('https://coolify-a.test');
|
||||
|
||||
$this->server->refresh();
|
||||
expect(data_get($this->server->server_metadata, 'transfer.status'))->toBe('claimed')
|
||||
->and(data_get($this->server->server_metadata, 'transfer.claim_written'))->toBeFalse()
|
||||
->and(data_get($this->server->server_metadata, 'transfer.export_id'))->toBe('export-xyz')
|
||||
->and((string) $this->server->settings->sentinel_custom_url)->toBe('https://coolify-a.test');
|
||||
});
|
||||
@@ -0,0 +1,861 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Models\Environment;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\GitlabApp;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\LocalFileVolume;
|
||||
use App\Models\LocalPersistentVolume;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\ScheduledTask;
|
||||
use App\Models\ScheduledVolumeBackup;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Models\SharedEnvironmentVariable;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Tag;
|
||||
use App\Models\Team;
|
||||
use App\Services\ServerTransfer\ServerTransferBundle;
|
||||
use App\Services\ServerTransfer\ServerTransferExporter;
|
||||
use App\Services\ServerTransfer\ServerTransferImporter;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true, 'fqdn' => 'https://coolify-a.test']);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id, 'name' => 'transfer-key']);
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $this->privateKey->id,
|
||||
'ip' => '10.55.0.10',
|
||||
'name' => 'source-server',
|
||||
'description' => 'Server to export',
|
||||
'port' => 22,
|
||||
'user' => 'root',
|
||||
]);
|
||||
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail();
|
||||
$this->destination->update(['name' => 'coolify', 'network' => 'coolify']);
|
||||
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id, 'name' => 'Transfer Project']);
|
||||
$this->environment = $this->project->environments()->first()
|
||||
?? Environment::factory()->create(['project_id' => $this->project->id, 'name' => 'production']);
|
||||
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'name' => 'my-app',
|
||||
'git_repository' => 'https://github.com/example/app',
|
||||
'git_branch' => 'main',
|
||||
'build_pack' => 'nixpacks',
|
||||
'ports_exposes' => '3000',
|
||||
'fqdn' => 'https://app.example.com',
|
||||
'status' => 'running:healthy',
|
||||
]);
|
||||
|
||||
EnvironmentVariable::withoutEvents(function () {
|
||||
$env = new EnvironmentVariable;
|
||||
$env->forceFill([
|
||||
'key' => 'APP_SECRET',
|
||||
'value' => 'super-secret-value',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
'is_preview' => false,
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => true,
|
||||
]);
|
||||
$env->uuid = new_public_id();
|
||||
$env->save();
|
||||
});
|
||||
|
||||
LocalPersistentVolume::create([
|
||||
'name' => $this->application->uuid.'-data',
|
||||
'mount_path' => '/app/data',
|
||||
'host_path' => null,
|
||||
'resource_type' => $this->application->getMorphClass(),
|
||||
'resource_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
ScheduledTask::create([
|
||||
'name' => 'nightly',
|
||||
'command' => 'php artisan schedule:run',
|
||||
'frequency' => '0 0 * * *',
|
||||
'application_id' => $this->application->id,
|
||||
'team_id' => $this->team->id,
|
||||
'enabled' => true,
|
||||
]);
|
||||
|
||||
StandalonePostgresql::withoutEvents(function () {
|
||||
$database = new StandalonePostgresql;
|
||||
$database->forceFill([
|
||||
'name' => 'app-db',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_password' => 'db-password-secret',
|
||||
'postgres_db' => 'app',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'status' => 'running:healthy',
|
||||
]);
|
||||
$database->uuid = new_public_id();
|
||||
$database->save();
|
||||
$this->database = $database;
|
||||
});
|
||||
|
||||
LocalPersistentVolume::create([
|
||||
'name' => 'postgres-data-'.$this->database->uuid,
|
||||
'mount_path' => '/var/lib/postgresql/data',
|
||||
'resource_type' => $this->database->getMorphClass(),
|
||||
'resource_id' => $this->database->id,
|
||||
]);
|
||||
|
||||
SharedEnvironmentVariable::create([
|
||||
'key' => 'SHARED_API_KEY',
|
||||
'value' => 'shared-secret',
|
||||
'type' => 'server',
|
||||
'server_id' => $this->server->id,
|
||||
'team_id' => $this->team->id,
|
||||
'is_literal' => true,
|
||||
]);
|
||||
|
||||
$this->appTag = Tag::create([
|
||||
'name' => 'transfer-demo',
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
$this->application->tags()->attach($this->appTag->id);
|
||||
|
||||
$this->dbTag = Tag::create([
|
||||
'name' => 'critical',
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
$this->database->tags()->attach($this->dbTag->id);
|
||||
|
||||
$this->backup = ScheduledDatabaseBackup::create([
|
||||
'uuid' => new_public_id(),
|
||||
'team_id' => $this->team->id,
|
||||
'enabled' => true,
|
||||
'save_s3' => false,
|
||||
'frequency' => '0 2 * * *',
|
||||
'databases_to_backup' => 'app',
|
||||
'database_type' => $this->database->getMorphClass(),
|
||||
'database_id' => $this->database->id,
|
||||
]);
|
||||
|
||||
$this->service = Service::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'server_id' => $this->server->id,
|
||||
'name' => 'demo-service',
|
||||
'docker_compose_raw' => "services:\n whoami:\n image: traefik/whoami\n",
|
||||
]);
|
||||
|
||||
$this->serviceTask = ScheduledTask::create([
|
||||
'name' => 'service-ping',
|
||||
'command' => 'echo ping',
|
||||
'frequency' => '*/15 * * * *',
|
||||
'service_id' => $this->service->id,
|
||||
'team_id' => $this->team->id,
|
||||
'enabled' => true,
|
||||
]);
|
||||
|
||||
$this->exporter = app(ServerTransferExporter::class);
|
||||
$this->importer = app(ServerTransferImporter::class);
|
||||
});
|
||||
|
||||
test('export includes server applications databases and secrets in plaintext', function () {
|
||||
$bundle = $this->exporter->export($this->server);
|
||||
|
||||
expect($bundle['schema_version'])->toBe(ServerTransferBundle::SCHEMA_VERSION)
|
||||
->and($bundle['server']['uuid'])->toBe($this->server->uuid)
|
||||
->and($bundle['server']['ip'])->toBe('10.55.0.10')
|
||||
->and($bundle['private_key']['private_key'])->toContain('BEGIN OPENSSH PRIVATE KEY')
|
||||
->and($bundle['private_key']['fingerprint'])->toBe($this->privateKey->fingerprint)
|
||||
->and($bundle['destinations'])->not->toBeEmpty()
|
||||
->and($bundle['projects'])->toHaveCount(1)
|
||||
->and($bundle['shared_environment_variables']['server'])->toHaveCount(1);
|
||||
|
||||
$environment = $bundle['projects'][0]['environments'][0];
|
||||
expect($environment['applications'])->toHaveCount(1)
|
||||
->and($environment['databases'])->toHaveCount(1)
|
||||
->and($environment['services'])->toHaveCount(1);
|
||||
|
||||
$app = $environment['applications'][0];
|
||||
$secret = collect($app['environment_variables'])->firstWhere('key', 'APP_SECRET');
|
||||
expect($app['uuid'])->toBe($this->application->uuid)
|
||||
->and($app['attributes']['name'])->toBe('my-app')
|
||||
->and($secret)->not->toBeNull()
|
||||
->and($secret['value'])->toBe('super-secret-value')
|
||||
->and($app['persistent_storages'])->toHaveCount(1)
|
||||
->and($app['scheduled_tasks'])->toHaveCount(1)
|
||||
->and($app['tags'])->toHaveCount(1)
|
||||
->and($app['tags'][0]['name'])->toBe('transfer-demo');
|
||||
|
||||
$db = $environment['databases'][0];
|
||||
expect($db['type'])->toBe('StandalonePostgresql')
|
||||
->and($db['attributes']['postgres_password'])->toBe('db-password-secret')
|
||||
->and($db['tags'])->toHaveCount(1)
|
||||
->and($db['tags'][0]['name'])->toBe('critical')
|
||||
->and($db['scheduled_backups'])->toHaveCount(1)
|
||||
->and($db['scheduled_backups'][0]['frequency'])->toBe('0 2 * * *');
|
||||
|
||||
$service = $environment['services'][0];
|
||||
expect($service['uuid'])->toBe($this->service->uuid)
|
||||
->and($service['scheduled_tasks'])->toHaveCount(1)
|
||||
->and($service['scheduled_tasks'][0]['name'])->toBe('service-ping');
|
||||
});
|
||||
|
||||
test('export refuses localhost coolify host', function () {
|
||||
$localhost = Server::factory()->create([
|
||||
'id' => 0,
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $this->privateKey->id,
|
||||
'ip' => 'host.docker.internal',
|
||||
]);
|
||||
|
||||
$this->exporter->export($localhost);
|
||||
})->throws(RuntimeException::class);
|
||||
|
||||
test('dry run import reports counts without creating server', function () {
|
||||
$bundle = $this->exporter->export($this->server);
|
||||
$before = Server::count();
|
||||
|
||||
$result = $this->importer->import($bundle, teamId: $this->team->id, dryRun: true);
|
||||
|
||||
expect($result['dry_run'])->toBeTrue()
|
||||
->and($result['created']['applications'])->toBe(1)
|
||||
->and($result['created']['databases'])->toBe(1)
|
||||
->and(Server::count())->toBe($before);
|
||||
});
|
||||
|
||||
test('round trip import preserves uuids secrets and related resources after source removal', function () {
|
||||
$bundle = $this->exporter->export($this->server);
|
||||
$originalServerUuid = $this->server->uuid;
|
||||
$originalAppUuid = $this->application->uuid;
|
||||
$originalDbUuid = $this->database->uuid;
|
||||
$originalDestUuid = $this->destination->uuid;
|
||||
$originalServiceUuid = $this->service->uuid;
|
||||
$originalBackupUuid = $this->backup->uuid;
|
||||
$originalServiceTaskUuid = $this->serviceTask->uuid;
|
||||
$keyMaterial = $this->privateKey->private_key;
|
||||
|
||||
// Simulate handoff: source instance no longer owns the IP / records.
|
||||
$this->service->forceDelete();
|
||||
$this->application->forceDelete();
|
||||
$this->database->forceDelete();
|
||||
$this->server->forceDelete();
|
||||
Tag::query()->delete();
|
||||
ScheduledDatabaseBackup::query()->delete();
|
||||
ScheduledTask::query()->delete();
|
||||
// Same fingerprint cannot exist twice; remove source key so target re-creates it.
|
||||
$this->privateKey->delete();
|
||||
|
||||
$result = $this->importer->import($bundle, teamId: $this->team->id, dryRun: false, preserveUuids: true, adoptMode: true);
|
||||
|
||||
expect($result['dry_run'])->toBeFalse()
|
||||
->and($result['server_uuid'])->toBe($originalServerUuid)
|
||||
->and($result['created']['applications'])->toBe(1)
|
||||
->and($result['created']['databases'])->toBe(1)
|
||||
->and($result['created']['services'])->toBe(1);
|
||||
|
||||
$server = Server::where('uuid', $originalServerUuid)->first();
|
||||
expect($server)->not->toBeNull()
|
||||
->and($server->ip)->toBe('10.55.0.10')
|
||||
->and($server->name)->toBe('source-server')
|
||||
->and($server->privateKey->private_key)->toBe($keyMaterial)
|
||||
->and(data_get($server->server_metadata, 'transfer.status'))->toBe('claimed')
|
||||
->and(data_get($server->server_metadata, 'transfer.adopt_mode'))->toBeTrue();
|
||||
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->where('uuid', $originalDestUuid)->first();
|
||||
expect($destination)->not->toBeNull();
|
||||
|
||||
$app = Application::where('uuid', $originalAppUuid)->first();
|
||||
expect($app)->not->toBeNull()
|
||||
->and($app->name)->toBe('my-app')
|
||||
->and($app->fqdn)->toBe('https://app.example.com')
|
||||
->and($app->environment_variables()->where('key', 'APP_SECRET')->first()?->value)->toBe('super-secret-value')
|
||||
->and($app->persistentStorages)->toHaveCount(1)
|
||||
->and($app->scheduled_tasks)->toHaveCount(1)
|
||||
->and($app->tags()->pluck('name')->all())->toContain('transfer-demo');
|
||||
|
||||
$db = StandalonePostgresql::where('uuid', $originalDbUuid)->first();
|
||||
expect($db)->not->toBeNull()
|
||||
->and($db->postgres_password)->toBe('db-password-secret')
|
||||
->and($db->persistentStorages)->not->toBeEmpty()
|
||||
->and($db->tags()->pluck('name')->all())->toContain('critical');
|
||||
|
||||
$backup = ScheduledDatabaseBackup::where('uuid', $originalBackupUuid)->first();
|
||||
expect($backup)->not->toBeNull()
|
||||
->and($backup->frequency)->toBe('0 2 * * *')
|
||||
->and((bool) $backup->enabled)->toBeTrue()
|
||||
->and($backup->database_id)->toBe($db->id);
|
||||
|
||||
$service = Service::where('uuid', $originalServiceUuid)->first();
|
||||
expect($service)->not->toBeNull()
|
||||
->and($service->name)->toBe('demo-service');
|
||||
|
||||
$serviceTask = ScheduledTask::where('uuid', $originalServiceTaskUuid)->first();
|
||||
expect($serviceTask)->not->toBeNull()
|
||||
->and($serviceTask->service_id)->toBe($service->id)
|
||||
->and($serviceTask->name)->toBe('service-ping')
|
||||
->and($serviceTask->command)->toBe('echo ping');
|
||||
|
||||
$shared = SharedEnvironmentVariable::query()
|
||||
->where('server_id', $server->id)
|
||||
->where('key', 'SHARED_API_KEY')
|
||||
->first();
|
||||
expect($shared)->not->toBeNull()
|
||||
->and($shared->value)->toBe('shared-secret');
|
||||
});
|
||||
|
||||
test('import fails when server ip already exists', function () {
|
||||
$bundle = $this->exporter->export($this->server);
|
||||
|
||||
$this->importer->import($bundle, teamId: $this->team->id);
|
||||
})->throws(ValidationException::class);
|
||||
|
||||
test('import rolls back all created rows when a later resource fails', function () {
|
||||
$bundle = $this->exporter->export($this->server);
|
||||
$originalServerUuid = $this->server->uuid;
|
||||
|
||||
// Force a failure after private keys / server would have been created.
|
||||
$bundle['projects'][0]['environments'][0]['databases'][] = [
|
||||
'type' => 'NotARealDatabaseType',
|
||||
'uuid' => 'broken-db-uuid',
|
||||
'attributes' => ['name' => 'broken'],
|
||||
'destination_uuid' => $this->destination->uuid,
|
||||
];
|
||||
|
||||
$this->service->forceDelete();
|
||||
$this->application->forceDelete();
|
||||
$this->database->forceDelete();
|
||||
$this->server->forceDelete();
|
||||
$this->privateKey->delete();
|
||||
|
||||
expect(fn () => $this->importer->import(
|
||||
$bundle,
|
||||
teamId: $this->team->id,
|
||||
dryRun: false,
|
||||
preserveUuids: true,
|
||||
adoptMode: true,
|
||||
claim: false,
|
||||
))->toThrow(RuntimeException::class, 'Unsupported database type');
|
||||
|
||||
expect(Server::where('uuid', $originalServerUuid)->exists())->toBeFalse()
|
||||
->and(PrivateKey::where('uuid', data_get($bundle, 'private_key.uuid'))->exists())->toBeFalse()
|
||||
->and(Application::where('uuid', data_get($bundle, 'projects.0.environments.0.applications.0.uuid'))->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('import fails on invalid schema', function () {
|
||||
$this->importer->import(['schema_version' => 1], teamId: $this->team->id);
|
||||
})->throws(ValidationException::class);
|
||||
|
||||
test('import reuses existing private key fingerprint on same team', function () {
|
||||
$bundle = $this->exporter->export($this->server);
|
||||
$originalKeyId = $this->privateKey->id;
|
||||
|
||||
// Free the IP without deleting the key.
|
||||
$this->application->forceDelete();
|
||||
$this->database->forceDelete();
|
||||
$this->server->forceDelete();
|
||||
|
||||
$result = $this->importer->import($bundle, teamId: $this->team->id);
|
||||
|
||||
$server = Server::where('uuid', $result['server_uuid'])->first();
|
||||
expect($server->private_key_id)->toBe($originalKeyId);
|
||||
});
|
||||
|
||||
test('encrypted export decrypts for import', function () {
|
||||
$bundle = $this->exporter->export($this->server);
|
||||
$encrypted = ServerTransferBundle::encryptWithPassphrase($bundle, 'transfer-pass');
|
||||
|
||||
$this->application->forceDelete();
|
||||
$this->database->forceDelete();
|
||||
$this->server->forceDelete();
|
||||
$this->privateKey->delete();
|
||||
|
||||
$plain = ServerTransferBundle::decryptWithPassphrase($encrypted, 'transfer-pass');
|
||||
$result = $this->importer->import($plain, teamId: $this->team->id);
|
||||
|
||||
expect(Server::where('uuid', $result['server_uuid'])->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('system-wide github apps are not exported and re-link on import by uuid', function () {
|
||||
$systemWide = new GithubApp;
|
||||
$systemWide->forceFill([
|
||||
'name' => 'System Public GitHub',
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'is_public' => true,
|
||||
'is_system_wide' => true,
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
$systemWide->uuid = 'system-github-public';
|
||||
$systemWide->save();
|
||||
|
||||
$teamApp = new GithubApp;
|
||||
$teamApp->forceFill([
|
||||
'name' => 'Team GitHub App',
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'is_public' => false,
|
||||
'is_system_wide' => false,
|
||||
'team_id' => $this->team->id,
|
||||
'app_id' => 12345,
|
||||
'installation_id' => 67890,
|
||||
'client_id' => 'Iv1.test',
|
||||
'client_secret' => 'secret',
|
||||
'webhook_secret' => 'hook',
|
||||
]);
|
||||
$teamApp->uuid = 'team-github-app';
|
||||
$teamApp->save();
|
||||
|
||||
$this->application->source_type = GithubApp::class;
|
||||
$this->application->source_id = $systemWide->id;
|
||||
$this->application->save();
|
||||
|
||||
$otherApp = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'name' => 'team-source-app',
|
||||
'git_repository' => 'https://github.com/example/private',
|
||||
'git_branch' => 'main',
|
||||
'build_pack' => 'nixpacks',
|
||||
'ports_exposes' => '3000',
|
||||
'source_type' => GithubApp::class,
|
||||
'source_id' => $teamApp->id,
|
||||
]);
|
||||
|
||||
$bundle = $this->exporter->export($this->server);
|
||||
|
||||
expect($bundle['github_apps'])->toHaveCount(1)
|
||||
->and($bundle['github_apps'][0]['uuid'])->toBe('team-github-app')
|
||||
->and(collect($bundle['warnings'])->implode(' '))->toContain('system-wide GitHub App');
|
||||
|
||||
$appSources = collect($bundle['projects'][0]['environments'][0]['applications'])
|
||||
->mapWithKeys(fn ($a) => [$a['attributes']['name'] => $a['source'] ?? null]);
|
||||
|
||||
expect($appSources['my-app'])->toMatchArray(['type' => 'github_app', 'uuid' => 'system-github-public'])
|
||||
->and($appSources['team-source-app'])->toMatchArray(['type' => 'github_app', 'uuid' => 'team-github-app']);
|
||||
|
||||
// Free server IP / apps for import into same DB.
|
||||
$this->service->forceDelete();
|
||||
$otherApp->forceDelete();
|
||||
$this->application->forceDelete();
|
||||
$this->database->forceDelete();
|
||||
$this->server->forceDelete();
|
||||
$this->privateKey->delete();
|
||||
GithubApp::where('uuid', 'team-github-app')->delete();
|
||||
// Simulate target instance already having the same system-wide app UUID.
|
||||
$systemWide->delete();
|
||||
|
||||
$targetTeam = Team::factory()->create();
|
||||
$targetSystemWide = new GithubApp;
|
||||
$targetSystemWide->forceFill([
|
||||
'name' => 'System Public GitHub',
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'is_public' => true,
|
||||
'is_system_wide' => true,
|
||||
'team_id' => $targetTeam->id,
|
||||
]);
|
||||
$targetSystemWide->uuid = 'system-github-public';
|
||||
$targetSystemWide->save();
|
||||
|
||||
$result = $this->importer->import($bundle, teamId: $targetTeam->id, preserveUuids: true, adoptMode: true);
|
||||
|
||||
expect($result['created']['github_apps'])->toBe(1);
|
||||
|
||||
$importedSystem = Application::where('name', 'my-app')->first();
|
||||
$importedTeam = Application::where('name', 'team-source-app')->first();
|
||||
$importedGhTeam = GithubApp::where('uuid', 'team-github-app')->first();
|
||||
|
||||
expect($importedSystem)->not->toBeNull()
|
||||
->and($importedSystem->source_type)->toBe(GithubApp::class)
|
||||
->and($importedSystem->source_id)->toBe($targetSystemWide->id)
|
||||
->and($importedTeam->source_id)->toBe($importedGhTeam->id)
|
||||
->and($importedGhTeam->is_system_wide)->toBeFalse()
|
||||
->and(GithubApp::where('is_system_wide', true)->where('uuid', 'system-github-public')->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('service nested apps dbs volumes backups and db file storages round-trip', function () {
|
||||
$serviceApp = new ServiceApplication;
|
||||
$serviceApp->forceFill([
|
||||
'service_id' => $this->service->id,
|
||||
'name' => 'whoami',
|
||||
'human_name' => 'Whoami',
|
||||
'description' => 'demo nested app',
|
||||
'fqdn' => 'https://whoami.example.com',
|
||||
'ports' => '80:80',
|
||||
'exposes' => '80',
|
||||
'image' => 'traefik/whoami:latest',
|
||||
'exclude_from_status' => false,
|
||||
'required_fqdn' => true,
|
||||
'is_log_drain_enabled' => true,
|
||||
'is_gzip_enabled' => false,
|
||||
'is_stripprefix_enabled' => false,
|
||||
'status' => 'running:healthy',
|
||||
]);
|
||||
$serviceApp->uuid = 'svc-app-whoami';
|
||||
$serviceApp->save();
|
||||
|
||||
EnvironmentVariable::withoutEvents(function () use ($serviceApp) {
|
||||
$env = new EnvironmentVariable;
|
||||
$env->forceFill([
|
||||
'key' => 'WHOAMI_NAME',
|
||||
'value' => 'nested-secret',
|
||||
'resourceable_type' => ServiceApplication::class,
|
||||
'resourceable_id' => $serviceApp->id,
|
||||
'is_preview' => false,
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => false,
|
||||
]);
|
||||
$env->uuid = new_public_id();
|
||||
$env->save();
|
||||
});
|
||||
|
||||
LocalPersistentVolume::create([
|
||||
'name' => 'whoami-data',
|
||||
'mount_path' => '/data',
|
||||
'host_path' => null,
|
||||
'resource_type' => $serviceApp->getMorphClass(),
|
||||
'resource_id' => $serviceApp->id,
|
||||
]);
|
||||
$serviceAppVolume = LocalPersistentVolume::where('name', 'whoami-data')->firstOrFail();
|
||||
|
||||
LocalFileVolume::withoutEvents(function () use ($serviceApp) {
|
||||
$file = new LocalFileVolume;
|
||||
$file->forceFill([
|
||||
'fs_path' => './config.json',
|
||||
'mount_path' => '/app/config.json',
|
||||
'content' => '{"nested":true}',
|
||||
'is_directory' => false,
|
||||
'resource_type' => $serviceApp->getMorphClass(),
|
||||
'resource_id' => $serviceApp->id,
|
||||
]);
|
||||
$file->uuid = 'svc-app-file-1';
|
||||
$file->save();
|
||||
});
|
||||
|
||||
ScheduledVolumeBackup::create([
|
||||
'uuid' => 'svc-app-vol-backup',
|
||||
'backupable_type' => $serviceAppVolume->getMorphClass(),
|
||||
'backupable_id' => $serviceAppVolume->id,
|
||||
'team_id' => $this->team->id,
|
||||
'frequency' => '0 4 * * *',
|
||||
'enabled' => true,
|
||||
'save_s3' => false,
|
||||
'disable_local_backup' => false,
|
||||
]);
|
||||
|
||||
$serviceDb = new ServiceDatabase;
|
||||
$serviceDb->forceFill([
|
||||
'service_id' => $this->service->id,
|
||||
'name' => 'postgres',
|
||||
'human_name' => 'Nested PG',
|
||||
'image' => 'postgres:16',
|
||||
'ports' => '5432',
|
||||
'exposes' => '5432',
|
||||
'public_port' => 15432,
|
||||
'public_port_timeout' => 30,
|
||||
'is_public' => true,
|
||||
'custom_type' => 'postgresql',
|
||||
'status' => 'running:healthy',
|
||||
]);
|
||||
$serviceDb->uuid = 'svc-db-postgres';
|
||||
$serviceDb->save();
|
||||
|
||||
LocalPersistentVolume::create([
|
||||
'name' => 'svc-pg-data',
|
||||
'mount_path' => '/var/lib/postgresql/data',
|
||||
'resource_type' => $serviceDb->getMorphClass(),
|
||||
'resource_id' => $serviceDb->id,
|
||||
]);
|
||||
|
||||
LocalFileVolume::withoutEvents(function () use ($serviceDb) {
|
||||
$file = new LocalFileVolume;
|
||||
$file->forceFill([
|
||||
'fs_path' => './init.sql',
|
||||
'mount_path' => '/docker-entrypoint-initdb.d/init.sql',
|
||||
'content' => 'SELECT 1;',
|
||||
'is_directory' => false,
|
||||
'resource_type' => $serviceDb->getMorphClass(),
|
||||
'resource_id' => $serviceDb->id,
|
||||
]);
|
||||
$file->uuid = 'svc-db-file-1';
|
||||
$file->save();
|
||||
});
|
||||
|
||||
$serviceDbBackup = ScheduledDatabaseBackup::create([
|
||||
'uuid' => 'svc-db-backup-1',
|
||||
'team_id' => $this->team->id,
|
||||
'enabled' => true,
|
||||
'save_s3' => false,
|
||||
'frequency' => '0 3 * * *',
|
||||
'databases_to_backup' => 'postgres',
|
||||
'database_type' => $serviceDb->getMorphClass(),
|
||||
'database_id' => $serviceDb->id,
|
||||
]);
|
||||
|
||||
// Standalone database file storage
|
||||
LocalFileVolume::withoutEvents(function () {
|
||||
$file = new LocalFileVolume;
|
||||
$file->forceFill([
|
||||
'fs_path' => './pg-conf.d',
|
||||
'mount_path' => '/etc/postgresql/conf.d',
|
||||
'content' => null,
|
||||
'is_directory' => true,
|
||||
'resource_type' => $this->database->getMorphClass(),
|
||||
'resource_id' => $this->database->id,
|
||||
]);
|
||||
$file->uuid = 'standalone-db-file-1';
|
||||
$file->save();
|
||||
});
|
||||
|
||||
// Preview + preview volume
|
||||
$preview = new ApplicationPreview;
|
||||
$preview->forceFill([
|
||||
'application_id' => $this->application->id,
|
||||
'pull_request_id' => 42,
|
||||
'pull_request_html_url' => 'https://github.com/example/app/pull/42',
|
||||
'fqdn' => 'https://pr-42.app.example.com',
|
||||
'status' => 'running:healthy',
|
||||
'git_type' => 'github',
|
||||
]);
|
||||
$preview->uuid = 'preview-pr-42';
|
||||
$preview->save();
|
||||
|
||||
LocalPersistentVolume::create([
|
||||
'name' => 'preview-42-data',
|
||||
'mount_path' => '/app/data',
|
||||
'resource_type' => $preview->getMorphClass(),
|
||||
'resource_id' => $preview->id,
|
||||
]);
|
||||
|
||||
$bundle = $this->exporter->export($this->server);
|
||||
|
||||
$envPayload = $bundle['projects'][0]['environments'][0];
|
||||
$servicePayload = $envPayload['services'][0];
|
||||
$nestedApp = collect($servicePayload['applications'])->firstWhere('uuid', 'svc-app-whoami');
|
||||
$nestedDb = collect($servicePayload['databases'])->firstWhere('uuid', 'svc-db-postgres');
|
||||
$appPayload = $envPayload['applications'][0];
|
||||
$dbPayload = $envPayload['databases'][0];
|
||||
|
||||
expect($nestedApp)->not->toBeNull()
|
||||
->and($nestedApp['ports'])->toBe('80:80')
|
||||
->and($nestedApp['exposes'])->toBe('80')
|
||||
->and($nestedApp['environment_variables'])->toHaveCount(1)
|
||||
->and($nestedApp['environment_variables'][0]['value'])->toBe('nested-secret')
|
||||
->and($nestedApp['persistent_storages'])->toHaveCount(1)
|
||||
->and($nestedApp['file_storages'])->toHaveCount(1)
|
||||
->and($nestedApp['file_storages'][0]['content'])->toBe('{"nested":true}')
|
||||
->and($nestedDb)->not->toBeNull()
|
||||
->and($nestedDb['public_port'])->toBe(15432)
|
||||
->and($nestedDb['custom_type'])->toBe('postgresql')
|
||||
->and($nestedDb['persistent_storages'])->toHaveCount(1)
|
||||
->and($nestedDb['file_storages'])->toHaveCount(1)
|
||||
->and($nestedDb['scheduled_backups'])->toHaveCount(1)
|
||||
->and($nestedDb['scheduled_backups'][0]['uuid'])->toBe('svc-db-backup-1')
|
||||
->and($dbPayload['file_storages'])->toHaveCount(1)
|
||||
->and($dbPayload['file_storages'][0]['uuid'])->toBe('standalone-db-file-1')
|
||||
->and($appPayload['previews'])->toHaveCount(1)
|
||||
->and($appPayload['previews'][0]['uuid'])->toBe('preview-pr-42')
|
||||
->and($appPayload['previews'][0]['persistent_storages'])->toHaveCount(1)
|
||||
->and(collect($bundle['volume_backups'])->pluck('uuid')->all())->toContain('svc-app-vol-backup');
|
||||
|
||||
$originalServerUuid = $this->server->uuid;
|
||||
$originalAppUuid = $this->application->uuid;
|
||||
$originalDbUuid = $this->database->uuid;
|
||||
$originalServiceUuid = $this->service->uuid;
|
||||
|
||||
// Free server IP / UUIDs for re-import. Clear soft-deleted previews (unique FQDN)
|
||||
// without firing remote docker cleanup hooks.
|
||||
ApplicationPreview::withoutEvents(function () {
|
||||
ApplicationPreview::withTrashed()->get()->each->forceDelete();
|
||||
});
|
||||
ServiceApplication::withoutEvents(function () {
|
||||
ServiceApplication::withTrashed()->get()->each->forceDelete();
|
||||
});
|
||||
ServiceDatabase::withoutEvents(function () {
|
||||
ServiceDatabase::withTrashed()->get()->each->forceDelete();
|
||||
});
|
||||
DB::table('environment_variables')->delete();
|
||||
DB::table('local_file_volumes')->delete();
|
||||
DB::table('local_persistent_volumes')->delete();
|
||||
DB::table('scheduled_volume_backups')->delete();
|
||||
DB::table('scheduled_database_backups')->delete();
|
||||
DB::table('scheduled_tasks')->delete();
|
||||
DB::table('services')->delete();
|
||||
DB::table('applications')->delete();
|
||||
DB::table('standalone_postgresqls')->delete();
|
||||
DB::table('standalone_dockers')->delete();
|
||||
DB::table('servers')->delete();
|
||||
$this->privateKey->delete();
|
||||
Tag::query()->delete();
|
||||
|
||||
$result = $this->importer->import($bundle, teamId: $this->team->id, preserveUuids: true, adoptMode: true);
|
||||
|
||||
expect($result['created']['services'])->toBe(1)
|
||||
->and($result['server_uuid'])->toBe($originalServerUuid);
|
||||
|
||||
$importedService = Service::where('uuid', $originalServiceUuid)->first();
|
||||
expect($importedService)->not->toBeNull();
|
||||
|
||||
$importedSvcApp = ServiceApplication::where('uuid', 'svc-app-whoami')->first();
|
||||
expect($importedSvcApp)->not->toBeNull()
|
||||
->and($importedSvcApp->ports)->toBe('80:80')
|
||||
->and($importedSvcApp->exposes)->toBe('80')
|
||||
->and((bool) $importedSvcApp->is_gzip_enabled)->toBeFalse()
|
||||
->and((bool) $importedSvcApp->is_stripprefix_enabled)->toBeFalse()
|
||||
->and($importedSvcApp->environment_variables()->where('key', 'WHOAMI_NAME')->first()?->value)->toBe('nested-secret')
|
||||
->and($importedSvcApp->persistentStorages)->toHaveCount(1)
|
||||
->and($importedSvcApp->fileStorages)->toHaveCount(1)
|
||||
->and($importedSvcApp->fileStorages->first()->content)->toBe('{"nested":true}');
|
||||
|
||||
$importedSvcDb = ServiceDatabase::where('uuid', 'svc-db-postgres')->first();
|
||||
expect($importedSvcDb)->not->toBeNull()
|
||||
->and($importedSvcDb->public_port)->toBe(15432)
|
||||
->and($importedSvcDb->custom_type)->toBe('postgresql')
|
||||
->and($importedSvcDb->persistentStorages)->toHaveCount(1)
|
||||
->and($importedSvcDb->fileStorages)->toHaveCount(1)
|
||||
->and($importedSvcDb->scheduledBackups)->toHaveCount(1)
|
||||
->and($importedSvcDb->scheduledBackups->first()->uuid)->toBe('svc-db-backup-1');
|
||||
|
||||
$importedStandaloneDb = StandalonePostgresql::where('uuid', $originalDbUuid)->first();
|
||||
expect($importedStandaloneDb)->not->toBeNull()
|
||||
->and($importedStandaloneDb->fileStorages)->toHaveCount(1)
|
||||
->and($importedStandaloneDb->fileStorages->first()->uuid)->toBe('standalone-db-file-1');
|
||||
|
||||
$importedApp = Application::where('uuid', $originalAppUuid)->first();
|
||||
expect($importedApp)->not->toBeNull()
|
||||
->and($importedApp->previews)->toHaveCount(1)
|
||||
->and($importedApp->previews->first()->uuid)->toBe('preview-pr-42')
|
||||
->and($importedApp->previews->first()->persistentStorages)->toHaveCount(1);
|
||||
|
||||
$importedVolBackup = ScheduledVolumeBackup::where('uuid', 'svc-app-vol-backup')->first();
|
||||
expect($importedVolBackup)->not->toBeNull()
|
||||
->and($importedVolBackup->frequency)->toBe('0 4 * * *')
|
||||
->and((bool) $importedVolBackup->enabled)->toBeTrue();
|
||||
});
|
||||
|
||||
test('export refuses servers with additional destinations', function () {
|
||||
$extraDestination = StandaloneDocker::create([
|
||||
'name' => 'extra-net',
|
||||
'network' => 'extra-net-block',
|
||||
'server_id' => $this->server->id,
|
||||
]);
|
||||
|
||||
$this->application->additional_networks()->attach($extraDestination->id, [
|
||||
'server_id' => $this->server->id,
|
||||
'status' => 'running:healthy',
|
||||
]);
|
||||
|
||||
expect(fn () => $this->exporter->export($this->server))
|
||||
->toThrow(RuntimeException::class, 'additional destinations');
|
||||
});
|
||||
|
||||
test('system-wide gitlab apps are not exported and re-link on import by uuid', function () {
|
||||
$systemWide = new GitlabApp;
|
||||
$systemWide->forceFill([
|
||||
'name' => 'System Public GitLab',
|
||||
'api_url' => 'https://gitlab.com/api/v4',
|
||||
'html_url' => 'https://gitlab.com',
|
||||
'is_public' => true,
|
||||
'is_system_wide' => true,
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
$systemWide->uuid = 'system-gitlab-public';
|
||||
$systemWide->save();
|
||||
|
||||
$teamApp = new GitlabApp;
|
||||
$teamApp->forceFill([
|
||||
'name' => 'Team GitLab App',
|
||||
'api_url' => 'https://gitlab.com/api/v4',
|
||||
'html_url' => 'https://gitlab.com',
|
||||
'is_public' => false,
|
||||
'is_system_wide' => false,
|
||||
'team_id' => $this->team->id,
|
||||
'app_id' => '123',
|
||||
'app_secret' => 'secret',
|
||||
'oauth_id' => 1,
|
||||
'client_id' => 'client',
|
||||
'client_secret' => 'csecret',
|
||||
'group_name' => 'team',
|
||||
]);
|
||||
$teamApp->uuid = 'team-gitlab-app';
|
||||
$teamApp->save();
|
||||
|
||||
$this->application->source_type = GitlabApp::class;
|
||||
$this->application->source_id = $systemWide->id;
|
||||
$this->application->save();
|
||||
|
||||
Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'name' => 'team-gl-source-app',
|
||||
'git_repository' => 'https://gitlab.com/example/private',
|
||||
'git_branch' => 'main',
|
||||
'build_pack' => 'nixpacks',
|
||||
'ports_exposes' => '3000',
|
||||
'source_type' => GitlabApp::class,
|
||||
'source_id' => $teamApp->id,
|
||||
]);
|
||||
|
||||
$bundle = $this->exporter->export($this->server);
|
||||
|
||||
expect($bundle['gitlab_apps'])->toHaveCount(1)
|
||||
->and($bundle['gitlab_apps'][0]['uuid'])->toBe('team-gitlab-app')
|
||||
->and(collect($bundle['warnings'])->implode(' '))->toContain('system-wide GitLab App');
|
||||
|
||||
$this->service->forceDelete();
|
||||
Application::query()->forceDelete();
|
||||
$this->database->forceDelete();
|
||||
$this->server->forceDelete();
|
||||
$this->privateKey->delete();
|
||||
GitlabApp::where('uuid', 'team-gitlab-app')->delete();
|
||||
$systemWide->delete();
|
||||
|
||||
$targetTeam = Team::factory()->create();
|
||||
$targetSystemWide = new GitlabApp;
|
||||
$targetSystemWide->forceFill([
|
||||
'name' => 'System Public GitLab',
|
||||
'api_url' => 'https://gitlab.com/api/v4',
|
||||
'html_url' => 'https://gitlab.com',
|
||||
'is_public' => true,
|
||||
'is_system_wide' => true,
|
||||
'team_id' => $targetTeam->id,
|
||||
]);
|
||||
$targetSystemWide->uuid = 'system-gitlab-public';
|
||||
$targetSystemWide->save();
|
||||
|
||||
$result = $this->importer->import($bundle, teamId: $targetTeam->id, preserveUuids: true, adoptMode: true);
|
||||
|
||||
expect($result['created']['gitlab_apps'])->toBe(1);
|
||||
|
||||
$importedSystem = Application::where('name', 'my-app')->first();
|
||||
$importedTeam = Application::where('name', 'team-gl-source-app')->first();
|
||||
$importedGlTeam = GitlabApp::where('uuid', 'team-gitlab-app')->first();
|
||||
|
||||
expect($importedSystem)->not->toBeNull()
|
||||
->and($importedSystem->source_type)->toBe(GitlabApp::class)
|
||||
->and($importedSystem->source_id)->toBe($targetSystemWide->id)
|
||||
->and($importedTeam->source_id)->toBe($importedGlTeam->id)
|
||||
->and($importedGlTeam->is_system_wide)->toBeFalse()
|
||||
->and(GitlabApp::where('is_system_wide', true)->where('uuid', 'system-gitlab-public')->count())->toBe(1);
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Services\ServerTransfer\ServerTransferClaimer;
|
||||
use App\Services\ServerTransfer\ServerTransferMigrator;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true, 'fqdn' => 'https://coolify-a.test']);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $this->privateKey->id,
|
||||
'ip' => '10.88.0.10',
|
||||
'name' => 'migrate-me',
|
||||
]);
|
||||
$destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail();
|
||||
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$environment = $project->environments()->first()
|
||||
?? Environment::factory()->create(['project_id' => $project->id]);
|
||||
Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
'name' => 'app-on-server',
|
||||
'ports_exposes' => '3000',
|
||||
]);
|
||||
});
|
||||
|
||||
test('migrate exports imports via http and completes locally', function () {
|
||||
Http::fake([
|
||||
'http://target.test/api/v1/servers/import' => Http::response([
|
||||
'dry_run' => false,
|
||||
'server_uuid' => $this->server->uuid,
|
||||
'claimed' => true,
|
||||
'export_id' => 'remote-export',
|
||||
'created' => ['applications' => 1],
|
||||
'warnings' => [],
|
||||
], 201),
|
||||
]);
|
||||
|
||||
$result = app(ServerTransferMigrator::class)->migrate(
|
||||
server: $this->server,
|
||||
targetUrl: 'http://target.test',
|
||||
targetToken: 'target-token-xyz',
|
||||
writeRemote: false,
|
||||
);
|
||||
|
||||
expect($result['server_uuid'])->toBe($this->server->uuid)
|
||||
->and($result['target_url'])->toBe('http://target.test')
|
||||
->and($result['import']['claimed'])->toBeTrue()
|
||||
->and($result['message'])->toContain('migrated');
|
||||
|
||||
$this->server->refresh();
|
||||
expect(data_get($this->server->server_metadata, 'transfer.status'))->toBe('transferred')
|
||||
->and((bool) $this->server->settings->force_disabled)->toBeTrue();
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return $request->url() === 'http://target.test/api/v1/servers/import'
|
||||
&& $request->hasHeader('Authorization', 'Bearer target-token-xyz')
|
||||
&& data_get($request->data(), 'claim') === true
|
||||
&& data_get($request->data(), 'bundle.server.uuid') === $this->server->uuid;
|
||||
});
|
||||
});
|
||||
|
||||
test('migrate rewrites localhost target when running in docker style env', function () {
|
||||
// Simulate container: create a temp marker if missing is hard; instead assert host rewrite helper via migrate call
|
||||
// with Http fake matching host.docker.internal when /.dockerenv exists — skip if not in docker.
|
||||
if (! file_exists('/.dockerenv') && ! is_file('/run/.containerenv')) {
|
||||
expect(true)->toBeTrue();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Http::fake([
|
||||
'http://host.docker.internal:8001/api/v1/servers/import' => Http::response([
|
||||
'dry_run' => false,
|
||||
'server_uuid' => $this->server->uuid,
|
||||
'claimed' => true,
|
||||
'warnings' => [],
|
||||
], 201),
|
||||
]);
|
||||
|
||||
app(ServerTransferMigrator::class)->migrate(
|
||||
$this->server,
|
||||
'http://localhost:8001',
|
||||
'token',
|
||||
);
|
||||
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), 'host.docker.internal:8001'));
|
||||
});
|
||||
|
||||
test('migrate fails clearly when target is unreachable', function () {
|
||||
Http::fake([
|
||||
'http://down.test/*' => Http::failedConnection(),
|
||||
]);
|
||||
|
||||
expect(fn () => app(ServerTransferMigrator::class)->migrate(
|
||||
$this->server,
|
||||
'http://down.test',
|
||||
'token',
|
||||
))->toThrow(RuntimeException::class, 'Could not reach target');
|
||||
});
|
||||
|
||||
test('migrate fails when target returns error', function () {
|
||||
Http::fake([
|
||||
'http://target.test/api/v1/servers/import' => Http::response([
|
||||
'message' => 'A server with IP/domain already exists',
|
||||
], 422),
|
||||
]);
|
||||
|
||||
expect(fn () => app(ServerTransferMigrator::class)->migrate(
|
||||
$this->server,
|
||||
'http://target.test',
|
||||
'token',
|
||||
))->toThrow(RuntimeException::class, 'Target import failed');
|
||||
|
||||
// Source must remain unmanaged-away only after successful remote import+complete.
|
||||
$this->server->refresh();
|
||||
expect(data_get($this->server->server_metadata, 'transfer.status'))->not->toBe('transferred')
|
||||
->and((bool) $this->server->settings->force_disabled)->toBeFalse();
|
||||
});
|
||||
|
||||
test('migrate surfaces recovery guidance when complete fails after successful remote import', function () {
|
||||
Http::fake([
|
||||
'http://target.test/api/v1/servers/import' => Http::response([
|
||||
'dry_run' => false,
|
||||
'server_uuid' => $this->server->uuid,
|
||||
'claimed' => true,
|
||||
'warnings' => [],
|
||||
], 201),
|
||||
]);
|
||||
|
||||
$claimer = Mockery::mock(ServerTransferClaimer::class)->makePartial();
|
||||
$claimer->shouldReceive('markTransferred')
|
||||
->once()
|
||||
->andThrow(new RuntimeException('simulated complete failure'));
|
||||
app()->instance(ServerTransferClaimer::class, $claimer);
|
||||
|
||||
expect(fn () => app(ServerTransferMigrator::class)->migrate(
|
||||
$this->server,
|
||||
'http://target.test',
|
||||
'token',
|
||||
))->toThrow(RuntimeException::class, 'Retry complete');
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Server\ValidateServer;
|
||||
use App\Jobs\ValidateAndInstallServerJob;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
$this->team = Team::factory()->create();
|
||||
$this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $this->privateKey->id,
|
||||
]);
|
||||
});
|
||||
|
||||
test('transferred servers cannot be validated', function () {
|
||||
expect($this->server->canBeValidated())->toBeTrue()
|
||||
->and($this->server->isTransferredAway())->toBeFalse();
|
||||
|
||||
$this->server->server_metadata = [
|
||||
'transfer' => ['status' => 'transferred'],
|
||||
];
|
||||
$this->server->save();
|
||||
$this->server->forceDisableServer();
|
||||
|
||||
expect($this->server->fresh()->isTransferredAway())->toBeTrue()
|
||||
->and($this->server->fresh()->canBeValidated())->toBeFalse();
|
||||
});
|
||||
|
||||
test('forceEnableServer does not clear transferred servers', function () {
|
||||
$this->server->server_metadata = [
|
||||
'transfer' => ['status' => 'transferred'],
|
||||
];
|
||||
$this->server->save();
|
||||
$this->server->forceDisableServer();
|
||||
|
||||
$this->server->forceEnableServer();
|
||||
|
||||
expect((bool) $this->server->fresh()->settings->force_disabled)->toBeTrue()
|
||||
->and($this->server->fresh()->canBeValidated())->toBeFalse();
|
||||
});
|
||||
|
||||
test('ValidateServer action rejects transferred servers', function () {
|
||||
$this->server->server_metadata = [
|
||||
'transfer' => ['status' => 'transferred'],
|
||||
];
|
||||
$this->server->save();
|
||||
|
||||
expect(fn () => ValidateServer::run($this->server))
|
||||
->toThrow(Exception::class, 'transferred');
|
||||
});
|
||||
|
||||
test('ValidateAndInstallServerJob no-ops for transferred servers', function () {
|
||||
$this->server->server_metadata = [
|
||||
'transfer' => ['status' => 'transferred'],
|
||||
];
|
||||
$this->server->save();
|
||||
|
||||
(new ValidateAndInstallServerJob($this->server))->handle();
|
||||
|
||||
expect((bool) $this->server->fresh()->is_validating)->toBeFalse()
|
||||
->and((string) $this->server->fresh()->validation_logs)->toContain('transferred');
|
||||
});
|
||||
Reference in New Issue
Block a user