feat(deployments): track application configuration diffs
Store deployment configuration snapshots on application deployment queues and compare them against the current application state. Surface grouped pending changes in the configuration checker and use build-impact diffs to decide when an existing image can skip the build step.
This commit is contained in:
parent
f098895abf
commit
f8849aba73
23 changed files with 1171 additions and 89 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@ 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()
|
||||
|
|
@ -42,6 +46,17 @@ public function render()
|
|||
|
||||
public function configurationChanged()
|
||||
{
|
||||
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,6 +1173,83 @@ 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) {
|
||||
|
|
@ -1177,26 +1257,8 @@ public function isConfigurationChanged(bool $save = false)
|
|||
} else {
|
||||
$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()
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
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,76 @@
|
|||
<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"
|
||||
@click="configurationDiffModalOpen = true">
|
||||
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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
70
tests/Feature/ApplicationConfigurationChangedTest.php
Normal file
70
tests/Feature/ApplicationConfigurationChangedTest.php
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\Environment;
|
||||
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('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)');
|
||||
});
|
||||
|
|
|
|||
119
tests/Feature/Livewire/ConfigurationCheckerTest.php
Normal file
119
tests/Feature/Livewire/ConfigurationCheckerTest.php
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
<?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('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');
|
||||
});
|
||||
|
|
@ -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