coolify/app/Models/ApplicationDeploymentQueue.php
Andras Bacsai f8849aba73 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.
2026-05-13 09:58:58 +02:00

203 lines
6 KiB
PHP

<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use OpenApi\Attributes as OA;
#[OA\Schema(
description: 'Project model',
type: 'object',
properties: [
'id' => ['type' => 'integer'],
'application_id' => ['type' => 'string'],
'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'],
'is_webhook' => ['type' => 'boolean'],
'is_api' => ['type' => 'boolean'],
'created_at' => ['type' => 'string'],
'updated_at' => ['type' => 'string'],
'logs' => ['type' => 'string'],
'current_process_id' => ['type' => 'string'],
'restart_only' => ['type' => 'boolean'],
'git_type' => ['type' => 'string'],
'server_id' => ['type' => 'integer'],
'application_name' => ['type' => 'string'],
'server_name' => ['type' => 'string'],
'deployment_url' => ['type' => 'string'],
'destination_id' => ['type' => 'string'],
'only_this_server' => ['type' => 'boolean'],
'rollback' => ['type' => 'boolean'],
'commit_message' => ['type' => 'string'],
],
)]
class ApplicationDeploymentQueue extends Model
{
protected $fillable = [
'application_id',
'deployment_uuid',
'pull_request_id',
'docker_registry_image_tag',
'configuration_hash',
'configuration_snapshot',
'configuration_diff',
'force_rebuild',
'commit',
'status',
'is_webhook',
'logs',
'current_process_id',
'restart_only',
'git_type',
'server_id',
'application_name',
'server_name',
'deployment_url',
'destination_id',
'only_this_server',
'rollback',
'commit_message',
'is_api',
'build_server_id',
'horizon_job_id',
'horizon_job_worker',
'finished_at',
];
protected $casts = [
'pull_request_id' => 'integer',
'finished_at' => 'datetime',
'configuration_snapshot' => 'array',
'configuration_diff' => 'array',
];
public function application()
{
return $this->belongsTo(Application::class);
}
public function server(): Attribute
{
return Attribute::make(
get: fn () => Server::find($this->server_id),
);
}
public function setStatus(string $status)
{
$this->update([
'status' => $status,
]);
}
public function getOutput($name)
{
if (! $this->logs) {
return null;
}
return collect(json_decode($this->logs))->where('name', $name)->first()?->output ?? null;
}
public function getHorizonJobStatus()
{
return getJobStatus($this->horizon_job_id);
}
public function commitMessage()
{
if (empty($this->commit_message) || is_null($this->commit_message)) {
return null;
}
return str($this->commit_message)->value();
}
private function redactSensitiveInfo($text)
{
$text = remove_iip($text);
$app = $this->application;
if (! $app) {
return $text;
}
$lockedVars = collect([]);
if ($app->environment_variables) {
$lockedVars = $lockedVars->merge(
$app->environment_variables
->where('is_shown_once', true)
->pluck('real_value', 'key')
->filter()
);
}
if ($this->pull_request_id !== 0 && $app->environment_variables_preview) {
$lockedVars = $lockedVars->merge(
$app->environment_variables_preview
->where('is_shown_once', true)
->pluck('real_value', 'key')
->filter()
);
}
foreach ($lockedVars as $key => $value) {
$escapedValue = preg_quote($value, '/');
$text = preg_replace(
'/'.$escapedValue.'/',
REDACTED,
$text
);
}
return $text;
}
public function addLogEntry(string $message, string $type = 'stdout', bool $hidden = false)
{
if ($type === 'error') {
$type = 'stderr';
}
$message = str($message)->trim();
if ($message->startsWith('╔')) {
$message = "\n".$message;
}
$newLogEntry = [
'command' => null,
'output' => $this->redactSensitiveInfo($message),
'type' => $type,
'timestamp' => Carbon::now('UTC'),
'hidden' => $hidden,
'batch' => 1,
];
// Use a transaction to ensure atomicity
DB::transaction(function () use ($newLogEntry) {
// Reload the model to get the latest logs
$this->refresh();
if ($this->logs) {
$previousLogs = json_decode($this->logs, associative: true, flags: JSON_THROW_ON_ERROR);
$newLogEntry['order'] = count($previousLogs) + 1;
$previousLogs[] = $newLogEntry;
$this->logs = json_encode($previousLogs, flags: JSON_THROW_ON_ERROR);
} else {
$this->logs = json_encode([$newLogEntry], flags: JSON_THROW_ON_ERROR);
}
// Save without triggering events to prevent potential race conditions
$this->saveQuietly();
});
}
}