mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-14 12:16:45 +00:00
feat(v5): add server reconciliation and canvas APIs
Split V5 dashboard behavior into domain controllers and policies, add agent token rotation/revocation, status reconciliation jobs, ingress firewall syncing, and canvas connection APIs. Add migrations for V5 status tracking, server capabilities, resource connection aliases, and revoked agent tokens.
This commit is contained in:
1 parent
df854feee8
commit
6ae45684f9
147 files changed
+22444
-10407
No files matched your search
@@ -4,6 +4,7 @@ namespace App\Models;
|
||||
|
||||
use App\Actions\User\RevokeUserTeamTokens;
|
||||
use App\Events\ServerReachabilityChanged;
|
||||
use App\Jobs\V5TeardownTeamJob;
|
||||
use App\Notifications\Channels\SendsDiscord;
|
||||
use App\Notifications\Channels\SendsEmail;
|
||||
use App\Notifications\Channels\SendsPushover;
|
||||
@@ -75,6 +76,17 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
|
||||
});
|
||||
|
||||
static::deleting(function (Team $team) {
|
||||
// Best-effort on-host teardown of this team's v5 resources BEFORE the
|
||||
// DB cascade removes the servers/applications/private keys. Captured
|
||||
// synchronously into a queued job so an unreachable host cannot block
|
||||
// or fail the team deletion (see V5TeardownTeamJob). Guarded so a v5
|
||||
// teardown problem never breaks v4 team deletion.
|
||||
try {
|
||||
V5TeardownTeamJob::dispatchForTeam($team);
|
||||
} catch (\Throwable $exception) {
|
||||
report($exception);
|
||||
}
|
||||
|
||||
RevokeUserTeamTokens::forTeam($team->id);
|
||||
|
||||
foreach ($team->privateKeys as $key) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models\V5;
|
||||
|
||||
use App\Enums\V5\ApplicationStatus;
|
||||
use App\Events\V5CanvasResourceUpdated;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
@@ -9,6 +10,7 @@ use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class Application extends V5Model
|
||||
{
|
||||
@@ -26,6 +28,7 @@ class Application extends V5Model
|
||||
'container_name',
|
||||
'status',
|
||||
'status_message',
|
||||
'status_observed_at',
|
||||
'runtime_container_id',
|
||||
'mesh_namespace',
|
||||
'ingress_enabled',
|
||||
@@ -35,7 +38,7 @@ class Application extends V5Model
|
||||
];
|
||||
|
||||
protected $attributes = [
|
||||
'status' => 'creating',
|
||||
'status' => ApplicationStatus::Creating->value,
|
||||
'mesh_namespace' => 'default',
|
||||
'ingress_enabled' => false,
|
||||
'canvas_x' => 0,
|
||||
@@ -46,7 +49,7 @@ class Application extends V5Model
|
||||
{
|
||||
static::updated(function (self $application): void {
|
||||
if ($application->wasChanged(['status', 'status_message', 'runtime_container_id'])) {
|
||||
V5CanvasResourceUpdated::dispatch($application->team_id, $application->id);
|
||||
DB::afterCommit(fn () => V5CanvasResourceUpdated::dispatch($application->team_id, $application->id));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -54,6 +57,7 @@ class Application extends V5Model
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status_observed_at' => 'datetime',
|
||||
'ingress_enabled' => 'boolean',
|
||||
'internal_port' => 'integer',
|
||||
'canvas_x' => 'integer',
|
||||
|
||||
@@ -8,6 +8,8 @@ class ApplicationDomain extends V5Model
|
||||
{
|
||||
protected $table = 'v5_application_domains';
|
||||
|
||||
protected bool $hasUuidColumn = false;
|
||||
|
||||
protected $fillable = [
|
||||
'application_id',
|
||||
'domain',
|
||||
|
||||
@@ -11,6 +11,12 @@ class Cluster extends V5Model
|
||||
{
|
||||
protected $table = 'v5_clusters';
|
||||
|
||||
/**
|
||||
* Single source of truth for cluster defaults: `$attributes` below is
|
||||
* built from these consts, and the column defaults in
|
||||
* 2026_06_16_130649_v5_create_clusters_table mirror them (kept there for
|
||||
* historical rows only — update both when changing a default).
|
||||
*/
|
||||
public const DEFAULT_WIREGUARD_INTERFACE = 'wg0';
|
||||
|
||||
public const DEFAULT_WIREGUARD_MANAGEMENT_POOL = '100.64.0.0/16';
|
||||
|
||||
@@ -9,6 +9,8 @@ class ContainerStatus extends V5Model
|
||||
{
|
||||
protected $table = 'v5_container_statuses';
|
||||
|
||||
protected bool $hasUuidColumn = false;
|
||||
|
||||
protected $fillable = [
|
||||
'team_id',
|
||||
'server_id',
|
||||
@@ -17,12 +19,14 @@ class ContainerStatus extends V5Model
|
||||
'image',
|
||||
'status',
|
||||
'status_message',
|
||||
'status_observed_at',
|
||||
'last_seen_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status_observed_at' => 'datetime',
|
||||
'last_seen_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ class ResourceConnectionRule extends V5Model
|
||||
{
|
||||
protected $table = 'v5_resource_connection_rules';
|
||||
|
||||
protected bool $hasUuidColumn = false;
|
||||
|
||||
protected $fillable = [
|
||||
'connection_id',
|
||||
'source_resource_type',
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\V5;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* A host-agent JWT that has been revoked (typically on server destroy/re-home).
|
||||
*
|
||||
* flux does not yet consult this list (see AgentTokenIssuer::revoke docblock);
|
||||
* it exists so the Laravel side owns the data and API needed for revocation the
|
||||
* moment flux gains a revocation check.
|
||||
*/
|
||||
class RevokedAgentToken extends V5Model
|
||||
{
|
||||
protected bool $hasUuidColumn = false;
|
||||
|
||||
protected $table = 'v5_revoked_agent_tokens';
|
||||
|
||||
protected $fillable = [
|
||||
'jti',
|
||||
'server_id',
|
||||
'revoked_at',
|
||||
'expires_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'revoked_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Server, $this>
|
||||
*/
|
||||
public function server(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Server::class);
|
||||
}
|
||||
}
|
||||
+55
-14
@@ -2,12 +2,15 @@
|
||||
|
||||
namespace App\Models\V5;
|
||||
|
||||
use App\Enums\V5\IngressStatus;
|
||||
use App\Events\V5CanvasResourceUpdated;
|
||||
use App\Events\V5ClusterUpdated;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class Server extends V5Model
|
||||
{
|
||||
@@ -24,9 +27,12 @@ class Server extends V5Model
|
||||
'ssh_user',
|
||||
'ssh_port',
|
||||
'status',
|
||||
'status_observed_at',
|
||||
'ingress_type',
|
||||
'ingress_status',
|
||||
'capabilities',
|
||||
'has_coold',
|
||||
'is_ingress',
|
||||
'builder_enabled',
|
||||
'builder_capacity',
|
||||
'builder_cpu_quota',
|
||||
@@ -35,6 +41,9 @@ class Server extends V5Model
|
||||
'wireguard_endpoint_override',
|
||||
'wireguard_management_ip',
|
||||
'wireguard_public_key',
|
||||
'coold_version',
|
||||
'agent_token_jti',
|
||||
'agent_token_expires_at',
|
||||
'container_subnets',
|
||||
'canvas_x',
|
||||
'canvas_y',
|
||||
@@ -60,22 +69,22 @@ class Server extends V5Model
|
||||
}
|
||||
|
||||
if ($server->wasChanged('status') && $server->cluster_id !== null) {
|
||||
V5ClusterUpdated::dispatch($server->team_id, $server->cluster_id);
|
||||
DB::afterCommit(fn () => V5ClusterUpdated::dispatch($server->team_id, $server->cluster_id));
|
||||
}
|
||||
|
||||
if ($server->wasChanged('status')) {
|
||||
V5CanvasResourceUpdated::dispatch(
|
||||
DB::afterCommit(fn () => V5CanvasResourceUpdated::dispatch(
|
||||
$server->team_id,
|
||||
null,
|
||||
$server->isIngress() ? $server->id : null,
|
||||
$server->id,
|
||||
);
|
||||
));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($server->isIngress()) {
|
||||
V5CanvasResourceUpdated::dispatch($server->team_id, null, $server->id);
|
||||
DB::afterCommit(fn () => V5CanvasResourceUpdated::dispatch($server->team_id, null, $server->id));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -83,20 +92,56 @@ class Server extends V5Model
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'capabilities' => 'array',
|
||||
'has_coold' => 'boolean',
|
||||
'is_ingress' => 'boolean',
|
||||
'builder_enabled' => 'boolean',
|
||||
'container_subnets' => 'array',
|
||||
'canvas_x' => 'integer',
|
||||
'canvas_y' => 'integer',
|
||||
'status_observed_at' => 'datetime',
|
||||
'agent_token_expires_at' => 'datetime',
|
||||
'last_bootstrapped_at' => 'datetime',
|
||||
'last_bootstrap_ran_at' => 'datetime',
|
||||
'last_status_checked_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function fluxHostId(): string
|
||||
{
|
||||
return (string) $this->uuid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Virtual attribute kept for wire-format compatibility: capabilities are
|
||||
* stored as the indexed has_coold / is_ingress booleans, but reads and
|
||||
* writes of `capabilities` keep working with the historical string array.
|
||||
* Unknown capability names are dropped on write.
|
||||
*
|
||||
* The dropped `capabilities` column intentionally stays in `$fillable`:
|
||||
* call sites still mass-assign it, and this mutator maps those writes
|
||||
* onto the boolean columns.
|
||||
*/
|
||||
protected function capabilities(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => array_values(array_filter([
|
||||
$this->has_coold ? 'coold' : null,
|
||||
$this->is_ingress ? 'ingress' : null,
|
||||
])),
|
||||
set: fn (?array $capabilities) => [
|
||||
'has_coold' => in_array('coold', $capabilities ?? [], true),
|
||||
'is_ingress' => in_array('ingress', $capabilities ?? [], true),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function hasCapability(string $capability): bool
|
||||
{
|
||||
return in_array($capability, $this->capabilities ?? [], true);
|
||||
return match ($capability) {
|
||||
'coold' => (bool) $this->has_coold,
|
||||
'ingress' => (bool) $this->is_ingress,
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,7 +149,7 @@ class Server extends V5Model
|
||||
*/
|
||||
public function withCapability(string $capability): array
|
||||
{
|
||||
return collect($this->capabilities ?? [])
|
||||
return collect($this->capabilities)
|
||||
->push($capability)
|
||||
->unique()
|
||||
->values()
|
||||
@@ -116,7 +161,7 @@ class Server extends V5Model
|
||||
*/
|
||||
public function withoutCapability(string $capability): array
|
||||
{
|
||||
return collect($this->capabilities ?? [])
|
||||
return collect($this->capabilities)
|
||||
->reject(fn (string $existingCapability) => $existingCapability === $capability)
|
||||
->values()
|
||||
->all();
|
||||
@@ -124,16 +169,12 @@ class Server extends V5Model
|
||||
|
||||
public function isIngress(): bool
|
||||
{
|
||||
return $this->hasCapability('ingress');
|
||||
return (bool) $this->is_ingress;
|
||||
}
|
||||
|
||||
public function ingressStatus(): string
|
||||
{
|
||||
if ($this->ingress_status !== null) {
|
||||
return $this->ingress_status;
|
||||
}
|
||||
|
||||
return $this->status === 'installed' ? 'running' : 'unknown';
|
||||
return $this->ingress_status ?? IngressStatus::Unknown->value;
|
||||
}
|
||||
|
||||
public function ingressType(): string
|
||||
|
||||
@@ -3,26 +3,55 @@
|
||||
namespace App\Models\V5;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
abstract class V5Model extends Model
|
||||
{
|
||||
/**
|
||||
* Whether the model's table has a `uuid` column. Models without one (set
|
||||
* this to false there) skip public-id generation and route on the primary
|
||||
* key instead.
|
||||
*/
|
||||
protected bool $hasUuidColumn = true;
|
||||
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return 'uuid';
|
||||
return $this->hasUuidColumn ? 'uuid' : $this->getKeyName();
|
||||
}
|
||||
|
||||
protected static function boot(): void
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function (Model $model): void {
|
||||
if (
|
||||
Schema::hasColumn($model->getTable(), 'uuid')
|
||||
&& ! $model->getAttribute('uuid')
|
||||
) {
|
||||
$model->setAttribute('uuid', new_public_id());
|
||||
static::creating(function (self $model): void {
|
||||
if ($model->hasUuidColumn && ! $model->getAttribute('uuid')) {
|
||||
$model->setAttribute('uuid', $model->newUniquePublicId());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a public id, regenerating (up to three candidates) when one is
|
||||
* already taken. A concurrent insert between this exists() check and our
|
||||
* own insert can still collide; the unique index then rejects the insert,
|
||||
* which is an acceptable residual race for these cheap, retryable writes.
|
||||
*/
|
||||
protected function newUniquePublicId(): string
|
||||
{
|
||||
$attempts = 0;
|
||||
|
||||
do {
|
||||
$candidate = $this->newPublicIdCandidate();
|
||||
$attempts++;
|
||||
} while (
|
||||
$attempts < 3
|
||||
&& $this->newModelQuery()->where('uuid', $candidate)->exists()
|
||||
);
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
protected function newPublicIdCandidate(): string
|
||||
{
|
||||
return new_public_id();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user