feat(v5): gate V5 to development environments only

Introduce V5Feature and config so V5 routes, jobs, commands, morph maps,
and model queries run only when enabled. Move V5 migrations to
migrations-v5 (loaded only when enabled), remove Flux from production
Docker/install paths, and add isolation tests.
This commit is contained in:
Andras Bacsai
2026-07-19 11:44:12 +02:00
parent 1cb1d028de
commit d0247d3b09
45 changed files with 273 additions and 218 deletions
+4 -4
View File
@@ -3,6 +3,7 @@
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;
@@ -13,8 +14,7 @@ class FluxDev extends Command
{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}
{--force : Allow running outside local/development environments}';
{--output= : Optional path to write the token with 0600 permissions}';
protected $description = 'Run Flux development helpers.';
@@ -30,8 +30,8 @@ class FluxDev extends Command
public function handle(AgentTokenIssuer $agentTokenIssuer): int
{
if (! app()->environment(['local', 'development', 'testing']) && ! $this->option('force')) {
$this->error('This command is intended for development only. Use --force to override.');
if (! V5Feature::enabled()) {
$this->error('V5 is only available in development environments.');
return self::FAILURE;
}
@@ -3,6 +3,7 @@
namespace App\Console\Commands;
use App\Services\Flux\AgentTokenIssuer;
use App\Support\V5\V5Feature;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
@@ -25,6 +26,12 @@ class V5FluxGenerateKeys extends Command
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');
@@ -6,6 +6,7 @@ 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
@@ -15,15 +16,14 @@ class V5SyncDevLimaServers extends Command
{--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}
{--force : Allow running outside local/development environments}';
{--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 (! app()->environment(['local', 'development', 'testing']) && ! $this->option('force')) {
$this->error('This command is intended for development only. Use --force to override.');
if (! V5Feature::enabled()) {
$this->error('V5 is only available in development environments.');
return self::FAILURE;
}
+5 -7
View File
@@ -18,6 +18,7 @@ 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;
@@ -51,13 +52,10 @@ class Kernel extends ConsoleKernel
$this->scheduleInstance->command('sanctum:prune-expired --hours=1')->hourly()->onOneServer();
$this->scheduleInstance->job(new ApiTokenExpirationWarningJob)->hourly()->onOneServer();
// V5 reconciliation loop: pull-based safety net for the push-only
// coold -> flux -> webhook status pipeline, plus container status pruning.
$this->scheduleInstance->job(new V5ReconcileServersJob)->everyFiveMinutes()->withoutOverlapping()->onOneServer();
// V5 host JWT rotation: re-mints and SSH-pushes a fresh host token
// before the on-disk token expires so coold reconnects stay authorized.
$this->scheduleInstance->job(new V5RotateAgentTokensJob)->everyFifteenMinutes()->withoutOverlapping()->onOneServer();
if (V5Feature::enabled()) {
$this->scheduleInstance->job(new V5ReconcileServersJob)->everyFiveMinutes()->withoutOverlapping()->onOneServer();
$this->scheduleInstance->job(new V5RotateAgentTokensJob)->everyFifteenMinutes()->withoutOverlapping()->onOneServer();
}
if (isDev()) {
// Instance Jobs
+25 -17
View File
@@ -5,6 +5,7 @@ namespace App\Livewire\Project\Resource;
use App\Models\Environment;
use App\Models\Project;
use App\Models\V5\Application as V5Application;
use App\Support\V5\V5Feature;
use Illuminate\Support\Collection;
use Livewire\Component;
@@ -58,21 +59,26 @@ class Index extends Component
// Load projects and environments for breadcrumb navigation
$this->allProjects = Project::ownedByCurrentTeamCached();
$environmentRelations = [
'applications:id,uuid,name,environment_id',
'services:id,uuid,name,environment_id',
'postgresqls:id,uuid,name,environment_id',
'redis:id,uuid,name,environment_id',
'mongodbs:id,uuid,name,environment_id',
'mysqls:id,uuid,name,environment_id',
'mariadbs:id,uuid,name,environment_id',
'keydbs:id,uuid,name,environment_id',
'dragonflies:id,uuid,name,environment_id',
'clickhouses:id,uuid,name,environment_id',
];
if (V5Feature::enabled()) {
$environmentRelations[] = 'v5Applications:id,uuid,name,environment_id,status';
}
$this->allEnvironments = $project->environments()
->select('id', 'uuid', 'name', 'project_id')
->with([
'applications:id,uuid,name,environment_id',
'v5Applications:id,uuid,name,environment_id,status',
'services:id,uuid,name,environment_id',
'postgresqls:id,uuid,name,environment_id',
'redis:id,uuid,name,environment_id',
'mongodbs:id,uuid,name,environment_id',
'mysqls:id,uuid,name,environment_id',
'mariadbs:id,uuid,name,environment_id',
'keydbs:id,uuid,name,environment_id',
'dragonflies:id,uuid,name,environment_id',
'clickhouses:id,uuid,name,environment_id',
])
->with($environmentRelations)
->get();
$this->environment = $environment->loadCount([
@@ -105,8 +111,8 @@ class Index extends Component
return $application;
});
$this->applications = $this->applications
->merge(V5Application::query()
if (V5Feature::enabled()) {
$this->applications = $this->applications->merge(V5Application::query()
->where('team_id', currentTeam()->id)
->where('project_id', $this->project->id)
->where('environment_id', $this->environment->id)
@@ -120,8 +126,10 @@ class Index extends Component
]);
return $application;
}))
->sortBy('name');
}));
}
$this->applications = $this->applications->sortBy('name');
// Load all database resources in a single query per type
$databaseTypes = [
+5 -2
View File
@@ -4,6 +4,7 @@ namespace App\Models;
use App\Models\V5\Application as V5Application;
use App\Models\V5\ResourceConnection as V5ResourceConnection;
use App\Support\V5\V5Feature;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -56,8 +57,10 @@ class Environment extends BaseModel
public function isEmpty()
{
return ! V5Application::query()->where('environment_id', $this->id)->exists() &&
! V5ResourceConnection::query()->where('environment_id', $this->id)->exists() &&
return (! V5Feature::enabled() || (
! V5Application::query()->where('environment_id', $this->id)->exists() &&
! V5ResourceConnection::query()->where('environment_id', $this->id)->exists()
)) &&
$this->applications()->count() == 0 &&
$this->redis()->count() == 0 &&
$this->postgresqls()->count() == 0 &&
+5 -2
View File
@@ -4,6 +4,7 @@ namespace App\Models;
use App\Models\V5\Application as V5Application;
use App\Models\V5\ResourceConnection as V5ResourceConnection;
use App\Support\V5\V5Feature;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -146,8 +147,10 @@ class Project extends BaseModel
public function isEmpty()
{
return ! V5Application::query()->where('project_id', $this->id)->exists() &&
! V5ResourceConnection::query()->where('project_id', $this->id)->exists() &&
return (! V5Feature::enabled() || (
! V5Application::query()->where('project_id', $this->id)->exists() &&
! V5ResourceConnection::query()->where('project_id', $this->id)->exists()
)) &&
$this->applications()->count() == 0 &&
$this->redis()->count() == 0 &&
$this->postgresqls()->count() == 0 &&
+9 -5
View File
@@ -8,6 +8,7 @@ use App\Jobs\V5TeardownTeamJob;
use App\Notifications\Channels\SendsDiscord;
use App\Notifications\Channels\SendsEmail;
use App\Notifications\Channels\SendsPushover;
use App\Support\V5\V5Feature;
use App\Notifications\Channels\SendsSlack;
use App\Traits\HasNotificationSettings;
use App\Traits\HasSafeStringAttribute;
@@ -80,11 +81,14 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
// 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);
// teardown problem never breaks v4 team deletion. This is disabled
// with the rest of v5 outside development environments.
if (V5Feature::enabled()) {
try {
V5TeardownTeamJob::dispatchForTeam($team);
} catch (\Throwable $exception) {
report($exception);
}
}
RevokeUserTeamTokens::forTeam($team->id);
+7 -1
View File
@@ -4,6 +4,7 @@ namespace App\Providers;
use App\Models\PersonalAccessToken;
use App\Models\V5\Application;
use App\Support\V5\V5Feature;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Facades\App;
@@ -29,7 +30,12 @@ class AppServiceProvider extends ServiceProvider
public function boot(): void
{
$this->configureCommands();
$this->configureMorphMap();
if (V5Feature::enabled()) {
$this->loadMigrationsFrom(database_path('migrations-v5'));
$this->configureMorphMap();
}
$this->configureModels();
$this->configurePasswords();
$this->configureSanctumModel();
+12 -11
View File
@@ -2,6 +2,7 @@
namespace App\Providers;
use App\Support\V5\V5Feature;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Http\Request;
@@ -34,10 +35,12 @@ class RouteServiceProvider extends ServiceProvider
Route::prefix('webhooks')
->group(base_path('routes/webhooks.php'));
Route::middleware('v5.web')
->prefix('v5')
->as('v5.')
->group(base_path('routes/v5.php'));
if (V5Feature::enabled()) {
Route::middleware('v5.web')
->prefix('v5')
->as('v5.')
->group(base_path('routes/v5.php'));
}
Route::middleware('web')
->group(base_path('routes/web.php'));
@@ -60,13 +63,11 @@ class RouteServiceProvider extends ServiceProvider
return Limit::perMinute(5)->by($request->user()?->id ?: $request->ip());
});
// v5 authenticated web endpoints run synchronous SSH/Flux work per
// request (connectivity checks, bootstrap, diagnostics). Throttle per
// user so a single member cannot pin FPM workers by hammering them,
// while leaving ample headroom for the canvas's 3s cluster polling.
RateLimiter::for('v5', function (Request $request) {
return Limit::perMinute(120)->by($request->user()?->id ?: $request->ip());
});
if (V5Feature::enabled()) {
RateLimiter::for('v5', function (Request $request) {
return Limit::perMinute(120)->by($request->user()?->id ?: $request->ip());
});
}
RateLimiter::for('feedback', function (Request $request) {
return Limit::perMinute(3)->by($request->user()?->id ?: $request->ip());
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace App\Support\V5;
class V5Feature
{
private const DEVELOPMENT_ENVIRONMENTS = ['local', 'development', 'dev', 'testing'];
public static function enabled(): bool
{
return (bool) config('v5.enabled');
}
public static function enabledForEnvironment(string $environment): bool
{
return in_array($environment, self::DEVELOPMENT_ENVIRONMENTS, true);
}
}
+28 -27
View File
@@ -1,7 +1,17 @@
<?php
use App\Support\V5\V5Feature;
use Illuminate\Support\Str;
$v5Enabled = V5Feature::enabledForEnvironment((string) env('APP_ENV', 'production'));
$v5ReconcileSupervisor = $v5Enabled ? [
'v5reconcile' => [
'autoScalingStrategy' => 'size',
'minProcesses' => env('HORIZON_V5_RECONCILE_MIN_PROCESSES', 1),
'maxProcesses' => env('HORIZON_V5_RECONCILE_MAX_PROCESSES', 1),
],
] : [];
return [
/*
@@ -193,23 +203,20 @@ return [
'timeout' => env('HORIZON_TIMEOUT', 36000),
],
// Dedicated low-priority pool for the v5 reconcile + host-token rotation
// jobs (queue `v5-reconcile`, set via onQueue()). Isolated from the
// user-facing high/default deploy pool so a starved rotation cannot let
// host tokens drift to expiry, and so the 5-minute fleet fan-out never
// blocks deploys.
'v5reconcile' => [
'connection' => 'redis',
'balance' => env('HORIZON_V5_RECONCILE_BALANCE', 'false'),
'queue' => 'v5-reconcile',
'maxTime' => env('HORIZON_V5_RECONCILE_MAX_TIME', 0),
'maxJobs' => 200,
'memory' => 128,
'tries' => 1,
'nice' => 10,
'sleep' => 3,
'timeout' => env('HORIZON_V5_RECONCILE_TIMEOUT', 300),
],
...($v5Enabled ? [
'v5reconcile' => [
'connection' => 'redis',
'balance' => env('HORIZON_V5_RECONCILE_BALANCE', 'false'),
'queue' => 'v5-reconcile',
'maxTime' => env('HORIZON_V5_RECONCILE_MAX_TIME', 0),
'maxJobs' => 200,
'memory' => 128,
'tries' => 1,
'nice' => 10,
'sleep' => 3,
'timeout' => env('HORIZON_V5_RECONCILE_TIMEOUT', 300),
],
] : []),
],
'environments' => [
@@ -221,11 +228,6 @@ return [
'balanceMaxShift' => env('HORIZON_BALANCE_MAX_SHIFT', 1),
'balanceCooldown' => env('HORIZON_BALANCE_COOLDOWN', 1),
],
'v5reconcile' => [
'autoScalingStrategy' => 'size',
'minProcesses' => env('HORIZON_V5_RECONCILE_MIN_PROCESSES', 1),
'maxProcesses' => env('HORIZON_V5_RECONCILE_MAX_PROCESSES', 2),
],
],
'local' => [
's6' => [
@@ -235,11 +237,10 @@ return [
'balanceMaxShift' => env('HORIZON_BALANCE_MAX_SHIFT', 1),
'balanceCooldown' => env('HORIZON_BALANCE_COOLDOWN', 1),
],
'v5reconcile' => [
'autoScalingStrategy' => 'size',
'minProcesses' => env('HORIZON_V5_RECONCILE_MIN_PROCESSES', 1),
'maxProcesses' => env('HORIZON_V5_RECONCILE_MAX_PROCESSES', 1),
],
...$v5ReconcileSupervisor,
],
'development' => $v5ReconcileSupervisor,
'dev' => $v5ReconcileSupervisor,
'testing' => $v5ReconcileSupervisor,
],
];
+7
View File
@@ -0,0 +1,7 @@
<?php
use App\Support\V5\V5Feature;
return [
'enabled' => V5Feature::enabledForEnvironment((string) env('APP_ENV', 'production')),
];
-3
View File
@@ -11,7 +11,6 @@ services:
- /data/coolify/databases:/var/www/html/storage/app/databases
- /data/coolify/services:/var/www/html/storage/app/services
- /data/coolify/backups:/var/www/html/storage/app/backups
- /data/coolify/flux:/var/www/html/storage/app/flux
environment:
- APP_ENV=${APP_ENV:-production}
- PHP_MEMORY_LIMIT=${PHP_MEMORY_LIMIT:-256M}
@@ -24,10 +23,8 @@ services:
- /data/coolify/source/.env
ports:
- "${APP_PORT:-8000}:8080"
- "${COOLIFY_FLUX_PORT:-6443}:6443"
expose:
- "${APP_PORT:-8000}"
- "${COOLIFY_FLUX_PORT:-6443}"
healthcheck:
test: curl --fail http://127.0.0.1:8080/api/health || exit 1
interval: 5s
-50
View File
@@ -5,9 +5,6 @@ ARG SERVERSIDEUP_PHP_VERSION=8.4-fpm-nginx-alpine
ARG MINIO_VERSION=RELEASE.2025-05-21T01-59-54Z
# https://github.com/cloudflare/cloudflared/releases
ARG CLOUDFLARED_VERSION=2025.7.0
# https://github.com/coollabsio/coold/releases/tag/nightly
ARG COOLIFY_FLUX_VERSION=nightly
ARG COOLIFY_CLI_VERSION=nightly
# https://www.postgresql.org/support/versioning/
# Note: We are using version 18 of the postgres client (while still using postgres 15 for the postgres server) as version 15 has been removed from Alpine 3.23+ https://pkgs.alpinelinux.org/packages?name=postgresql*-client&branch=v3.23&repo=&arch=x86_64&origin=&flagged=&maintainer=
ARG POSTGRES_VERSION=18
@@ -76,11 +73,8 @@ FROM serversideup/php:${SERVERSIDEUP_PHP_VERSION}
ARG USER_ID
ARG GROUP_ID
ARG TARGETPLATFORM
ARG TARGETARCH
ARG POSTGRES_VERSION
ARG CLOUDFLARED_VERSION
ARG COOLIFY_FLUX_VERSION
ARG COOLIFY_CLI_VERSION
ARG NGINX_VERSION
ARG CI=true
@@ -114,7 +108,6 @@ RUN --mount=type=cache,target=/var/cache/apk \
apk add --no-cache \
postgresql${POSTGRES_VERSION}-client \
openssh-client \
openssl \
git \
git-lfs \
jq \
@@ -135,49 +128,6 @@ RUN mkdir -p /usr/local/bin && \
fi && \
chmod +x /usr/local/bin/cloudflared
# Install Flux from coold nightly release based on architecture
RUN set -eux; \
mkdir -p /usr/local/bin /run/coolify /etc/coolify; \
chown -R www-data:www-data /run/coolify /etc/coolify; \
case "${TARGETARCH:-}" in \
amd64|arm64) FLUX_ARCH="${TARGETARCH}" ;; \
"") \
case "$(uname -m)" in \
x86_64) FLUX_ARCH="amd64" ;; \
aarch64) FLUX_ARCH="arm64" ;; \
*) echo "unsupported Flux arch: $(uname -m)" >&2; exit 1 ;; \
esac ;; \
*) echo "unsupported Flux TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \
esac; \
curl -fsSL --retry 3 --max-time 120 \
-o /tmp/flux.tar.gz \
"https://github.com/coollabsio/coold/releases/download/${COOLIFY_FLUX_VERSION}/flux-linux-musl-${FLUX_ARCH}.tar.gz"; \
tar -xzf /tmp/flux.tar.gz -C /tmp; \
test -f /tmp/flux; \
install -m 0755 /tmp/flux /usr/local/bin/flux; \
rm -f /tmp/flux /tmp/flux.tar.gz
# Install coolify from coold nightly release based on architecture
RUN set -eux; \
mkdir -p /usr/local/bin; \
case "${TARGETARCH:-}" in \
amd64|arm64) COOLIFY_CLI_ARCH="${TARGETARCH}" ;; \
"") \
case "$(uname -m)" in \
x86_64) COOLIFY_CLI_ARCH="amd64" ;; \
aarch64) COOLIFY_CLI_ARCH="arm64" ;; \
*) echo "unsupported coolify arch: $(uname -m)" >&2; exit 1 ;; \
esac ;; \
*) echo "unsupported coolify TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \
esac; \
curl -fsSL --retry 3 --max-time 120 \
-o /tmp/coolify.tar.gz \
"https://github.com/coollabsio/coold/releases/download/${COOLIFY_CLI_VERSION}/coolify-linux-musl-${COOLIFY_CLI_ARCH}.tar.gz"; \
tar -xzf /tmp/coolify.tar.gz -C /tmp; \
test -f /tmp/coolify; \
install -m 0755 /tmp/coolify /usr/local/bin/coolify; \
rm -f /tmp/coolify /tmp/coolify.tar.gz
# Configure PHP
COPY docker/production/etc/php/conf.d/zzz-custom-php.ini /usr/local/etc/php/conf.d/zzz-custom-php.ini
ENV PHP_OPCACHE_ENABLE=1
@@ -1,47 +0,0 @@
#!/bin/sh
cd /var/www/html
. /etc/s6-overlay/scripts/container-role
role="$(coolify_container_role_value)"
if ! coolify_container_has_role flux; then
echo " INFO Flux is disabled for role '$role', sleeping."
exec sleep infinity
fi
if grep -qE '^COOLIFY_FLUX_ENABLED=false' .env 2>/dev/null || [ "${COOLIFY_FLUX_ENABLED:-}" = "false" ]; then
echo " INFO Flux is disabled, sleeping."
exec sleep infinity
fi
export COOLIFY_FLUX_GRPC_BIND="${COOLIFY_FLUX_GRPC_BIND:-0.0.0.0:6443}"
export COOLIFY_FLUX_UNIX_SOCKET_PATH="${COOLIFY_FLUX_UNIX_SOCKET_PATH:-/run/coolify/flux.sock}"
export COOLIFY_FLUX_JWT_PRIVATE_KEY_PATH="${COOLIFY_FLUX_JWT_PRIVATE_KEY_PATH:-/var/www/html/storage/app/flux/jwt.priv}"
export COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH="${COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH:-/var/www/html/storage/app/flux/jwt.pub}"
export COOLIFY_FLUX_ALLOW_PUBLIC_BIND="${COOLIFY_FLUX_ALLOW_PUBLIC_BIND:-1}"
export COOLIFY_FLUX_LARAVEL_API_URL="${COOLIFY_FLUX_LARAVEL_API_URL:-http://127.0.0.1:8080}"
if [ -z "${COOLIFY_FLUX_LARAVEL_API_TOKEN:-}" ] && [ -f .env ]; then
COOLIFY_FLUX_LARAVEL_API_TOKEN="$(grep -E '^COOLIFY_FLUX_LARAVEL_API_TOKEN=' .env 2>/dev/null | tail -n1 | cut -d= -f2- | sed "s/^['\"]//; s/['\"]$//")"
fi
export COOLIFY_FLUX_LARAVEL_API_TOKEN="${COOLIFY_FLUX_LARAVEL_API_TOKEN:-}"
if [ ! -r "$COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH" ]; then
echo " INFO Flux JWT public key not found at $COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH, generating keypair..."
mkdir -p "$(dirname "$COOLIFY_FLUX_JWT_PRIVATE_KEY_PATH")" "$(dirname "$COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH")"
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out "$COOLIFY_FLUX_JWT_PRIVATE_KEY_PATH.tmp"
chmod 0600 "$COOLIFY_FLUX_JWT_PRIVATE_KEY_PATH.tmp"
mv "$COOLIFY_FLUX_JWT_PRIVATE_KEY_PATH.tmp" "$COOLIFY_FLUX_JWT_PRIVATE_KEY_PATH"
openssl pkey -in "$COOLIFY_FLUX_JWT_PRIVATE_KEY_PATH" -pubout -out "$COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH.tmp"
chmod 0644 "$COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH.tmp"
mv "$COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH.tmp" "$COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH"
fi
if ! /usr/local/bin/flux --version >/dev/null 2>&1; then
echo " ERROR Flux binary cannot run in this container. Check that the installed coold nightly Flux artifact is compatible with Alpine."
exec sleep infinity
fi
mkdir -p "$(dirname "$COOLIFY_FLUX_UNIX_SOCKET_PATH")"
echo " INFO Flux is enabled for role '$role', starting..."
exec /usr/local/bin/flux
@@ -1 +0,0 @@
longrun
+1 -2
View File
@@ -228,12 +228,11 @@ if [ "$WARNING_SPACE" = true ]; then
sleep 5
fi
mkdir -p /data/coolify/{source,ssh,applications,databases,backups,services,proxy,sentinel,flux}
mkdir -p /data/coolify/{source,ssh,applications,databases,backups,services,proxy,sentinel}
mkdir -p /data/coolify/ssh/{keys,mux}
mkdir -p /data/coolify/proxy/dynamic
chown -R 9999:root /data/coolify
chown -R 9999:root /data/coolify/flux
chmod -R 700 /data/coolify
INSTALLATION_LOG_WITH_DATE="/data/coolify/source/installation-${DATE}.log"
-3
View File
@@ -171,9 +171,6 @@ else
log "Network 'coolify' already exists"
fi
mkdir -p /data/coolify/flux
chown -R 9999:root /data/coolify/flux
# Check if Docker config file exists
DOCKER_CONFIG_MOUNT=""
if [ -f /root/.docker/config.json ]; then
@@ -91,7 +91,7 @@
->merge($env->clickhouses ?? collect());
$envResources = collect()
->merge($env->applications->map(fn($app) => ['type' => 'application', 'resource' => $app]))
->merge($env->v5Applications->map(fn($app) => ['type' => 'v5-application', 'resource' => $app]))
->merge(config('v5.enabled') ? $env->v5Applications->map(fn($app) => ['type' => 'v5-application', 'resource' => $app]) : collect())
->merge($envDatabases->map(fn($db) => ['type' => 'database', 'resource' => $db]))
->merge($env->services->map(fn($svc) => ['type' => 'service', 'resource' => $svc]))
->sortBy(fn($item) => strtolower($item['resource']->name));
@@ -141,7 +141,7 @@
->merge($env->clickhouses ?? collect());
$envResources = collect()
->merge($env->applications->map(fn($app) => ['type' => 'application', 'resource' => $app]))
->merge($env->v5Applications->map(fn($app) => ['type' => 'v5-application', 'resource' => $app]))
->merge(config('v5.enabled') ? $env->v5Applications->map(fn($app) => ['type' => 'v5-application', 'resource' => $app]) : collect())
->merge($envDatabases->map(fn($db) => ['type' => 'database', 'resource' => $db]))
->merge($env->services->map(fn($svc) => ['type' => 'service', 'resource' => $svc]));
@endphp
+5 -1
View File
@@ -9,6 +9,7 @@ use App\Http\Controllers\Api\DigitalOceanController;
use App\Http\Controllers\Api\GithubController;
use App\Http\Controllers\Api\HetznerController;
use App\Http\Controllers\Api\Internal\FluxResourceStatusController;
use App\Support\V5\V5Feature;
use App\Http\Controllers\Api\OtherController;
use App\Http\Controllers\Api\ProjectController;
use App\Http\Controllers\Api\ResourcesController;
@@ -287,7 +288,10 @@ Route::group([
Route::group([
'prefix' => 'v1',
], function () {
Route::post('/internal/flux/resource-status', FluxResourceStatusController::class);
if (V5Feature::enabled()) {
Route::post('/internal/flux/resource-status', FluxResourceStatusController::class);
}
Route::post('/sentinel/push', [SentinelController::class, 'push']);
});
+1 -2
View File
@@ -228,12 +228,11 @@ if [ "$WARNING_SPACE" = true ]; then
sleep 5
fi
mkdir -p /data/coolify/{source,ssh,applications,databases,backups,services,proxy,sentinel,flux}
mkdir -p /data/coolify/{source,ssh,applications,databases,backups,services,proxy,sentinel}
mkdir -p /data/coolify/ssh/{keys,mux}
mkdir -p /data/coolify/proxy/dynamic
chown -R 9999:root /data/coolify
chown -R 9999:root /data/coolify/flux
chmod -R 700 /data/coolify
INSTALLATION_LOG_WITH_DATE="/data/coolify/source/installation-${DATE}.log"
-3
View File
@@ -171,9 +171,6 @@ else
log "Network 'coolify' already exists"
fi
mkdir -p /data/coolify/flux
chown -R 9999:root /data/coolify/flux
# Fix SSH directory ownership if not owned by container user UID 9999 (fixes #6621)
# Only changes owner — preserves existing group to respect custom setups
SSH_OWNER=$(stat -c '%u' /data/coolify/ssh 2>/dev/null || echo "unknown")
+2 -2
View File
@@ -93,7 +93,7 @@ it('rewrites legacy fqcn morph rows to the v5 application alias', function () {
'updated_at' => now(),
]);
$migration = include database_path('migrations/2026_07_06_090000_v5_convert_resource_connection_morphs_to_aliases.php');
$migration = include database_path('migrations-v5/2026_07_06_090000_v5_convert_resource_connection_morphs_to_aliases.php');
$migration->up();
$connection = DB::table('v5_resource_connections')->where('uuid', 'legacy-connection')->first();
@@ -155,7 +155,7 @@ it('backfills the capability booleans from the legacy json column', function ()
['name' => 'empty', 'capabilities' => null],
]);
$migration = include database_path('migrations/2026_07_06_090100_v5_convert_server_capabilities_to_booleans.php');
$migration = include database_path('migrations-v5/2026_07_06_090100_v5_convert_server_capabilities_to_booleans.php');
$migration->up();
$serversByName = DB::table('v5_servers')->get()->keyBy('name');
+21 -21
View File
@@ -7,7 +7,7 @@ beforeEach(function () {
});
it('reuses existing projects instead of creating v5 projects', function () {
expect(file_exists(database_path('migrations/2026_06_04_050157_v5_create_projects_table.php')))->toBeFalse()
expect(file_exists(database_path('migrations-v5/2026_06_04_050157_v5_create_projects_table.php')))->toBeFalse()
->and(file_exists(app_path('Models/V5/Project.php')))->toBeFalse();
});
@@ -16,10 +16,10 @@ it('creates v5 cluster tables and lets each server belong to one cluster', funct
Schema::dropIfExists('v5_servers');
Schema::dropIfExists('v5_clusters');
$clusterMigration = include database_path('migrations/2026_06_16_130649_v5_create_clusters_table.php');
$clusterMigration = include database_path('migrations-v5/2026_06_16_130649_v5_create_clusters_table.php');
$clusterMigration->up();
$serverMigration = include database_path('migrations/2026_06_16_130650_v5_create_servers_table.php');
$serverMigration = include database_path('migrations-v5/2026_06_16_130650_v5_create_servers_table.php');
$serverMigration->up();
expect(Schema::hasTable('v5_clusters'))->toBeTrue()
@@ -61,10 +61,10 @@ it('creates v5 server tables in the shared database', function () {
Schema::dropIfExists('v5_servers');
Schema::dropIfExists('v5_clusters');
$clusterMigration = include database_path('migrations/2026_06_16_130649_v5_create_clusters_table.php');
$clusterMigration = include database_path('migrations-v5/2026_06_16_130649_v5_create_clusters_table.php');
$clusterMigration->up();
$migration = include database_path('migrations/2026_06_16_130650_v5_create_servers_table.php');
$migration = include database_path('migrations-v5/2026_06_16_130650_v5_create_servers_table.php');
$migration->up();
expect(Schema::hasTable('v5_servers'))->toBeTrue()
@@ -109,10 +109,10 @@ it('creates v5 server canvas columns for movable caddy ingress nodes', function
Schema::dropIfExists('v5_servers');
Schema::dropIfExists('v5_clusters');
$clusterMigration = include database_path('migrations/2026_06_16_130649_v5_create_clusters_table.php');
$clusterMigration = include database_path('migrations-v5/2026_06_16_130649_v5_create_clusters_table.php');
$clusterMigration->up();
$serverMigration = include database_path('migrations/2026_06_16_130650_v5_create_servers_table.php');
$serverMigration = include database_path('migrations-v5/2026_06_16_130650_v5_create_servers_table.php');
$serverMigration->up();
expect(Schema::hasColumns('v5_servers', [
@@ -127,10 +127,10 @@ it('creates v5 server ingress columns', function () {
Schema::dropIfExists('v5_servers');
Schema::dropIfExists('v5_clusters');
$clusterMigration = include database_path('migrations/2026_06_16_130649_v5_create_clusters_table.php');
$clusterMigration = include database_path('migrations-v5/2026_06_16_130649_v5_create_clusters_table.php');
$clusterMigration->up();
$serverMigration = include database_path('migrations/2026_06_16_130650_v5_create_servers_table.php');
$serverMigration = include database_path('migrations-v5/2026_06_16_130650_v5_create_servers_table.php');
$serverMigration->up();
expect(Schema::hasColumns('v5_servers', [
@@ -147,13 +147,13 @@ it('creates v5 application tables for dashboard canvas nodes', function () {
Schema::dropIfExists('v5_servers');
Schema::dropIfExists('v5_clusters');
$clusterMigration = include database_path('migrations/2026_06_16_130649_v5_create_clusters_table.php');
$clusterMigration = include database_path('migrations-v5/2026_06_16_130649_v5_create_clusters_table.php');
$clusterMigration->up();
$serverMigration = include database_path('migrations/2026_06_16_130650_v5_create_servers_table.php');
$serverMigration = include database_path('migrations-v5/2026_06_16_130650_v5_create_servers_table.php');
$serverMigration->up();
$applicationMigration = include database_path('migrations/2026_06_19_140000_v5_create_applications_table.php');
$applicationMigration = include database_path('migrations-v5/2026_06_19_140000_v5_create_applications_table.php');
$applicationMigration->up();
expect(Schema::hasTable('v5_applications'))->toBeTrue()
@@ -188,13 +188,13 @@ it('creates v5 application domain tables for zero or more inbound routes', funct
Schema::dropIfExists('v5_servers');
Schema::dropIfExists('v5_clusters');
$clusterMigration = include database_path('migrations/2026_06_16_130649_v5_create_clusters_table.php');
$clusterMigration = include database_path('migrations-v5/2026_06_16_130649_v5_create_clusters_table.php');
$clusterMigration->up();
$serverMigration = include database_path('migrations/2026_06_16_130650_v5_create_servers_table.php');
$serverMigration = include database_path('migrations-v5/2026_06_16_130650_v5_create_servers_table.php');
$serverMigration->up();
$applicationMigration = include database_path('migrations/2026_06_19_140000_v5_create_applications_table.php');
$applicationMigration = include database_path('migrations-v5/2026_06_19_140000_v5_create_applications_table.php');
$applicationMigration->up();
expect(Schema::hasTable('v5_application_domains'))->toBeTrue()
@@ -221,16 +221,16 @@ it('creates generic v5 resource connection tables', function () {
Schema::dropIfExists('v5_servers');
Schema::dropIfExists('v5_clusters');
$clusterMigration = include database_path('migrations/2026_06_16_130649_v5_create_clusters_table.php');
$clusterMigration = include database_path('migrations-v5/2026_06_16_130649_v5_create_clusters_table.php');
$clusterMigration->up();
$serverMigration = include database_path('migrations/2026_06_16_130650_v5_create_servers_table.php');
$serverMigration = include database_path('migrations-v5/2026_06_16_130650_v5_create_servers_table.php');
$serverMigration->up();
$applicationMigration = include database_path('migrations/2026_06_19_140000_v5_create_applications_table.php');
$applicationMigration = include database_path('migrations-v5/2026_06_19_140000_v5_create_applications_table.php');
$applicationMigration->up();
$connectionMigration = include database_path('migrations/2026_06_19_142000_v5_create_resource_connections_table.php');
$connectionMigration = include database_path('migrations-v5/2026_06_19_142000_v5_create_resource_connections_table.php');
$connectionMigration->up();
expect(Schema::hasTable('v5_resource_connections'))->toBeTrue()
@@ -269,10 +269,10 @@ it('keeps v5 server fields in the initial migration', function () {
Schema::dropIfExists('v5_servers');
Schema::dropIfExists('v5_clusters');
$clusterMigration = include database_path('migrations/2026_06_16_130649_v5_create_clusters_table.php');
$clusterMigration = include database_path('migrations-v5/2026_06_16_130649_v5_create_clusters_table.php');
$clusterMigration->up();
$serverMigration = include database_path('migrations/2026_06_16_130650_v5_create_servers_table.php');
$serverMigration = include database_path('migrations-v5/2026_06_16_130650_v5_create_servers_table.php');
$serverMigration->up();
expect(Schema::hasColumns('v5_servers', [
@@ -0,0 +1,82 @@
<?php
use Illuminate\Support\Facades\Artisan;
use Symfony\Component\Process\Process;
function inspectV5RegistrationForEnvironment(string $environment): array
{
$script = <<<'PHP'
$environment = $argv[1];
putenv("APP_ENV={$environment}");
$_ENV['APP_ENV'] = $environment;
$_SERVER['APP_ENV'] = $environment;
require 'vendor/autoload.php';
$app = require 'bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
echo json_encode([
'routes' => collect($app['router']->getRoutes()->getRoutes())
->map(fn ($route) => $route->uri())
->values()
->all(),
'migration_paths' => $app->make('migrator')->paths(),
'horizon_supervisors' => array_keys(config("horizon.environments.{$environment}", [])),
], JSON_THROW_ON_ERROR);
PHP;
$process = new Process([PHP_BINARY, '-r', $script, $environment], base_path());
$process->mustRun();
return json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR);
}
it('registers v5 routes, migrations, and workers in development environments', function (string $environment) {
$registration = inspectV5RegistrationForEnvironment($environment);
expect($registration['routes'])
->toContain('v5')
->toContain('api/v1/internal/flux/resource-status')
->and($registration['migration_paths'])->toContain(database_path('migrations-v5'))
->and($registration['horizon_supervisors'])->toContain('v5reconcile');
})->with(['local', 'development', 'dev', 'testing']);
it('does not register v5 routes, migrations, or workers outside development', function (string $environment) {
$registration = inspectV5RegistrationForEnvironment($environment);
expect($registration['routes'])
->not->toContain('v5')
->not->toContain('api/v1/internal/flux/resource-status')
->and($registration['migration_paths'])->not->toContain(database_path('migrations-v5'))
->and($registration['horizon_supervisors'])->not->toContain('v5reconcile');
})->with(['production', 'staging']);
it('keeps v5 schema changes out of the default migration path', function () {
$v5MigrationFiles = collect(glob(database_path('migrations/*.php')))
->filter(fn (string $path) => str_contains((string) file_get_contents($path), "'v5_"));
expect($v5MigrationFiles)->toBeEmpty();
});
it('does not ship the v5 Flux runtime in the production container', function () {
$productionCompose = file_get_contents(base_path('docker-compose.prod.yml'));
$productionDockerfile = file_get_contents(base_path('docker/production/Dockerfile'));
expect($productionCompose)
->not->toContain('COOLIFY_FLUX')
->not->toContain('/data/coolify/flux')
->and($productionDockerfile)
->not->toContain('COOLIFY_FLUX_VERSION')
->not->toContain('/usr/local/bin/flux');
});
it('blocks v5 console commands when v5 is disabled', function (string $command) {
config()->set('v5.enabled', false);
expect(Artisan::call($command))->toBe(1)
->and(Artisan::output())->toContain('V5 is only available in development environments.');
})->with([
'flux:dev',
'v5:flux-generate-keys',
'v5:sync-dev-lima-servers',
]);
@@ -0,0 +1,22 @@
<?php
use App\Models\Environment;
use App\Models\Project;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\Support\V5TestSchema;
uses(RefreshDatabase::class);
it('keeps v4 project and environment checks working without the v5 schema', function () {
config()->set('v5.enabled', false);
$team = Team::factory()->create();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
V5TestSchema::dropAllTables();
expect($project->isEmpty())->toBeTrue()
->and($environment->isEmpty())->toBeTrue();
});
+1 -1
View File
@@ -9,7 +9,7 @@ use Illuminate\Support\Facades\Schema;
* build their schema by hand instead of running the full migration set.
*
* The column definitions must exactly mirror the final state produced by the
* database/migrations/*v5* files (including the 2026_07_05/2026_07_06
* database/migrations-v5 files (including the 2026_07_05/2026_07_06
* additions: status_observed_at, coold_version, has_coold/is_ingress booleans
* replacing the dropped capabilities json, and NOT NULL server uuids). Update
* this class in the same change as any v5 migration.