Merge remote-tracking branch 'origin/next' into feat/noindex-domains

This commit is contained in:
Andras Bacsai committed 2026-08-08 11:26:20 +02:00
commit 003e44d123
1274 files changed
+156179 -25249

No files matched your search

+213 -39
View File
@@ -76,6 +76,7 @@ use Symfony\Component\Yaml\Yaml;
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares.'],
'status' => ['type' => 'string', 'description' => 'Application status.'],
'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'],
'max_restart_count' => ['type' => 'integer', 'description' => 'Maximum container restart count before stopping.'],
'destination_type' => ['type' => 'string', 'description' => 'Destination type.'],
'destination_id' => ['type' => 'integer', 'description' => 'Destination identifier.'],
'source_id' => ['type' => 'integer', 'nullable' => true, 'description' => 'Source identifier.'],
@@ -113,7 +114,7 @@ use Symfony\Component\Yaml\Yaml;
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'],
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
'settings' => new OA\Property(ref: '#/components/schemas/ApplicationSetting'),
new OA\Property(property: 'settings', ref: '#/components/schemas/ApplicationSetting'),
]
)]
@@ -183,6 +184,7 @@ class Application extends BaseModel
'docker_compose',
'docker_compose_raw',
'docker_compose_domains',
'domain_dns_statuses',
'docker_compose_custom_start_command',
'docker_compose_custom_build_command',
'swarm_replicas',
@@ -235,6 +237,7 @@ class Application extends BaseModel
'docker_compose',
'docker_compose_raw',
'custom_labels',
'domain_dns_statuses',
];
protected function casts(): array
@@ -246,6 +249,7 @@ class Application extends BaseModel
'manual_webhook_secret_bitbucket' => 'encrypted',
'manual_webhook_secret_gitea' => 'encrypted',
'noindex_domains' => 'array',
'domain_dns_statuses' => 'array',
'restart_count' => 'integer',
'max_restart_count' => 'integer',
'last_restart_at' => 'datetime',
@@ -1101,16 +1105,110 @@ class Application extends BaseModel
return ApplicationDeploymentQueue::where('application_id', $this->id)->where('created_at', '>=', now()->subDays(7))->orderBy('created_at', 'desc')->get();
}
public function deployments(int $skip = 0, int $take = 10, ?string $pullRequestId = null)
{
$deployments = ApplicationDeploymentQueue::where('application_id', $this->id)->orderBy('created_at', 'desc');
/**
* @param array<int, string> $filters
* @return array{count: int, deployments: Collection<int, ApplicationDeploymentQueue>}
*/
public function deployments(
int $skip = 0,
int $take = 10,
?string $pullRequestId = null,
?string $search = null,
array $filters = [],
string $sort = 'newest',
): array {
$deployments = ApplicationDeploymentQueue::query()
->where('application_id', $this->id);
if ($pullRequestId) {
$deployments = $deployments->where('pull_request_id', $pullRequestId);
$deployments->where('pull_request_id', $pullRequestId);
}
$search = trim((string) $search);
if ($search !== '') {
$normalizedSearch = Str::lower($search);
$statusAliases = [
'success' => ApplicationDeploymentStatus::FINISHED->value,
'in progress' => ApplicationDeploymentStatus::IN_PROGRESS->value,
'cancelled' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
];
$deployments->where(function ($query) use ($search, $normalizedSearch, $statusAliases) {
$query
->whereLike('deployment_uuid', "%{$search}%")
->orWhereLike('commit', "%{$search}%")
->orWhereLike('commit_message', "%{$search}%")
->orWhereLike('server_name', "%{$search}%")
->orWhereLike('status', "%{$search}%");
if (isset($statusAliases[$normalizedSearch])) {
$query->orWhere('status', $statusAliases[$normalizedSearch]);
}
if (is_numeric($search) && (int) $search > 0) {
$query->orWhere('pull_request_id', (int) $search);
}
match ($normalizedSearch) {
'pull request', 'pull requests', 'pr' => $query->orWhere('pull_request_id', '>', 0),
'webhook', 'webhooks' => $query->orWhere('is_webhook', true),
'rollback', 'rollbacks' => $query->orWhere('rollback', true),
'api' => $query->orWhere('is_api', true),
'manual' => $query->orWhere(function ($sourceQuery) {
$sourceQuery
->where('pull_request_id', '<=', 0)
->where('is_webhook', false)
->where('rollback', false)
->where('is_api', false);
}),
default => null,
};
});
}
$statusFilters = collect($filters)
->filter(fn (string $filter) => Str::startsWith($filter, 'status:'))
->map(fn (string $filter) => Str::after($filter, 'status:'));
if ($statusFilters->isNotEmpty()) {
$deployments->whereIn('status', $statusFilters);
}
$sourceFilters = collect($filters)
->filter(fn (string $filter) => Str::startsWith($filter, 'source:'))
->map(fn (string $filter) => Str::after($filter, 'source:'));
if ($sourceFilters->isNotEmpty()) {
$deployments->where(function ($query) use ($sourceFilters) {
foreach ($sourceFilters as $source) {
$query->orWhere(function ($sourceQuery) use ($source) {
match ($source) {
'pull-request' => $sourceQuery->where('pull_request_id', '>', 0),
'webhook' => $sourceQuery->where('pull_request_id', '<=', 0)->where('is_webhook', true),
'rollback' => $sourceQuery->where('pull_request_id', '<=', 0)->where('is_webhook', false)->where('rollback', true),
'api' => $sourceQuery->where('pull_request_id', '<=', 0)->where('is_webhook', false)->where('rollback', false)->where('is_api', true),
'manual' => $sourceQuery->where('pull_request_id', '<=', 0)->where('is_webhook', false)->where('rollback', false)->where('is_api', false),
default => $sourceQuery->where('id', -1),
};
});
}
});
}
$serverFilters = collect($filters)
->filter(fn (string $filter) => Str::startsWith($filter, 'server:'))
->map(fn (string $filter) => Str::after($filter, 'server:'))
->filter(fn (string $serverId) => ctype_digit($serverId))
->map(fn (string $serverId) => (int) $serverId);
if ($serverFilters->isNotEmpty()) {
$deployments->whereIn('server_id', $serverFilters);
}
$count = $deployments->count();
$deployments = $deployments->skip($skip)->take($take)->get();
$deployments = $deployments
->orderBy('created_at', $sort === 'oldest' ? 'asc' : 'desc')
->orderBy('id', $sort === 'oldest' ? 'asc' : 'desc')
->skip($skip)
->take($take)
->get();
return [
'count' => $count,
@@ -1383,11 +1481,11 @@ class Application extends BaseModel
if ($this->deploymentType() === 'source') {
$source_html_url = data_get($this, 'source.html_url');
$url = parse_url(filter_var($source_html_url, FILTER_SANITIZE_URL));
$source_html_url_host = $url['host'];
$source_html_url_scheme = $url['scheme'];
if ($this->source->getMorphClass() == 'App\Models\GithubApp') {
$url = parse_url(filter_var($source_html_url, FILTER_SANITIZE_URL)) ?: [];
$source_html_url_host = $url['host'] ?? '';
$source_html_url_scheme = $url['scheme'] ?? '';
$escapedCustomRepository = escapeshellarg($customRepository);
if ($this->source->is_public) {
$escapedRepoUrl = escapeshellarg("{$this->source->html_url}/{$customRepository}");
@@ -1425,6 +1523,32 @@ class Application extends BaseModel
if ($this->source->getMorphClass() === GitlabApp::class) {
$gitlabSource = $this->source;
if ($gitlabSource->isConnected()) {
$url = parse_url(filter_var($source_html_url, FILTER_SANITIZE_URL)) ?: [];
$source_html_url_host = $this->urlHostWithPort($url);
$source_html_url_scheme = $url['scheme'] ?? '';
$token = generateGitlabCloneToken($gitlabSource);
$encodedToken = rawurlencode($token);
$pathPrefix = rtrim($url['path'] ?? '', '/');
$repoUrl = "{$source_html_url_scheme}://oauth2:{$encodedToken}@{$source_html_url_host}{$pathPrefix}/{$customRepository}.git";
$escapedRepoUrl = escapeshellarg($repoUrl);
$fullRepoUrl = $repoUrl;
$base_command = "{$base_command} {$escapedRepoUrl}";
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, $base_command));
} else {
$commands->push($base_command);
}
return [
'commands' => $commands->implode(' && '),
'branch' => $branch,
'fullRepoUrl' => $fullRepoUrl,
];
}
$private_key = data_get($gitlabSource, 'privateKey.private_key');
if ($private_key) {
@@ -1449,7 +1573,6 @@ class Application extends BaseModel
];
}
// GitLab source without private key — use URL as-is (supports user-embedded basic auth)
$fullRepoUrl = $customRepository;
$escapedCustomRepository = escapeshellarg($customRepository);
$base_command = "{$base_command} {$escapedCustomRepository}";
@@ -1682,6 +1805,52 @@ class Application extends BaseModel
if ($this->source->getMorphClass() === GitlabApp::class) {
$gitlabSource = $this->source;
if ($gitlabSource->isConnected()) {
$token = generateGitlabCloneToken($gitlabSource);
$encodedToken = rawurlencode($token);
$pathPrefix = rtrim($url['path'] ?? '', '/');
$source_html_url_host = $this->urlHostWithPort($url ?: []);
// Rewrite same-host HTTPS submodule URLs to auth with the OAuth token (mirrors the GitHub path) without persisting credentials.
$gitConfigOption = '-c '.escapeshellarg("url.{$source_html_url_scheme}://oauth2:{$encodedToken}@{$source_html_url_host}{$pathPrefix}/.insteadOf={$source_html_url_scheme}://{$source_html_url_host}{$pathPrefix}/");
$gitConfigOptions = $this->withGitHttpTransportConfig($gitConfigOption);
$repoUrl = "{$source_html_url_scheme}://oauth2:{$encodedToken}@{$source_html_url_host}{$pathPrefix}/{$customRepository}.git";
$escapedRepoUrl = escapeshellarg($repoUrl);
$fullRepoUrl = $repoUrl;
$git_clone_command_base = $this->applyGitConfigOptionsToCloneCommand("{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}", $gitConfigOptions);
if ($only_checkout) {
$git_clone_command = $git_clone_command_base;
} else {
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitConfigOptions: $gitConfigOptions);
}
if ($pull_request_id !== 0) {
$branch = "merge-requests/{$pull_request_id}/head:{$pr_branch_name}";
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, "echo 'Checking out {$branch}'"));
} else {
$commands->push("echo 'Checking out {$branch}'");
}
$git_checkout_command = $this->buildGitCheckoutCommand($pr_branch_name, gitConfigOptions: $gitConfigOptions);
$escapedPrBranch = escapeshellarg($branch);
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && git {$gitConfigOptions} fetch origin {$escapedPrBranch} && {$git_checkout_command}";
}
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, $git_clone_command));
} else {
$commands->push($git_clone_command);
}
return [
'commands' => $commands->implode(' && '),
'branch' => $branch,
'fullRepoUrl' => $fullRepoUrl,
];
}
$private_key = data_get($gitlabSource, 'privateKey.private_key');
if ($private_key) {
@@ -1722,7 +1891,6 @@ class Application extends BaseModel
];
}
// GitLab source without private key — use URL as-is (supports user-embedded basic auth)
$fullRepoUrl = $customRepository;
$escapedCustomRepository = escapeshellarg($customRepository);
$git_clone_command = "{$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}";
@@ -2016,34 +2184,7 @@ class Application extends BaseModel
$this->save();
$parsedServices = $this->parse();
if ($this->docker_compose_domains) {
$decoded = json_decode($this->docker_compose_domains, true);
$json = collect(is_array($decoded) ? $decoded : []);
$normalized = collect();
foreach ($json as $key => $value) {
$normalizedKey = (string) str($key)->replace('-', '_')->replace('.', '_');
$normalized->put($normalizedKey, $value);
}
$json = $normalized;
$services = collect(data_get($parsedServices, 'services', []));
foreach ($services as $name => $service) {
if (str($name)->contains('-') || str($name)->contains('.')) {
$replacedName = str($name)->replace('-', '_')->replace('.', '_');
$services->put((string) $replacedName, $service);
$services->forget((string) $name);
}
}
$names = collect($services)->keys()->toArray();
$jsonNames = $json->keys()->toArray();
$diff = array_diff($jsonNames, $names);
$json = $json->filter(function ($value, $key) use ($diff) {
return ! in_array($key, $diff);
});
if ($json) {
$this->docker_compose_domains = json_encode($json);
} else {
$this->docker_compose_domains = null;
}
$this->save();
$this->reconcileDockerComposeDomains($parsedServices);
}
return [
@@ -2060,6 +2201,31 @@ class Application extends BaseModel
}
}
private function reconcileDockerComposeDomains(mixed $parsedServices): void
{
$services = collect(data_get($parsedServices, 'services', []));
$serviceNames = $services->keys()->map(fn ($name) => (string) $name)->all();
if ($serviceNames === []) {
return;
}
$decoded = json_decode($this->docker_compose_domains, true);
$rekeyed = rekeyComposeDomainsToServiceNames(
is_array($decoded) ? $decoded : [],
$serviceNames,
);
$domains = collect($rekeyed)->filter(
fn ($value, $key) => findComposeServiceName((string) $key, $serviceNames) !== null
);
$this->docker_compose_domains = $domains->isNotEmpty()
? json_encode($domains->all())
: null;
$this->save();
}
public function parseContainerLabels(?ApplicationPreview $preview = null)
{
$customLabels = data_get($this, 'custom_labels');
@@ -2387,6 +2553,14 @@ class Application extends BaseModel
];
}
private function urlHostWithPort(array $url): string
{
$host = $url['host'] ?? '';
$port = isset($url['port']) ? ":{$url['port']}" : '';
return "{$host}{$port}";
}
public function generateConfig($is_json = false)
{
$generator = new ConfigurationGenerator($this);
+77 -26
View File
@@ -124,34 +124,55 @@ class ApplicationPreview extends BaseModel
public function generate_preview_fqdn_compose()
{
$services = collect(json_decode($this->application->docker_compose_domains)) ?? collect();
$docker_compose_domains = data_get($this, 'docker_compose_domains');
$docker_compose_domains = json_decode($docker_compose_domains, true) ?? [];
$applicationDomains = json_decode($this->application->docker_compose_domains ?: '[]', true) ?: [];
$previewDomains = json_decode(data_get($this, 'docker_compose_domains') ?: '[]', true) ?: [];
// Get all services from the parsed compose file to ensure all services have entries
$parsedServices = $this->application->parse(pull_request_id: $this->pull_request_id);
if (isset($parsedServices['services'])) {
foreach ($parsedServices['services'] as $serviceName => $service) {
if (! isDatabaseImage(data_get($service, 'image'))) {
// Remove PR suffix from service name to get original service name
$originalServiceName = str($serviceName)->replaceLast('-pr-'.$this->pull_request_id, '')->toString();
$composeServiceNames = $this->composeServiceNamesForPreview();
// Canonical compose names first; collapse leftover domain-map twins when parse is empty/missing.
$domainKeys = collect(array_keys($applicationDomains))
->merge(array_keys($previewDomains))
->map(fn ($name) => (string) $name);
$unmappedDomainKeys = $domainKeys
->reject(fn (string $key) => findComposeServiceName($key, $composeServiceNames) !== null)
->all();
$knownServiceNames = collect($composeServiceNames)
->merge(preferredComposeServiceNamesFromDomainKeys(
$composeServiceNames === [] ? $domainKeys->all() : $unmappedDomainKeys
))
->unique()
->values()
->all();
// Ensure all services have an entry, even if empty
if (! $services->has($originalServiceName)) {
$services->put($originalServiceName, ['domain' => '']);
}
}
$applicationDomains = rekeyComposeDomainsToServiceNames($applicationDomains, $knownServiceNames);
$previewDomains = rekeyComposeDomainsToServiceNames($previewDomains, $knownServiceNames);
// Ensure every non-database compose service has a domain slot (empty if unset).
foreach ($composeServiceNames as $serviceName) {
if (! array_key_exists($serviceName, $applicationDomains)) {
$applicationDomains[$serviceName] = ['domain' => ''];
}
}
foreach ($services as $service_name => $service_config) {
$domain_string = data_get($service_config, 'domain');
$serviceNames = collect(array_keys($applicationDomains))
->merge($composeServiceNames)
->map(fn ($name) => (string) $name)
->unique()
->values()
->all();
$docker_compose_domains = [];
foreach ($serviceNames as $service_name) {
$domain_string = getComposeServiceDomainString($applicationDomains, $service_name);
// If domain string is empty or null, don't auto-generate domain
// Only generate domains when main app already has domains set
if (empty($domain_string)) {
// Ensure service has an empty domain entry for form binding
$docker_compose_domains[$service_name]['domain'] = '';
$docker_compose_domains = putComposeServiceDomain(
$docker_compose_domains,
$service_name,
'',
$serviceNames,
);
continue;
}
@@ -180,19 +201,22 @@ class ApplicationPreview extends BaseModel
$preview_domains[] = $preview_fqdn;
}
if (! empty($preview_domains)) {
$docker_compose_domains[$service_name]['domain'] = implode(',', $preview_domains);
} else {
// Ensure service has an empty domain entry for form binding
$docker_compose_domains[$service_name]['domain'] = '';
}
$docker_compose_domains = putComposeServiceDomain(
$docker_compose_domains,
$service_name,
! empty($preview_domains) ? implode(',', $preview_domains) : '',
$serviceNames,
);
}
// Drop any leftover twin keys that were not rewritten above.
$docker_compose_domains = rekeyComposeDomainsToServiceNames($docker_compose_domains, $serviceNames);
$this->docker_compose_domains = json_encode($docker_compose_domains);
// Populate fqdn from generated domains so webhook notifications can read it
$allDomains = collect($docker_compose_domains)
->pluck('domain')
->map(fn ($entry) => composeDomainEntryString($entry))
->filter(fn ($d) => ! empty($d))
->flatMap(fn ($d) => explode(',', $d))
->implode(',');
@@ -201,4 +225,31 @@ class ApplicationPreview extends BaseModel
$this->save();
}
/**
* Original compose service names for this preview (PR suffix stripped), excluding database images.
*
* @return list<string>
*/
private function composeServiceNamesForPreview(): array
{
$parsedServices = $this->application->parse(pull_request_id: $this->pull_request_id);
$services = data_get($parsedServices, 'services', []);
if (! is_iterable($services)) {
return [];
}
$names = [];
foreach ($services as $serviceName => $service) {
if (isDatabaseImage(data_get($service, 'image'))) {
continue;
}
$names[] = str((string) $serviceName)
->replaceLast('-pr-'.$this->pull_request_id, '')
->toString();
}
return array_values(array_unique($names));
}
}
+13 -1
View File
@@ -2,6 +2,9 @@
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;
@@ -54,7 +57,11 @@ class Environment extends BaseModel
public function isEmpty()
{
return $this->applications()->count() == 0 &&
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 &&
$this->mysqls()->count() == 0 &&
@@ -76,6 +83,11 @@ class Environment extends BaseModel
return $this->hasMany(Application::class);
}
public function v5Applications()
{
return $this->hasMany(V5Application::class);
}
public function postgresqls()
{
return $this->hasMany(StandalonePostgresql::class);
+13
View File
@@ -98,4 +98,17 @@ class GithubApp extends BaseModel
},
);
}
/**
* A private GitHub App is connected once it has been registered and installed.
* Public sources do not require installation credentials.
*/
public function isConnected(): bool
{
if ($this->is_public) {
return true;
}
return filled($this->app_id) && filled($this->installation_id);
}
}
+115 -1
View File
@@ -2,6 +2,10 @@
namespace App\Models;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Support\Facades\Crypt;
class GitlabApp extends BaseModel
{
protected $fillable = [
@@ -16,20 +20,109 @@ class GitlabApp extends BaseModel
'app_id',
'app_secret',
'oauth_id',
'client_id',
'client_secret',
'access_token',
'refresh_token',
'expires_at',
'redirect_uri',
'group_name',
'public_key',
'webhook_token',
'deploy_key_id',
'private_key_id',
'team_id',
];
protected $hidden = [
'webhook_token',
'app_secret',
'client_secret',
'access_token',
'refresh_token',
];
protected function casts(): array
{
return [
'access_token' => 'encrypted',
'refresh_token' => 'encrypted',
'client_secret' => 'encrypted',
'is_system_wide' => 'boolean',
'is_public' => 'boolean',
];
}
/**
* Encrypt webhook tokens at rest. Supports legacy plaintext values until they are re-saved.
* Not a standard encrypted cast: webhooks look up by token value (see findByWebhookToken).
*/
protected function webhookToken(): Attribute
{
return Attribute::make(
get: function (?string $value): ?string {
if ($value === null || $value === '') {
return $value;
}
try {
return Crypt::decryptString($value);
} catch (DecryptException) {
// Legacy rows stored the token in plaintext.
return $value;
}
},
set: function (?string $value): ?string {
if ($value === null || $value === '') {
return $value;
}
return Crypt::encryptString($value);
},
);
}
public static function findByWebhookToken(string $token): ?self
{
if ($token === '') {
return null;
}
// Encrypted values cannot be matched with a SQL equality; sources are few per instance.
return static::query()->get()->first(
fn (self $app): bool => filled($app->webhook_token) && hash_equals((string) $app->webhook_token, $token)
);
}
protected static function booted(): void
{
static::deleting(function (GitlabApp $gitlabApp) {
if ($gitlabApp->applications()->count() > 0) {
throw new \RuntimeException('This source is being used by an application. Please delete all applications first.');
}
});
}
public static function ownedByCurrentTeam()
{
return GitlabApp::whereTeamId(currentTeam()->id);
return GitlabApp::where(function ($query) {
$query->where('team_id', currentTeam()->id)
->orWhere('is_system_wide', true);
});
}
public static function public()
{
return GitlabApp::where(function ($query) {
$query->where('team_id', currentTeam()->id)->orWhere('is_system_wide', true);
})->where('is_public', true);
}
public static function private()
{
return GitlabApp::where(function ($query) {
$query->where('team_id', currentTeam()->id)->orWhere('is_system_wide', true);
})->where('is_public', false)->whereNotNull('access_token');
}
public function applications()
@@ -41,4 +134,25 @@ class GitlabApp extends BaseModel
{
return $this->belongsTo(PrivateKey::class);
}
public function team()
{
return $this->belongsTo(Team::class);
}
public function isConnected(): bool
{
return ! empty($this->access_token) && ! empty($this->refresh_token);
}
public function apiUrlBase(): string
{
$apiUrl = rtrim($this->api_url, '/');
if (! str_contains($apiUrl, '/api/v4')) {
$apiUrl .= '/api/v4';
}
return $apiUrl;
}
}
+5
View File
@@ -33,6 +33,7 @@ class InstanceSettings extends Model
'resend_api_key',
'is_dns_validation_enabled',
'custom_dns_servers',
'domain_connect_private_key',
'instance_name',
'is_api_enabled',
'allowed_ips',
@@ -48,6 +49,8 @@ class InstanceSettings extends Model
'is_mcp_server_enabled',
'webhook_allowed_internal_hosts',
'webhook_allow_localhost',
'avatar_storage_type',
'avatar_s3_storage_id',
];
protected $hidden = [
@@ -58,6 +61,7 @@ class InstanceSettings extends Model
'smtp_username',
'smtp_password',
'resend_api_key',
'domain_connect_private_key',
'sentinel_token',
];
@@ -74,6 +78,7 @@ class InstanceSettings extends Model
'resend_enabled' => 'boolean',
'resend_api_key' => 'encrypted',
'domain_connect_private_key' => 'encrypted',
'allowed_ip_ranges' => 'array',
'is_auto_update_enabled' => 'boolean',
+26 -1
View File
@@ -6,6 +6,8 @@ use App\Events\FileStorageChanged;
use App\Jobs\ServerStorageSaveJob;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Symfony\Component\Yaml\Yaml;
class LocalFileVolume extends BaseModel
@@ -57,6 +59,12 @@ class LocalFileVolume extends BaseModel
$fileVolume->load(['service']);
dispatch(new ServerStorageSaveJob($fileVolume));
});
static::deleting(function (LocalFileVolume $fileVolume): void {
if ($fileVolume->scheduledBackups()->exists()) {
throw new \RuntimeException('Delete this directory backup schedule and its archives before deleting the directory.');
}
});
}
protected function isBinary(): Attribute
@@ -73,11 +81,28 @@ class LocalFileVolume extends BaseModel
);
}
public function service()
public function resource(): MorphTo
{
return $this->morphTo();
}
public function service(): MorphTo
{
return $this->morphTo('resource');
}
public function scheduledBackups(): MorphMany
{
return $this->morphMany(ScheduledVolumeBackup::class, 'backupable');
}
public function abortIfScheduledBackupsExist(): void
{
if ($this->scheduledBackups()->exists()) {
abort(422, 'Delete this directory backup schedule and its archives before deleting the directory.');
}
}
public function loadStorageOnServer()
{
if ($this->is_host_file) {
+22
View File
@@ -3,10 +3,20 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Symfony\Component\Yaml\Yaml;
class LocalPersistentVolume extends BaseModel
{
protected static function booted(): void
{
static::deleting(function (LocalPersistentVolume $volume): void {
if ($volume->scheduledBackups()->exists()) {
throw new \RuntimeException('Delete this volume backup schedule and its archives before deleting the volume.');
}
});
}
protected $fillable = [
'name',
'mount_path',
@@ -41,6 +51,18 @@ class LocalPersistentVolume extends BaseModel
return $this->morphTo('resource');
}
public function scheduledBackups(): MorphMany
{
return $this->morphMany(ScheduledVolumeBackup::class, 'backupable');
}
public function abortIfScheduledBackupsExist(): void
{
if ($this->scheduledBackups()->exists()) {
abort(422, 'Delete this volume backup schedule and its archives before deleting the volume.');
}
}
protected function customizeName($value)
{
return str($value)->trim()->value;
+8 -1
View File
@@ -2,6 +2,9 @@
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;
@@ -144,7 +147,11 @@ class Project extends BaseModel
public function isEmpty()
{
return $this->applications()->count() == 0 &&
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 &&
$this->mysqls()->count() == 0 &&
+46 -14
View File
@@ -7,6 +7,7 @@ use App\Rules\ValidS3BucketName;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
@@ -69,6 +70,15 @@ class S3Storage extends BaseModel
'save_s3' => false,
's3_storage_id' => null,
]);
ScheduledVolumeBackupExecution::where('s3_storage_id', $storage->id)
->update([
's3_storage_deleted' => true,
's3_cleanup_pending' => false,
]);
ScheduledVolumeBackup::where('s3_storage_id', $storage->id)->update([
'save_s3' => false,
's3_storage_id' => null,
]);
});
}
@@ -101,6 +111,11 @@ class S3Storage extends BaseModel
return $this->hasMany(ScheduledDatabaseBackup::class, 's3_storage_id');
}
public function scheduledVolumeBackups()
{
return $this->hasMany(ScheduledVolumeBackup::class, 's3_storage_id');
}
public function awsUrl()
{
return "{$this->endpoint}/{$this->bucket}";
@@ -158,7 +173,7 @@ class S3Storage extends BaseModel
'bucket' => $this['bucket'],
],
[
'endpoint' => ['required', new SafeWebhookUrl],
'endpoint' => ['required', new SafeWebhookUrl(trustedInternalHosts: $this->trustedInternalHosts())],
'bucket' => ['required', new ValidS3BucketName],
],
);
@@ -170,19 +185,7 @@ class S3Storage extends BaseModel
throw new \RuntimeException('S3 bucket name is not allowed: '.$validator->errors()->first('bucket'));
}
$disk = Storage::build([
'driver' => 's3',
'region' => $this['region'],
'key' => $this['key'],
'secret' => $this['secret'],
'bucket' => $this['bucket'],
'endpoint' => $this['endpoint'],
'use_path_style_endpoint' => true,
'http' => array_merge(SafeWebhookUrl::httpClientOptions($this['endpoint']), [
'connect_timeout' => self::CONNECTION_TIMEOUT_SECONDS,
'timeout' => self::REQUEST_TIMEOUT_SECONDS,
]),
]);
$disk = $this->filesystem();
// Test the connection by listing files with ListObjectsV2 (S3)
$disk->files();
@@ -221,6 +224,35 @@ class S3Storage extends BaseModel
}
}
public function filesystem(): FilesystemAdapter
{
return Storage::build([
'driver' => 's3',
'region' => $this['region'],
'key' => $this['key'],
'secret' => $this['secret'],
'bucket' => $this['bucket'],
'endpoint' => $this['endpoint'],
'use_path_style_endpoint' => true,
'http' => array_merge(SafeWebhookUrl::httpClientOptions($this['endpoint'], $this->trustedInternalHosts()), [
'connect_timeout' => self::CONNECTION_TIMEOUT_SECONDS,
'timeout' => self::REQUEST_TIMEOUT_SECONDS,
]),
]);
}
/**
* The bundled MinIO container is a trusted internal S3 target, not a user-supplied webhook destination.
*
* @return array<int, string>
*/
public function trustedInternalHosts(): array
{
return $this->uuid === 'minio' && parse_url($this->endpoint, PHP_URL_HOST) === 'coolify-minio'
? ['coolify-minio']
: [];
}
private function toUserFriendlyConnectionException(\Throwable $exception): \Throwable
{
$message = str($exception->getMessage())->lower();
+186
View File
@@ -0,0 +1,186 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class ScheduledVolumeBackup extends BaseModel
{
protected $fillable = [
'uuid',
'backupable_type',
'backupable_id',
'team_id',
's3_storage_id',
'frequency',
'enabled',
'save_s3',
'disable_local_backup',
'stop_during_backup',
'retention_amount_locally',
'retention_days_locally',
'retention_max_storage_locally',
'retention_amount_s3',
'retention_days_s3',
'retention_max_storage_s3',
'timeout',
];
protected function casts(): array
{
return [
'enabled' => 'boolean',
'save_s3' => 'boolean',
'disable_local_backup' => 'boolean',
'stop_during_backup' => 'boolean',
'retention_amount_locally' => 'integer',
'retention_days_locally' => 'integer',
'retention_max_storage_locally' => 'float',
'retention_amount_s3' => 'integer',
'retention_days_s3' => 'integer',
'retention_max_storage_s3' => 'float',
'timeout' => 'integer',
];
}
public function scopeForApplication(Builder $query, Application $application): Builder
{
$volumeIds = $application->persistentStorages()->pluck('id');
$directoryIds = $application->fileStorages()
->where('is_directory', true)
->where('is_host_file', false)
->pluck('id');
return $query->where(function (Builder $query) use ($volumeIds, $directoryIds): void {
$query->where(function (Builder $query) use ($volumeIds): void {
$query->where('backupable_type', (new LocalPersistentVolume)->getMorphClass())
->whereIn('backupable_id', $volumeIds);
})->orWhere(function (Builder $query) use ($directoryIds): void {
$query->where('backupable_type', (new LocalFileVolume)->getMorphClass())
->whereIn('backupable_id', $directoryIds);
});
});
}
public function scopeForService(Builder $query, Service $service): Builder
{
$resources = $service->applications()->get()->concat($service->databases()->get());
if ($resources->isEmpty()) {
return $query->whereRaw('1 = 0');
}
$resourceIdsByType = $resources->groupBy(fn (Model $resource): string => $resource->getMorphClass());
$volumeIds = LocalPersistentVolume::query()
->where(function (Builder $query) use ($resourceIdsByType): void {
foreach ($resourceIdsByType as $type => $resources) {
$query->orWhere(fn (Builder $query) => $query
->where('resource_type', $type)
->whereIn('resource_id', $resources->pluck('id')));
}
})->pluck('id');
$directoryIds = LocalFileVolume::query()
->where('is_directory', true)
->where('is_host_file', false)
->where(function (Builder $query) use ($resourceIdsByType): void {
foreach ($resourceIdsByType as $type => $resources) {
$query->orWhere(fn (Builder $query) => $query
->where('resource_type', $type)
->whereIn('resource_id', $resources->pluck('id')));
}
})->pluck('id');
return $query->where(function (Builder $query) use ($volumeIds, $directoryIds): void {
$query->where(fn (Builder $query) => $query
->where('backupable_type', (new LocalPersistentVolume)->getMorphClass())
->whereIn('backupable_id', $volumeIds))
->orWhere(fn (Builder $query) => $query
->where('backupable_type', (new LocalFileVolume)->getMorphClass())
->whereIn('backupable_id', $directoryIds));
});
}
public function backupable(): MorphTo
{
return $this->morphTo();
}
public function team(): BelongsTo
{
return $this->belongsTo(Team::class);
}
public function s3(): BelongsTo
{
return $this->belongsTo(S3Storage::class, 's3_storage_id');
}
public function executions(): HasMany
{
return $this->hasMany(ScheduledVolumeBackupExecution::class)->latest();
}
public function latestExecution(): HasOne
{
return $this->hasOne(ScheduledVolumeBackupExecution::class)->latestOfMany();
}
public function server(): ?Server
{
$resource = $this->targetResource();
if ($resource instanceof ServiceApplication || $resource instanceof ServiceDatabase) {
return $resource->service?->server?->fresh();
}
return $resource?->destination?->server?->fresh();
}
public function targetResource(): ?Model
{
return $this->backupable?->resource;
}
public function targetType(): string
{
return $this->backupable instanceof LocalFileVolume ? 'Directory' : 'Volume';
}
public function targetName(): string
{
return match (true) {
$this->backupable instanceof LocalFileVolume => $this->backupable->fs_path,
$this->backupable instanceof LocalPersistentVolume => $this->backupable->name,
default => 'Unknown storage',
};
}
public function sourcePath(): string
{
$target = $this->backupable;
if ($target instanceof LocalPersistentVolume) {
return filled($target->host_path) ? $target->host_path : $target->name;
}
if (! $target instanceof LocalFileVolume || ! $target->is_directory) {
throw new \RuntimeException('The backup target is not a directory or persistent volume.');
}
$path = str($target->fs_path);
if ($path->startsWith('.')) {
$resource = $this->targetResource();
if (! $resource || ! method_exists($resource, 'workdir')) {
throw new \RuntimeException('The directory backup workdir is unavailable.');
}
return $resource->workdir().$path->after('.')->toString();
}
return $path->toString();
}
}
@@ -0,0 +1,49 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ScheduledVolumeBackupExecution extends BaseModel
{
protected $fillable = [
'uuid',
'scheduled_volume_backup_id',
's3_storage_id',
'status',
'message',
'size',
'filename',
'stop_container_ids',
'stop_recovery_pending',
's3_cleanup_pending',
'finished_at',
'local_storage_deleted',
's3_storage_deleted',
's3_uploaded',
];
protected function casts(): array
{
return [
'size' => 'integer',
'finished_at' => 'datetime',
'stop_container_ids' => 'array',
'stop_recovery_pending' => 'boolean',
's3_cleanup_pending' => 'boolean',
'local_storage_deleted' => 'boolean',
's3_storage_deleted' => 'boolean',
's3_uploaded' => 'boolean',
];
}
public function scheduledVolumeBackup(): BelongsTo
{
return $this->belongsTo(ScheduledVolumeBackup::class);
}
public function s3(): BelongsTo
{
return $this->belongsTo(S3Storage::class, 's3_storage_id');
}
}
+43 -2
View File
@@ -509,9 +509,29 @@ class Server extends BaseModel
});
}
public static function isUsable()
public static function isUsable(): Builder
{
return Server::ownedByCurrentTeam()->whereRelation('settings', 'is_reachable', true)->whereRelation('settings', 'is_usable', true)->whereRelation('settings', 'is_swarm_worker', false)->whereRelation('settings', 'is_build_server', false)->whereRelation('settings', 'force_disabled', false);
return self::usableByBuildServerStatus(false);
}
public static function isUsableBuildServer(): Builder
{
return self::usableByBuildServerStatus(true);
}
private static function usableByBuildServerStatus(bool $isBuildServer): Builder
{
return Server::ownedByCurrentTeam()
->whereRelation('settings', 'is_reachable', true)
->whereRelation('settings', 'is_usable', true)
->whereRelation('settings', 'is_swarm_worker', false)
->whereRelation('settings', 'is_build_server', $isBuildServer)
->whereRelation('settings', 'force_disabled', false);
}
public function canHostResources(): bool
{
return ! $this->isBuildServer();
}
public function settings()
@@ -842,8 +862,29 @@ $schema://$host {
return $this->settings->force_disabled;
}
/**
* Server was migrated away from this Coolify instance (source side).
* Must not be revalidated or re-enabled as a live managed host.
*/
public function isTransferredAway(): bool
{
return data_get($this->server_metadata, 'transfer.status') === 'transferred';
}
/**
* Whether this server may be validated / installed against from this instance.
*/
public function canBeValidated(): bool
{
return ! $this->isTransferredAway();
}
public function forceEnableServer()
{
if ($this->isTransferredAway()) {
return;
}
$this->settings->force_disabled = false;
$this->settings->save();
}
+42 -1
View File
@@ -109,6 +109,7 @@ class ServerSetting extends Model
'sentinel_token' => 'encrypted',
'is_reachable' => 'boolean',
'is_usable' => 'boolean',
'is_build_server' => 'boolean',
'is_terminal_enabled' => 'boolean',
'disable_application_image_retention' => 'boolean',
'connection_timeout' => 'integer',
@@ -218,7 +219,22 @@ class ServerSetting extends Model
return $token;
}
public function generateSentinelUrl(bool $save = true, bool $ignoreEvent = false)
public function ensureSentinelUrl(): string
{
$url = $this->sentinel_custom_url;
if (blank($url)) {
$url = $this->generateSentinelUrl(ignoreEvent: true);
}
if (blank($url)) {
throw new \RuntimeException('Set an instance FQDN, public IP, or reachable Coolify URL before enabling Sentinel.');
}
return $url;
}
public function generateSentinelUrl(bool $save = true, bool $ignoreEvent = false): ?string
{
$domain = null;
$settings = InstanceSettings::get();
@@ -230,6 +246,8 @@ class ServerSetting extends Model
$domain = 'http://'.$settings->public_ipv4.':8000';
} elseif ($settings->public_ipv6) {
$domain = 'http://'.$settings->public_ipv6.':8000';
} else {
$domain = $this->sentinelUrlFromCurrentRequest();
}
$this->sentinel_custom_url = $domain;
if ($save) {
@@ -243,6 +261,29 @@ class ServerSetting extends Model
return $domain;
}
private function sentinelUrlFromCurrentRequest(): ?string
{
if (! app()->bound('request')) {
return null;
}
$request = request();
$host = strtolower($request->getHost());
if (
$host === 'localhost' ||
str_ends_with($host, '.localhost') ||
$host === '::1' ||
$host === '::' ||
$host === '0.0.0.0' ||
str_starts_with($host, '127.')
) {
return null;
}
return $request->getSchemeAndHttpHost();
}
public function server()
{
return $this->belongsTo(Server::class);
+10 -10
View File
@@ -1641,16 +1641,16 @@ class Service extends BaseModel
protected function isDeployable(): Attribute
{
return Attribute::make(
get: function () {
$envs = $this->environment_variables()->where('is_required', true)->get();
foreach ($envs as $env) {
if ($env->is_really_required) {
return false;
}
}
return true;
}
get: fn (): bool => $this->missingRequiredEnvironmentVariables()->isEmpty()
);
}
public function missingRequiredEnvironmentVariables(): Collection
{
return $this->environment_variables()
->where('is_required', true)
->get()
->filter(fn (EnvironmentVariable $environmentVariable): bool => $environmentVariable->is_really_required)
->values();
}
}
+19 -7
View File
@@ -19,6 +19,8 @@ class ServiceApplication extends BaseModel
'description',
'fqdn',
'noindex_domains',
'redirect',
'domain_dns_statuses',
'ports',
'exposes',
'status',
@@ -33,6 +35,23 @@ class ServiceApplication extends BaseModel
'is_migrated',
];
/**
* Internal DNS check cache — not part of the public API surface.
*
* @var list<string>
*/
protected $hidden = [
'domain_dns_statuses',
];
protected function casts(): array
{
return [
'domain_dns_statuses' => 'array',
'noindex_domains' => 'array',
];
}
protected static function booted()
{
static::deleting(function ($service) {
@@ -50,13 +69,6 @@ class ServiceApplication extends BaseModel
});
}
protected function casts(): array
{
return [
'noindex_domains' => 'array',
];
}
public function restart()
{
$container_id = $this->name.'-'.$this->service->uuid;
+7
View File
@@ -33,6 +33,13 @@ class ServiceDatabase extends BaseModel
];
protected $casts = [
'exclude_from_status' => 'boolean',
'is_public' => 'boolean',
'is_log_drain_enabled' => 'boolean',
'is_include_timestamps' => 'boolean',
'is_gzip_enabled' => 'boolean',
'is_stripprefix_enabled' => 'boolean',
'public_port' => 'integer',
'public_port_timeout' => 'integer',
];
+8
View File
@@ -39,6 +39,14 @@ class SharedEnvironmentVariable extends Model
'value' => 'encrypted',
];
/**
* Scope shared environment variables to a team (API token team_id).
*/
public static function ownedByCurrentTeamAPI(int $teamId)
{
return static::where('team_id', $teamId);
}
protected function key(): Attribute
{
return Attribute::make(
+1 -1
View File
@@ -370,6 +370,6 @@ class StandaloneClickhouse extends BaseModel
public function isBackupSolutionAvailable()
{
return false;
return true;
}
}
+25 -3
View File
@@ -4,10 +4,12 @@ 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;
use App\Notifications\Channels\SendsSlack;
use App\Support\V5\V5Feature;
use App\Traits\HasNotificationSettings;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
@@ -50,6 +52,10 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
'is_mcp_server_enabled',
];
protected $attributes = [
'is_mcp_server_enabled' => true,
];
protected $casts = [
'personal_team' => 'boolean',
'is_mcp_server_enabled' => 'boolean',
@@ -75,6 +81,20 @@ 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. 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);
foreach ($team->privateKeys as $key) {
@@ -219,13 +239,15 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
$this->getNotificationSettings('webhook')?->isEnabled();
}
public function subscriptionEnded()
public function subscriptionEnded(?Subscription $subscription = null): void
{
if (! $this->subscription) {
$subscription ??= $this->subscription;
if (! $subscription) {
return;
}
$this->subscription->update([
$subscription->update([
'stripe_subscription_id' => null,
'stripe_cancel_at_period_end' => false,
'stripe_invoice_paid' => false,
+3
View File
@@ -53,6 +53,9 @@ class User extends Authenticatable implements SendsEmail
'pending_email',
'email_change_code',
'email_change_code_expires_at',
'avatar_path',
'avatar_storage_type',
'avatar_s3_storage_id',
];
protected $hidden = [
+97
View File
@@ -0,0 +1,97 @@
<?php
namespace App\Models\V5;
use App\Enums\V5\ApplicationStatus;
use App\Events\V5CanvasResourceUpdated;
use App\Models\Environment;
use App\Models\Project;
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
{
protected $table = 'v5_applications';
protected $fillable = [
'uuid',
'team_id',
'project_id',
'environment_id',
'server_id',
'created_by_user_id',
'name',
'image',
'container_name',
'status',
'status_message',
'status_observed_at',
'runtime_container_id',
'mesh_namespace',
'ingress_enabled',
'internal_port',
'canvas_x',
'canvas_y',
];
protected $attributes = [
'status' => ApplicationStatus::Creating->value,
'mesh_namespace' => 'default',
'ingress_enabled' => false,
'canvas_x' => 0,
'canvas_y' => 0,
];
protected static function booted(): void
{
static::updated(function (self $application): void {
if ($application->wasChanged(['status', 'status_message', 'runtime_container_id'])) {
DB::afterCommit(fn () => V5CanvasResourceUpdated::dispatch($application->team_id, $application->id));
}
});
}
protected function casts(): array
{
return [
'status_observed_at' => 'datetime',
'ingress_enabled' => 'boolean',
'internal_port' => 'integer',
'canvas_x' => 'integer',
'canvas_y' => 'integer',
];
}
public function team(): BelongsTo
{
return $this->belongsTo(Team::class);
}
public function server(): BelongsTo
{
return $this->belongsTo(Server::class);
}
public function project(): BelongsTo
{
return $this->belongsTo(Project::class);
}
public function environment(): BelongsTo
{
return $this->belongsTo(Environment::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_user_id');
}
public function domains(): HasMany
{
return $this->hasMany(ApplicationDomain::class);
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Models\V5;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ApplicationDomain extends V5Model
{
protected $table = 'v5_application_domains';
protected bool $hasUuidColumn = false;
protected $fillable = [
'application_id',
'domain',
];
public function application(): BelongsTo
{
return $this->belongsTo(Application::class);
}
}
+118
View File
@@ -0,0 +1,118 @@
<?php
namespace App\Models\V5;
use App\Models\Team;
use App\Models\User;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
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';
public const DEFAULT_WIREGUARD_LISTEN_PORT = 51820;
public const DEFAULT_CONTAINER_NETWORK_POOL = '10.210.0.0/16';
public const DEFAULT_CONTAINER_NETWORK_PREFIX = 24;
public const DEFAULT_NAMESPACES = ['default'];
public const DEFAULT_COOLD_VERSION = 'nightly';
public const DEFAULT_CORROSION_VERSION = 'v1.0.0';
public const DEFAULT_CORROSION_GOSSIP_PORT = 8787;
public const DEFAULT_CORROSION_API_PORT = 8080;
public const DEFAULT_BUILDER_CAPACITY = 2;
public const DEFAULT_BUILDER_CPU_QUOTA = '200%';
public const DEFAULT_BUILDER_MEMORY_MAX = '2G';
public const DEFAULT_BUILDER_TIMEOUT_SECS = 1800;
protected $fillable = [
'uuid',
'team_id',
'created_by_user_id',
'name',
'description',
'wireguard_interface',
'wireguard_management_pool',
'wireguard_listen_port',
'container_network_pool',
'container_network_prefix',
'namespaces',
'default_deny_containers',
'coold_version',
'corrosion_version',
'corrosion_gossip_port',
'corrosion_api_port',
'builder_enabled',
'builder_capacity',
'builder_cpu_quota',
'builder_memory_max',
'builder_timeout_secs',
'last_cli_action',
'last_cli_status',
'last_cli_summary',
'last_cli_ran_at',
];
protected $attributes = [
'wireguard_interface' => self::DEFAULT_WIREGUARD_INTERFACE,
'wireguard_management_pool' => self::DEFAULT_WIREGUARD_MANAGEMENT_POOL,
'wireguard_listen_port' => self::DEFAULT_WIREGUARD_LISTEN_PORT,
'container_network_pool' => self::DEFAULT_CONTAINER_NETWORK_POOL,
'container_network_prefix' => self::DEFAULT_CONTAINER_NETWORK_PREFIX,
'default_deny_containers' => true,
'coold_version' => self::DEFAULT_COOLD_VERSION,
'corrosion_version' => self::DEFAULT_CORROSION_VERSION,
'corrosion_gossip_port' => self::DEFAULT_CORROSION_GOSSIP_PORT,
'corrosion_api_port' => self::DEFAULT_CORROSION_API_PORT,
'builder_enabled' => true,
'builder_capacity' => self::DEFAULT_BUILDER_CAPACITY,
'builder_cpu_quota' => self::DEFAULT_BUILDER_CPU_QUOTA,
'builder_memory_max' => self::DEFAULT_BUILDER_MEMORY_MAX,
'builder_timeout_secs' => self::DEFAULT_BUILDER_TIMEOUT_SECS,
];
protected function casts(): array
{
return [
'namespaces' => 'array',
'default_deny_containers' => 'boolean',
'builder_enabled' => 'boolean',
'last_cli_ran_at' => 'datetime',
];
}
public function team(): BelongsTo
{
return $this->belongsTo(Team::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_user_id');
}
public function servers(): HasMany
{
return $this->hasMany(Server::class);
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Models\V5;
use App\Models\Team;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ContainerStatus extends V5Model
{
protected $table = 'v5_container_statuses';
protected bool $hasUuidColumn = false;
protected $fillable = [
'team_id',
'server_id',
'container_id',
'container_name',
'image',
'status',
'status_message',
'status_observed_at',
'last_seen_at',
];
protected function casts(): array
{
return [
'status_observed_at' => 'datetime',
'last_seen_at' => 'datetime',
];
}
public function team(): BelongsTo
{
return $this->belongsTo(Team::class);
}
public function server(): BelongsTo
{
return $this->belongsTo(Server::class);
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
namespace App\Models\V5;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class ResourceConnection extends V5Model
{
protected $table = 'v5_resource_connections';
protected $fillable = [
'uuid',
'team_id',
'project_id',
'environment_id',
'resource_one_type',
'resource_one_id',
'resource_two_type',
'resource_two_id',
'resource_pair_key',
'created_by_user_id',
];
public function team(): BelongsTo
{
return $this->belongsTo(Team::class);
}
public function project(): BelongsTo
{
return $this->belongsTo(Project::class);
}
public function environment(): BelongsTo
{
return $this->belongsTo(Environment::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_user_id');
}
public function resourceOne(): MorphTo
{
return $this->morphTo('resource_one');
}
public function resourceTwo(): MorphTo
{
return $this->morphTo('resource_two');
}
public function rules(): HasMany
{
return $this->hasMany(ResourceConnectionRule::class, 'connection_id');
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace App\Models\V5;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class ResourceConnectionRule extends V5Model
{
protected $table = 'v5_resource_connection_rules';
protected bool $hasUuidColumn = false;
protected $fillable = [
'connection_id',
'source_resource_type',
'source_resource_id',
'target_resource_type',
'target_resource_id',
'protocol',
'port',
];
protected $attributes = [
'protocol' => 'tcp',
];
protected function casts(): array
{
return [
'port' => 'integer',
];
}
public function connection(): BelongsTo
{
return $this->belongsTo(ResourceConnection::class, 'connection_id');
}
public function sourceResource(): MorphTo
{
return $this->morphTo('source_resource');
}
public function targetResource(): MorphTo
{
return $this->morphTo('target_resource');
}
}
+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);
}
}
+204
View File
@@ -0,0 +1,204 @@
<?php
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
{
protected $table = 'v5_servers';
protected $fillable = [
'uuid',
'team_id',
'cluster_id',
'created_by_user_id',
'private_key_id',
'name',
'host',
'ssh_user',
'ssh_port',
'status',
'status_observed_at',
'ingress_type',
'ingress_status',
'capabilities',
'has_coold',
'is_ingress',
'builder_enabled',
'builder_capacity',
'builder_cpu_quota',
'node_address',
'wireguard_listen_port_override',
'wireguard_endpoint_override',
'wireguard_management_ip',
'wireguard_public_key',
'coold_version',
'agent_token_jti',
'agent_token_expires_at',
'container_subnets',
'canvas_x',
'canvas_y',
'last_bootstrapped_at',
'last_bootstrap_action',
'last_bootstrap_status',
'last_bootstrap_output',
'last_bootstrap_ran_at',
'last_status_check',
'last_status_output',
'last_status_checked_at',
];
protected static function booted(): void
{
static::updated(function (self $server): void {
if (
! $server->wasChanged('status')
&& ! $server->wasChanged('ingress_type')
&& ! $server->wasChanged('ingress_status')
) {
return;
}
if ($server->wasChanged('status') && $server->cluster_id !== null) {
DB::afterCommit(fn () => V5ClusterUpdated::dispatch($server->team_id, $server->cluster_id));
}
if ($server->wasChanged('status')) {
DB::afterCommit(fn () => V5CanvasResourceUpdated::dispatch(
$server->team_id,
null,
$server->isIngress() ? $server->id : null,
$server->id,
));
return;
}
if ($server->isIngress()) {
DB::afterCommit(fn () => V5CanvasResourceUpdated::dispatch($server->team_id, null, $server->id));
}
});
}
protected function casts(): array
{
return [
'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 match ($capability) {
'coold' => (bool) $this->has_coold,
'ingress' => (bool) $this->is_ingress,
default => false,
};
}
/**
* @return array<int, string>
*/
public function withCapability(string $capability): array
{
return collect($this->capabilities)
->push($capability)
->unique()
->values()
->all();
}
/**
* @return array<int, string>
*/
public function withoutCapability(string $capability): array
{
return collect($this->capabilities)
->reject(fn (string $existingCapability) => $existingCapability === $capability)
->values()
->all();
}
public function isIngress(): bool
{
return (bool) $this->is_ingress;
}
public function ingressStatus(): string
{
return $this->ingress_status ?? IngressStatus::Unknown->value;
}
public function ingressType(): string
{
return $this->ingress_type ?? 'caddy';
}
public function cluster(): BelongsTo
{
return $this->belongsTo(Cluster::class);
}
public function team(): BelongsTo
{
return $this->belongsTo(Team::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_user_id');
}
public function privateKey(): BelongsTo
{
return $this->belongsTo(PrivateKey::class);
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace App\Models\V5;
use Illuminate\Database\Eloquent\Model;
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 $this->hasUuidColumn ? 'uuid' : $this->getKeyName();
}
protected static function boot(): void
{
parent::boot();
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();
}
}