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 */ 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 $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 */ 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(); } }