Files
coolify/app/Models/ApplicationDeploymentQueue.php
Andras BacsaiandClaude Opus 5.5 a82d32c946 chore: merge main into release/v4.5
Resolve conflicts between main's security fixes and v4.5 features:
- Keep v4.5 features (log redaction, multi-S3 backups, transfer flow,
  scheduled task modal, sidebar) and port main's fixes into them.
- Reuse RequiresDeployAbility for the Hostinger create endpoint.
- Align MCP tool abilities and descriptions with main's new deploy
  requirements, and use deploy tokens in the affected MCP tests.
- Drop the duplicate DockerCleanupJobTest helper and tests, and the
  exportBundle test for a method v4.5 removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 10:00:05 +02:00

343 lines
12 KiB
PHP

<?php
namespace App\Models;
use App\Casts\EncryptedArrayCast;
use App\Enums\ApplicationDeploymentStatus;
use App\Services\Security\SensitiveDataRedactor;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
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
{
/**
* Log redaction variants of remote secrets. Kept in memory only, never saved.
*
* @var array<int, string>
*/
private array $remoteSecretsForRedaction = [];
public const HIDDEN_ENV_VALUES_HINT = 'Some environment variable values are hidden in this log. To show values that are not locked, go to Environment Variables > Environment values in logs and select "Hide locked values only".';
/** @var array{known: array<int, string>, locked: array<int, string>}|null */
private ?array $logRedactionSecrets = null;
private bool $hiddenEnvValuesHintAdded = false;
private bool $corruptedLogsWarned = false;
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);
}
/**
* Scope to deployments of the team's applications.
*/
public function scopeOwnedByTeam(Builder $query, int $teamId): Builder
{
// application_id is varchar; whereHas joins to applications.id (bigint) and breaks on PostgreSQL.
$applicationIds = Application::withTrashed()
->whereRelation('environment.project.team', 'id', $teamId)
->pluck('id')
->map(fn ($id) => (string) $id);
return $query->whereIn('application_id', $applicationIds);
}
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 = EnvironmentVariable::remoteSecretLogRedactionValues($secrets);
}
/**
* Redact a deployment log text. The application secrets are collected once per deployment.
*/
public function redactLogText(string $text): string
{
try {
// The deployment job registers its remote secrets with redactRemoteSecrets(), so they are not fetched again.
$this->logRedactionSecrets ??= EnvironmentVariable::logRedactionSecretsFor($this->application, $this->pull_request_id !== 0, resolveRemoteSecrets: false);
} catch (\Throwable) {
return REDACTED;
}
return app(SensitiveDataRedactor::class)->redactText(
$text,
$this->logRedactionSecrets['known'],
array_merge($this->logRedactionSecrets['locked'], $this->remoteSecretsForRedaction),
);
}
/**
* Tell if the "redact all values" setting hides an environment value that is not locked in this text.
*/
private function hidesUnlockedEnvValues(string $text): bool
{
if ($this->logRedactionSecrets === null) {
return false;
}
$unlockedValues = array_diff($this->logRedactionSecrets['known'], $this->logRedactionSecrets['locked'], $this->remoteSecretsForRedaction);
return app(SensitiveDataRedactor::class)->containsKnownSecret($text, $unlockedValues);
}
public function addLogEntry(
string $message,
string $type = 'stdout',
bool $hidden = false,
?string $command = null,
int $batch = 1,
): void {
if ($type === 'error') {
$type = 'stderr';
}
$message = str($message)->trim();
if ($message->startsWith('╔')) {
$message = "\n".$message;
}
$newLogEntry = [
'command' => $command === null ? null : $this->redactLogText($command),
'output' => $this->redactLogText($message),
'type' => $type,
'timestamp' => Carbon::now('UTC'),
'hidden' => $hidden,
'batch' => $batch,
];
$addHiddenEnvValuesHint = ! $this->hiddenEnvValuesHintAdded && $this->hidesUnlockedEnvValues($message.' '.$command);
DB::transaction(function () use ($newLogEntry, $addHiddenEnvValuesHint): void {
$storedLogs = static::query()->whereKey($this->getKey())->lockForUpdate()->value('logs');
$previousLogs = $this->decodeLogs($storedLogs);
if ($previousLogs === null) {
// Keep the stored log history instead of overwriting it with a new list.
if (! $this->corruptedLogsWarned) {
$this->corruptedLogsWarned = true;
Log::warning('Skipped a deployment log entry because the stored deployment logs cannot be decoded.', [
'deployment_uuid' => $this->deployment_uuid,
]);
}
return;
}
$newLogEntry['order'] = count($previousLogs) + 1;
$previousLogs[] = $newLogEntry;
if ($addHiddenEnvValuesHint) {
$this->hiddenEnvValuesHintAdded = true;
if (! collect($previousLogs)->contains('output', self::HIDDEN_ENV_VALUES_HINT)) {
$previousLogs[] = [...$newLogEntry, 'command' => null, 'output' => self::HIDDEN_ENV_VALUES_HINT, 'type' => 'stdout', 'hidden' => false, 'order' => $newLogEntry['order'] + 1];
}
}
$logs = json_encode($previousLogs, flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_SUBSTITUTE);
// Update only the logs column without model events to prevent race conditions.
static::query()->whereKey($this->getKey())->update(['logs' => $logs]);
$this->logs = $logs;
$this->syncOriginalAttribute('logs');
});
}
/**
* @return array<int, mixed>|null Null when the stored logs are not blank and cannot be decoded.
*/
private function decodeLogs(?string $logs): ?array
{
if (blank($logs)) {
return [];
}
try {
$decoded = json_decode($logs, associative: true, flags: JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return null;
}
return is_array($decoded) ? array_values($decoded) : null;
}
}