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:
Andras Bacsai committed 2026-07-06 17:40:37 +02:00
1 parent df854feee8
commit 6ae45684f9
147 files changed
+22444 -10407

No files matched your search

+12
View File
@@ -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) {
+6 -2
View File
@@ -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',
+2
View File
@@ -8,6 +8,8 @@ class ApplicationDomain extends V5Model
{
protected $table = 'v5_application_domains';
protected bool $hasUuidColumn = false;
protected $fillable = [
'application_id',
'domain',
+6
View File
@@ -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';
+4
View File
@@ -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',
];
}
+2
View File
@@ -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',
+45
View File
@@ -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
View File
@@ -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
+37 -8
View File
@@ -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();
}
}