258 lines
7.9 KiB
PHP
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();
|
|
}
|
|
}
|