mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-14 06:17:36 +00:00
Merge remote-tracking branch 'origin/next' into feat/noindex-domains
This commit is contained in:
commit
003e44d123
1274 files changed
+156179
-25249
No files matched your search
+213
-39
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,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;
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -370,6 +370,6 @@ class StandaloneClickhouse extends BaseModel
|
||||
|
||||
public function isBackupSolutionAvailable()
|
||||
{
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+25
-3
@@ -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,
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user