fix(servers): retain cloud instances awaiting IPs
Persist DigitalOcean, Hetzner, and Vultr servers before public IP assignment, then backfill placeholder addresses from provider state. Treat partial Sentinel snapshots as non-authoritative and document the destinations API with OpenAPI schemas.
This commit is contained in:
parent
d3fbb32c52
commit
e01b8a057e
29 changed files with 1275 additions and 59 deletions
|
|
@ -10,6 +10,7 @@
|
|||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class DestinationsController extends Controller
|
||||
{
|
||||
|
|
@ -59,6 +60,22 @@ private function findDestinationForTeam(int $teamId, string $uuid): StandaloneDo
|
|||
?? SwarmDocker::with('server:id,uuid,team_id')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->whereUuid($uuid)->firstOrFail();
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List destinations',
|
||||
description: 'List all Docker network destinations for the authenticated team.',
|
||||
path: '/destinations',
|
||||
operationId: 'list-destinations',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Destinations'],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Destinations for the authenticated team.',
|
||||
content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/Destination')),
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
],
|
||||
)]
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = $this->teamIdOrAbort();
|
||||
|
|
@ -74,6 +91,26 @@ public function index(Request $request): JsonResponse
|
|||
);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List destinations by server',
|
||||
description: 'List Docker network destinations attached to a server owned by the authenticated team.',
|
||||
path: '/servers/{server_uuid}/destinations',
|
||||
operationId: 'list-server-destinations',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Destinations'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'server_uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Destinations attached to the server.',
|
||||
content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/Destination')),
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
],
|
||||
)]
|
||||
public function index_by_server(Request $request, string $server_uuid): JsonResponse
|
||||
{
|
||||
$teamId = $this->teamIdOrAbort();
|
||||
|
|
@ -89,6 +126,26 @@ public function index_by_server(Request $request, string $server_uuid): JsonResp
|
|||
return response()->json($list->map(fn ($destination) => $this->transform($destination))->values());
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get destination',
|
||||
description: 'Get a Docker network destination by UUID.',
|
||||
path: '/destinations/{uuid}',
|
||||
operationId: 'get-destination-by-uuid',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Destinations'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Destination UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Destination details.',
|
||||
content: new OA\JsonContent(ref: '#/components/schemas/Destination'),
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
],
|
||||
)]
|
||||
public function show(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$teamId = $this->teamIdOrAbort();
|
||||
|
|
@ -100,6 +157,40 @@ public function show(Request $request, string $uuid): JsonResponse
|
|||
return response()->json($this->transform($destination));
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Create destination',
|
||||
description: 'Create a Docker network destination on a server owned by the authenticated team.',
|
||||
path: '/servers/{server_uuid}/destinations',
|
||||
operationId: 'create-server-destination',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Destinations'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'server_uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['network'],
|
||||
properties: [
|
||||
new OA\Property(property: 'name', type: 'string', maxLength: 255),
|
||||
new OA\Property(property: 'network', type: 'string', maxLength: 255, pattern: '^[a-zA-Z0-9][a-zA-Z0-9._-]*$'),
|
||||
new OA\Property(property: 'type', type: 'string', enum: ['standalone', 'swarm']),
|
||||
],
|
||||
type: 'object',
|
||||
),
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 201,
|
||||
description: 'Destination created.',
|
||||
content: new OA\JsonContent(ref: '#/components/schemas/Destination'),
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 409, description: 'A destination with this network already exists.'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
],
|
||||
)]
|
||||
public function create(Request $request, string $server_uuid): JsonResponse
|
||||
{
|
||||
$teamId = $this->teamIdOrAbort();
|
||||
|
|
@ -183,6 +274,32 @@ private function isUniqueConstraintViolation(QueryException $exception): bool
|
|||
|| in_array($driverCode, ['19', '1062', '2067'], true);
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
summary: 'Delete destination',
|
||||
description: 'Delete an unused Docker network destination.',
|
||||
path: '/destinations/{uuid}',
|
||||
operationId: 'delete-destination-by-uuid',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Destinations'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Destination UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Destination deleted.',
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property(property: 'message', type: 'string', example: 'Deleted.'),
|
||||
],
|
||||
type: 'object',
|
||||
),
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 409, description: 'Destination has attached resources.'),
|
||||
],
|
||||
)]
|
||||
public function delete(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$teamId = $this->teamIdOrAbort();
|
||||
|
|
|
|||
|
|
@ -143,8 +143,9 @@ private function shouldDispatchUpdate(Server $server, array $data): bool
|
|||
* health checks can flap between starting/healthy/unhealthy while the
|
||||
* container lifecycle state remains unchanged. Both would otherwise defeat
|
||||
* the hash and dispatch DB-heavy PushServerUpdateJob instances too often.
|
||||
* The force window still refreshes full state periodically. Sorted by name
|
||||
* so container ordering from Sentinel does not affect the hash.
|
||||
* The snapshot completeness flag is included so a complete snapshot always
|
||||
* dispatches after a partial snapshot. Sorted by name so container ordering
|
||||
* from Sentinel does not affect the hash.
|
||||
*/
|
||||
private function containerStateHash(array $data): string
|
||||
{
|
||||
|
|
@ -157,6 +158,14 @@ private function containerStateHash(array $data): string
|
|||
->values()
|
||||
->all();
|
||||
|
||||
return hash('xxh128', json_encode($containers));
|
||||
return hash('xxh128', json_encode([
|
||||
'snapshot_complete' => $this->isCompleteSnapshot($data),
|
||||
'containers' => $containers,
|
||||
]));
|
||||
}
|
||||
|
||||
private function isCompleteSnapshot(array $data): bool
|
||||
{
|
||||
return data_get($data, 'snapshot.complete', true) !== false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -311,6 +311,10 @@ public function handle()
|
|||
}
|
||||
}
|
||||
|
||||
if (! $this->isCompleteSnapshot()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->updateProxyStatus();
|
||||
|
||||
$this->updateNotFoundApplicationStatus();
|
||||
|
|
@ -329,6 +333,11 @@ public function handle()
|
|||
$this->checkLogDrainContainer();
|
||||
}
|
||||
|
||||
private function isCompleteSnapshot(): bool
|
||||
{
|
||||
return data_get($this->data, 'snapshot.complete', true) !== false;
|
||||
}
|
||||
|
||||
private function loadApplications(): Collection
|
||||
{
|
||||
[$standaloneDockerIds, $swarmDockerIds] = $this->serverDestinationIds();
|
||||
|
|
@ -700,6 +709,9 @@ private function updateDatabaseStatus(string $databaseUuid, string $containerSta
|
|||
$database->status = $containerStatus;
|
||||
$database->save();
|
||||
}
|
||||
if (! $this->isCompleteSnapshot()) {
|
||||
return;
|
||||
}
|
||||
if ($this->isRunning($containerStatus) && $tcpProxy) {
|
||||
$tcpProxyContainerFound = $this->containers->filter(function ($value, $key) use ($databaseUuid) {
|
||||
return data_get($value, 'name') === "$databaseUuid-proxy" && data_get($value, 'state') === 'running';
|
||||
|
|
|
|||
|
|
@ -402,11 +402,11 @@ public function clearCloudInitScript(): void
|
|||
}
|
||||
|
||||
/**
|
||||
* @return array{droplet: array, ip: string|null}
|
||||
* Create the droplet on DigitalOcean and return the raw droplet payload.
|
||||
* The public IP may not be assigned yet at this point.
|
||||
*/
|
||||
private function createDigitalOceanDroplet(string $token): array
|
||||
private function createDigitalOceanDroplet(DigitalOceanService $digitalOceanService): array
|
||||
{
|
||||
$digitalOceanService = new DigitalOceanService($token);
|
||||
$privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id);
|
||||
$md5Fingerprint = PrivateKey::generateMd5Fingerprint($privateKey->private_key);
|
||||
|
||||
|
|
@ -442,14 +442,7 @@ private function createDigitalOceanDroplet(string $token): array
|
|||
$params['user_data'] = $this->cloud_init_script;
|
||||
}
|
||||
|
||||
$droplet = $digitalOceanService->createDroplet($params);
|
||||
$droplet = $digitalOceanService->waitForPublicIp($droplet, true, $this->enable_ipv6);
|
||||
$ipAddress = $digitalOceanService->getPublicIpAddress($droplet, true, $this->enable_ipv6);
|
||||
|
||||
return [
|
||||
'droplet' => $droplet,
|
||||
'ip' => $ipAddress,
|
||||
];
|
||||
return $digitalOceanService->createDroplet($params);
|
||||
}
|
||||
|
||||
public function submit()
|
||||
|
|
@ -473,17 +466,14 @@ public function submit()
|
|||
]);
|
||||
}
|
||||
|
||||
$result = $this->createDigitalOceanDroplet($this->getDigitalOceanToken());
|
||||
$droplet = $result['droplet'];
|
||||
$ipAddress = $result['ip'];
|
||||
|
||||
if (! $ipAddress) {
|
||||
throw new \Exception('No public IP address available for the new droplet.');
|
||||
}
|
||||
$digitalOceanService = new DigitalOceanService($this->getDigitalOceanToken());
|
||||
$droplet = $this->createDigitalOceanDroplet($digitalOceanService);
|
||||
|
||||
// Persist the server immediately so the droplet is always tracked
|
||||
// in Coolify, even if waiting for the public IP fails below.
|
||||
$server = Server::create([
|
||||
'name' => strtolower(trim($this->server_name)),
|
||||
'ip' => $ipAddress,
|
||||
'ip' => Server::PLACEHOLDER_IP,
|
||||
'user' => 'root',
|
||||
'port' => 22,
|
||||
'team_id' => currentTeam()->id,
|
||||
|
|
@ -497,6 +487,20 @@ public function submit()
|
|||
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
|
||||
$server->save();
|
||||
|
||||
try {
|
||||
$droplet = $digitalOceanService->waitForPublicIp($droplet, true, $this->enable_ipv6);
|
||||
$ipAddress = $digitalOceanService->getPublicIpAddress($droplet, true, $this->enable_ipv6);
|
||||
if ($ipAddress) {
|
||||
$server->update([
|
||||
'ip' => $ipAddress,
|
||||
'digitalocean_droplet_status' => $droplet['status'] ?? $server->digitalocean_droplet_status,
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Non-fatal: the server page polling backfills the IP later.
|
||||
report($e);
|
||||
}
|
||||
|
||||
if ($this->from_onboarding) {
|
||||
currentTeam()->update([
|
||||
'show_boarding' => false,
|
||||
|
|
|
|||
|
|
@ -717,20 +717,19 @@ public function submit()
|
|||
$ipAddress = $hetznerServer['public_net']['ipv6']['ip'];
|
||||
}
|
||||
|
||||
if (! $ipAddress) {
|
||||
throw new \Exception('No public IP address available. Enable at least one of IPv4 or IPv6.');
|
||||
}
|
||||
|
||||
// Create server in Coolify database
|
||||
// Create server in Coolify database immediately so the Hetzner
|
||||
// server is always tracked, even when no IP is assigned yet —
|
||||
// the server page polling backfills the placeholder IP later.
|
||||
$server = Server::create([
|
||||
'name' => $this->server_name,
|
||||
'ip' => $ipAddress,
|
||||
'ip' => $ipAddress ?? Server::PLACEHOLDER_IP,
|
||||
'user' => 'root',
|
||||
'port' => 22,
|
||||
'team_id' => currentTeam()->id,
|
||||
'private_key_id' => $this->private_key_id,
|
||||
'cloud_provider_token_id' => $this->selected_token_id,
|
||||
'hetzner_server_id' => $hetznerServer['id'],
|
||||
'hetzner_server_status' => $hetznerServer['status'] ?? null,
|
||||
]);
|
||||
|
||||
$server->proxy->set('status', 'exited');
|
||||
|
|
|
|||
|
|
@ -438,7 +438,7 @@ public function submit(): mixed
|
|||
|
||||
$vultrService = new VultrService($this->getVultrToken());
|
||||
$vultrInstance = $this->createVultrServer($this->getVultrToken());
|
||||
$ipAddress = $vultrService->getPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6) ?? '0.0.0.0';
|
||||
$ipAddress = $vultrService->getPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6) ?? Server::PLACEHOLDER_IP;
|
||||
|
||||
$server = Server::create([
|
||||
'name' => strtolower(trim($this->server_name)),
|
||||
|
|
@ -452,13 +452,18 @@ public function submit(): mixed
|
|||
'vultr_instance_status' => $vultrInstance['status'] ?? null,
|
||||
]);
|
||||
|
||||
$vultrInstance = $vultrService->waitForPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6);
|
||||
$assignedIpAddress = $vultrService->getPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6);
|
||||
if ($assignedIpAddress && $assignedIpAddress !== $server->ip) {
|
||||
$server->update([
|
||||
'ip' => $assignedIpAddress,
|
||||
'vultr_instance_status' => $vultrInstance['status'] ?? $server->vultr_instance_status,
|
||||
]);
|
||||
try {
|
||||
$vultrInstance = $vultrService->waitForPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6);
|
||||
$assignedIpAddress = $vultrService->getPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6);
|
||||
if ($assignedIpAddress && $assignedIpAddress !== $server->ip) {
|
||||
$server->update([
|
||||
'ip' => $assignedIpAddress,
|
||||
'vultr_instance_status' => $vultrInstance['status'] ?? $server->vultr_instance_status,
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Non-fatal: the server page polling backfills the IP later.
|
||||
report($e);
|
||||
}
|
||||
|
||||
$server->proxy->set('status', 'exited');
|
||||
|
|
|
|||
|
|
@ -488,6 +488,11 @@ public function checkHetznerServerStatus(bool $manual = false)
|
|||
$this->server->hetzner_server_status = $this->hetznerServerStatus;
|
||||
$this->server->update(['hetzner_server_status' => $this->hetznerServerStatus]);
|
||||
}
|
||||
|
||||
$assignedIp = data_get($serverData, 'public_net.ipv4.ip') ?? data_get($serverData, 'public_net.ipv6.ip');
|
||||
if ($this->server->backfillPlaceholderIp($assignedIp)) {
|
||||
$this->ip = $this->server->ip;
|
||||
}
|
||||
if ($manual) {
|
||||
$this->dispatch('success', 'Server status refreshed: '.ucfirst($this->hetznerServerStatus ?? 'unknown'));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -112,6 +112,13 @@ class Server extends BaseModel
|
|||
{
|
||||
use ClearsGlobalSearchCache, HasFactory, HasMetrics, SchemalessAttributesTrait, SoftDeletes;
|
||||
|
||||
/**
|
||||
* Sentinel IP for servers that do not have a real address yet
|
||||
* (cloud provisioning in progress or parked as unreachable).
|
||||
* Scheduled jobs skip these servers via skipServer().
|
||||
*/
|
||||
public const PLACEHOLDER_IP = '1.2.3.4';
|
||||
|
||||
public static $batch_counter = 0;
|
||||
|
||||
/**
|
||||
|
|
@ -307,6 +314,29 @@ public function type()
|
|||
return 'server';
|
||||
}
|
||||
|
||||
public function hasPlaceholderIp(): bool
|
||||
{
|
||||
// Cast: the saving hook stores the ip as a Stringable in memory.
|
||||
$ip = (string) $this->ip;
|
||||
|
||||
return blank($ip) || in_array($ip, [self::PLACEHOLDER_IP, '0.0.0.0', '::'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a placeholder IP with the real address once the cloud
|
||||
* provider reports one. Returns true when the IP was updated.
|
||||
*/
|
||||
public function backfillPlaceholderIp(?string $ip): bool
|
||||
{
|
||||
if ($ip && $this->hasPlaceholderIp()) {
|
||||
$this->update(['ip' => $ip]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function refreshVultrState(): ?string
|
||||
{
|
||||
if (! $this->vultr_instance_id || ! $this->cloudProviderToken) {
|
||||
|
|
@ -339,8 +369,7 @@ public function refreshVultrState(): ?string
|
|||
$updates['vultr_instance_status'] = $status;
|
||||
}
|
||||
|
||||
$hasPlaceholderIp = blank($this->ip) || in_array($this->ip, ['0.0.0.0', '::'], true);
|
||||
if ($hasPlaceholderIp && $publicIp) {
|
||||
if ($this->hasPlaceholderIp() && $publicIp) {
|
||||
$updates['ip'] = $publicIp;
|
||||
}
|
||||
|
||||
|
|
@ -388,7 +417,7 @@ public function refreshDigitalOceanState(): ?string
|
|||
$ip = $digitalOceanService->getPublicIpAddress($droplet);
|
||||
|
||||
$updates = ['digitalocean_droplet_status' => $status];
|
||||
if ($ip && $ip !== $this->ip) {
|
||||
if ($ip && $this->hasPlaceholderIp()) {
|
||||
$updates['ip'] = $ip;
|
||||
}
|
||||
|
||||
|
|
@ -1176,7 +1205,7 @@ public function isProxyShouldRun()
|
|||
|
||||
public function skipServer()
|
||||
{
|
||||
if ($this->ip === '1.2.3.4') {
|
||||
if ($this->hasPlaceholderIp()) {
|
||||
return true;
|
||||
}
|
||||
if ($this->settings->force_disabled === true) {
|
||||
|
|
@ -1188,7 +1217,7 @@ public function skipServer()
|
|||
|
||||
public function isFunctional()
|
||||
{
|
||||
$isFunctional = data_get($this->settings, 'is_reachable') && data_get($this->settings, 'is_usable') && data_get($this->settings, 'force_disabled') === false && $this->ip !== '1.2.3.4';
|
||||
$isFunctional = data_get($this->settings, 'is_reachable') && data_get($this->settings, 'is_usable') && data_get($this->settings, 'force_disabled') === false && ! $this->hasPlaceholderIp();
|
||||
|
||||
if ($isFunctional === false) {
|
||||
Storage::disk('ssh-mux')->delete($this->muxFilename());
|
||||
|
|
|
|||
|
|
@ -7,7 +7,22 @@
|
|||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
#[OA\Schema(
|
||||
schema: 'Destination',
|
||||
description: 'A Docker network destination attached to a server.',
|
||||
type: 'object',
|
||||
properties: [
|
||||
new OA\Property(property: 'uuid', type: 'string'),
|
||||
new OA\Property(property: 'name', type: 'string'),
|
||||
new OA\Property(property: 'network', type: 'string'),
|
||||
new OA\Property(property: 'type', type: 'string', enum: ['standalone', 'swarm']),
|
||||
new OA\Property(property: 'server_uuid', type: 'string'),
|
||||
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
|
||||
new OA\Property(property: 'updated_at', type: 'string', format: 'date-time'),
|
||||
],
|
||||
)]
|
||||
class StandaloneDocker extends BaseModel
|
||||
{
|
||||
use HasFactory;
|
||||
|
|
@ -23,6 +38,10 @@ protected static function boot()
|
|||
{
|
||||
parent::boot();
|
||||
static::created(function ($newStandaloneDocker) {
|
||||
if (app()->runningUnitTests()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$server = $newStandaloneDocker->server;
|
||||
$safeNetwork = escapeshellarg($newStandaloneDocker->network);
|
||||
instant_remote_process([
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ public function up(): void
|
|||
|
||||
DB::table('cloud_init_scripts')
|
||||
->whereNull('uuid')
|
||||
->orderBy('id')
|
||||
->lazyById()
|
||||
->each(function (object $script): void {
|
||||
DB::table('cloud_init_scripts')
|
||||
->where('id', $script->id)
|
||||
|
|
|
|||
291
openapi.json
291
openapi.json
|
|
@ -8059,6 +8059,260 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"\/destinations": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Destinations"
|
||||
],
|
||||
"summary": "List destinations",
|
||||
"description": "List all Docker network destinations for the authenticated team.",
|
||||
"operationId": "list-destinations",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Destinations for the authenticated team.",
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#\/components\/schemas\/Destination"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/servers\/{server_uuid}\/destinations": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Destinations"
|
||||
],
|
||||
"summary": "List destinations by server",
|
||||
"description": "List Docker network destinations attached to a server owned by the authenticated team.",
|
||||
"operationId": "list-server-destinations",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "server_uuid",
|
||||
"in": "path",
|
||||
"description": "Server UUID",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Destinations attached to the server.",
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#\/components\/schemas\/Destination"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"tags": [
|
||||
"Destinations"
|
||||
],
|
||||
"summary": "Create destination",
|
||||
"description": "Create a Docker network destination on a server owned by the authenticated team.",
|
||||
"operationId": "create-server-destination",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "server_uuid",
|
||||
"in": "path",
|
||||
"description": "Server UUID",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"required": [
|
||||
"network"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"maxLength": 255
|
||||
},
|
||||
"network": {
|
||||
"type": "string",
|
||||
"maxLength": 255,
|
||||
"pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"standalone",
|
||||
"swarm"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Destination created.",
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"$ref": "#\/components\/schemas\/Destination"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
},
|
||||
"409": {
|
||||
"description": "A destination with this network already exists."
|
||||
},
|
||||
"422": {
|
||||
"$ref": "#\/components\/responses\/422"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/destinations\/{uuid}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Destinations"
|
||||
],
|
||||
"summary": "Get destination",
|
||||
"description": "Get a Docker network destination by UUID.",
|
||||
"operationId": "get-destination-by-uuid",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "uuid",
|
||||
"in": "path",
|
||||
"description": "Destination UUID",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Destination details.",
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"$ref": "#\/components\/schemas\/Destination"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Destinations"
|
||||
],
|
||||
"summary": "Delete destination",
|
||||
"description": "Delete an unused Docker network destination.",
|
||||
"operationId": "delete-destination-by-uuid",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "uuid",
|
||||
"in": "path",
|
||||
"description": "Destination UUID",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Destination deleted.",
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string",
|
||||
"example": "Deleted."
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
},
|
||||
"409": {
|
||||
"description": "Destination has attached resources."
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/digitalocean\/regions": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
|
@ -15514,6 +15768,39 @@
|
|||
},
|
||||
"type": "object"
|
||||
},
|
||||
"Destination": {
|
||||
"description": "A Docker network destination attached to a server.",
|
||||
"properties": {
|
||||
"uuid": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"network": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"standalone",
|
||||
"swarm"
|
||||
]
|
||||
},
|
||||
"server_uuid": {
|
||||
"type": "string"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"Tag": {
|
||||
"description": "Tag model",
|
||||
"properties": {
|
||||
|
|
@ -15754,6 +16041,10 @@
|
|||
"name": "Deployments",
|
||||
"description": "Deployments"
|
||||
},
|
||||
{
|
||||
"name": "Destinations",
|
||||
"description": "Destinations"
|
||||
},
|
||||
{
|
||||
"name": "DigitalOcean",
|
||||
"description": "DigitalOcean"
|
||||
|
|
|
|||
190
openapi.yaml
190
openapi.yaml
|
|
@ -5232,6 +5232,170 @@ paths:
|
|||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
/destinations:
|
||||
get:
|
||||
tags:
|
||||
- Destinations
|
||||
summary: 'List destinations'
|
||||
description: 'List all Docker network destinations for the authenticated team.'
|
||||
operationId: list-destinations
|
||||
responses:
|
||||
'200':
|
||||
description: 'Destinations for the authenticated team.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Destination'
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
'/servers/{server_uuid}/destinations':
|
||||
get:
|
||||
tags:
|
||||
- Destinations
|
||||
summary: 'List destinations by server'
|
||||
description: 'List Docker network destinations attached to a server owned by the authenticated team.'
|
||||
operationId: list-server-destinations
|
||||
parameters:
|
||||
-
|
||||
name: server_uuid
|
||||
in: path
|
||||
description: 'Server UUID'
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: 'Destinations attached to the server.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Destination'
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
post:
|
||||
tags:
|
||||
- Destinations
|
||||
summary: 'Create destination'
|
||||
description: 'Create a Docker network destination on a server owned by the authenticated team.'
|
||||
operationId: create-server-destination
|
||||
parameters:
|
||||
-
|
||||
name: server_uuid
|
||||
in: path
|
||||
description: 'Server UUID'
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
required:
|
||||
- network
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
maxLength: 255
|
||||
network:
|
||||
type: string
|
||||
maxLength: 255
|
||||
pattern: '^[a-zA-Z0-9][a-zA-Z0-9._-]*$'
|
||||
type:
|
||||
type: string
|
||||
enum: [standalone, swarm]
|
||||
type: object
|
||||
responses:
|
||||
'201':
|
||||
description: 'Destination created.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Destination'
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
'409':
|
||||
description: 'A destination with this network already exists.'
|
||||
'422':
|
||||
$ref: '#/components/responses/422'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
'/destinations/{uuid}':
|
||||
get:
|
||||
tags:
|
||||
- Destinations
|
||||
summary: 'Get destination'
|
||||
description: 'Get a Docker network destination by UUID.'
|
||||
operationId: get-destination-by-uuid
|
||||
parameters:
|
||||
-
|
||||
name: uuid
|
||||
in: path
|
||||
description: 'Destination UUID'
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: 'Destination details.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Destination'
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
delete:
|
||||
tags:
|
||||
- Destinations
|
||||
summary: 'Delete destination'
|
||||
description: 'Delete an unused Docker network destination.'
|
||||
operationId: delete-destination-by-uuid
|
||||
parameters:
|
||||
-
|
||||
name: uuid
|
||||
in: path
|
||||
description: 'Destination UUID'
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: 'Destination deleted.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
message: { type: string, example: Deleted. }
|
||||
type: object
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
'409':
|
||||
description: 'Destination has attached resources.'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
/digitalocean/regions:
|
||||
get:
|
||||
tags:
|
||||
|
|
@ -9958,6 +10122,29 @@ components:
|
|||
type: string
|
||||
description: 'The date and time when the service was deleted.'
|
||||
type: object
|
||||
Destination:
|
||||
description: 'A Docker network destination attached to a server.'
|
||||
properties:
|
||||
uuid:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
network:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
enum:
|
||||
- standalone
|
||||
- swarm
|
||||
server_uuid:
|
||||
type: string
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
type: object
|
||||
Tag:
|
||||
description: 'Tag model'
|
||||
properties:
|
||||
|
|
@ -10117,6 +10304,9 @@ tags:
|
|||
-
|
||||
name: Deployments
|
||||
description: Deployments
|
||||
-
|
||||
name: Destinations
|
||||
description: Destinations
|
||||
-
|
||||
name: DigitalOcean
|
||||
description: DigitalOcean
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
<h1>Server</h1>
|
||||
<div class="pt-2 pb-4 md:pb-10">
|
||||
<div class="flex-col md:flex-row flex gap-2">
|
||||
<div data-testid="server-subtitle" class="text-xs lg:text-sm min-w-0 truncate">
|
||||
<div data-testid="server-subtitle" class="text-xs lg:text-sm min-w-0 truncate text-neutral-600 dark:text-neutral-400">
|
||||
{{ data_get($server, 'name') }}
|
||||
</div>
|
||||
@php
|
||||
|
|
|
|||
|
|
@ -102,6 +102,26 @@ function destinationsApiToken(User $user, Team $team, array $abilities): string
|
|||
});
|
||||
|
||||
describe('POST /api/v1/servers/{server_uuid}/destinations', function () {
|
||||
test('creates a standalone destination', function () {
|
||||
$response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/servers/{$this->server->uuid}/destinations", [
|
||||
'name' => 'API Standalone',
|
||||
'network' => 'api-standalone-network',
|
||||
]);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJson([
|
||||
'name' => 'API Standalone',
|
||||
'network' => 'api-standalone-network',
|
||||
'type' => 'standalone',
|
||||
'server_uuid' => $this->server->uuid,
|
||||
]);
|
||||
|
||||
expect(StandaloneDocker::where('server_id', $this->server->id)
|
||||
->where('network', 'api-standalone-network')
|
||||
->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('requires a write token', function () {
|
||||
$readOnlyToken = destinationsApiToken($this->user, $this->team, ['read']);
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,10 @@
|
|||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::updateOrCreate(['id' => 0], ['is_api_enabled' => true]);
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['is_api_enabled' => true],
|
||||
));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Server\New\ByDigitalOcean;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
|
@ -25,6 +29,88 @@
|
|||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->digitalOceanToken = CloudProviderToken::create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'digitalocean',
|
||||
'token' => 'test-digitalocean-token',
|
||||
'name' => 'Test DigitalOcean Token',
|
||||
]);
|
||||
|
||||
$this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
|
||||
});
|
||||
|
||||
function submitDigitalOceanServer(): void
|
||||
{
|
||||
Livewire::test(ByDigitalOcean::class, ['selectedTokenUuid' => test()->digitalOceanToken->uuid])
|
||||
->assertSet('current_step', 2)
|
||||
->set('server_name', 'test-do-server')
|
||||
->set('selected_region', 'nyc1')
|
||||
->set('selected_size', 's-1vcpu-1gb')
|
||||
->set('selected_image', 'ubuntu-24-04-x64')
|
||||
->set('private_key_id', test()->privateKey->id)
|
||||
->call('submit')
|
||||
->assertHasNoErrors();
|
||||
}
|
||||
|
||||
it('persists the server with a placeholder IP when waiting for the droplet IP fails', function () {
|
||||
Http::fake([
|
||||
'https://api.digitalocean.com/v2/account/keys' => Http::response([
|
||||
'ssh_key' => ['id' => 123],
|
||||
], 201),
|
||||
'https://api.digitalocean.com/v2/account/keys*' => Http::response([
|
||||
'ssh_keys' => [],
|
||||
], 200),
|
||||
'https://api.digitalocean.com/v2/droplets' => Http::response([
|
||||
'droplet' => ['id' => 555, 'status' => 'new'],
|
||||
], 202),
|
||||
'https://api.digitalocean.com/v2/droplets/555' => Http::response(['message' => 'server error'], 500),
|
||||
]);
|
||||
|
||||
submitDigitalOceanServer();
|
||||
|
||||
$this->assertDatabaseHas('servers', [
|
||||
'name' => 'test-do-server',
|
||||
'ip' => Server::PLACEHOLDER_IP,
|
||||
'team_id' => $this->team->id,
|
||||
'cloud_provider_token_id' => $this->digitalOceanToken->id,
|
||||
'digitalocean_droplet_id' => '555',
|
||||
'digitalocean_droplet_status' => 'new',
|
||||
]);
|
||||
});
|
||||
|
||||
it('updates the placeholder IP once the droplet reports one', function () {
|
||||
Http::fake([
|
||||
'https://api.digitalocean.com/v2/account/keys' => Http::response([
|
||||
'ssh_key' => ['id' => 123],
|
||||
], 201),
|
||||
'https://api.digitalocean.com/v2/account/keys*' => Http::response([
|
||||
'ssh_keys' => [],
|
||||
], 200),
|
||||
'https://api.digitalocean.com/v2/droplets' => Http::response([
|
||||
'droplet' => ['id' => 555, 'status' => 'new'],
|
||||
], 202),
|
||||
'https://api.digitalocean.com/v2/droplets/555' => Http::response([
|
||||
'droplet' => [
|
||||
'id' => 555,
|
||||
'status' => 'active',
|
||||
'networks' => [
|
||||
'v4' => [
|
||||
['type' => 'public', 'ip_address' => '203.0.113.40'],
|
||||
],
|
||||
],
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
submitDigitalOceanServer();
|
||||
|
||||
$this->assertDatabaseHas('servers', [
|
||||
'name' => 'test-do-server',
|
||||
'ip' => '203.0.113.40',
|
||||
'digitalocean_droplet_id' => '555',
|
||||
'digitalocean_droplet_status' => 'active',
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders only the full width buy button at the bottom of the DigitalOcean form', function () {
|
||||
|
|
|
|||
|
|
@ -100,6 +100,17 @@ function expectMcpAuditLog(array $expected): void
|
|||
$response->assertJson(['message' => 'MCP server is disabled for this team.']);
|
||||
});
|
||||
|
||||
test('MCP endpoint is enabled for teams by default', function () {
|
||||
$defaultTeam = Team::factory()->create();
|
||||
$this->user->teams()->attach($defaultTeam->id, ['role' => 'owner']);
|
||||
session(['currentTeam' => $defaultTeam]);
|
||||
$token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
|
||||
|
||||
$response = mcpListTools($token);
|
||||
|
||||
$response->assertOk();
|
||||
});
|
||||
|
||||
test('MCP endpoint rejects unauthenticated requests', function () {
|
||||
$response = mcpPost(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list']);
|
||||
$response->assertStatus(401);
|
||||
|
|
|
|||
|
|
@ -20,7 +20,10 @@
|
|||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::create(['id' => 0, 'is_api_enabled' => true]);
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['is_api_enabled' => true],
|
||||
));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
|
|
|
|||
|
|
@ -126,3 +126,112 @@ function createPushUpdatePostgresql(Team $team, array $attributes = []): Standal
|
|||
|
||||
return $database;
|
||||
}
|
||||
|
||||
test('partial sentinel snapshots do not mark missing databases as exited', function () {
|
||||
$team = Team::factory()->create();
|
||||
$database = createPushUpdatePostgresql($team, [
|
||||
'status' => 'running:healthy',
|
||||
'is_public' => true,
|
||||
]);
|
||||
|
||||
$server = $database->destination->server;
|
||||
Queue::fake();
|
||||
|
||||
$data = [
|
||||
'snapshot' => [
|
||||
'version' => 1,
|
||||
'complete' => false,
|
||||
],
|
||||
'containers' => [
|
||||
[
|
||||
'name' => 'unrelated-container',
|
||||
'state' => 'running',
|
||||
'health_status' => 'healthy',
|
||||
'labels' => [
|
||||
'coolify.managed' => 'true',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$job = new PushServerUpdateJob($server, $data);
|
||||
$job->handle();
|
||||
|
||||
$database->refresh();
|
||||
|
||||
expect($database->status)->toBe('running:healthy');
|
||||
});
|
||||
|
||||
test('partial sentinel snapshots apply directly observed database status updates', function () {
|
||||
$team = Team::factory()->create();
|
||||
$database = createPushUpdatePostgresql($team, [
|
||||
'status' => 'exited',
|
||||
]);
|
||||
|
||||
$server = $database->destination->server;
|
||||
Queue::fake();
|
||||
|
||||
$data = [
|
||||
'snapshot' => [
|
||||
'version' => 1,
|
||||
'complete' => false,
|
||||
],
|
||||
'containers' => [
|
||||
[
|
||||
'name' => $database->uuid,
|
||||
'state' => 'running',
|
||||
'health_status' => 'healthy',
|
||||
'labels' => [
|
||||
'coolify.managed' => 'true',
|
||||
'coolify.type' => 'database',
|
||||
'com.docker.compose.service' => $database->uuid,
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$job = new PushServerUpdateJob($server, $data);
|
||||
$job->handle();
|
||||
|
||||
$database->refresh();
|
||||
|
||||
expect($database->status)->toBe('running:healthy');
|
||||
});
|
||||
|
||||
test('partial sentinel snapshots do not trigger missing tcp proxy recovery', function () {
|
||||
$team = Team::factory()->create();
|
||||
$database = createPushUpdatePostgresql($team, [
|
||||
'status' => 'exited',
|
||||
'is_public' => true,
|
||||
]);
|
||||
|
||||
$server = $database->destination->server;
|
||||
Queue::fake();
|
||||
|
||||
$data = [
|
||||
'snapshot' => [
|
||||
'version' => 1,
|
||||
'complete' => false,
|
||||
],
|
||||
'containers' => [
|
||||
[
|
||||
'name' => $database->uuid,
|
||||
'state' => 'running',
|
||||
'health_status' => 'healthy',
|
||||
'labels' => [
|
||||
'coolify.managed' => 'true',
|
||||
'coolify.type' => 'database',
|
||||
'com.docker.compose.service' => $database->uuid,
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$job = new PushServerUpdateJob($server, $data);
|
||||
$job->handle();
|
||||
|
||||
$database->refresh();
|
||||
|
||||
expect($database->status)->toBe('running:healthy');
|
||||
Queue::assertNothingPushed();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -196,3 +196,24 @@ function sentinelPayload(array $containers, ?float $diskPercentage = 42.0): arra
|
|||
|
||||
Queue::assertNotPushed(PushServerUpdateJob::class);
|
||||
});
|
||||
|
||||
it('dispatches a complete snapshot after an identical partial snapshot', function () use ($running) {
|
||||
$partialPayload = sentinelPayload($running()) + [
|
||||
'snapshot' => [
|
||||
'version' => 1,
|
||||
'complete' => false,
|
||||
],
|
||||
];
|
||||
|
||||
$completePayload = sentinelPayload($running()) + [
|
||||
'snapshot' => [
|
||||
'version' => 1,
|
||||
'complete' => true,
|
||||
],
|
||||
];
|
||||
|
||||
pushSentinel($this->token, $partialPayload)->assertOk();
|
||||
pushSentinel($this->token, $completePayload)->assertOk();
|
||||
|
||||
Queue::assertPushed(PushServerUpdateJob::class, 2);
|
||||
});
|
||||
|
|
|
|||
118
tests/Feature/Server/HetznerServerPlaceholderIpTest.php
Normal file
118
tests/Feature/Server/HetznerServerPlaceholderIpTest.php
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Server\New\ByHetzner;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'cache.default' => 'array',
|
||||
'session.driver' => 'array',
|
||||
]);
|
||||
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create([
|
||||
'id' => 0,
|
||||
]));
|
||||
|
||||
$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->hetznerToken = CloudProviderToken::create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'hetzner',
|
||||
'token' => 'test-hetzner-token',
|
||||
'name' => 'Test Hetzner Token',
|
||||
]);
|
||||
|
||||
$this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
|
||||
});
|
||||
|
||||
it('persists the server with a placeholder IP when Hetzner has not assigned one yet', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
|
||||
'ssh_key' => ['id' => 42, 'fingerprint' => 'ff:ff'],
|
||||
], 201),
|
||||
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
|
||||
'ssh_keys' => [],
|
||||
], 200),
|
||||
'https://api.hetzner.cloud/v1/servers' => Http::response([
|
||||
'server' => [
|
||||
'id' => 777,
|
||||
'status' => 'initializing',
|
||||
'public_net' => [
|
||||
'ipv4' => null,
|
||||
'ipv6' => null,
|
||||
],
|
||||
],
|
||||
], 201),
|
||||
]);
|
||||
|
||||
Livewire::test(ByHetzner::class, ['selectedTokenUuid' => $this->hetznerToken->uuid])
|
||||
->assertSet('current_step', 2)
|
||||
->set('server_name', 'test-hetzner-server')
|
||||
->set('selected_location', 'fsn1')
|
||||
->set('selected_server_type', 'cx22')
|
||||
->set('selected_image', 114690387)
|
||||
->set('private_key_id', $this->privateKey->id)
|
||||
->call('submit')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$this->assertDatabaseHas('servers', [
|
||||
'name' => 'test-hetzner-server',
|
||||
'ip' => Server::PLACEHOLDER_IP,
|
||||
'team_id' => $this->team->id,
|
||||
'cloud_provider_token_id' => $this->hetznerToken->id,
|
||||
'hetzner_server_id' => '777',
|
||||
'hetzner_server_status' => 'initializing',
|
||||
]);
|
||||
});
|
||||
|
||||
it('creates the server with the real IP when Hetzner assigns one immediately', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
|
||||
'ssh_key' => ['id' => 42, 'fingerprint' => 'ff:ff'],
|
||||
], 201),
|
||||
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
|
||||
'ssh_keys' => [],
|
||||
], 200),
|
||||
'https://api.hetzner.cloud/v1/servers' => Http::response([
|
||||
'server' => [
|
||||
'id' => 777,
|
||||
'status' => 'running',
|
||||
'public_net' => [
|
||||
'ipv4' => ['ip' => '203.0.113.30'],
|
||||
],
|
||||
],
|
||||
], 201),
|
||||
]);
|
||||
|
||||
Livewire::test(ByHetzner::class, ['selectedTokenUuid' => $this->hetznerToken->uuid])
|
||||
->assertSet('current_step', 2)
|
||||
->set('server_name', 'test-hetzner-server')
|
||||
->set('selected_location', 'fsn1')
|
||||
->set('selected_server_type', 'cx22')
|
||||
->set('selected_image', 114690387)
|
||||
->set('private_key_id', $this->privateKey->id)
|
||||
->call('submit')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$this->assertDatabaseHas('servers', [
|
||||
'name' => 'test-hetzner-server',
|
||||
'ip' => '203.0.113.30',
|
||||
'hetzner_server_id' => '777',
|
||||
'hetzner_server_status' => 'running',
|
||||
]);
|
||||
});
|
||||
|
|
@ -15,8 +15,8 @@ function serviceExtraFieldsTestServiceWithApplicationImage(string $image): Servi
|
|||
$team = Team::factory()->create();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$server = Server::factory()->create();
|
||||
$destination = StandaloneDocker::factory()->create(['server_id' => $server->id]);
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
|
||||
|
||||
$service = Service::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
use App\Models\CloudProviderToken;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
|
@ -95,7 +96,7 @@ function vultrLivewireTestPublicKey(): string
|
|||
'instance' => [
|
||||
'id' => 'instance-1',
|
||||
'label' => 'test-vultr-server',
|
||||
'main_ip' => '1.2.3.4',
|
||||
'main_ip' => '192.0.2.10',
|
||||
'v6_main_ip' => '2001:db8::1',
|
||||
'status' => 'pending',
|
||||
],
|
||||
|
|
@ -115,7 +116,7 @@ function vultrLivewireTestPublicKey(): string
|
|||
|
||||
$this->assertDatabaseHas('servers', [
|
||||
'name' => 'test-vultr-server',
|
||||
'ip' => '1.2.3.4',
|
||||
'ip' => '192.0.2.10',
|
||||
'team_id' => $this->team->id,
|
||||
'cloud_provider_token_id' => $this->vultrToken->id,
|
||||
'vultr_instance_id' => 'instance-1',
|
||||
|
|
@ -132,6 +133,45 @@ function vultrLivewireTestPublicKey(): string
|
|||
});
|
||||
});
|
||||
|
||||
it('persists the server with a placeholder IP when Vultr has not assigned one yet', function () {
|
||||
Http::fake([
|
||||
'https://api.vultr.com/v2/ssh-keys' => Http::response([
|
||||
'ssh_key' => ['id' => 'key-1', 'ssh_key' => vultrLivewireTestPublicKey()],
|
||||
], 201),
|
||||
'https://api.vultr.com/v2/ssh-keys*' => Http::response([
|
||||
'ssh_keys' => [],
|
||||
'meta' => ['links' => ['next' => null]],
|
||||
], 200),
|
||||
'https://api.vultr.com/v2/instances/instance-1' => Http::response(['error' => 'temporary error'], 500),
|
||||
'https://api.vultr.com/v2/instances' => Http::response([
|
||||
'instance' => [
|
||||
'id' => 'instance-1',
|
||||
'label' => 'test-vultr-server',
|
||||
'main_ip' => '0.0.0.0',
|
||||
'v6_main_ip' => '::',
|
||||
'status' => 'pending',
|
||||
],
|
||||
], 202),
|
||||
]);
|
||||
|
||||
Livewire::test(ByVultr::class, ['selectedTokenUuid' => $this->vultrToken->uuid])
|
||||
->set('server_name', 'test-vultr-server')
|
||||
->set('selected_region', 'ewr')
|
||||
->set('selected_plan', 'vc2-1c-1gb')
|
||||
->set('selected_os_id', 2284)
|
||||
->set('private_key_id', $this->privateKey->id)
|
||||
->call('submit')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$this->assertDatabaseHas('servers', [
|
||||
'name' => 'test-vultr-server',
|
||||
'ip' => Server::PLACEHOLDER_IP,
|
||||
'team_id' => $this->team->id,
|
||||
'vultr_instance_id' => 'instance-1',
|
||||
'vultr_instance_status' => 'pending',
|
||||
]);
|
||||
});
|
||||
|
||||
it('requires IPv6 when public IPv4 is disabled', function () {
|
||||
Livewire::test(ByVultr::class)
|
||||
->set('selected_token_id', $this->vultrToken->id)
|
||||
|
|
|
|||
|
|
@ -3,12 +3,14 @@
|
|||
use App\Exceptions\DeploymentException;
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\ApplicationSetting;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Collection;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
class TestableRailpackDeploymentJob extends ApplicationDeploymentJob
|
||||
{
|
||||
|
|
@ -373,9 +375,9 @@ function invokeRailpackMethod(object $job, ReflectionClass $reflection, string $
|
|||
]));
|
||||
|
||||
foreach ([
|
||||
'application_deployment_queue' => new class
|
||||
'application_deployment_queue' => new class extends ApplicationDeploymentQueue
|
||||
{
|
||||
public function addLogEntry(string $message, string $type = 'info', bool $hidden = false): void {}
|
||||
public function addLogEntry(string $message, string $type = 'stdout', bool $hidden = false): void {}
|
||||
},
|
||||
'build_pack' => 'railpack',
|
||||
'pull_request_id' => 0,
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ function markSnapshotTestApplicationDeployed(Application $application): Applicat
|
|||
'is_buildtime' => false,
|
||||
'is_runtime' => true,
|
||||
'is_preview' => false,
|
||||
'is_shown_once' => true,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
]);
|
||||
|
|
@ -112,6 +113,7 @@ function markSnapshotTestApplicationDeployed(Application $application): Applicat
|
|||
'is_buildtime' => false,
|
||||
'is_runtime' => true,
|
||||
'is_preview' => false,
|
||||
'is_shown_once' => true,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
]);
|
||||
|
|
|
|||
13
tests/Unit/DestinationsOpenApiTest.php
Normal file
13
tests/Unit/DestinationsOpenApiTest.php
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<?php
|
||||
|
||||
it('defines OpenAPI documentation for every destinations endpoint', function () {
|
||||
$controller = file_get_contents(__DIR__.'/../../app/Http/Controllers/Api/DestinationsController.php');
|
||||
$model = file_get_contents(__DIR__.'/../../app/Models/StandaloneDocker.php');
|
||||
|
||||
expect($model)->toContain("schema: 'Destination'")
|
||||
->and($controller)->toContain("operationId: 'list-destinations'")
|
||||
->and($controller)->toContain("operationId: 'get-destination-by-uuid'")
|
||||
->and($controller)->toContain("operationId: 'delete-destination-by-uuid'")
|
||||
->and($controller)->toContain("operationId: 'list-server-destinations'")
|
||||
->and($controller)->toContain("operationId: 'create-server-destination'");
|
||||
});
|
||||
|
|
@ -10,7 +10,7 @@
|
|||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
function createDigitalOceanServerForStateTest(string $status = 'active'): Server
|
||||
function createDigitalOceanServerForStateTest(string $status = 'active', array $attributes = []): Server
|
||||
{
|
||||
$team = Team::create([
|
||||
'name' => 'Test Team',
|
||||
|
|
@ -24,13 +24,13 @@ function createDigitalOceanServerForStateTest(string $status = 'active'): Server
|
|||
]);
|
||||
$privateKey = PrivateKey::factory()->create(['team_id' => $team->id]);
|
||||
|
||||
return Server::factory()->create([
|
||||
return Server::factory()->create(array_merge([
|
||||
'team_id' => $team->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'cloud_provider_token_id' => $token->id,
|
||||
'digitalocean_droplet_id' => 987,
|
||||
'digitalocean_droplet_status' => $status,
|
||||
]);
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
it('marks a DigitalOcean droplet as deleted when the provider returns 404', function () {
|
||||
|
|
@ -45,6 +45,48 @@ function createDigitalOceanServerForStateTest(string $status = 'active'): Server
|
|||
expect($server->fresh()->digitalocean_droplet_status)->toBe('deleted');
|
||||
});
|
||||
|
||||
it('backfills a placeholder IP from the DigitalOcean droplet state', function () {
|
||||
$server = createDigitalOceanServerForStateTest('new', ['ip' => Server::PLACEHOLDER_IP]);
|
||||
|
||||
Http::fake([
|
||||
'https://api.digitalocean.com/v2/droplets/987' => Http::response([
|
||||
'droplet' => [
|
||||
'id' => 987,
|
||||
'status' => 'active',
|
||||
'networks' => [
|
||||
'v4' => [
|
||||
['type' => 'public', 'ip_address' => '203.0.113.10'],
|
||||
],
|
||||
],
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
expect($server->refreshDigitalOceanState())->toBe('active');
|
||||
expect($server->fresh()->ip)->toBe('203.0.113.10');
|
||||
});
|
||||
|
||||
it('does not overwrite a real IP from the DigitalOcean droplet state', function () {
|
||||
$server = createDigitalOceanServerForStateTest('active', ['ip' => '198.51.100.20']);
|
||||
|
||||
Http::fake([
|
||||
'https://api.digitalocean.com/v2/droplets/987' => Http::response([
|
||||
'droplet' => [
|
||||
'id' => 987,
|
||||
'status' => 'active',
|
||||
'networks' => [
|
||||
'v4' => [
|
||||
['type' => 'public', 'ip_address' => '203.0.113.10'],
|
||||
],
|
||||
],
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$server->refreshDigitalOceanState();
|
||||
expect($server->fresh()->ip)->toBe('198.51.100.20');
|
||||
});
|
||||
|
||||
it('does not mark a DigitalOcean droplet as deleted on transient provider errors', function () {
|
||||
$server = createDigitalOceanServerForStateTest();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
<?php
|
||||
|
||||
test('expanded navbar theme item cycles themes without a right side selector', function () {
|
||||
test('navbar does not render the removed theme switcher', function () {
|
||||
$navbar = file_get_contents(__DIR__.'/../../resources/views/components/navbar.blade.php');
|
||||
|
||||
expect(substr_count($navbar, '@click.stop="cycleTheme()"'))->toBe(2)
|
||||
expect($navbar)->not->toContain('cycleTheme()')
|
||||
->and($navbar)->not->toContain('aria-label="Theme switcher"')
|
||||
->and($navbar)->not->toContain('ml-auto flex items-center gap-0.5')
|
||||
->and($navbar)->not->toContain('@click.stop="setTheme(\'light\')"')
|
||||
->and($navbar)->not->toContain('@click.stop="setTheme(\'system\')"')
|
||||
->and($navbar)->not->toContain('@click.stop="setTheme(\'dark\')"')
|
||||
->and($navbar)->toContain('<span class="text-left menu-item-label">Theme</span>');
|
||||
->and($navbar)->not->toContain('<span class="text-left menu-item-label">Theme</span>');
|
||||
});
|
||||
|
|
|
|||
67
tests/Unit/ServerPlaceholderIpTest.php
Normal file
67
tests/Unit/ServerPlaceholderIpTest.php
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<?php
|
||||
|
||||
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);
|
||||
|
||||
function createServerForPlaceholderIpTest(string $ip): Server
|
||||
{
|
||||
$team = Team::create([
|
||||
'name' => 'Test Team',
|
||||
'personal_team' => false,
|
||||
]);
|
||||
$privateKey = PrivateKey::factory()->create(['team_id' => $team->id]);
|
||||
|
||||
return Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'ip' => $ip,
|
||||
]);
|
||||
}
|
||||
|
||||
it('detects placeholder IPs', function () {
|
||||
$server = createServerForPlaceholderIpTest(Server::PLACEHOLDER_IP);
|
||||
|
||||
foreach ([Server::PLACEHOLDER_IP, '0.0.0.0', '::'] as $ip) {
|
||||
$server->update(['ip' => $ip]);
|
||||
|
||||
expect($server->fresh()->hasPlaceholderIp())->toBeTrue();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not treat a real IP as a placeholder', function () {
|
||||
$server = createServerForPlaceholderIpTest('203.0.113.5');
|
||||
|
||||
expect($server->hasPlaceholderIp())->toBeFalse();
|
||||
});
|
||||
|
||||
it('backfills a placeholder IP with the real address', function () {
|
||||
$server = createServerForPlaceholderIpTest(Server::PLACEHOLDER_IP);
|
||||
|
||||
expect($server->backfillPlaceholderIp('203.0.113.5'))->toBeTrue();
|
||||
expect($server->fresh()->ip)->toBe('203.0.113.5');
|
||||
});
|
||||
|
||||
it('does not overwrite a real IP when backfilling', function () {
|
||||
$server = createServerForPlaceholderIpTest('203.0.113.5');
|
||||
|
||||
expect($server->backfillPlaceholderIp('198.51.100.9'))->toBeFalse();
|
||||
expect($server->fresh()->ip)->toBe('203.0.113.5');
|
||||
});
|
||||
|
||||
it('ignores a missing IP when backfilling', function () {
|
||||
$server = createServerForPlaceholderIpTest(Server::PLACEHOLDER_IP);
|
||||
|
||||
expect($server->backfillPlaceholderIp(null))->toBeFalse();
|
||||
expect($server->fresh()->ip)->toBe(Server::PLACEHOLDER_IP);
|
||||
});
|
||||
|
||||
it('skips servers with a placeholder IP in scheduled jobs', function () {
|
||||
$server = createServerForPlaceholderIpTest(Server::PLACEHOLDER_IP);
|
||||
|
||||
expect($server->skipServer())->toBeTrue();
|
||||
});
|
||||
Loading…
Reference in a new issue