From 9fbec77ac74bbc885b4854a7397cba8962d4e47c Mon Sep 17 00:00:00 2001 From: RDarius Date: Fri, 24 Apr 2026 14:59:20 +0300 Subject: [PATCH] build logs --- .env.example | 1 + .../DockerImageBuilderController.php | 234 +++++++ app/Http/Requests/DockerImageBuildRequest.php | 68 ++ app/Jobs/RunDockerImageBuild.php | 257 ++++++++ app/Models/DockerImageBuild.php | 80 +++ app/Models/User.php | 9 + config/queue.php | 2 +- .../factories/DockerImageBuildFactory.php | 41 ++ ...10553_create_docker_image_builds_table.php | 42 ++ resources/js/components/AppSidebar.vue | 22 +- resources/js/pages/docker/BuildHistory.vue | 202 ++++++ resources/js/pages/docker/ImageBuilder.vue | 613 ++++++++++++++++++ routes/web.php | 8 + tests/Feature/DockerImageBuilderTest.php | 128 ++++ tests/Feature/RunDockerImageBuildJobTest.php | 99 +++ 15 files changed, 1804 insertions(+), 2 deletions(-) create mode 100644 app/Http/Controllers/DockerImageBuilderController.php create mode 100644 app/Http/Requests/DockerImageBuildRequest.php create mode 100644 app/Jobs/RunDockerImageBuild.php create mode 100644 app/Models/DockerImageBuild.php create mode 100644 database/factories/DockerImageBuildFactory.php create mode 100644 database/migrations/2026_04_24_110553_create_docker_image_builds_table.php create mode 100644 resources/js/pages/docker/BuildHistory.vue create mode 100644 resources/js/pages/docker/ImageBuilder.vue create mode 100644 tests/Feature/DockerImageBuilderTest.php create mode 100644 tests/Feature/RunDockerImageBuildJobTest.php diff --git a/.env.example b/.env.example index c0660ea..bd552fe 100644 --- a/.env.example +++ b/.env.example @@ -36,6 +36,7 @@ SESSION_DOMAIN=null BROADCAST_CONNECTION=log FILESYSTEM_DISK=local QUEUE_CONNECTION=database +DB_QUEUE_RETRY_AFTER=21900 CACHE_STORE=database # CACHE_PREFIX= diff --git a/app/Http/Controllers/DockerImageBuilderController.php b/app/Http/Controllers/DockerImageBuilderController.php new file mode 100644 index 0000000..9c3b240 --- /dev/null +++ b/app/Http/Controllers/DockerImageBuilderController.php @@ -0,0 +1,234 @@ + $this->detailedPayload($this->resolveActiveBuild($request)), + ]); + } + + /** + * Show the Docker image build history page. + */ + public function history(Request $request): Response + { + return Inertia::render('docker/BuildHistory', [ + 'builds' => $request->user() + ->dockerImageBuilds() + ->latest('id') + ->limit(25) + ->get() + ->map(fn (DockerImageBuild $dockerImageBuild): array => $this->summaryPayload($dockerImageBuild)) + ->all(), + ]); + } + + /** + * Return the latest persisted status for a specific build. + */ + public function show(Request $request, DockerImageBuild $dockerImageBuild): JsonResponse + { + return response()->json([ + 'build' => $this->detailedPayload( + $this->ownedBuild($request, $dockerImageBuild)->fresh(), + ), + ]); + } + + /** + * Queue a Docker image build from the submitted Dockerfile contents. + */ + public function store(DockerImageBuildRequest $request): RedirectResponse + { + $validated = $request->validated(); + $environment = $this->normalizeEnvironment($validated['environment'] ?? []); + + $dockerImageBuild = DB::transaction(function () use ($request, $validated, $environment): DockerImageBuild { + $dockerImageBuild = $request->user()->dockerImageBuilds()->create([ + 'image_name' => $validated['image_name'], + 'image_tag' => $validated['image_tag'], + 'dockerfile_content' => $validated['dockerfile'], + 'environment' => $environment, + 'status' => DockerImageBuild::STATUS_QUEUED, + 'queued_at' => now(), + ]); + + RunDockerImageBuild::dispatch($dockerImageBuild->id)->afterCommit(); + + return $dockerImageBuild; + }); + + Inertia::flash('toast', [ + 'type' => 'success', + 'message' => __('Docker build queued. Logs will update automatically while the queue worker runs.'), + ]); + + return to_route('docker-builder.index', [ + 'build' => $dockerImageBuild->id, + ]); + } + + /** + * Resolve the currently selected build for the authenticated user. + * + * @throws NotFoundHttpException + */ + protected function resolveActiveBuild(Request $request): ?DockerImageBuild + { + $buildId = $request->integer('build'); + + if ($buildId > 0) { + return $request->user() + ->dockerImageBuilds() + ->findOrFail($buildId); + } + + return $request->user() + ->dockerImageBuilds() + ->latest('id') + ->first(); + } + + /** + * Ensure the given build belongs to the authenticated user. + */ + protected function ownedBuild(Request $request, DockerImageBuild $dockerImageBuild): DockerImageBuild + { + abort_unless( + $dockerImageBuild->user_id === $request->user()->getAuthIdentifier(), + 404, + ); + + return $dockerImageBuild; + } + + /** + * Normalize environment rows before they are saved. + * + * @param array $environment + * @return array + */ + protected function normalizeEnvironment(array $environment): array + { + return collect($environment) + ->map(fn (array $variable): array => [ + 'key' => trim($variable['key'] ?? ''), + 'value' => $variable['value'] ?? '', + ]) + ->filter(fn (array $variable): bool => $variable['key'] !== '' || $variable['value'] !== '') + ->values() + ->all(); + } + + /** + * Transform a build into the detailed payload used by the builder page. + */ + protected function detailedPayload(?DockerImageBuild $dockerImageBuild): ?array + { + if ($dockerImageBuild === null) { + return null; + } + + return [ + 'id' => $dockerImageBuild->id, + 'image' => $dockerImageBuild->image(), + 'image_name' => $dockerImageBuild->image_name, + 'image_tag' => $dockerImageBuild->image_tag, + 'dockerfile_content' => $dockerImageBuild->dockerfile_content, + 'environment' => $dockerImageBuild->environment ?? [], + 'environment_count' => count($dockerImageBuild->environment ?? []), + 'status' => $dockerImageBuild->status, + 'status_label' => Str::headline($dockerImageBuild->status), + 'successful' => $dockerImageBuild->successful, + 'exit_code' => $dockerImageBuild->exit_code, + 'build_output' => $dockerImageBuild->build_output ?? '', + 'queued_at' => $dockerImageBuild->queued_at?->toIso8601String(), + 'queued_at_human' => $dockerImageBuild->queued_at?->diffForHumans(), + 'started_at' => $dockerImageBuild->started_at?->toIso8601String(), + 'started_at_human' => $dockerImageBuild->started_at?->diffForHumans(), + 'finished_at' => $dockerImageBuild->finished_at?->toIso8601String(), + 'finished_at_human' => $dockerImageBuild->finished_at?->diffForHumans(), + 'duration' => $this->durationFor( + $dockerImageBuild->started_at, + $dockerImageBuild->finished_at, + ), + ]; + } + + /** + * Transform a build into the summary payload used by the history page. + */ + protected function summaryPayload(DockerImageBuild $dockerImageBuild): array + { + $output = trim((string) $dockerImageBuild->build_output); + $dockerfilePreview = collect(preg_split('/\R/', trim($dockerImageBuild->dockerfile_content)) ?: []) + ->take(4) + ->implode(PHP_EOL); + + return [ + 'id' => $dockerImageBuild->id, + 'image' => $dockerImageBuild->image(), + 'status' => $dockerImageBuild->status, + 'status_label' => Str::headline($dockerImageBuild->status), + 'successful' => $dockerImageBuild->successful, + 'exit_code' => $dockerImageBuild->exit_code, + 'environment_count' => count($dockerImageBuild->environment ?? []), + 'queued_at_human' => $dockerImageBuild->queued_at?->diffForHumans(), + 'duration' => $this->durationFor( + $dockerImageBuild->started_at, + $dockerImageBuild->finished_at, + ), + 'dockerfile_preview' => $dockerfilePreview !== '' + ? $dockerfilePreview + : 'Dockerfile was empty.', + 'output_preview' => $output !== '' + ? Str::limit($output, 220) + : null, + ]; + } + + /** + * Render a short duration string for build timing metadata. + */ + protected function durationFor(?CarbonInterface $startedAt, ?CarbonInterface $finishedAt): ?string + { + if ($startedAt === null) { + return null; + } + + $totalSeconds = $startedAt->diffInSeconds($finishedAt ?? now()); + $hours = intdiv($totalSeconds, 3600); + $minutes = intdiv($totalSeconds % 3600, 60); + $seconds = $totalSeconds % 60; + + $parts = collect([ + $hours > 0 ? "{$hours}h" : null, + $minutes > 0 ? "{$minutes}m" : null, + $hours === 0 ? "{$seconds}s" : null, + ])->filter()->take(2); + + return $parts->isNotEmpty() + ? $parts->implode(' ') + : '0s'; + } +} diff --git a/app/Http/Requests/DockerImageBuildRequest.php b/app/Http/Requests/DockerImageBuildRequest.php new file mode 100644 index 0000000..0cfe866 --- /dev/null +++ b/app/Http/Requests/DockerImageBuildRequest.php @@ -0,0 +1,68 @@ +input('environment', [])) + ->map(fn (mixed $variable): mixed => is_array($variable) + ? [ + 'key' => isset($variable['key']) ? trim((string) $variable['key']) : null, + 'value' => isset($variable['value']) ? (string) $variable['value'] : null, + ] + : $variable + ) + ->all(); + + $this->merge([ + 'dockerfile' => trim((string) $this->input('dockerfile', '')), + 'environment' => $environment, + 'image_name' => trim((string) $this->input('image_name', '')), + 'image_tag' => trim((string) $this->input('image_tag', '')), + ]); + } + + /** + * Determine if the user is authorized to make this request. + */ + public function authorize(): bool + { + return $this->user() !== null; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'dockerfile' => ['required', 'string', 'max:20000'], + 'image_name' => ['required', 'string', 'max:255', 'regex:/^\S+$/'], + 'image_tag' => ['required', 'string', 'max:128', 'regex:/^\S+$/'], + 'environment' => ['sometimes', 'array', 'max:25'], + 'environment.*.key' => [ + 'nullable', + 'required_with:environment.*.value', + 'string', + 'max:100', + 'regex:/^[A-Za-z_][A-Za-z0-9_]*$/', + ], + 'environment.*.value' => [ + 'nullable', + 'required_with:environment.*.key', + 'string', + 'max:5000', + ], + ]; + } +} diff --git a/app/Jobs/RunDockerImageBuild.php b/app/Jobs/RunDockerImageBuild.php new file mode 100644 index 0000000..c8b11af --- /dev/null +++ b/app/Jobs/RunDockerImageBuild.php @@ -0,0 +1,257 @@ +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(); + } +} diff --git a/app/Models/DockerImageBuild.php b/app/Models/DockerImageBuild.php new file mode 100644 index 0000000..e31d88e --- /dev/null +++ b/app/Models/DockerImageBuild.php @@ -0,0 +1,80 @@ + */ + use HasFactory; + + public const STATUS_QUEUED = 'queued'; + + public const STATUS_RUNNING = 'running'; + + public const STATUS_SUCCEEDED = 'succeeded'; + + public const STATUS_FAILED = 'failed'; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'environment' => 'array', + 'successful' => 'boolean', + 'queued_at' => 'datetime', + 'started_at' => 'datetime', + 'finished_at' => 'datetime', + ]; + } + + /** + * Get the user that owns the Docker image build. + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * Get the full Docker image reference. + */ + public function image(): string + { + return "{$this->image_name}:{$this->image_tag}"; + } + + /** + * Determine if the build has finished running. + */ + public function isFinished(): bool + { + return in_array($this->status, [ + self::STATUS_SUCCEEDED, + self::STATUS_FAILED, + ], true); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index f40a71d..71cdbf3 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -7,6 +7,7 @@ use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Fortify\TwoFactorAuthenticatable; @@ -31,4 +32,12 @@ class User extends Authenticatable 'two_factor_confirmed_at' => 'datetime', ]; } + + /** + * Get the Docker image builds created by the user. + */ + public function dockerImageBuilds(): HasMany + { + return $this->hasMany(DockerImageBuild::class); + } } diff --git a/config/queue.php b/config/queue.php index 79c2c0a..2928b8e 100644 --- a/config/queue.php +++ b/config/queue.php @@ -40,7 +40,7 @@ return [ 'connection' => env('DB_QUEUE_CONNECTION'), 'table' => env('DB_QUEUE_TABLE', 'jobs'), 'queue' => env('DB_QUEUE', 'default'), - 'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90), + 'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 21_900), 'after_commit' => false, ], diff --git a/database/factories/DockerImageBuildFactory.php b/database/factories/DockerImageBuildFactory.php new file mode 100644 index 0000000..13bc0a1 --- /dev/null +++ b/database/factories/DockerImageBuildFactory.php @@ -0,0 +1,41 @@ + + */ +class DockerImageBuildFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'image_name' => 'ghcr.io/acme/'.fake()->slug(2), + 'image_tag' => 'latest', + 'dockerfile_content' => implode("\n", [ + 'FROM alpine:3.20', + 'RUN echo "building"', + ]), + 'environment' => [ + ['key' => 'APP_ENV', 'value' => 'production'], + ], + 'status' => DockerImageBuild::STATUS_QUEUED, + 'build_output' => null, + 'successful' => null, + 'exit_code' => null, + 'queued_at' => now(), + 'started_at' => null, + 'finished_at' => null, + ]; + } +} diff --git a/database/migrations/2026_04_24_110553_create_docker_image_builds_table.php b/database/migrations/2026_04_24_110553_create_docker_image_builds_table.php new file mode 100644 index 0000000..15d8479 --- /dev/null +++ b/database/migrations/2026_04_24_110553_create_docker_image_builds_table.php @@ -0,0 +1,42 @@ +id(); + $table->foreignIdFor(User::class)->constrained()->cascadeOnDelete(); + $table->string('image_name'); + $table->string('image_tag', 128); + $table->longText('dockerfile_content'); + $table->json('environment')->nullable(); + $table->string('status', 20)->index(); + $table->longText('build_output')->nullable(); + $table->boolean('successful')->nullable(); + $table->integer('exit_code')->nullable(); + $table->timestamp('queued_at')->nullable()->index(); + $table->timestamp('started_at')->nullable(); + $table->timestamp('finished_at')->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('docker_image_builds'); + } +}; diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index e1873b3..8bc73d2 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -1,6 +1,12 @@ + + diff --git a/resources/js/pages/docker/ImageBuilder.vue b/resources/js/pages/docker/ImageBuilder.vue new file mode 100644 index 0000000..e6d7fa9 --- /dev/null +++ b/resources/js/pages/docker/ImageBuilder.vue @@ -0,0 +1,613 @@ + + +