Merge remote-tracking branch 'origin/next' into v5-parallel-inertia-react

This commit is contained in:
Andras Bacsai
2026-07-18 15:57:26 +02:00
139 changed files with 8677 additions and 545 deletions
+1
View File
@@ -111,6 +111,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'],
new OA\Property(property: 'settings', ref: '#/components/schemas/ApplicationSetting'),
]
)]
+53
View File
@@ -4,7 +4,49 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use OpenApi\Attributes as OA;
#[OA\Schema(
description: 'Application settings.',
type: 'object',
properties: [
'is_static' => ['type' => 'boolean'],
'is_git_submodules_enabled' => ['type' => 'boolean'],
'is_git_lfs_enabled' => ['type' => 'boolean'],
'is_auto_deploy_enabled' => ['type' => 'boolean'],
'is_force_https_enabled' => ['type' => 'boolean'],
'is_debug_enabled' => ['type' => 'boolean'],
'is_preview_deployments_enabled' => ['type' => 'boolean'],
'is_log_drain_enabled' => ['type' => 'boolean'],
'is_gpu_enabled' => ['type' => 'boolean'],
'gpu_driver' => ['type' => 'string', 'nullable' => true],
'gpu_count' => ['type' => 'string', 'nullable' => true],
'gpu_device_ids' => ['type' => 'string', 'nullable' => true],
'gpu_options' => ['type' => 'string', 'nullable' => true],
'is_include_timestamps' => ['type' => 'boolean'],
'is_swarm_only_worker_nodes' => ['type' => 'boolean'],
'is_raw_compose_deployment_enabled' => ['type' => 'boolean'],
'is_build_server_enabled' => ['type' => 'boolean'],
'is_consistent_container_name_enabled' => ['type' => 'boolean'],
'is_gzip_enabled' => ['type' => 'boolean'],
'is_stripprefix_enabled' => ['type' => 'boolean'],
'connect_to_docker_network' => ['type' => 'boolean'],
'custom_internal_name' => ['type' => 'string', 'nullable' => true],
'is_container_label_escape_enabled' => ['type' => 'boolean'],
'is_env_sorting_enabled' => ['type' => 'boolean'],
'is_container_label_readonly_enabled' => ['type' => 'boolean'],
'is_preserve_repository_enabled' => ['type' => 'boolean'],
'disable_build_cache' => ['type' => 'boolean'],
'is_spa' => ['type' => 'boolean'],
'is_git_shallow_clone_enabled' => ['type' => 'boolean'],
'is_pr_deployments_public_enabled' => ['type' => 'boolean'],
'use_build_secrets' => ['type' => 'boolean'],
'inject_build_args_to_dockerfile' => ['type' => 'boolean'],
'include_source_commit_in_build' => ['type' => 'boolean'],
'docker_images_to_keep' => ['type' => 'integer'],
'stop_grace_period' => ['type' => 'integer', 'nullable' => true],
]
)]
class ApplicationSetting extends Model
{
protected $casts = [
@@ -27,6 +69,17 @@ class ApplicationSetting extends Model
'is_git_shallow_clone_enabled' => 'boolean',
'docker_images_to_keep' => 'integer',
'stop_grace_period' => 'integer',
'is_log_drain_enabled' => 'boolean',
'is_gpu_enabled' => 'boolean',
'is_include_timestamps' => 'boolean',
'is_swarm_only_worker_nodes' => 'boolean',
'is_raw_compose_deployment_enabled' => 'boolean',
'is_consistent_container_name_enabled' => 'boolean',
'is_gzip_enabled' => 'boolean',
'is_stripprefix_enabled' => 'boolean',
'connect_to_docker_network' => 'boolean',
'is_env_sorting_enabled' => 'boolean',
'disable_build_cache' => 'boolean',
];
protected $fillable = [
+120 -24
View File
@@ -18,6 +18,7 @@ use App\Notifications\Server\Reachable;
use App\Notifications\Server\Unreachable;
use App\Services\ConfigurationRepository;
use App\Services\DigitalOceanService;
use App\Services\HetznerService;
use App\Services\VultrService;
use App\Support\ValidationPatterns;
use App\Traits\ClearsGlobalSearchCache;
@@ -112,6 +113,15 @@ class Server extends BaseModel
{
use ClearsGlobalSearchCache, HasFactory, HasMetrics, SchemalessAttributesTrait, SoftDeletes;
/**
* Sentinel IP for servers that do not have a real address yet
* (cloud provisioning in progress or parked as unreachable).
* Scheduled jobs skip these servers via skipServer().
*/
public const PLACEHOLDER_IP = '1.2.3.4';
public const PLACEHOLDER_IPS = [self::PLACEHOLDER_IP, '0.0.0.0', '::'];
public static $batch_counter = 0;
/**
@@ -307,6 +317,85 @@ class Server extends BaseModel
return 'server';
}
public function hasPlaceholderIp(): bool
{
// Cast: the saving hook stores the ip as a Stringable in memory.
return self::isPlaceholderIp((string) $this->ip);
}
public static function isPlaceholderIp(?string $ip): bool
{
return blank($ip) || in_array($ip, self::PLACEHOLDER_IPS, true);
}
/**
* Replace a placeholder IP with the real address once the cloud
* provider reports one. Returns true when the IP was updated.
*/
public function backfillPlaceholderIp(?string $ip): bool
{
if (self::isPlaceholderIp($ip)) {
return false;
}
$updated = static::query()
->whereKey($this->getKey())
->where(function (Builder $query): void {
$query->whereNull('ip')
->orWhere('ip', '')
->orWhereIn('ip', self::PLACEHOLDER_IPS);
})
->update(['ip' => $ip]);
if ($updated === 0) {
return false;
}
$this->forceFill(['ip' => $ip]);
$this->syncOriginalAttribute('ip');
static::flushIdentityMap();
return true;
}
/**
* Persist provider status without saving a stale in-memory IP value.
*
* @param array<string, mixed> $updates
*/
private function persistProviderState(array $updates): void
{
if (empty($updates)) {
return;
}
static::query()->whereKey($this->getKey())->update($updates);
$this->forceFill($updates);
$this->syncOriginalAttributes(array_keys($updates));
static::flushIdentityMap();
}
public function refreshHetznerState(): ?string
{
if (! $this->hetzner_server_id || ! $this->cloudProviderToken || $this->cloudProviderToken->provider !== 'hetzner') {
return $this->hetzner_server_status;
}
$hetznerService = new HetznerService($this->cloudProviderToken->token);
$server = $hetznerService->getServer($this->hetzner_server_id);
$status = $server['status'] ?? null;
$assignedIp = data_get($server, 'public_net.ipv4.ip') ?? data_get($server, 'public_net.ipv6.ip');
$updates = [];
if ($this->hetzner_server_status !== $status) {
$updates['hetzner_server_status'] = $status;
}
$this->persistProviderState($updates);
$this->backfillPlaceholderIp($assignedIp);
return $status;
}
public function refreshVultrState(): ?string
{
if (! $this->vultr_instance_id || ! $this->cloudProviderToken) {
@@ -322,8 +411,7 @@ class Server extends BaseModel
}
if ($this->vultr_instance_status !== 'deleted') {
$this->update(['vultr_instance_status' => 'deleted']);
$this->forceFill(['vultr_instance_status' => 'deleted']);
$this->persistProviderState(['vultr_instance_status' => 'deleted']);
}
return 'deleted';
@@ -338,16 +426,8 @@ class Server extends BaseModel
if ($this->vultr_instance_status !== $status) {
$updates['vultr_instance_status'] = $status;
}
$hasPlaceholderIp = blank($this->ip) || in_array($this->ip, ['0.0.0.0', '::'], true);
if ($hasPlaceholderIp && $publicIp) {
$updates['ip'] = $publicIp;
}
if (! empty($updates)) {
$this->update($updates);
$this->forceFill($updates);
}
$this->persistProviderState($updates);
$this->backfillPlaceholderIp($publicIp);
return $status;
}
@@ -364,7 +444,7 @@ class Server extends BaseModel
$droplet = $digitalOceanService->getDroplet((int) $this->digitalocean_droplet_id);
} catch (RequestException $e) {
if ($e->response?->status() === 404) {
$this->update(['digitalocean_droplet_status' => 'deleted']);
$this->persistProviderState(['digitalocean_droplet_status' => 'deleted']);
return 'deleted';
}
@@ -372,7 +452,7 @@ class Server extends BaseModel
throw $e;
} catch (\Throwable $e) {
if ((int) $e->getCode() === 404) {
$this->update(['digitalocean_droplet_status' => 'deleted']);
$this->persistProviderState(['digitalocean_droplet_status' => 'deleted']);
return 'deleted';
}
@@ -387,12 +467,8 @@ class Server extends BaseModel
$status = $droplet['status'] ?? null;
$ip = $digitalOceanService->getPublicIpAddress($droplet);
$updates = ['digitalocean_droplet_status' => $status];
if ($ip && $ip !== $this->ip) {
$updates['ip'] = $ip;
}
$this->update($updates);
$this->persistProviderState(['digitalocean_droplet_status' => $status]);
$this->backfillPlaceholderIp($ip);
return $status;
}
@@ -433,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()
@@ -1176,7 +1272,7 @@ $schema://$host {
public function skipServer()
{
if ($this->ip === '1.2.3.4') {
if ($this->hasPlaceholderIp()) {
return true;
}
if ($this->settings->force_disabled === true) {
@@ -1188,7 +1284,7 @@ $schema://$host {
public function isFunctional()
{
$isFunctional = data_get($this->settings, 'is_reachable') && data_get($this->settings, 'is_usable') && data_get($this->settings, 'force_disabled') === false && $this->ip !== '1.2.3.4';
$isFunctional = data_get($this->settings, 'is_reachable') && data_get($this->settings, 'is_usable') && data_get($this->settings, 'force_disabled') === false && ! $this->hasPlaceholderIp();
if ($isFunctional === false) {
Storage::disk('ssh-mux')->delete($this->muxFilename());
+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',
+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',
];
+1 -1
View File
@@ -370,6 +370,6 @@ class StandaloneClickhouse extends BaseModel
public function isBackupSolutionAvailable()
{
return false;
return true;
}
}
+19
View File
@@ -7,7 +7,22 @@ use App\Support\ValidationPatterns;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use OpenApi\Attributes as OA;
#[OA\Schema(
schema: 'Destination',
description: 'A Docker network destination attached to a server.',
type: 'object',
properties: [
new OA\Property(property: 'uuid', type: 'string'),
new OA\Property(property: 'name', type: 'string'),
new OA\Property(property: 'network', type: 'string'),
new OA\Property(property: 'type', type: 'string', enum: ['standalone', 'swarm']),
new OA\Property(property: 'server_uuid', type: 'string'),
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
new OA\Property(property: 'updated_at', type: 'string', format: 'date-time'),
],
)]
class StandaloneDocker extends BaseModel
{
use HasFactory;
@@ -23,6 +38,10 @@ class StandaloneDocker extends BaseModel
{
parent::boot();
static::created(function ($newStandaloneDocker) {
if (app()->runningUnitTests()) {
return;
}
$server = $newStandaloneDocker->server;
$safeNetwork = escapeshellarg($newStandaloneDocker->network);
instant_remote_process([
+5 -3
View File
@@ -231,13 +231,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,