Merge remote-tracking branch 'origin/next' into next
This commit is contained in:
commit
52e60f1dcc
31 changed files with 1405 additions and 107 deletions
|
|
@ -12,7 +12,6 @@
|
|||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Laravel\Horizon\Contracts\Silenced;
|
||||
|
||||
class ApiTokenExpirationWarningJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
|
|
@ -29,20 +28,36 @@ public function handle(): void
|
|||
->whereNotNull('expires_at')
|
||||
->where('expires_at', '>', now())
|
||||
->where('expires_at', '<=', now()->addDay())
|
||||
->whereNull('api_token_expiration_warning_sent_at')
|
||||
->where('tokenable_type', User::class)
|
||||
->chunkById(100, function ($tokens) {
|
||||
foreach ($tokens as $token) {
|
||||
if (! $token->team_id) {
|
||||
continue;
|
||||
}
|
||||
RateLimiter::attempt(
|
||||
'api-token-expiring:'.$token->id,
|
||||
$maxAttempts = 0,
|
||||
function () use ($token) {
|
||||
Team::find($token->team_id)?->notify(new ApiTokenExpiringNotification($token));
|
||||
},
|
||||
$decaySeconds = 7 * 24 * 3600,
|
||||
);
|
||||
|
||||
$team = Team::find($token->team_id);
|
||||
if (! $team) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$warningSentAt = now();
|
||||
|
||||
$team->notify(new ApiTokenExpiringNotification($token));
|
||||
|
||||
$markedAsSent = PersonalAccessToken::query()
|
||||
->whereKey($token->getKey())
|
||||
->whereNotNull('expires_at')
|
||||
->where('expires_at', '>', now())
|
||||
->where('expires_at', '<=', now()->addDay())
|
||||
->whereNull('api_token_expiration_warning_sent_at')
|
||||
->update(['api_token_expiration_warning_sent_at' => $warningSentAt]);
|
||||
|
||||
if ($markedAsSent !== 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$token->forceFill(['api_token_expiration_warning_sent_at' => $warningSentAt]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -537,11 +537,6 @@ private function post_deployment()
|
|||
\Log::warning('Post deployment command failed for '.$this->deployment_uuid.': '.$e->getMessage());
|
||||
}
|
||||
|
||||
try {
|
||||
$this->application->isConfigurationChanged(true);
|
||||
} catch (Exception $e) {
|
||||
\Log::warning('Failed to mark configuration as changed for deployment '.$this->deployment_uuid.': '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function deploy_simple_dockerfile()
|
||||
|
|
@ -1238,8 +1233,9 @@ private function should_skip_build()
|
|||
|
||||
return true;
|
||||
}
|
||||
if (! $this->application->isConfigurationChanged()) {
|
||||
$this->application_deployment_queue->addLogEntry("No configuration changed & image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped.");
|
||||
$configurationDiff = $this->application->pendingDeploymentConfigurationDiff();
|
||||
if (! $configurationDiff->requiresBuild()) {
|
||||
$this->application_deployment_queue->addLogEntry("No build configuration changed & image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped.");
|
||||
$this->skip_build = true;
|
||||
$this->generate_compose_file();
|
||||
|
||||
|
|
@ -1251,7 +1247,7 @@ private function should_skip_build()
|
|||
|
||||
return true;
|
||||
} else {
|
||||
$this->application_deployment_queue->addLogEntry('Configuration changed. Rebuilding image.');
|
||||
$this->application_deployment_queue->addLogEntry('Build configuration changed. Rebuilding image.');
|
||||
}
|
||||
} else {
|
||||
$this->application_deployment_queue->addLogEntry("Image not found ({$this->production_image_name}). Building new image.");
|
||||
|
|
@ -4738,6 +4734,12 @@ private function handleSuccessfulDeployment(): void
|
|||
'last_restart_type' => null,
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->application->markDeploymentConfigurationApplied($this->application_deployment_queue);
|
||||
} catch (Exception $e) {
|
||||
\Log::warning('Failed to mark configuration as applied for deployment '.$this->deployment_uuid.': '.$e->getMessage());
|
||||
}
|
||||
|
||||
event(new ApplicationConfigurationChanged($this->application->team()->id));
|
||||
|
||||
if (! $this->only_this_server) {
|
||||
|
|
|
|||
|
|
@ -219,6 +219,7 @@ public function submit()
|
|||
}
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Settings saved.');
|
||||
$this->dispatch('configurationChanged');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
|
@ -237,6 +238,7 @@ public function saveCustomName()
|
|||
if (is_null($this->customInternalName)) {
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Custom name saved.');
|
||||
$this->dispatch('configurationChanged');
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
@ -256,6 +258,7 @@ public function saveCustomName()
|
|||
}
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Custom name saved.');
|
||||
$this->dispatch('configurationChanged');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ public function setPrivateKey(int $privateKeyId)
|
|||
$this->application->refresh();
|
||||
$this->privateKeyName = $this->application->private_key->name;
|
||||
$this->dispatch('success', 'Private key updated!');
|
||||
$this->dispatch('configurationChanged');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
|
@ -124,6 +125,7 @@ public function submit()
|
|||
}
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Application source updated!');
|
||||
$this->dispatch('configurationChanged');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,15 +12,20 @@
|
|||
use App\Models\StandaloneMysql;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Livewire\Component;
|
||||
|
||||
class ConfigurationChecker extends Component
|
||||
{
|
||||
public bool $isConfigurationChanged = false;
|
||||
|
||||
public array $configurationDiff = [];
|
||||
|
||||
public array $groupedConfigurationChanges = [];
|
||||
|
||||
public Application|Service|StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource;
|
||||
|
||||
public function getListeners()
|
||||
public function getListeners(): array
|
||||
{
|
||||
$teamId = auth()->user()->currentTeam()->id;
|
||||
|
||||
|
|
@ -30,18 +35,36 @@ public function getListeners()
|
|||
];
|
||||
}
|
||||
|
||||
public function mount()
|
||||
public function mount(): void
|
||||
{
|
||||
$this->configurationChanged();
|
||||
}
|
||||
|
||||
public function render()
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.project.shared.configuration-checker');
|
||||
}
|
||||
|
||||
public function configurationChanged()
|
||||
public function refreshConfigurationChanges(): void
|
||||
{
|
||||
$this->configurationChanged();
|
||||
}
|
||||
|
||||
public function configurationChanged(): void
|
||||
{
|
||||
$this->resource->refresh();
|
||||
|
||||
if ($this->resource instanceof Application) {
|
||||
$diff = $this->resource->pendingDeploymentConfigurationDiff();
|
||||
$this->isConfigurationChanged = $diff->isChanged();
|
||||
$this->configurationDiff = $diff->toArray();
|
||||
$this->groupedConfigurationChanges = $diff->groupedChanges();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->isConfigurationChanged = $this->resource->isConfigurationChanged();
|
||||
$this->configurationDiff = [];
|
||||
$this->groupedConfigurationChanges = [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@
|
|||
|
||||
use App\Enums\ApplicationDeploymentStatus;
|
||||
use App\Services\ConfigurationGenerator;
|
||||
use App\Services\DeploymentConfiguration\ApplicationConfigurationSnapshot;
|
||||
use App\Services\DeploymentConfiguration\ConfigurationDiff;
|
||||
use App\Services\DeploymentConfiguration\ConfigurationDiffer;
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasConfiguration;
|
||||
use App\Traits\HasMetrics;
|
||||
|
|
@ -720,14 +723,14 @@ public function dockerfileLocation(): Attribute
|
|||
return Attribute::make(
|
||||
set: function ($value) {
|
||||
if (is_null($value) || $value === '') {
|
||||
return '/Dockerfile';
|
||||
} else {
|
||||
if ($value !== '/') {
|
||||
return Str::start(Str::replaceEnd('/', '', $value), '/');
|
||||
}
|
||||
|
||||
return Str::start($value, '/');
|
||||
return $this->build_pack === 'dockerfile' ? '/Dockerfile' : null;
|
||||
}
|
||||
|
||||
if ($value !== '/') {
|
||||
return Str::start(Str::replaceEnd('/', '', $value), '/');
|
||||
}
|
||||
|
||||
return Str::start($value, '/');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
@ -1059,7 +1062,7 @@ public function isDeploymentInprogress()
|
|||
|
||||
public function get_last_successful_deployment()
|
||||
{
|
||||
return ApplicationDeploymentQueue::where('application_id', $this->id)->where('status', ApplicationDeploymentStatus::FINISHED)->where('pull_request_id', 0)->orderBy('created_at', 'desc')->first();
|
||||
return ApplicationDeploymentQueue::where('application_id', $this->id)->where('status', ApplicationDeploymentStatus::FINISHED->value)->where('pull_request_id', 0)->orderBy('created_at', 'desc')->first();
|
||||
}
|
||||
|
||||
public function get_last_days_deployments()
|
||||
|
|
@ -1170,33 +1173,92 @@ public function isLogDrainEnabled()
|
|||
}
|
||||
|
||||
public function isConfigurationChanged(bool $save = false)
|
||||
{
|
||||
$configurationDiff = $this->pendingDeploymentConfigurationDiff();
|
||||
|
||||
if ($save) {
|
||||
$this->markDeploymentConfigurationApplied();
|
||||
}
|
||||
|
||||
return $configurationDiff->isChanged();
|
||||
}
|
||||
|
||||
public function pendingDeploymentConfigurationDiff(): ConfigurationDiff
|
||||
{
|
||||
$currentSnapshot = $this->deploymentConfigurationSnapshot();
|
||||
$lastDeployment = $this->get_last_successful_deployment();
|
||||
|
||||
if ($lastDeployment?->configuration_snapshot) {
|
||||
return app(ConfigurationDiffer::class)->diff($lastDeployment->configuration_snapshot, $currentSnapshot);
|
||||
}
|
||||
|
||||
$oldConfigHash = data_get($this, 'config_hash');
|
||||
|
||||
if ($oldConfigHash === null) {
|
||||
return ConfigurationDiff::legacy(true);
|
||||
}
|
||||
|
||||
return ConfigurationDiff::legacy($oldConfigHash !== $this->legacyConfigurationHash());
|
||||
}
|
||||
|
||||
public function hasPendingDeploymentConfigurationChanges(): bool
|
||||
{
|
||||
return $this->pendingDeploymentConfigurationDiff()->isChanged();
|
||||
}
|
||||
|
||||
public function deploymentConfigurationSnapshot(): array
|
||||
{
|
||||
return (new ApplicationConfigurationSnapshot($this))->toArray();
|
||||
}
|
||||
|
||||
public function deploymentConfigurationHash(): string
|
||||
{
|
||||
return ApplicationConfigurationSnapshot::hashSnapshot($this->deploymentConfigurationSnapshot());
|
||||
}
|
||||
|
||||
public function markDeploymentConfigurationApplied(?ApplicationDeploymentQueue $deployment = null): void
|
||||
{
|
||||
$this->refresh();
|
||||
|
||||
if (! $deployment) {
|
||||
$this->forceFill(['config_hash' => $this->legacyConfigurationHash()])->save();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$snapshot = $this->deploymentConfigurationSnapshot();
|
||||
$hash = ApplicationConfigurationSnapshot::hashSnapshot($snapshot);
|
||||
|
||||
$previousDeployment = ApplicationDeploymentQueue::query()
|
||||
->where('application_id', $this->id)
|
||||
->where('status', ApplicationDeploymentStatus::FINISHED->value)
|
||||
->where('pull_request_id', $deployment->pull_request_id ?? 0)
|
||||
->where('id', '!=', $deployment->id)
|
||||
->whereNotNull('configuration_snapshot')
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
$deployment->update([
|
||||
'configuration_hash' => $hash,
|
||||
'configuration_snapshot' => $snapshot,
|
||||
'configuration_diff' => $previousDeployment?->configuration_snapshot
|
||||
? app(ConfigurationDiffer::class)->diff($previousDeployment->configuration_snapshot, $snapshot)->toArray()
|
||||
: null,
|
||||
]);
|
||||
|
||||
$this->forceFill(['config_hash' => $hash])->save();
|
||||
}
|
||||
|
||||
private function legacyConfigurationHash(): string
|
||||
{
|
||||
$newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->custom_network_aliases.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->settings?->use_build_secrets.$this->settings?->inject_build_args_to_dockerfile.$this->settings?->include_source_commit_in_build);
|
||||
if ($this->pull_request_id === 0 || $this->pull_request_id === null) {
|
||||
$newConfigHash .= json_encode($this->environment_variables()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort());
|
||||
} else {
|
||||
$newConfigHash .= json_encode($this->environment_variables_preview->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort());
|
||||
$newConfigHash .= json_encode($this->environment_variables_preview()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort());
|
||||
}
|
||||
$newConfigHash = md5($newConfigHash);
|
||||
$oldConfigHash = data_get($this, 'config_hash');
|
||||
if ($oldConfigHash === null) {
|
||||
if ($save) {
|
||||
$this->config_hash = $newConfigHash;
|
||||
$this->save();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
if ($oldConfigHash === $newConfigHash) {
|
||||
return false;
|
||||
} else {
|
||||
if ($save) {
|
||||
$this->config_hash = $newConfigHash;
|
||||
$this->save();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
return md5($newConfigHash);
|
||||
}
|
||||
|
||||
public function customRepository()
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@
|
|||
'deployment_uuid' => ['type' => 'string'],
|
||||
'pull_request_id' => ['type' => 'integer'],
|
||||
'docker_registry_image_tag' => ['type' => 'string', 'nullable' => true],
|
||||
'configuration_hash' => ['type' => 'string', 'nullable' => true],
|
||||
'configuration_snapshot' => ['type' => 'object', 'nullable' => true],
|
||||
'configuration_diff' => ['type' => 'object', 'nullable' => true],
|
||||
'force_rebuild' => ['type' => 'boolean'],
|
||||
'commit' => ['type' => 'string'],
|
||||
'status' => ['type' => 'string'],
|
||||
|
|
@ -45,6 +48,9 @@ class ApplicationDeploymentQueue extends Model
|
|||
'deployment_uuid',
|
||||
'pull_request_id',
|
||||
'docker_registry_image_tag',
|
||||
'configuration_hash',
|
||||
'configuration_snapshot',
|
||||
'configuration_diff',
|
||||
'force_rebuild',
|
||||
'commit',
|
||||
'status',
|
||||
|
|
@ -71,6 +77,8 @@ class ApplicationDeploymentQueue extends Model
|
|||
protected $casts = [
|
||||
'pull_request_id' => 'integer',
|
||||
'finished_at' => 'datetime',
|
||||
'configuration_snapshot' => 'array',
|
||||
'configuration_diff' => 'array',
|
||||
];
|
||||
|
||||
public function application()
|
||||
|
|
|
|||
|
|
@ -11,6 +11,14 @@ class PersonalAccessToken extends SanctumPersonalAccessToken
|
|||
'token',
|
||||
'abilities',
|
||||
'expires_at',
|
||||
'api_token_expiration_warning_sent_at',
|
||||
'team_id',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'api_token_expiration_warning_sent_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,338 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\DeploymentConfiguration;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class ApplicationConfigurationSnapshot
|
||||
{
|
||||
public const SCHEMA_VERSION = 1;
|
||||
|
||||
public function __construct(protected Application $application) {}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
$this->application->load('settings');
|
||||
|
||||
return [
|
||||
'schema_version' => self::SCHEMA_VERSION,
|
||||
'resource_type' => Application::class,
|
||||
'resource_id' => $this->application->id,
|
||||
'sections' => [
|
||||
'source' => [
|
||||
'label' => 'Source',
|
||||
'items' => $this->sourceItems(),
|
||||
],
|
||||
'build' => [
|
||||
'label' => 'Build',
|
||||
'items' => $this->buildItems(),
|
||||
],
|
||||
'runtime' => [
|
||||
'label' => 'Runtime',
|
||||
'items' => $this->runtimeItems(),
|
||||
],
|
||||
'domains' => [
|
||||
'label' => 'Domains & Proxy',
|
||||
'items' => $this->domainItems(),
|
||||
],
|
||||
'environment' => [
|
||||
'label' => 'Environment Variables',
|
||||
'items' => $this->environmentItems(),
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function hash(): string
|
||||
{
|
||||
return self::hashSnapshot($this->toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $snapshot
|
||||
*/
|
||||
public static function hashSnapshot(array $snapshot): string
|
||||
{
|
||||
return hash('sha256', json_encode(self::comparableSnapshot($snapshot), JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $snapshot
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function comparableSnapshot(array $snapshot): array
|
||||
{
|
||||
$sections = collect(data_get($snapshot, 'sections', []))
|
||||
->mapWithKeys(function (array $section, string $sectionKey): array {
|
||||
$items = collect(data_get($section, 'items', []))
|
||||
->mapWithKeys(fn (array $item): array => [
|
||||
$item['key'] => [
|
||||
'compare_value' => $item['compare_value'] ?? null,
|
||||
'impact' => $item['impact'] ?? 'redeploy',
|
||||
],
|
||||
])
|
||||
->sortKeys()
|
||||
->all();
|
||||
|
||||
return [$sectionKey => $items];
|
||||
})
|
||||
->sortKeys()
|
||||
->all();
|
||||
|
||||
return [
|
||||
'schema_version' => data_get($snapshot, 'schema_version'),
|
||||
'sections' => $sections,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function sourceItems(): array
|
||||
{
|
||||
return [
|
||||
$this->item('git_repository', 'Repository', $this->application->git_repository, 'build'),
|
||||
$this->item('git_branch', 'Branch', $this->application->git_branch, 'build'),
|
||||
$this->item('git_commit_sha', 'Commit SHA', $this->application->git_commit_sha, 'build'),
|
||||
$this->item('private_key_id', 'Private key', $this->application->private_key_id, 'build'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function buildItems(): array
|
||||
{
|
||||
return [
|
||||
$this->item('build_pack', 'Build pack', $this->application->build_pack, 'build'),
|
||||
$this->item('static_image', 'Static image', $this->application->static_image, 'build'),
|
||||
$this->item('base_directory', 'Base directory', $this->application->base_directory, 'build'),
|
||||
$this->item('publish_directory', 'Publish directory', $this->application->publish_directory, 'build'),
|
||||
$this->item('install_command', 'Install command', $this->application->install_command, 'build'),
|
||||
$this->item('build_command', 'Build command', $this->application->build_command, 'build'),
|
||||
$this->item('dockerfile', 'Dockerfile', $this->application->dockerfile, 'build', displayValue: $this->summarizeText($this->application->dockerfile)),
|
||||
$this->item('dockerfile_location', 'Dockerfile location', $this->application->dockerfile_location, 'build'),
|
||||
$this->item('dockerfile_target_build', 'Dockerfile target', $this->application->dockerfile_target_build, 'build'),
|
||||
$this->item('docker_compose_location', 'Docker Compose location', $this->application->docker_compose_location, 'build'),
|
||||
$this->item('docker_compose', 'Docker Compose', $this->application->docker_compose, 'build', displayValue: $this->summarizeText($this->application->docker_compose)),
|
||||
$this->item('docker_compose_raw', 'Raw Docker Compose', $this->application->docker_compose_raw, 'build', displayValue: $this->summarizeText($this->application->docker_compose_raw)),
|
||||
$this->item('docker_compose_custom_build_command', 'Docker Compose custom build command', $this->application->docker_compose_custom_build_command, 'build'),
|
||||
$this->item('custom_docker_run_options', 'Custom Docker run options', $this->application->custom_docker_run_options, 'build'),
|
||||
$this->item('use_build_secrets', 'Use build secrets', data_get($this->application, 'settings.use_build_secrets'), 'build'),
|
||||
$this->item('inject_build_args_to_dockerfile', 'Inject build args to Dockerfile', data_get($this->application, 'settings.inject_build_args_to_dockerfile'), 'build'),
|
||||
$this->item('include_source_commit_in_build', 'Include source commit in build', data_get($this->application, 'settings.include_source_commit_in_build'), 'build'),
|
||||
$this->item('disable_build_cache', 'Disable build cache', data_get($this->application, 'settings.disable_build_cache'), 'build'),
|
||||
$this->item('is_build_server_enabled', 'Build server', data_get($this->application, 'settings.is_build_server_enabled'), 'build'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function runtimeItems(): array
|
||||
{
|
||||
return [
|
||||
$this->item('start_command', 'Start command', $this->application->start_command, 'redeploy'),
|
||||
$this->item('docker_compose_custom_start_command', 'Docker Compose custom start command', $this->application->docker_compose_custom_start_command, 'redeploy'),
|
||||
$this->item('ports_exposes', 'Exposed ports', $this->application->ports_exposes, 'redeploy'),
|
||||
$this->item('ports_mappings', 'Port mappings', $this->application->ports_mappings, 'redeploy'),
|
||||
$this->item('custom_network_aliases', 'Network aliases', $this->application->custom_network_aliases, 'redeploy'),
|
||||
$this->item('connect_to_docker_network', 'Connect to Docker network', data_get($this->application, 'settings.connect_to_docker_network'), 'redeploy'),
|
||||
$this->item('custom_internal_name', 'Custom container name', data_get($this->application, 'settings.custom_internal_name'), 'redeploy'),
|
||||
$this->item('is_raw_compose_deployment_enabled', 'Raw Compose deployment', data_get($this->application, 'settings.is_raw_compose_deployment_enabled'), 'redeploy'),
|
||||
$this->item('is_gpu_enabled', 'GPU enabled', data_get($this->application, 'settings.is_gpu_enabled'), 'redeploy'),
|
||||
$this->item('gpu_driver', 'GPU driver', data_get($this->application, 'settings.gpu_driver'), 'redeploy'),
|
||||
$this->item('gpu_count', 'GPU count', data_get($this->application, 'settings.gpu_count'), 'redeploy'),
|
||||
$this->item('gpu_device_ids', 'GPU device IDs', data_get($this->application, 'settings.gpu_device_ids'), 'redeploy'),
|
||||
$this->item('gpu_options', 'GPU options', data_get($this->application, 'settings.gpu_options'), 'redeploy'),
|
||||
...$this->healthCheckItems(),
|
||||
...$this->limitItems(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function domainItems(): array
|
||||
{
|
||||
return [
|
||||
$this->item('fqdn', 'Domains', $this->application->fqdn, 'redeploy'),
|
||||
$this->item('redirect', 'Redirect', $this->application->redirect, 'redeploy'),
|
||||
$this->item('custom_labels', 'Container labels', $this->application->custom_labels, 'redeploy', displayValue: $this->summarizeText($this->application->custom_labels)),
|
||||
$this->item('custom_nginx_configuration', 'Custom Nginx configuration', $this->application->custom_nginx_configuration, 'redeploy', displayValue: $this->summarizeText($this->application->custom_nginx_configuration)),
|
||||
$this->item('is_force_https_enabled', 'Force HTTPS', data_get($this->application, 'settings.is_force_https_enabled'), 'redeploy'),
|
||||
$this->item('is_gzip_enabled', 'Gzip', data_get($this->application, 'settings.is_gzip_enabled'), 'redeploy'),
|
||||
$this->item('is_stripprefix_enabled', 'Strip prefix', data_get($this->application, 'settings.is_stripprefix_enabled'), 'redeploy'),
|
||||
$this->item('is_http_basic_auth_enabled', 'HTTP basic auth', $this->application->is_http_basic_auth_enabled, 'redeploy'),
|
||||
$this->item('http_basic_auth_username', 'HTTP basic auth username', $this->application->http_basic_auth_username, 'redeploy'),
|
||||
$this->item('http_basic_auth_password', 'HTTP basic auth password', $this->application->http_basic_auth_password, 'redeploy', sensitive: true),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function environmentItems(): array
|
||||
{
|
||||
return $this->application->environment_variables()
|
||||
->get()
|
||||
->sortBy('key', SORT_NATURAL | SORT_FLAG_CASE)
|
||||
->values()
|
||||
->map(fn (EnvironmentVariable $environmentVariable): array => $this->environmentItem($environmentVariable))
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function healthCheckItems(): array
|
||||
{
|
||||
return collect([
|
||||
'health_check_enabled' => 'Health check enabled',
|
||||
'health_check_path' => 'Health check path',
|
||||
'health_check_port' => 'Health check port',
|
||||
'health_check_host' => 'Health check host',
|
||||
'health_check_method' => 'Health check method',
|
||||
'health_check_return_code' => 'Health check return code',
|
||||
'health_check_scheme' => 'Health check scheme',
|
||||
'health_check_response_text' => 'Health check response text',
|
||||
'health_check_interval' => 'Health check interval',
|
||||
'health_check_timeout' => 'Health check timeout',
|
||||
'health_check_retries' => 'Health check retries',
|
||||
'health_check_start_period' => 'Health check start period',
|
||||
'health_check_type' => 'Health check type',
|
||||
'health_check_command' => 'Health check command',
|
||||
])->map(fn (string $label, string $key): array => $this->item($key, $label, data_get($this->application, $key), 'redeploy'))->values()->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function limitItems(): array
|
||||
{
|
||||
return collect([
|
||||
'limits_memory' => 'Memory limit',
|
||||
'limits_memory_swap' => 'Memory swap limit',
|
||||
'limits_memory_swappiness' => 'Memory swappiness',
|
||||
'limits_memory_reservation' => 'Memory reservation',
|
||||
'limits_cpus' => 'CPU limit',
|
||||
'limits_cpuset' => 'CPU set',
|
||||
'limits_cpu_shares' => 'CPU shares',
|
||||
'swarm_replicas' => 'Swarm replicas',
|
||||
'swarm_placement_constraints' => 'Swarm placement constraints',
|
||||
])->map(fn (string $label, string $key): array => $this->item($key, $label, data_get($this->application, $key), 'redeploy'))->values()->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function environmentItem(EnvironmentVariable $environmentVariable): array
|
||||
{
|
||||
$impact = $environmentVariable->is_buildtime ? 'build' : 'redeploy';
|
||||
$compareValue = [
|
||||
'value_hash' => $this->sensitiveHash($environmentVariable->value),
|
||||
'is_multiline' => $environmentVariable->is_multiline,
|
||||
'is_literal' => $environmentVariable->is_literal,
|
||||
'is_buildtime' => $environmentVariable->is_buildtime,
|
||||
'is_runtime' => $environmentVariable->is_runtime,
|
||||
];
|
||||
|
||||
return $this->item(
|
||||
key: (string) $environmentVariable->key,
|
||||
label: (string) $environmentVariable->key,
|
||||
value: $compareValue,
|
||||
impact: $impact,
|
||||
sensitive: true,
|
||||
displayValue: $this->environmentDisplayValue($environmentVariable),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function item(string $key, string $label, mixed $value, string $impact, bool $sensitive = false, mixed $displayValue = null): array
|
||||
{
|
||||
$normalizedValue = $this->normalizeValue($value);
|
||||
|
||||
return [
|
||||
'key' => $key,
|
||||
'label' => $label,
|
||||
'impact' => $impact,
|
||||
'sensitive' => $sensitive,
|
||||
'compare_value' => $sensitive ? $this->sensitiveHash($normalizedValue) : $normalizedValue,
|
||||
'display_value' => $displayValue ?? $this->displayValue($normalizedValue),
|
||||
];
|
||||
}
|
||||
|
||||
private function environmentDisplayValue(EnvironmentVariable $environmentVariable): string
|
||||
{
|
||||
$flags = collect([
|
||||
$environmentVariable->is_buildtime ? 'build-time' : null,
|
||||
$environmentVariable->is_runtime ? 'runtime' : null,
|
||||
$environmentVariable->is_multiline ? 'multiline' : null,
|
||||
$environmentVariable->is_literal ? 'literal' : null,
|
||||
])->filter()->implode(', ');
|
||||
|
||||
return $flags ? "Hidden ({$flags})" : 'Hidden';
|
||||
}
|
||||
|
||||
private function sensitiveHash(mixed $value): string
|
||||
{
|
||||
return hash_hmac('sha256', json_encode($value, JSON_THROW_ON_ERROR), (string) config('app.key', 'coolify'));
|
||||
}
|
||||
|
||||
private function normalizeValue(mixed $value): mixed
|
||||
{
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_bool($value) || is_numeric($value) || $value === null || is_string($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
return Arr::sortRecursive($value);
|
||||
}
|
||||
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
private function displayValue(mixed $value): string
|
||||
{
|
||||
if ($value === null) {
|
||||
return 'Not set';
|
||||
}
|
||||
|
||||
if (is_bool($value)) {
|
||||
return $value ? 'Enabled' : 'Disabled';
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
return $this->summarizeText(json_encode($value, JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
return $this->summarizeText((string) $value);
|
||||
}
|
||||
|
||||
private function summarizeText(?string $value): string
|
||||
{
|
||||
if (blank($value)) {
|
||||
return 'Not set';
|
||||
}
|
||||
|
||||
$value = trim((string) $value);
|
||||
$lines = substr_count($value, "\n") + 1;
|
||||
|
||||
if ($lines > 1) {
|
||||
return str($value)->limit(80)." ({$lines} lines)";
|
||||
}
|
||||
|
||||
return str($value)->limit(120)->value();
|
||||
}
|
||||
}
|
||||
112
app/Services/DeploymentConfiguration/ConfigurationDiff.php
Normal file
112
app/Services/DeploymentConfiguration/ConfigurationDiff.php
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\DeploymentConfiguration;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class ConfigurationDiff
|
||||
{
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $changes
|
||||
*/
|
||||
public function __construct(
|
||||
protected array $changes = [],
|
||||
protected bool $legacyFallback = false,
|
||||
) {}
|
||||
|
||||
public static function unchanged(): self
|
||||
{
|
||||
return new self;
|
||||
}
|
||||
|
||||
public static function legacy(bool $changed): self
|
||||
{
|
||||
if (! $changed) {
|
||||
return self::unchanged();
|
||||
}
|
||||
|
||||
return new self([
|
||||
[
|
||||
'key' => 'legacy.configuration',
|
||||
'section' => 'configuration',
|
||||
'section_label' => 'Configuration',
|
||||
'label' => 'Configuration',
|
||||
'type' => 'changed',
|
||||
'impact' => 'build',
|
||||
'sensitive' => false,
|
||||
'old_display_value' => 'Previously deployed configuration',
|
||||
'new_display_value' => 'Current configuration',
|
||||
],
|
||||
], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $changes
|
||||
*/
|
||||
public static function fromChanges(array $changes): self
|
||||
{
|
||||
return new self(array_values($changes));
|
||||
}
|
||||
|
||||
public function isChanged(): bool
|
||||
{
|
||||
return $this->changes !== [];
|
||||
}
|
||||
|
||||
public function isLegacyFallback(): bool
|
||||
{
|
||||
return $this->legacyFallback;
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return count($this->changes);
|
||||
}
|
||||
|
||||
public function requiresBuild(): bool
|
||||
{
|
||||
return collect($this->changes)->contains(fn (array $change): bool => $change['impact'] === 'build');
|
||||
}
|
||||
|
||||
public function requiresRedeploy(): bool
|
||||
{
|
||||
return $this->isChanged();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function changes(): array
|
||||
{
|
||||
return $this->changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{label: string, changes: array<int, array<string, mixed>>}>
|
||||
*/
|
||||
public function groupedChanges(): array
|
||||
{
|
||||
return collect($this->changes)
|
||||
->groupBy('section')
|
||||
->map(fn (Collection $changes): array => [
|
||||
'label' => (string) data_get($changes->first(), 'section_label', str((string) $changes->keys()->first())->headline()),
|
||||
'changes' => $changes->values()->all(),
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{changed: bool, count: int, requires_build: bool, requires_redeploy: bool, legacy_fallback: bool, changes: array<int, array<string, mixed>>}
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'changed' => $this->isChanged(),
|
||||
'count' => $this->count(),
|
||||
'requires_build' => $this->requiresBuild(),
|
||||
'requires_redeploy' => $this->requiresRedeploy(),
|
||||
'legacy_fallback' => $this->isLegacyFallback(),
|
||||
'changes' => $this->changes(),
|
||||
];
|
||||
}
|
||||
}
|
||||
69
app/Services/DeploymentConfiguration/ConfigurationDiffer.php
Normal file
69
app/Services/DeploymentConfiguration/ConfigurationDiffer.php
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\DeploymentConfiguration;
|
||||
|
||||
class ConfigurationDiffer
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $previousSnapshot
|
||||
* @param array<string, mixed> $currentSnapshot
|
||||
*/
|
||||
public function diff(array $previousSnapshot, array $currentSnapshot): ConfigurationDiff
|
||||
{
|
||||
$previousItems = $this->flattenItems($previousSnapshot);
|
||||
$currentItems = $this->flattenItems($currentSnapshot);
|
||||
$keys = collect(array_keys($previousItems))->merge(array_keys($currentItems))->unique()->sort();
|
||||
$changes = [];
|
||||
|
||||
foreach ($keys as $key) {
|
||||
$previous = $previousItems[$key] ?? null;
|
||||
$current = $currentItems[$key] ?? null;
|
||||
|
||||
if (($previous['compare_value'] ?? null) === ($current['compare_value'] ?? null)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$item = $current ?? $previous;
|
||||
$sensitive = (bool) data_get($item, 'sensitive', false);
|
||||
$type = $previous === null ? 'added' : ($current === null ? 'removed' : 'changed');
|
||||
$displaySummary = $sensitive && $type === 'changed' ? 'Changed' : null;
|
||||
|
||||
$changes[] = [
|
||||
'key' => $key,
|
||||
'section' => data_get($item, 'section'),
|
||||
'section_label' => data_get($item, 'section_label'),
|
||||
'label' => data_get($item, 'label'),
|
||||
'type' => $type,
|
||||
'impact' => data_get($item, 'impact', 'redeploy'),
|
||||
'sensitive' => $sensitive,
|
||||
'display_summary' => $displaySummary,
|
||||
'old_display_value' => $sensitive ? ($previous === null ? 'Not set' : 'Set') : data_get($previous, 'display_value', 'Not set'),
|
||||
'new_display_value' => $sensitive ? ($current === null ? 'Removed' : 'Set') : data_get($current, 'display_value', 'Not set'),
|
||||
];
|
||||
}
|
||||
|
||||
return ConfigurationDiff::fromChanges($changes);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $snapshot
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
private function flattenItems(array $snapshot): array
|
||||
{
|
||||
return collect(data_get($snapshot, 'sections', []))
|
||||
->flatMap(function (array $section, string $sectionKey): array {
|
||||
return collect(data_get($section, 'items', []))
|
||||
->mapWithKeys(function (array $item) use ($section, $sectionKey): array {
|
||||
$key = $sectionKey.'.'.$item['key'];
|
||||
|
||||
return [$key => array_merge($item, [
|
||||
'section' => $sectionKey,
|
||||
'section_label' => data_get($section, 'label', str($sectionKey)->headline()->value()),
|
||||
])];
|
||||
})
|
||||
->all();
|
||||
})
|
||||
->all();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('application_deployment_queues', function (Blueprint $table) {
|
||||
$table->string('configuration_hash')->nullable()->after('docker_registry_image_tag');
|
||||
$table->json('configuration_snapshot')->nullable()->after('configuration_hash');
|
||||
$table->json('configuration_diff')->nullable()->after('configuration_snapshot');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('application_deployment_queues', function (Blueprint $table) {
|
||||
$table->dropColumn([
|
||||
'configuration_hash',
|
||||
'configuration_snapshot',
|
||||
'configuration_diff',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('personal_access_tokens', function (Blueprint $table) {
|
||||
$table->timestamp('api_token_expiration_warning_sent_at')->nullable()->after('expires_at');
|
||||
$table->index(['expires_at', 'api_token_expiration_warning_sent_at'], 'personal_access_tokens_expiration_warning_index');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('personal_access_tokens', function (Blueprint $table) {
|
||||
$table->dropIndex('personal_access_tokens_expiration_warning_index');
|
||||
$table->dropColumn('api_token_expiration_warning_sent_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
12
openapi.json
12
openapi.json
|
|
@ -12791,6 +12791,18 @@
|
|||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"configuration_hash": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"configuration_snapshot": {
|
||||
"type": "object",
|
||||
"nullable": true
|
||||
},
|
||||
"configuration_diff": {
|
||||
"type": "object",
|
||||
"nullable": true
|
||||
},
|
||||
"force_rebuild": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -8158,6 +8158,15 @@ components:
|
|||
docker_registry_image_tag:
|
||||
type: string
|
||||
nullable: true
|
||||
configuration_hash:
|
||||
type: string
|
||||
nullable: true
|
||||
configuration_snapshot:
|
||||
type: object
|
||||
nullable: true
|
||||
configuration_diff:
|
||||
type: object
|
||||
nullable: true
|
||||
force_rebuild:
|
||||
type: boolean
|
||||
commit:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
@props([
|
||||
'diff' => null,
|
||||
'compact' => false,
|
||||
])
|
||||
|
||||
@php
|
||||
$changes = data_get($diff, 'changes', []);
|
||||
$count = data_get($diff, 'count', count($changes));
|
||||
$requiresBuild = data_get($diff, 'requires_build', false);
|
||||
@endphp
|
||||
|
||||
@if ($count > 0)
|
||||
<div @class([
|
||||
'text-xs' => $compact,
|
||||
'text-sm' => ! $compact,
|
||||
])>
|
||||
<div class="mb-2 flex flex-wrap items-center gap-2 font-semibold text-black dark:text-white">
|
||||
<span>{{ $count }} configuration {{ $count === 1 ? 'change' : 'changes' }}</span>
|
||||
<span @class([
|
||||
'rounded-sm px-1.5 py-0.5 text-[0.65rem] font-semibold uppercase leading-none',
|
||||
'bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-300' => $requiresBuild,
|
||||
'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300' => ! $requiresBuild,
|
||||
])>
|
||||
{{ $requiresBuild ? 'Rebuild' : 'Redeploy' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@unless ($compact)
|
||||
<div class="space-y-2">
|
||||
@foreach (collect($changes)->groupBy('section_label') as $sectionLabel => $sectionChanges)
|
||||
<div>
|
||||
<div class="mb-0.5 text-[0.65rem] font-semibold uppercase tracking-wide text-neutral-600 dark:text-neutral-400">
|
||||
{{ $sectionLabel }}
|
||||
</div>
|
||||
<div class="overflow-x-auto rounded-sm border border-neutral-300 dark:border-coolgray-200">
|
||||
<div class="min-w-[44rem]">
|
||||
<div class="grid grid-cols-[minmax(12rem,1.4fr)_7rem_minmax(8rem,1fr)_1.5rem_minmax(8rem,1fr)] items-center gap-2 bg-neutral-100 px-3 py-1.5 text-[0.65rem] font-semibold uppercase tracking-wide text-neutral-500 dark:bg-coolgray-200 dark:text-neutral-400">
|
||||
<div>Field</div>
|
||||
<div>Type</div>
|
||||
<div>From</div>
|
||||
<div></div>
|
||||
<div>To</div>
|
||||
</div>
|
||||
<div class="divide-y divide-neutral-300 dark:divide-coolgray-200">
|
||||
@foreach ($sectionChanges as $change)
|
||||
<div class="grid grid-cols-[minmax(12rem,1.4fr)_7rem_minmax(8rem,1fr)_1.5rem_minmax(8rem,1fr)] items-center gap-2 px-3 py-1.5 text-neutral-700 dark:text-neutral-300">
|
||||
<div class="truncate font-medium text-black dark:text-white" title="{{ data_get($change, 'label') }}">
|
||||
{{ data_get($change, 'label') }}
|
||||
</div>
|
||||
<div class="text-neutral-500 dark:text-neutral-400">
|
||||
{{ data_get($change, 'type') }}
|
||||
</div>
|
||||
<div class="truncate" title="{{ data_get($change, 'old_display_value') }}">
|
||||
{{ data_get($change, 'old_display_value') }}
|
||||
</div>
|
||||
<div class="text-center text-neutral-500 dark:text-neutral-400">→</div>
|
||||
<div class="truncate" title="{{ data_get($change, 'new_display_value') }}">
|
||||
{{ data_get($change, 'new_display_value') }}
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endunless
|
||||
</div>
|
||||
@endif
|
||||
|
|
@ -80,9 +80,7 @@ class="text-xl font-bold tracking-wide dark:text-white hover:opacity-80 transiti
|
|||
</div>
|
||||
|
||||
<main class="transition-[padding] duration-200 p-6" :class="collapsed ? 'lg:pl-[6rem]' : 'lg:pl-[16rem]'">
|
||||
<div>
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
@endauth
|
||||
|
|
|
|||
|
|
@ -61,14 +61,6 @@ class="loading loading-xs dark:text-warning loading-spinner"></span>
|
|||
<x-forms.input id="publish_directory" required label="Publish Directory" />
|
||||
@endif
|
||||
</div>
|
||||
@if ($build_pack === 'railpack')
|
||||
<div>
|
||||
<span
|
||||
class="px-2 py-1 text-xs font-bold uppercase tracking-wide bg-coollabs/10 dark:bg-warning/20 text-coollabs dark:text-warning rounded">
|
||||
Beta
|
||||
</span>
|
||||
</div>
|
||||
@endif
|
||||
@if ($build_pack === 'dockercompose')
|
||||
<div x-data="{
|
||||
baseDir: '{{ $base_directory }}',
|
||||
|
|
|
|||
|
|
@ -93,14 +93,6 @@
|
|||
helper="If there is a build process involved (like Svelte, React, Next, etc..), please specify the output directory for the build assets." />
|
||||
@endif
|
||||
</div>
|
||||
@if ($build_pack === 'railpack')
|
||||
<div>
|
||||
<span
|
||||
class="px-2 py-1 text-xs font-bold uppercase tracking-wide bg-coollabs/10 dark:bg-warning/20 text-coollabs dark:text-warning rounded">
|
||||
Beta
|
||||
</span>
|
||||
</div>
|
||||
@endif
|
||||
@if ($build_pack === 'dockercompose')
|
||||
<div x-data="{
|
||||
baseDir: '{{ $base_directory }}',
|
||||
|
|
|
|||
|
|
@ -52,14 +52,6 @@
|
|||
helper="If there is a build process involved (like Svelte, React, Next, etc..), please specify the output directory for the build assets." />
|
||||
@endif
|
||||
</div>
|
||||
@if ($build_pack === 'railpack')
|
||||
<div>
|
||||
<span
|
||||
class="px-2 py-1 text-xs font-bold uppercase tracking-wide bg-coollabs/10 dark:bg-warning/20 text-coollabs dark:text-warning rounded">
|
||||
Beta
|
||||
</span>
|
||||
</div>
|
||||
@endif
|
||||
@if ($build_pack === 'dockercompose')
|
||||
<div x-data="{
|
||||
baseDir: '{{ $base_directory }}',
|
||||
|
|
|
|||
|
|
@ -1,22 +1,77 @@
|
|||
<div>
|
||||
@if ($isConfigurationChanged && !is_null($resource->config_hash) && !$resource->isExited())
|
||||
<x-popup-small>
|
||||
<x-slot:title>
|
||||
The latest configuration has not been applied
|
||||
</x-slot:title>
|
||||
<x-slot:icon>
|
||||
<svg class="hidden w-10 h-10 dark:text-warning lg:block" viewBox="0 0 256 256"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="currentColor"
|
||||
d="M240.26 186.1L152.81 34.23a28.74 28.74 0 0 0-49.62 0L15.74 186.1a27.45 27.45 0 0 0 0 27.71A28.31 28.31 0 0 0 40.55 228h174.9a28.31 28.31 0 0 0 24.79-14.19a27.45 27.45 0 0 0 .02-27.71m-20.8 15.7a4.46 4.46 0 0 1-4 2.2H40.55a4.46 4.46 0 0 1-4-2.2a3.56 3.56 0 0 1 0-3.73L124 46.2a4.77 4.77 0 0 1 8 0l87.44 151.87a3.56 3.56 0 0 1 .02 3.73M116 136v-32a12 12 0 0 1 24 0v32a12 12 0 0 1-24 0m28 40a16 16 0 1 1-16-16a16 16 0 0 1 16 16" />
|
||||
</svg>
|
||||
</x-slot:icon>
|
||||
<x-slot:description>
|
||||
<span>Please redeploy to apply the new configuration.</span>
|
||||
</x-slot:description>
|
||||
<x-slot:button-text @click="disableSponsorship()">
|
||||
Disable This Popup
|
||||
</x-slot:button-text>
|
||||
</x-popup-small>
|
||||
<div x-data="{ configurationDiffModalOpen: false }">
|
||||
<x-popup-small>
|
||||
<x-slot:title>
|
||||
The latest configuration has not been applied
|
||||
</x-slot:title>
|
||||
<x-slot:icon>
|
||||
<svg class="hidden w-10 h-10 dark:text-warning lg:block" viewBox="0 0 256 256"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="currentColor"
|
||||
d="M240.26 186.1L152.81 34.23a28.74 28.74 0 0 0-49.62 0L15.74 186.1a27.45 27.45 0 0 0 0 27.71A28.31 28.31 0 0 0 40.55 228h174.9a28.31 28.31 0 0 0 24.79-14.19a27.45 27.45 0 0 0 .02-27.71m-20.8 15.7a4.46 4.46 0 0 1-4 2.2H40.55a4.46 4.46 0 0 1-4-2.2a3.56 3.56 0 0 1 0-3.73L124 46.2a4.77 4.77 0 0 1 8 0l87.44 151.87a3.56 3.56 0 0 1 .02 3.73M116 136v-32a12 12 0 0 1 24 0v32a12 12 0 0 1-24 0m28 40a16 16 0 1 1-16-16a16 16 0 0 1 16 16" />
|
||||
</svg>
|
||||
</x-slot:icon>
|
||||
<x-slot:description>
|
||||
<span>
|
||||
@if (data_get($configurationDiff, 'count'))
|
||||
{{ data_get($configurationDiff, 'count') }} unapplied configuration
|
||||
{{ data_get($configurationDiff, 'count') === 1 ? 'change' : 'changes' }} detected.
|
||||
@if (data_get($configurationDiff, 'requires_build'))
|
||||
A rebuild is required.
|
||||
@else
|
||||
Please redeploy to apply the new configuration.
|
||||
@endif
|
||||
<button type="button" class="ml-1 font-semibold underline text-coollabs dark:text-warning"
|
||||
x-on:click="$wire.refreshConfigurationChanges().then(() => configurationDiffModalOpen = true)"
|
||||
wire:loading.attr="disabled" wire:target="refreshConfigurationChanges">
|
||||
View changes
|
||||
</button>
|
||||
@else
|
||||
Please redeploy to apply the new configuration.
|
||||
@endif
|
||||
</span>
|
||||
</x-slot:description>
|
||||
</x-popup-small>
|
||||
|
||||
@if (data_get($configurationDiff, 'count'))
|
||||
<template x-teleport="body">
|
||||
<div x-show="configurationDiffModalOpen" x-cloak
|
||||
class="fixed inset-0 z-99 flex h-screen w-screen items-center justify-center p-4"
|
||||
@keydown.escape.window="configurationDiffModalOpen = false">
|
||||
<div x-show="configurationDiffModalOpen" x-transition.opacity
|
||||
class="absolute inset-0 h-full w-full bg-black/20 backdrop-blur-xs"
|
||||
@click="configurationDiffModalOpen = false"></div>
|
||||
<div x-show="configurationDiffModalOpen" x-trap.inert.noscroll="configurationDiffModalOpen"
|
||||
x-transition:enter="ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
|
||||
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave="ease-in duration-100"
|
||||
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
|
||||
class="relative flex max-h-[85vh] w-full flex-col rounded-sm border border-neutral-200 bg-white shadow-lg dark:border-coolgray-300 dark:bg-base lg:max-w-4xl">
|
||||
<div class="flex shrink-0 items-center justify-between border-b border-neutral-200 px-6 py-5 dark:border-coolgray-300">
|
||||
<div>
|
||||
<h3 class="text-2xl font-bold text-black dark:text-white">Configuration changes</h3>
|
||||
<p class="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
These changes are not applied to the latest deployment yet.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" @click="configurationDiffModalOpen = false"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base">
|
||||
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto p-6">
|
||||
<x-deployment.configuration-diff :diff="$configurationDiff" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -85,8 +85,7 @@ function makeRailpackApp(array $overrides = []): Application
|
|||
$app->refresh();
|
||||
expect($app->build_pack)->toBe('railpack');
|
||||
expect($app->dockerfile)->toBeNull();
|
||||
// NOTE: dockerfile_location is normalized to '/Dockerfile' by the model
|
||||
// mutator when set to null, so we cannot assert it becomes null here.
|
||||
expect($app->dockerfile_location)->toBeNull();
|
||||
expect($app->dockerfile_target_build)->toBeNull();
|
||||
expect((bool) $app->custom_healthcheck_found)->toBeFalse();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
use App\Models\User;
|
||||
use App\Notifications\ApiTokenExpiringNotification;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Contracts\Notifications\Dispatcher;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
|
@ -29,11 +30,12 @@
|
|||
Notification::fake();
|
||||
});
|
||||
|
||||
function createTokenExpiring(User $user, Team $team, ?Carbon $expiresAt): PersonalAccessToken
|
||||
function createTokenExpiring(User $user, Team $team, ?Carbon $expiresAt, ?Carbon $warningSentAt = null): PersonalAccessToken
|
||||
{
|
||||
$plain = $user->createToken('t-'.uniqid(), ['read'], $expiresAt);
|
||||
$token = $plain->accessToken;
|
||||
$token->team_id = $team->id;
|
||||
$token->api_token_expiration_warning_sent_at = $warningSentAt;
|
||||
$token->save();
|
||||
|
||||
return $token->fresh();
|
||||
|
|
@ -41,14 +43,30 @@ function createTokenExpiring(User $user, Team $team, ?Carbon $expiresAt): Person
|
|||
|
||||
describe('ApiTokenExpirationWarningJob', function () {
|
||||
test('notifies team when token expires within 24h', function () {
|
||||
createTokenExpiring($this->user, $this->team, now()->addHours(23));
|
||||
$token = createTokenExpiring($this->user, $this->team, now()->addHours(23));
|
||||
|
||||
(new ApiTokenExpirationWarningJob)->handle();
|
||||
|
||||
Notification::assertSentTo($this->team, ApiTokenExpiringNotification::class);
|
||||
expect($token->fresh()->api_token_expiration_warning_sent_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('rate limiter prevents duplicate warnings on repeat runs', function () {
|
||||
test('does not mark token as warned when notification fails', function () {
|
||||
$token = createTokenExpiring($this->user, $this->team, now()->addHours(23));
|
||||
$dispatcher = Mockery::mock(Dispatcher::class);
|
||||
$dispatcher->shouldReceive('send')
|
||||
->once()
|
||||
->andThrow(new RuntimeException('Notification failed'));
|
||||
|
||||
$this->app->instance(Dispatcher::class, $dispatcher);
|
||||
|
||||
expect(fn () => (new ApiTokenExpirationWarningJob)->handle())
|
||||
->toThrow(RuntimeException::class, 'Notification failed');
|
||||
|
||||
expect($token->fresh()->api_token_expiration_warning_sent_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('database marker prevents duplicate warnings on repeat runs', function () {
|
||||
createTokenExpiring($this->user, $this->team, now()->addHours(12));
|
||||
|
||||
(new ApiTokenExpirationWarningJob)->handle();
|
||||
|
|
@ -57,6 +75,35 @@ function createTokenExpiring(User $user, Team $team, ?Carbon $expiresAt): Person
|
|||
Notification::assertSentToTimes($this->team, ApiTokenExpiringNotification::class, 1);
|
||||
});
|
||||
|
||||
test('database marker prevents duplicate warnings after cache is flushed', function () {
|
||||
createTokenExpiring($this->user, $this->team, now()->addHours(12));
|
||||
|
||||
(new ApiTokenExpirationWarningJob)->handle();
|
||||
|
||||
Cache::flush();
|
||||
|
||||
(new ApiTokenExpirationWarningJob)->handle();
|
||||
|
||||
Notification::assertSentToTimes($this->team, ApiTokenExpiringNotification::class, 1);
|
||||
});
|
||||
|
||||
test('skips tokens that already have an expiration warning marker', function () {
|
||||
createTokenExpiring($this->user, $this->team, now()->addHours(12), now()->subHour());
|
||||
|
||||
(new ApiTokenExpirationWarningJob)->handle();
|
||||
|
||||
Notification::assertNothingSent();
|
||||
});
|
||||
|
||||
test('notifies once for each unmarked expiring token', function () {
|
||||
createTokenExpiring($this->user, $this->team, now()->addHours(12));
|
||||
createTokenExpiring($this->user, $this->team, now()->addHours(23));
|
||||
|
||||
(new ApiTokenExpirationWarningJob)->handle();
|
||||
|
||||
Notification::assertSentToTimes($this->team, ApiTokenExpiringNotification::class, 2);
|
||||
});
|
||||
|
||||
test('skips tokens expiring more than 24h out', function () {
|
||||
createTokenExpiring($this->user, $this->team, now()->addDays(3));
|
||||
|
||||
|
|
|
|||
|
|
@ -240,6 +240,27 @@
|
|||
expect($application->dockerfile)->toBeNull();
|
||||
});
|
||||
|
||||
test('dockerfile location defaults only for dockerfile buildpack', function () {
|
||||
$team = Team::factory()->create();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
$nixpacksApplication = Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'build_pack' => 'nixpacks',
|
||||
'dockerfile_location' => null,
|
||||
]);
|
||||
|
||||
$dockerfileApplication = Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'build_pack' => 'dockerfile',
|
||||
'dockerfile_location' => null,
|
||||
]);
|
||||
|
||||
expect($nixpacksApplication->refresh()->dockerfile_location)->toBeNull();
|
||||
expect($dockerfileApplication->refresh()->dockerfile_location)->toBe('/Dockerfile');
|
||||
});
|
||||
|
||||
test('model does not trigger cleanup when build_pack is not changed', function () {
|
||||
$team = Team::factory()->create();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
|
|
|
|||
97
tests/Feature/ApplicationConfigurationChangedTest.php
Normal file
97
tests/Feature/ApplicationConfigurationChangedTest.php
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\Environment;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function configurationChangedTestApplication(array $attributes = []): Application
|
||||
{
|
||||
$team = Team::factory()->create();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
return Application::factory()->create(array_merge([
|
||||
'environment_id' => $environment->id,
|
||||
'status' => 'running:healthy',
|
||||
'build_command' => 'npm run build',
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
function configurationChangedDeployment(Application $application): ApplicationDeploymentQueue
|
||||
{
|
||||
return ApplicationDeploymentQueue::create([
|
||||
'application_id' => (string) $application->id,
|
||||
'deployment_uuid' => (string) Str::uuid(),
|
||||
'status' => 'finished',
|
||||
'commit' => 'HEAD',
|
||||
]);
|
||||
}
|
||||
|
||||
it('stores deployment configuration snapshot and clears pending changes', function () {
|
||||
$application = configurationChangedTestApplication();
|
||||
$deployment = configurationChangedDeployment($application);
|
||||
|
||||
$application->markDeploymentConfigurationApplied($deployment);
|
||||
|
||||
expect($deployment->refresh()->configuration_hash)->not->toBeNull()
|
||||
->and($deployment->configuration_snapshot)->toBeArray()
|
||||
->and($application->refresh()->pendingDeploymentConfigurationDiff()->isChanged())->toBeFalse();
|
||||
});
|
||||
|
||||
it('stores a diff between successful deployments', function () {
|
||||
$application = configurationChangedTestApplication();
|
||||
$firstDeployment = configurationChangedDeployment($application);
|
||||
$application->markDeploymentConfigurationApplied($firstDeployment);
|
||||
|
||||
$application->update(['build_command' => 'pnpm build']);
|
||||
$secondDeployment = configurationChangedDeployment($application->refresh());
|
||||
$application->markDeploymentConfigurationApplied($secondDeployment);
|
||||
|
||||
expect($secondDeployment->refresh()->configuration_diff['count'])->toBe(1)
|
||||
->and(data_get($secondDeployment->configuration_diff, 'changes.0.label'))->toBe('Build command');
|
||||
});
|
||||
|
||||
it('checks legacy preview deployment configuration hash using preview environment variable query', function () {
|
||||
$application = configurationChangedTestApplication();
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'APP_ENV',
|
||||
'value' => 'preview',
|
||||
'is_preview' => true,
|
||||
'is_multiline' => false,
|
||||
'is_literal' => false,
|
||||
'is_buildtime' => true,
|
||||
'is_runtime' => true,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
]);
|
||||
|
||||
$application->forceFill([
|
||||
'config_hash' => 'legacy-hash',
|
||||
'pull_request_id' => 123,
|
||||
]);
|
||||
|
||||
$diff = $application->pendingDeploymentConfigurationDiff();
|
||||
|
||||
expect($diff->isLegacyFallback())->toBeTrue()
|
||||
->and($diff->isChanged())->toBeTrue();
|
||||
});
|
||||
|
||||
it('falls back to legacy configuration hash when no deployment snapshot exists', function () {
|
||||
$application = configurationChangedTestApplication();
|
||||
$application->isConfigurationChanged(save: true);
|
||||
|
||||
expect($application->refresh()->pendingDeploymentConfigurationDiff()->isChanged())->toBeFalse();
|
||||
|
||||
$application->update(['build_command' => 'pnpm build']);
|
||||
|
||||
expect($application->refresh()->pendingDeploymentConfigurationDiff()->isLegacyFallback())->toBeTrue()
|
||||
->and($application->pendingDeploymentConfigurationDiff()->isChanged())->toBeTrue();
|
||||
});
|
||||
|
|
@ -67,7 +67,7 @@
|
|||
], false);
|
||||
});
|
||||
|
||||
test('existing application shows railpack beta badge in build helper copy', function () {
|
||||
test('existing application shows railpack beta label in build pack selector', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
|
|
@ -81,6 +81,5 @@
|
|||
|
||||
Livewire::test(General::class, ['application' => $application])
|
||||
->assertSuccessful()
|
||||
->assertSee('Railpack')
|
||||
->assertSee('Beta');
|
||||
->assertSee('Railpack (Beta)');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -24,12 +24,23 @@
|
|||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
});
|
||||
|
||||
function applicationSourceValidPrivateKey(): string
|
||||
{
|
||||
return '-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
|
||||
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
|
||||
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
|
||||
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
-----END OPENSSH PRIVATE KEY-----';
|
||||
}
|
||||
|
||||
describe('Application Source with localhost key (id=0)', function () {
|
||||
test('renders deploy key section when private_key_id is 0', function () {
|
||||
$privateKey = PrivateKey::create([
|
||||
'id' => 0,
|
||||
'name' => 'localhost',
|
||||
'private_key' => 'test-key-content',
|
||||
'private_key' => applicationSourceValidPrivateKey(),
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
|
|
@ -56,4 +67,19 @@
|
|||
->assertDontSee('Deploy Key')
|
||||
->assertSee('No source connected');
|
||||
});
|
||||
|
||||
test('dispatches configuration changed when source settings are saved', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'git_repository' => 'coollabsio/coolify',
|
||||
'git_branch' => 'main',
|
||||
'git_commit_sha' => 'HEAD',
|
||||
]);
|
||||
|
||||
Livewire::test(Source::class, ['application' => $application])
|
||||
->set('gitBranch', 'next')
|
||||
->call('submit')
|
||||
->assertHasNoErrors()
|
||||
->assertDispatched('configurationChanged');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
158
tests/Feature/Livewire/ConfigurationCheckerTest.php
Normal file
158
tests/Feature/Livewire/ConfigurationCheckerTest.php
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Project\Shared\ConfigurationChecker;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\Environment;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
});
|
||||
|
||||
function configurationCheckerApplication(Environment $environment, array $attributes = []): Application
|
||||
{
|
||||
return Application::factory()->create(array_merge([
|
||||
'environment_id' => $environment->id,
|
||||
'status' => 'running:healthy',
|
||||
'build_command' => 'npm run build',
|
||||
'fqdn' => 'https://example.com',
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
function markConfigurationCheckerApplicationDeployed(Application $application): void
|
||||
{
|
||||
$deployment = ApplicationDeploymentQueue::create([
|
||||
'application_id' => (string) $application->id,
|
||||
'deployment_uuid' => (string) Str::uuid(),
|
||||
'status' => 'finished',
|
||||
'commit' => 'HEAD',
|
||||
]);
|
||||
|
||||
$application->markDeploymentConfigurationApplied($deployment);
|
||||
}
|
||||
|
||||
it('does not render the notification for preview deployment toggles', function () {
|
||||
$application = configurationCheckerApplication($this->environment);
|
||||
markConfigurationCheckerApplicationDeployed($application);
|
||||
|
||||
$application->settings->update(['is_preview_deployments_enabled' => true]);
|
||||
|
||||
Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
|
||||
->assertDontSee('The latest deployment is not using the current configuration')
|
||||
->assertSet('isConfigurationChanged', false);
|
||||
});
|
||||
|
||||
it('renders the changed configuration labels', function () {
|
||||
$application = configurationCheckerApplication($this->environment);
|
||||
markConfigurationCheckerApplicationDeployed($application);
|
||||
|
||||
$application->update(['build_command' => 'pnpm build']);
|
||||
|
||||
Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
|
||||
->assertSee('The latest configuration has not been applied')
|
||||
->assertSee('Build command')
|
||||
->assertSee('A rebuild is required.');
|
||||
});
|
||||
|
||||
it('refreshes configuration changes when the event is received', function () {
|
||||
$application = configurationCheckerApplication($this->environment);
|
||||
markConfigurationCheckerApplicationDeployed($application);
|
||||
|
||||
$component = Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
|
||||
->assertSet('isConfigurationChanged', false)
|
||||
->assertDontSee('The latest configuration has not been applied');
|
||||
|
||||
$application->update(['build_command' => 'pnpm build']);
|
||||
|
||||
$component
|
||||
->dispatch('configurationChanged')
|
||||
->assertSet('isConfigurationChanged', true)
|
||||
->assertSee('The latest configuration has not been applied')
|
||||
->assertSee('Build command');
|
||||
});
|
||||
|
||||
it('refreshes stale modal configuration diff before opening changes', function () {
|
||||
$application = configurationCheckerApplication($this->environment);
|
||||
markConfigurationCheckerApplicationDeployed($application);
|
||||
|
||||
$application->update(['build_command' => 'pnpm build']);
|
||||
|
||||
$component = Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
|
||||
->assertSee('Build command')
|
||||
->assertDontSee('Start command');
|
||||
|
||||
$application->update([
|
||||
'build_command' => 'npm run build',
|
||||
'start_command' => 'node server.js',
|
||||
]);
|
||||
|
||||
$component
|
||||
->call('refreshConfigurationChanges')
|
||||
->assertSet('isConfigurationChanged', true)
|
||||
->assertSee('Start command')
|
||||
->assertDontSee('Build command');
|
||||
});
|
||||
|
||||
it('does not render environment variable secret values', function () {
|
||||
$application = configurationCheckerApplication($this->environment);
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'API_TOKEN',
|
||||
'value' => 'old-secret',
|
||||
'is_buildtime' => false,
|
||||
'is_runtime' => true,
|
||||
'is_preview' => false,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
]);
|
||||
markConfigurationCheckerApplicationDeployed($application->refresh());
|
||||
|
||||
$application->environment_variables()->where('key', 'API_TOKEN')->first()->update(['value' => 'new-secret']);
|
||||
|
||||
Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
|
||||
->assertSee('API_TOKEN')
|
||||
->assertSee('changed')
|
||||
->assertSee('Set')
|
||||
->assertDontSee('Hidden')
|
||||
->assertDontSee('old-secret')
|
||||
->assertDontSee('new-secret');
|
||||
});
|
||||
|
||||
it('renders added environment variables as set without exposing secret values', function () {
|
||||
$application = configurationCheckerApplication($this->environment);
|
||||
markConfigurationCheckerApplicationDeployed($application);
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'API_TOKEN',
|
||||
'value' => 'new-secret',
|
||||
'is_buildtime' => false,
|
||||
'is_runtime' => true,
|
||||
'is_preview' => false,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
]);
|
||||
|
||||
Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
|
||||
->assertSee('API_TOKEN')
|
||||
->assertSee('From')
|
||||
->assertSee('Not set')
|
||||
->assertSee('To')
|
||||
->assertSee('Set')
|
||||
->assertDontSee('Hidden')
|
||||
->assertDontSee('new-secret');
|
||||
});
|
||||
|
|
@ -48,6 +48,16 @@ function createApplicationForAdvancedStopGracePeriodTest(): Application
|
|||
expect($application->settings()->first()->stop_grace_period)->toBe(300);
|
||||
});
|
||||
|
||||
it('dispatches configuration changed when advanced settings are saved', function () {
|
||||
$application = createApplicationForAdvancedStopGracePeriodTest();
|
||||
|
||||
Livewire::test(Advanced::class, ['application' => $application])
|
||||
->set('includeSourceCommitInBuild', true)
|
||||
->call('submit')
|
||||
->assertHasNoErrors()
|
||||
->assertDispatched('configurationChanged');
|
||||
});
|
||||
|
||||
it('clears the stop grace period when submitted empty', function () {
|
||||
$application = createApplicationForAdvancedStopGracePeriodTest();
|
||||
$application->settings->update(['stop_grace_period' => 300]);
|
||||
|
|
|
|||
|
|
@ -38,14 +38,12 @@
|
|||
test('public repository flow keeps railpack available after branch lookup', function () {
|
||||
Livewire::test(PublicGitRepository::class, ['type' => 'public'])
|
||||
->set('branchFound', true)
|
||||
->assertSeeInOrder(['Nixpacks', 'Railpack (Beta)'])
|
||||
->assertSee('Beta');
|
||||
->assertSeeInOrder(['Nixpacks', 'Railpack (Beta)']);
|
||||
});
|
||||
|
||||
test('deploy key repository flow shows railpack beta label in build pack selector', function () {
|
||||
test('deploy key repository flow shows railpack beta label in build pack selector without beta badge', function () {
|
||||
Livewire::test(GithubPrivateRepositoryDeployKey::class, ['type' => 'private-deploy-key'])
|
||||
->set('current_step', 'repository')
|
||||
->assertSee('Railpack (Beta)')
|
||||
->assertSee('Beta');
|
||||
->assertSee('Railpack (Beta)');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\Environment;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
function snapshotTestApplication(array $attributes = []): Application
|
||||
{
|
||||
$team = Team::factory()->create();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
return Application::factory()->create(array_merge([
|
||||
'environment_id' => $environment->id,
|
||||
'status' => 'running:healthy',
|
||||
'fqdn' => 'https://example.com',
|
||||
'build_command' => 'npm run build',
|
||||
'start_command' => 'npm run start',
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
function markSnapshotTestApplicationDeployed(Application $application): ApplicationDeploymentQueue
|
||||
{
|
||||
$deployment = ApplicationDeploymentQueue::create([
|
||||
'application_id' => (string) $application->id,
|
||||
'deployment_uuid' => (string) Str::uuid(),
|
||||
'status' => 'finished',
|
||||
'commit' => 'HEAD',
|
||||
]);
|
||||
|
||||
$application->markDeploymentConfigurationApplied($deployment);
|
||||
|
||||
return $deployment->refresh();
|
||||
}
|
||||
|
||||
it('does not report preview deployment toggles as pending production configuration changes', function () {
|
||||
$application = snapshotTestApplication();
|
||||
markSnapshotTestApplicationDeployed($application);
|
||||
|
||||
$application->settings->update(['is_preview_deployments_enabled' => true]);
|
||||
|
||||
expect($application->refresh()->pendingDeploymentConfigurationDiff()->isChanged())->toBeFalse();
|
||||
});
|
||||
|
||||
it('detects build-impacting changes', function () {
|
||||
$application = snapshotTestApplication();
|
||||
markSnapshotTestApplicationDeployed($application);
|
||||
|
||||
$application->update(['build_command' => 'pnpm build']);
|
||||
$diff = $application->refresh()->pendingDeploymentConfigurationDiff();
|
||||
|
||||
expect($diff->isChanged())->toBeTrue()
|
||||
->and($diff->requiresBuild())->toBeTrue()
|
||||
->and(collect($diff->changes())->pluck('label'))->toContain('Build command');
|
||||
});
|
||||
|
||||
it('detects redeploy-only domain changes', function () {
|
||||
$application = snapshotTestApplication();
|
||||
markSnapshotTestApplicationDeployed($application);
|
||||
|
||||
$application->update(['fqdn' => 'https://new.example.com']);
|
||||
$diff = $application->refresh()->pendingDeploymentConfigurationDiff();
|
||||
|
||||
expect($diff->isChanged())->toBeTrue()
|
||||
->and($diff->requiresBuild())->toBeFalse()
|
||||
->and(collect($diff->changes())->pluck('label'))->toContain('Domains');
|
||||
});
|
||||
|
||||
it('detects environment variable value changes without exposing secret values', function () {
|
||||
$application = snapshotTestApplication();
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'API_TOKEN',
|
||||
'value' => 'old-secret',
|
||||
'is_buildtime' => false,
|
||||
'is_runtime' => true,
|
||||
'is_preview' => false,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
]);
|
||||
markSnapshotTestApplicationDeployed($application->refresh());
|
||||
|
||||
$application->environment_variables()->where('key', 'API_TOKEN')->first()->update(['value' => 'new-secret']);
|
||||
$diff = $application->refresh()->pendingDeploymentConfigurationDiff();
|
||||
$change = collect($diff->changes())->firstWhere('label', 'API_TOKEN');
|
||||
|
||||
expect($change)->not->toBeNull()
|
||||
->and($change['display_summary'])->toBe('Changed')
|
||||
->and($change['old_display_value'])->toBe('Set')
|
||||
->and($change['new_display_value'])->toBe('Set')
|
||||
->and(json_encode($diff->toArray()))->not->toContain('old-secret')->not->toContain('new-secret');
|
||||
});
|
||||
|
||||
it('describes added environment variables as set without exposing secret values', function () {
|
||||
$application = snapshotTestApplication();
|
||||
markSnapshotTestApplicationDeployed($application);
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'API_TOKEN',
|
||||
'value' => 'new-secret',
|
||||
'is_buildtime' => false,
|
||||
'is_runtime' => true,
|
||||
'is_preview' => false,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
]);
|
||||
|
||||
$diff = $application->refresh()->pendingDeploymentConfigurationDiff();
|
||||
$change = collect($diff->changes())->firstWhere('label', 'API_TOKEN');
|
||||
|
||||
expect($change)->not->toBeNull()
|
||||
->and($change['display_summary'])->toBeNull()
|
||||
->and($change['old_display_value'])->toBe('Not set')
|
||||
->and($change['new_display_value'])->toBe('Set')
|
||||
->and(json_encode($diff->toArray()))->not->toContain('new-secret');
|
||||
});
|
||||
Loading…
Reference in a new issue