Files
coolify/app/Models/ApplicationDeploymentQueue.php
T
Andras Bacsai be72005980 fix(deployments): expire missing Horizon jobs after deployment timeout
Keep reserved jobs active and bound missing Horizon records by the server's
 deployment timeout. Add regression coverage for active and finished jobs.

Use UTC PostgreSQL sessions and bump the release version to 4.4.1.
2026-10-06 21:51:58 +02:00

294 lines
9.4 KiB
PHP

<?php
namespace App\Models;
use App\Casts\EncryptedArrayCast;
use App\Enums\ApplicationDeploymentStatus;
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'],
'parent_deployment_uuid' => ['type' => 'string', 'nullable' => true],
'rollback' => ['type' => 'boolean'],
'commit_message' => ['type' => 'string'],
],
)]
class ApplicationDeploymentQueue extends Model
{
/**
* Kept in memory only, never saved.
*
* @var array<array-key, mixed>
*/
private array $remoteSecretsForRedaction = [];
protected static function booted(): void
{
static::created(function (ApplicationDeploymentQueue $deployment): void {
if (! auth()->check() || ! $deployment->rollback) {
return;
}
$application = $deployment->application;
$source = $deployment->is_api ? 'api' : 'ui';
auditLog("{$source}.application.rollback", [
'team_id' => $application?->team()?->id,
'application_uuid' => $application?->uuid,
'application_name' => $application?->name,
'deployment_uuid' => $deployment->deployment_uuid,
'commit' => $deployment->commit,
]);
});
static::updated(function (ApplicationDeploymentQueue $deployment): void {
if (! auth()->check()
|| ! $deployment->wasChanged('status')
|| $deployment->status !== ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
return;
}
$application = $deployment->application;
$source = $deployment->is_api ? 'api' : 'ui';
auditLog("{$source}.deployment.cancelled", [
'team_id' => $application?->team()?->id,
'application_uuid' => $application?->uuid,
'application_name' => $application?->name,
'deployment_uuid' => $deployment->deployment_uuid,
]);
});
}
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',
'parent_deployment_uuid',
'rollback',
'commit_message',
'is_api',
'build_server_id',
'horizon_job_id',
'horizon_job_worker',
'finished_at',
];
/**
* The configuration snapshot/diff hold full (decrypted on read) configuration,
* including unlocked environment variable values. They are only meant for the
* in-app diff modal (which redacts per role) and must never be serialized by the
* API, so hide them globally as defense in depth.
*
* @var array<int, string>
*/
protected $hidden = [
'logs',
'configuration_snapshot',
'configuration_diff',
];
protected $casts = [
'pull_request_id' => 'integer',
'finished_at' => 'datetime',
'configuration_snapshot' => EncryptedArrayCast::class,
'configuration_diff' => EncryptedArrayCast::class,
];
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);
}
/**
* Horizon drops its job record 'trim.pending' minutes after the push, also while the job runs,
* so a missing record counts as running only until the deployment timeout has passed.
*/
public function isHorizonJobActive(): bool
{
return match ($this->getHorizonJobStatus()) {
'reserved' => true,
'unknown' => $this->updated_at?->gt(now()->subSeconds($this->server?->settings?->dynamic_timeout ?? 3600)) ?? false,
default => false,
};
}
public function commitMessage()
{
if (empty($this->commit_message) || is_null($this->commit_message)) {
return null;
}
return str($this->commit_message)->value();
}
/**
* @param array<array-key, mixed> $secrets
*/
public function redactRemoteSecrets(array $secrets): void
{
$this->remoteSecretsForRedaction = $secrets;
}
private function redactSensitiveInfo($text)
{
try {
$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)
->flatMap(fn (EnvironmentVariable $variable): array => $variable->logRedactionValues())
->filter()
);
}
if ($this->pull_request_id !== 0 && $app->environment_variables_preview) {
$lockedVars = $lockedVars->merge(
$app->environment_variables_preview
->where('is_shown_once', true)
->flatMap(fn (EnvironmentVariable $variable): array => $variable->logRedactionValues())
->filter()
);
}
$lockedVars = $lockedVars->merge(EnvironmentVariable::remoteSecretLogRedactionValues($this->remoteSecretsForRedaction));
foreach ($lockedVars as $key => $value) {
$escapedValue = preg_quote($value, '/');
$text = preg_replace(
'/'.$escapedValue.'/',
REDACTED,
$text
);
}
return is_string($text) ? $text : REDACTED;
} catch (\Throwable) {
return REDACTED;
}
}
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();
});
}
}