Files
dockerfile.nyxtex.com/app/Jobs/RunDockerImageBuild.php
RDarius 9fbec77ac7
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
build logs
2026-04-24 14:59:20 +03:00

258 lines
7.9 KiB
PHP

<?php
namespace App\Jobs;
use App\Models\DockerImageBuild;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Str;
use Throwable;
class RunDockerImageBuild implements ShouldQueue
{
use Queueable;
public bool $failOnTimeout = true;
public int $timeout = 21_600;
public int $tries = 1;
/**
* Create a new job instance.
*/
public function __construct(public int $dockerImageBuildId) {}
/**
* Execute the job.
*/
public function handle(): void
{
$dockerImageBuild = DockerImageBuild::query()->findOrFail($this->dockerImageBuildId);
$dockerImageBuild->forceFill([
'status' => DockerImageBuild::STATUS_RUNNING,
'started_at' => now(),
'finished_at' => null,
'successful' => null,
'exit_code' => null,
'build_output' => null,
])->save();
$buildDirectory = storage_path('app/docker-builds/'.$dockerImageBuild->id.'-'.Str::uuid());
$capturedOutput = '';
$lastPersistedAt = microtime(true);
$lastPersistedLength = 0;
File::ensureDirectoryExists($buildDirectory);
try {
File::put(
$buildDirectory.'/Dockerfile',
$this->buildDockerfile(
$dockerImageBuild->dockerfile_content,
$this->normalizedEnvironment($dockerImageBuild),
),
);
$result = Process::path($buildDirectory)
->forever()
->run($this->commandFor($dockerImageBuild), function (string $type, string $buffer) use (
&$capturedOutput,
&$lastPersistedAt,
&$lastPersistedLength,
$dockerImageBuild,
): void {
$capturedOutput .= $buffer;
if (! $this->shouldPersistOutput($capturedOutput, $lastPersistedLength, $lastPersistedAt)) {
return;
}
$this->persistOutput($dockerImageBuild, $capturedOutput);
$lastPersistedLength = strlen($capturedOutput);
$lastPersistedAt = microtime(true);
});
if ($capturedOutput === '') {
$capturedOutput = $result->output().$result->errorOutput();
}
$dockerImageBuild->forceFill([
'status' => $result->successful()
? DockerImageBuild::STATUS_SUCCEEDED
: DockerImageBuild::STATUS_FAILED,
'successful' => $result->successful(),
'exit_code' => $result->exitCode(),
'build_output' => $this->normalizeOutput(
$capturedOutput,
$result->successful()
? 'No build output was captured.'
: 'Docker returned a non-zero exit code.',
),
'finished_at' => now(),
])->save();
} catch (Throwable $exception) {
$this->markAsFailed($dockerImageBuild, $capturedOutput, $exception);
throw $exception;
} finally {
File::deleteDirectory($buildDirectory);
}
}
/**
* Handle a queued job failure that prevented the normal completion path.
*/
public function failed(?Throwable $exception): void
{
$dockerImageBuild = DockerImageBuild::query()->find($this->dockerImageBuildId);
if ($dockerImageBuild === null || $dockerImageBuild->isFinished()) {
return;
}
$this->markAsFailed(
$dockerImageBuild,
(string) $dockerImageBuild->build_output,
$exception,
);
}
/**
* Get the command used to invoke the Docker build.
*
* @return array<int, string>
*/
protected function commandFor(DockerImageBuild $dockerImageBuild): array
{
return [
'docker',
'build',
'--tag',
$dockerImageBuild->image(),
'.',
];
}
/**
* Determine whether the in-memory build output should be persisted.
*/
protected function shouldPersistOutput(string $output, int $lastPersistedLength, float $lastPersistedAt): bool
{
if ($output === '' || strlen($output) === $lastPersistedLength) {
return false;
}
return strlen($output) - $lastPersistedLength >= 1_024
|| microtime(true) - $lastPersistedAt >= 1;
}
/**
* Persist the current build output to the database.
*/
protected function persistOutput(DockerImageBuild $dockerImageBuild, string $output): void
{
$dockerImageBuild->forceFill([
'build_output' => $output,
])->save();
}
/**
* Persist a failed build state and preserve any captured output.
*/
protected function markAsFailed(
DockerImageBuild $dockerImageBuild,
string $capturedOutput,
?Throwable $exception,
): void {
$failureMessage = $exception?->getMessage();
$output = $capturedOutput !== ''
? $capturedOutput
: (string) $dockerImageBuild->build_output;
if ($failureMessage !== null && ! str_contains($output, $failureMessage)) {
$output = $output === ''
? $failureMessage
: rtrim($output).PHP_EOL.PHP_EOL.$failureMessage;
}
$dockerImageBuild->forceFill([
'status' => DockerImageBuild::STATUS_FAILED,
'successful' => false,
'exit_code' => $dockerImageBuild->exit_code ?? 1,
'build_output' => $this->normalizeOutput(
$output,
'Docker build failed before output could be captured.',
),
'finished_at' => $dockerImageBuild->finished_at ?? now(),
])->save();
}
/**
* Normalize the submitted Dockerfile with appended environment variables.
*
* @param array<int, array{key: string, value: string}> $environment
*/
protected function buildDockerfile(string $dockerfile, array $environment): string
{
$dockerfile = rtrim($dockerfile);
if ($environment === []) {
return $dockerfile.PHP_EOL;
}
$environmentInstructions = collect($environment)
->map(fn (array $variable): string => sprintf(
'ENV %s="%s"',
$variable['key'],
$this->escapeEnvironmentValue($variable['value']),
))
->implode(PHP_EOL);
return $dockerfile.PHP_EOL.PHP_EOL.$environmentInstructions.PHP_EOL;
}
/**
* Escape an environment value for a Dockerfile ENV instruction.
*/
protected function escapeEnvironmentValue(string $value): string
{
return str_replace(
['\\', '"', "\r", "\n"],
['\\\\', '\\"', '', '\\n'],
$value,
);
}
/**
* Normalize the saved build output for display.
*/
protected function normalizeOutput(string $output, string $fallback): string
{
$output = rtrim($output);
return $output !== '' ? $output : $fallback;
}
/**
* Get the normalized environment variable rows from the build.
*
* @return array<int, array{key: string, value: string}>
*/
protected function normalizedEnvironment(DockerImageBuild $dockerImageBuild): array
{
return collect($dockerImageBuild->environment ?? [])
->map(fn (mixed $variable): array => [
'key' => trim((string) data_get($variable, 'key', '')),
'value' => (string) data_get($variable, 'value', ''),
])
->filter(fn (array $variable): bool => $variable['key'] !== '')
->values()
->all();
}
}