build logs
This commit is contained in:
257
app/Jobs/RunDockerImageBuild.php
Normal file
257
app/Jobs/RunDockerImageBuild.php
Normal file
@@ -0,0 +1,257 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user