chore(v5): archive V5 implementation and remove runtime integration

Move V5 source, migrations, UI, scripts, and tests into documentation, then remove V5 routes, models, jobs, configuration, dependencies, and application hooks.
This commit is contained in:
Andras Bacsai committed 2026-08-15 19:13:30 +02:00
1 parent 1440d35750
commit 76030a30d4
237 files changed
+288 -7079

No files matched your search

-76
View File
@@ -1,76 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Services\Flux\AgentTokenIssuer;
use App\Support\V5\V5Feature;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
class FluxDev extends Command
{
protected $signature = 'flux:dev
{host_id=coold-dev : Stable coold host id}
{--caps= : Comma-separated host capabilities}
{--ttl=3600 : Token lifetime in seconds}
{--output= : Optional path to write the token with 0600 permissions}';
protected $description = 'Run Flux development helpers.';
/**
* @return array<int, string>
*/
private function defaultCapabilities(): array
{
return [
'host-agent:dev',
];
}
public function handle(AgentTokenIssuer $agentTokenIssuer): int
{
if (! V5Feature::enabled()) {
$this->error('V5 is only available in development environments.');
return self::FAILURE;
}
$hostId = (string) $this->argument('host_id');
$ttl = max(60, (int) $this->option('ttl'));
$caps = collect(explode(',', (string) $this->option('caps')))
->map(fn (string $cap) => trim($cap))
->filter()
->unique()
->values()
->all();
if ($caps === []) {
$caps = $this->defaultCapabilities();
}
try {
$token = $agentTokenIssuer->issue($hostId, $caps, $ttl);
} catch (\RuntimeException $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
$output = $this->option('output');
if (is_string($output) && $output !== '') {
$outputPath = Str::startsWith($output, '/') ? $output : base_path($output);
File::ensureDirectoryExists(dirname($outputPath));
File::put($outputPath, $token.PHP_EOL);
chmod($outputPath, 0600);
$this->info("Host JWT written to {$outputPath}.");
return self::SUCCESS;
}
$this->line($token);
return self::SUCCESS;
}
}
-142
View File
@@ -1,142 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Services\Flux\AgentTokenIssuer;
use App\Support\V5\V5Feature;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
/**
* Generates the ES256 (EC P-256) keypair used to authorize coold host agents
* against flux. Laravel signs the per-host JWT with the private key
* (config('flux.jwt_private_key_path')); flux verifies it with the matching
* public key (config('flux.jwt_public_key_path')). Without this keypair a fresh
* install cannot mint host tokens, so this command is a bootstrap prerequisite.
*
* @see AgentTokenIssuer
*/
class V5FluxGenerateKeys extends Command
{
protected $signature = 'v5:flux-generate-keys
{--force : Overwrite an existing private key instead of refusing}
{--show-public : Print the generated public key PEM so it can be provisioned to flux}';
protected $description = 'Generate the ES256 keypair Flux uses to sign and verify coold host agent JWTs.';
public function handle(AgentTokenIssuer $agentTokenIssuer): int
{
if (! V5Feature::enabled()) {
$this->error('V5 is only available in development environments.');
return self::FAILURE;
}
$privateKeyPath = (string) config('flux.jwt_private_key_path');
$publicKeyPath = (string) config('flux.jwt_public_key_path');
if ($privateKeyPath === '' || $publicKeyPath === '') {
$this->error('Flux JWT key paths are not configured (flux.jwt_private_key_path / flux.jwt_public_key_path).');
return self::FAILURE;
}
// Idempotent by default: re-running during provisioning must not clobber
// a live key (which would instantly invalidate every host token on
// disk). Refuse unless --force is passed, and exit SUCCESS so a
// provisioning script can call this unconditionally on every deploy.
if (File::exists($privateKeyPath) && ! $this->option('force')) {
$this->warn("A Flux JWT private key already exists at {$privateKeyPath}.");
$this->line('Refusing to overwrite it. Re-run with --force to replace it (this invalidates every host token currently on disk).');
return self::SUCCESS;
}
// curve_name drives the actual EC key (P-256). private_key_bits is
// still validated by PHP's generic length check (>= 384) even though it
// is irrelevant to EC, so it must be present or openssl_pkey_new fails
// with "Private key length must be at least 384 bits, configured to 0".
$keyPair = openssl_pkey_new([
'private_key_type' => OPENSSL_KEYTYPE_EC,
'curve_name' => 'prime256v1',
'private_key_bits' => 384,
]);
if ($keyPair === false) {
$this->error('Failed to generate an EC P-256 keypair: '.openssl_error_string());
return self::FAILURE;
}
$privatePem = '';
if (! openssl_pkey_export($keyPair, $privatePem)) {
$this->error('Failed to export the private key PEM: '.openssl_error_string());
return self::FAILURE;
}
$details = openssl_pkey_get_details($keyPair);
if ($details === false || ! isset($details['key'])) {
$this->error('Failed to read the generated public key PEM.');
return self::FAILURE;
}
$publicPem = (string) $details['key'];
$this->writeKeyFile($privateKeyPath, $privatePem, 0600);
$this->writeKeyFile($publicKeyPath, $publicPem, 0644);
// Self-check: the whole point of this command is that AgentTokenIssuer
// can mint with the key we just wrote. If the format were wrong (e.g.
// not a PEM EC private key Firebase\JWT accepts for ES256) this fails
// loudly here instead of silently at the first real host bootstrap.
try {
$token = $agentTokenIssuer->issue('flux-keygen-selfcheck');
} catch (\Throwable $exception) {
$this->error('Generated a keypair but AgentTokenIssuer could not mint a token with it: '.$exception->getMessage());
return self::FAILURE;
}
if (substr_count($token, '.') !== 2) {
$this->error('Generated key produced a malformed JWT (expected 3 segments).');
return self::FAILURE;
}
$this->info('Generated a fresh ES256 (EC P-256) Flux keypair.');
$this->line(" Private key (0600): {$privateKeyPath}");
$this->line(" Public key (0644): {$publicKeyPath}");
$this->newLine();
$this->line('Provision the PUBLIC key to flux — flux verifies every host JWT with it.');
$this->line('Keep the PRIVATE key secret and on the Laravel host only.');
if ($this->option('show-public')) {
$this->newLine();
$this->line(rtrim($publicPem));
}
return self::SUCCESS;
}
/**
* Write a key file with exact permissions, creating the parent directory at
* 0700 if missing. chmod is applied after the write because umask can
* loosen both the mkdir mode and the created file mode.
*/
private function writeKeyFile(string $path, string $contents, int $mode): void
{
$directory = dirname($path);
if (! is_dir($directory)) {
File::makeDirectory($directory, 0700, true);
@chmod($directory, 0700);
}
File::put($path, $contents);
@chmod($path, $mode);
}
}
@@ -1,93 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Actions\V5\Server\SyncDevLimaServers;
use App\Models\PrivateKey;
use App\Models\Team;
use App\Models\User;
use App\Support\V5\V5Feature;
use Illuminate\Console\Command;
class V5SyncDevLimaServers extends Command
{
protected $signature = 'v5:sync-dev-lima-servers
{--team-id=0 : Team that owns the dev servers}
{--user-id=0 : User recorded as creator}
{--private-key-id= : Optional private key used by the dev servers}
{--cluster=Development-Lima : Cluster name for the dev Lima servers}
{--server=* : Server as name|host|ssh_user|ssh_port|wireguard_management_ip}';
protected $description = 'Sync development Lima VMs into the v5 server/cluster tables.';
public function handle(): int
{
if (! V5Feature::enabled()) {
$this->error('V5 is only available in development environments.');
return self::FAILURE;
}
$team = Team::query()->find((int) $this->option('team-id')) ?? Team::query()->orderBy('id')->first();
$user = User::query()->find((int) $this->option('user-id')) ?? User::query()->orderBy('id')->first();
$privateKeyId = $this->option('private-key-id');
$privateKey = is_numeric($privateKeyId)
? PrivateKey::query()->find((int) $privateKeyId)
: PrivateKey::query()
->where('team_id', $team?->id)
->where('is_git_related', false)
->orderBy('id')
->first();
if (! $team instanceof Team || ! $user instanceof User) {
$this->warn('Cannot sync dev Lima servers without an existing team and user.');
return self::SUCCESS;
}
$servers = $this->option('server');
if (! is_array($servers) || $servers === []) {
$this->warn('No dev Lima servers were provided.');
return self::SUCCESS;
}
$parsedServers = [];
foreach ($servers as $server) {
$parts = explode('|', (string) $server);
if (! in_array(count($parts), [4, 5], true)) {
$this->error("Invalid server '{$server}'. Expected name|host|ssh_user|ssh_port|wireguard_management_ip.");
return self::FAILURE;
}
[$name, $host, $sshUser, $sshPort] = array_slice($parts, 0, 4);
$wireguardManagementIp = ($parts[4] ?? null) ?: null;
$parsedServers[] = [
'name' => $name,
'host' => $host,
'ssh_user' => $sshUser,
'ssh_port' => (int) $sshPort,
'wireguard_management_ip' => $wireguardManagementIp,
];
}
SyncDevLimaServers::run(
team: $team,
user: $user,
privateKey: $privateKey,
clusterName: (string) $this->option('cluster'),
servers: $parsedServers,
);
foreach ($parsedServers as $server) {
$this->info("Synced {$server['name']} ({$server['host']}:{$server['ssh_port']}).");
}
return self::SUCCESS;
}
}
-8
View File
@@ -15,10 +15,7 @@ use App\Jobs\RegenerateSslCertJob;
use App\Jobs\ScheduledJobManager;
use App\Jobs\ServerManagerJob;
use App\Jobs\UpdateCoolifyJob;
use App\Jobs\V5ReconcileServersJob;
use App\Jobs\V5RotateAgentTokensJob;
use App\Models\InstanceSettings;
use App\Support\V5\V5Feature;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
@@ -52,11 +49,6 @@ class Kernel extends ConsoleKernel
$this->scheduleInstance->command('sanctum:prune-expired --hours=1')->hourly()->onOneServer();
$this->scheduleInstance->job(new ApiTokenExpirationWarningJob)->hourly()->onOneServer();
if (V5Feature::enabled()) {
$this->scheduleInstance->job(new V5ReconcileServersJob)->everyFiveMinutes()->withoutOverlapping()->onOneServer();
$this->scheduleInstance->job(new V5RotateAgentTokensJob)->everyFifteenMinutes()->withoutOverlapping()->onOneServer();
}
if (isDev()) {
// Instance Jobs
$this->scheduleInstance->command('horizon:snapshot')->everyMinute();