build logs
This commit is contained in:
@@ -36,6 +36,7 @@ SESSION_DOMAIN=null
|
|||||||
BROADCAST_CONNECTION=log
|
BROADCAST_CONNECTION=log
|
||||||
FILESYSTEM_DISK=local
|
FILESYSTEM_DISK=local
|
||||||
QUEUE_CONNECTION=database
|
QUEUE_CONNECTION=database
|
||||||
|
DB_QUEUE_RETRY_AFTER=21900
|
||||||
|
|
||||||
CACHE_STORE=database
|
CACHE_STORE=database
|
||||||
# CACHE_PREFIX=
|
# CACHE_PREFIX=
|
||||||
|
|||||||
234
app/Http/Controllers/DockerImageBuilderController.php
Normal file
234
app/Http/Controllers/DockerImageBuilderController.php
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Requests\DockerImageBuildRequest;
|
||||||
|
use App\Jobs\RunDockerImageBuild;
|
||||||
|
use App\Models\DockerImageBuild;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
class DockerImageBuilderController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Show the Docker image builder page.
|
||||||
|
*/
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
return Inertia::render('docker/ImageBuilder', [
|
||||||
|
'build' => $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<int, array{key?: string, value?: string}> $environment
|
||||||
|
* @return array<int, array{key: string, value: string}>
|
||||||
|
*/
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
68
app/Http/Requests/DockerImageBuildRequest.php
Normal file
68
app/Http/Requests/DockerImageBuildRequest.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class DockerImageBuildRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Prepare the data for validation.
|
||||||
|
*/
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$environment = collect($this->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, ValidationRule|array<mixed>|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',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
80
app/Models/DockerImageBuild.php
Normal file
80
app/Models/DockerImageBuild.php
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Database\Factories\DockerImageBuildFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
#[Fillable([
|
||||||
|
'user_id',
|
||||||
|
'image_name',
|
||||||
|
'image_tag',
|
||||||
|
'dockerfile_content',
|
||||||
|
'environment',
|
||||||
|
'status',
|
||||||
|
'build_output',
|
||||||
|
'successful',
|
||||||
|
'exit_code',
|
||||||
|
'queued_at',
|
||||||
|
'started_at',
|
||||||
|
'finished_at',
|
||||||
|
])]
|
||||||
|
class DockerImageBuild extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<DockerImageBuildFactory> */
|
||||||
|
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<string, string>
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ use Database\Factories\UserFactory;
|
|||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||||
@@ -31,4 +32,12 @@ class User extends Authenticatable
|
|||||||
'two_factor_confirmed_at' => 'datetime',
|
'two_factor_confirmed_at' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Docker image builds created by the user.
|
||||||
|
*/
|
||||||
|
public function dockerImageBuilds(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(DockerImageBuild::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ return [
|
|||||||
'connection' => env('DB_QUEUE_CONNECTION'),
|
'connection' => env('DB_QUEUE_CONNECTION'),
|
||||||
'table' => env('DB_QUEUE_TABLE', 'jobs'),
|
'table' => env('DB_QUEUE_TABLE', 'jobs'),
|
||||||
'queue' => env('DB_QUEUE', 'default'),
|
'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,
|
'after_commit' => false,
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
41
database/factories/DockerImageBuildFactory.php
Normal file
41
database/factories/DockerImageBuildFactory.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\DockerImageBuild;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<DockerImageBuild>
|
||||||
|
*/
|
||||||
|
class DockerImageBuildFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('docker_image_builds', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Link } from '@inertiajs/vue3';
|
import { Link } from '@inertiajs/vue3';
|
||||||
import { BookOpen, FolderGit2, LayoutGrid } from 'lucide-vue-next';
|
import {
|
||||||
|
BookOpen,
|
||||||
|
FolderGit2,
|
||||||
|
History,
|
||||||
|
LayoutGrid,
|
||||||
|
PackagePlus,
|
||||||
|
} from 'lucide-vue-next';
|
||||||
import AppLogo from '@/components/AppLogo.vue';
|
import AppLogo from '@/components/AppLogo.vue';
|
||||||
import NavFooter from '@/components/NavFooter.vue';
|
import NavFooter from '@/components/NavFooter.vue';
|
||||||
import NavMain from '@/components/NavMain.vue';
|
import NavMain from '@/components/NavMain.vue';
|
||||||
@@ -15,6 +21,10 @@ import {
|
|||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from '@/components/ui/sidebar';
|
} from '@/components/ui/sidebar';
|
||||||
import { dashboard } from '@/routes';
|
import { dashboard } from '@/routes';
|
||||||
|
import {
|
||||||
|
history as dockerBuildHistory,
|
||||||
|
index as dockerBuilder,
|
||||||
|
} from '@/routes/docker-builder';
|
||||||
import type { NavItem } from '@/types';
|
import type { NavItem } from '@/types';
|
||||||
|
|
||||||
const mainNavItems: NavItem[] = [
|
const mainNavItems: NavItem[] = [
|
||||||
@@ -23,6 +33,16 @@ const mainNavItems: NavItem[] = [
|
|||||||
href: dashboard(),
|
href: dashboard(),
|
||||||
icon: LayoutGrid,
|
icon: LayoutGrid,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Docker builder',
|
||||||
|
href: dockerBuilder(),
|
||||||
|
icon: PackagePlus,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Build history',
|
||||||
|
href: dockerBuildHistory(),
|
||||||
|
icon: History,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const footerNavItems: NavItem[] = [
|
const footerNavItems: NavItem[] = [
|
||||||
|
|||||||
202
resources/js/pages/docker/BuildHistory.vue
Normal file
202
resources/js/pages/docker/BuildHistory.vue
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Head, Link } from '@inertiajs/vue3';
|
||||||
|
import { ArrowUpRight, History, PackagePlus } from 'lucide-vue-next';
|
||||||
|
import Heading from '@/components/Heading.vue';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
history as buildHistory,
|
||||||
|
index as dockerBuilderIndex,
|
||||||
|
} from '@/routes/docker-builder';
|
||||||
|
|
||||||
|
type DockerImageBuildHistoryItem = {
|
||||||
|
id: number;
|
||||||
|
image: string;
|
||||||
|
status: string;
|
||||||
|
status_label: string;
|
||||||
|
successful: boolean | null;
|
||||||
|
exit_code: number | null;
|
||||||
|
environment_count: number;
|
||||||
|
queued_at_human: string | null;
|
||||||
|
duration: string | null;
|
||||||
|
dockerfile_preview: string;
|
||||||
|
output_preview: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
builds: DockerImageBuildHistoryItem[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
layout: {
|
||||||
|
breadcrumbs: [
|
||||||
|
{
|
||||||
|
title: 'Docker builder',
|
||||||
|
href: dockerBuilderIndex(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Build history',
|
||||||
|
href: buildHistory(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function statusVariant(
|
||||||
|
status: string,
|
||||||
|
): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||||
|
if (status === 'succeeded') {
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'failed') {
|
||||||
|
return 'destructive';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'running') {
|
||||||
|
return 'secondary';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'outline';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Head title="Build history" />
|
||||||
|
|
||||||
|
<div class="flex flex-1 flex-col gap-6 p-4">
|
||||||
|
<Heading
|
||||||
|
title="Build history"
|
||||||
|
description="Review saved Docker build jobs, the Dockerfiles they used, and the latest log output captured for each run."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<Button as-child>
|
||||||
|
<Link :href="dockerBuilderIndex()">
|
||||||
|
<PackagePlus />
|
||||||
|
New build
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="builds.length === 0"
|
||||||
|
class="rounded-xl border border-dashed border-border/70 p-10 text-center text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
No Docker builds have been saved yet.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="grid gap-4 xl:grid-cols-2">
|
||||||
|
<Card
|
||||||
|
v-for="build in builds"
|
||||||
|
:key="build.id"
|
||||||
|
class="gap-0 overflow-hidden"
|
||||||
|
>
|
||||||
|
<CardHeader class="border-b">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<CardTitle class="text-base break-all">
|
||||||
|
{{ build.image }}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{{
|
||||||
|
build.queued_at_human === null
|
||||||
|
? 'Queued recently'
|
||||||
|
: `Queued ${build.queued_at_human}`
|
||||||
|
}}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Badge :variant="statusVariant(build.status)">
|
||||||
|
{{ build.status_label }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent class="space-y-4 pt-6">
|
||||||
|
<div class="grid gap-3 sm:grid-cols-3">
|
||||||
|
<div class="rounded-lg border border-border/70 p-3">
|
||||||
|
<p class="text-xs text-muted-foreground uppercase">
|
||||||
|
Duration
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-sm font-medium">
|
||||||
|
{{ build.duration ?? 'Waiting to start' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-border/70 p-3">
|
||||||
|
<p class="text-xs text-muted-foreground uppercase">
|
||||||
|
Environment
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-sm font-medium">
|
||||||
|
{{ build.environment_count }} variables
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-border/70 p-3">
|
||||||
|
<p class="text-xs text-muted-foreground uppercase">
|
||||||
|
Exit code
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-sm font-medium">
|
||||||
|
{{
|
||||||
|
build.exit_code === null
|
||||||
|
? 'Pending'
|
||||||
|
: build.exit_code
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3 lg:grid-cols-2">
|
||||||
|
<div class="rounded-lg border border-border/70 p-3">
|
||||||
|
<p
|
||||||
|
class="mb-2 text-xs text-muted-foreground uppercase"
|
||||||
|
>
|
||||||
|
Dockerfile
|
||||||
|
</p>
|
||||||
|
<pre
|
||||||
|
class="font-mono text-xs leading-6 whitespace-pre-wrap"
|
||||||
|
><code>{{ build.dockerfile_preview }}</code></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-border/70 p-3">
|
||||||
|
<p
|
||||||
|
class="mb-2 text-xs text-muted-foreground uppercase"
|
||||||
|
>
|
||||||
|
Log preview
|
||||||
|
</p>
|
||||||
|
<pre
|
||||||
|
class="font-mono text-xs leading-6 whitespace-pre-wrap text-muted-foreground"
|
||||||
|
><code>{{
|
||||||
|
build.output_preview ?? 'No build output saved yet.'
|
||||||
|
}}</code></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<Button variant="outline" as-child>
|
||||||
|
<Link
|
||||||
|
:href="
|
||||||
|
dockerBuilderIndex({
|
||||||
|
query: { build: build.id },
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<History />
|
||||||
|
Open build
|
||||||
|
<ArrowUpRight />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
613
resources/js/pages/docker/ImageBuilder.vue
Normal file
613
resources/js/pages/docker/ImageBuilder.vue
Normal file
@@ -0,0 +1,613 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Head, Link, useForm, useHttp } from '@inertiajs/vue3';
|
||||||
|
import { History, Plus, TerminalSquare, Trash2 } from 'lucide-vue-next';
|
||||||
|
import { nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
||||||
|
import DockerImageBuilderController from '@/actions/App/Http/Controllers/DockerImageBuilderController';
|
||||||
|
import Heading from '@/components/Heading.vue';
|
||||||
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
history as buildHistory,
|
||||||
|
index as dockerBuilderIndex,
|
||||||
|
} from '@/routes/docker-builder';
|
||||||
|
|
||||||
|
type EnvironmentVariable = {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DockerImageBuild = {
|
||||||
|
id: number;
|
||||||
|
image: string;
|
||||||
|
image_name: string;
|
||||||
|
image_tag: string;
|
||||||
|
dockerfile_content: string;
|
||||||
|
environment: EnvironmentVariable[];
|
||||||
|
environment_count: number;
|
||||||
|
status: string;
|
||||||
|
status_label: string;
|
||||||
|
successful: boolean | null;
|
||||||
|
exit_code: number | null;
|
||||||
|
build_output: string;
|
||||||
|
queued_at: string | null;
|
||||||
|
queued_at_human: string | null;
|
||||||
|
started_at: string | null;
|
||||||
|
started_at_human: string | null;
|
||||||
|
finished_at: string | null;
|
||||||
|
finished_at_human: string | null;
|
||||||
|
duration: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dockerfilePlaceholder = `FROM alpine:3.20
|
||||||
|
RUN apk add --no-cache curl
|
||||||
|
CMD ["sh", "-c", "env && sleep infinity"]`;
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
build?: DockerImageBuild | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
layout: {
|
||||||
|
breadcrumbs: [
|
||||||
|
{
|
||||||
|
title: 'Docker builder',
|
||||||
|
href: dockerBuilderIndex(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<{
|
||||||
|
dockerfile: string;
|
||||||
|
environment: EnvironmentVariable[];
|
||||||
|
image_name: string;
|
||||||
|
image_tag: string;
|
||||||
|
}>({
|
||||||
|
dockerfile: '',
|
||||||
|
environment: [{ key: '', value: '' }],
|
||||||
|
image_name: '',
|
||||||
|
image_tag: 'latest',
|
||||||
|
});
|
||||||
|
|
||||||
|
const http = useHttp();
|
||||||
|
const activeBuild = ref<DockerImageBuild | null>(props.build ?? null);
|
||||||
|
const isRefreshingBuild = ref(false);
|
||||||
|
const logViewport = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
let pollTimer: ReturnType<typeof window.setInterval> | null = null;
|
||||||
|
|
||||||
|
function addEnvironmentVariable(): void {
|
||||||
|
form.environment.push({
|
||||||
|
key: '',
|
||||||
|
value: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeEnvironmentVariable(index: number): void {
|
||||||
|
if (form.environment.length === 1) {
|
||||||
|
form.environment[0] = {
|
||||||
|
key: '',
|
||||||
|
value: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.environment.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function environmentFieldError(
|
||||||
|
index: number,
|
||||||
|
field: 'key' | 'value',
|
||||||
|
): string | undefined {
|
||||||
|
return form.errors[`environment.${index}.${field}`];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLog(build: DockerImageBuild | null): string {
|
||||||
|
if (build === null) {
|
||||||
|
return 'Queue a build to start capturing Docker output.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (build.build_output !== '') {
|
||||||
|
return build.build_output;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (build.status === 'queued') {
|
||||||
|
return 'Build is queued. Start `php artisan queue:work` if the worker is not already running.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (build.status === 'running') {
|
||||||
|
return 'Build started. Waiting for Docker to emit log output...';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'No build output was captured.';
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusVariant(
|
||||||
|
status: string,
|
||||||
|
): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||||
|
if (status === 'succeeded') {
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'failed') {
|
||||||
|
return 'destructive';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'running') {
|
||||||
|
return 'secondary';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'outline';
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldPollBuild(build: DockerImageBuild | null): boolean {
|
||||||
|
return build !== null && ['queued', 'running'].includes(build.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling(): void {
|
||||||
|
if (pollTimer !== null) {
|
||||||
|
window.clearInterval(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshBuild(): Promise<void> {
|
||||||
|
if (activeBuild.value === null || isRefreshingBuild.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isRefreshingBuild.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = (await http.submit(
|
||||||
|
DockerImageBuilderController.show(activeBuild.value.id),
|
||||||
|
)) as {
|
||||||
|
build: DockerImageBuild;
|
||||||
|
};
|
||||||
|
|
||||||
|
activeBuild.value = response.build;
|
||||||
|
|
||||||
|
if (!shouldPollBuild(response.build)) {
|
||||||
|
stopPolling();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
stopPolling();
|
||||||
|
} finally {
|
||||||
|
isRefreshingBuild.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPolling(): void {
|
||||||
|
stopPolling();
|
||||||
|
|
||||||
|
if (!shouldPollBuild(activeBuild.value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pollTimer = window.setInterval(() => {
|
||||||
|
void refreshBuild();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit(): void {
|
||||||
|
form.transform((data) => ({
|
||||||
|
...data,
|
||||||
|
dockerfile: data.dockerfile.trim(),
|
||||||
|
image_name: data.image_name.trim(),
|
||||||
|
image_tag: data.image_tag.trim(),
|
||||||
|
environment: data.environment
|
||||||
|
.map((variable) => ({
|
||||||
|
key: variable.key.trim(),
|
||||||
|
value: variable.value,
|
||||||
|
}))
|
||||||
|
.filter((variable) => variable.key !== '' || variable.value !== ''),
|
||||||
|
})).post(DockerImageBuilderController.store.url());
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.build,
|
||||||
|
(build) => {
|
||||||
|
activeBuild.value = build ?? null;
|
||||||
|
startPolling();
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => activeBuild.value?.build_output,
|
||||||
|
async () => {
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
if (logViewport.value === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logViewport.value.scrollTop = logViewport.value.scrollHeight;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
stopPolling();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Head title="Docker builder" />
|
||||||
|
|
||||||
|
<div class="flex flex-1 flex-col gap-6 p-4">
|
||||||
|
<Heading
|
||||||
|
title="Docker image builder"
|
||||||
|
description="Paste a Dockerfile, queue a build, and watch the saved build log update while the queue worker runs."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="grid gap-6 xl:grid-cols-[minmax(0,2fr)_minmax(24rem,1fr)]">
|
||||||
|
<Card class="gap-0">
|
||||||
|
<CardHeader class="border-b">
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"
|
||||||
|
>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<CardTitle>Build definition</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
The build context contains the generated
|
||||||
|
Dockerfile only. Run `php artisan queue:work` so
|
||||||
|
queued builds can start and stream logs back
|
||||||
|
into the page.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant="outline" as-child>
|
||||||
|
<Link :href="buildHistory()">
|
||||||
|
<History />
|
||||||
|
Build history
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent class="space-y-6 pt-6">
|
||||||
|
<form class="space-y-6" @submit.prevent="submit">
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="image_name">Image name</Label>
|
||||||
|
<Input
|
||||||
|
id="image_name"
|
||||||
|
v-model="form.image_name"
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="ghcr.io/acme/my-image"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<InputError :message="form.errors.image_name" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="image_tag">Tag</Label>
|
||||||
|
<Input
|
||||||
|
id="image_tag"
|
||||||
|
v-model="form.image_tag"
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="latest"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<InputError :message="form.errors.image_tag" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="dockerfile">Dockerfile</Label>
|
||||||
|
<textarea
|
||||||
|
id="dockerfile"
|
||||||
|
v-model="form.dockerfile"
|
||||||
|
class="min-h-80 w-full rounded-md border border-input bg-transparent px-3 py-3 font-mono text-sm shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:aria-invalid:ring-destructive/40"
|
||||||
|
:placeholder="dockerfilePlaceholder"
|
||||||
|
required
|
||||||
|
spellcheck="false"
|
||||||
|
/>
|
||||||
|
<InputError :message="form.errors.dockerfile" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between gap-3"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Label>Environment variables</Label>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
These are appended to the Dockerfile as
|
||||||
|
`ENV KEY="value"` lines before the job
|
||||||
|
runs.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
@click="addEnvironmentVariable"
|
||||||
|
>
|
||||||
|
<Plus />
|
||||||
|
Add variable
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="(
|
||||||
|
variable, indexValue
|
||||||
|
) in form.environment"
|
||||||
|
:key="indexValue"
|
||||||
|
class="rounded-lg border border-border/70 p-4"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="grid gap-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] md:items-start"
|
||||||
|
>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label
|
||||||
|
:for="`environment_key_${indexValue}`"
|
||||||
|
>
|
||||||
|
Key
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
:id="`environment_key_${indexValue}`"
|
||||||
|
v-model="variable.key"
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="APP_ENV"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
:message="
|
||||||
|
environmentFieldError(
|
||||||
|
indexValue,
|
||||||
|
'key',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label
|
||||||
|
:for="`environment_value_${indexValue}`"
|
||||||
|
>
|
||||||
|
Value
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
:id="`environment_value_${indexValue}`"
|
||||||
|
v-model="variable.value"
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="production"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
:message="
|
||||||
|
environmentFieldError(
|
||||||
|
indexValue,
|
||||||
|
'value',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="md:pt-8">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
class="text-muted-foreground hover:text-foreground"
|
||||||
|
@click="
|
||||||
|
removeEnvironmentVariable(
|
||||||
|
indexValue,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
<span class="sr-only">
|
||||||
|
Remove environment variable
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="rounded-lg border border-dashed border-border/70 bg-muted/30 p-4 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
Each build is saved with the Dockerfile, image name,
|
||||||
|
tag, environment variables, status, and log output.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
:disabled="form.processing"
|
||||||
|
class="w-full md:w-auto"
|
||||||
|
>
|
||||||
|
<TerminalSquare />
|
||||||
|
{{
|
||||||
|
form.processing
|
||||||
|
? 'Queueing build...'
|
||||||
|
: 'Queue build'
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="gap-0">
|
||||||
|
<CardHeader class="border-b">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<CardTitle>
|
||||||
|
{{
|
||||||
|
activeBuild === null
|
||||||
|
? 'Latest build'
|
||||||
|
: activeBuild.image
|
||||||
|
}}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{{
|
||||||
|
activeBuild === null
|
||||||
|
? 'The selected build record and log output appear here.'
|
||||||
|
: 'Logs refresh automatically while the build is queued or running.'
|
||||||
|
}}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Badge
|
||||||
|
v-if="activeBuild !== null"
|
||||||
|
:variant="statusVariant(activeBuild.status)"
|
||||||
|
>
|
||||||
|
{{ activeBuild.status_label }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent class="space-y-6 pt-6">
|
||||||
|
<template v-if="activeBuild !== null">
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div class="rounded-lg border border-border/70 p-3">
|
||||||
|
<p
|
||||||
|
class="text-xs text-muted-foreground uppercase"
|
||||||
|
>
|
||||||
|
Queued
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-sm font-medium">
|
||||||
|
{{
|
||||||
|
activeBuild.queued_at_human ??
|
||||||
|
'Just now'
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-border/70 p-3">
|
||||||
|
<p
|
||||||
|
class="text-xs text-muted-foreground uppercase"
|
||||||
|
>
|
||||||
|
Duration
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-sm font-medium">
|
||||||
|
{{
|
||||||
|
activeBuild.duration ??
|
||||||
|
'Waiting to start'
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-border/70 p-3">
|
||||||
|
<p
|
||||||
|
class="text-xs text-muted-foreground uppercase"
|
||||||
|
>
|
||||||
|
Environment
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-sm font-medium">
|
||||||
|
{{ activeBuild.environment_count }}
|
||||||
|
variables
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-border/70 p-3">
|
||||||
|
<p
|
||||||
|
class="text-xs text-muted-foreground uppercase"
|
||||||
|
>
|
||||||
|
Exit code
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-sm font-medium">
|
||||||
|
{{
|
||||||
|
activeBuild.exit_code === null
|
||||||
|
? 'Pending'
|
||||||
|
: activeBuild.exit_code
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="activeBuild.environment.length > 0"
|
||||||
|
class="space-y-2"
|
||||||
|
>
|
||||||
|
<p class="text-sm font-medium">Saved environment</p>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="(
|
||||||
|
variable, indexValue
|
||||||
|
) in activeBuild.environment"
|
||||||
|
:key="`${activeBuild.id}-${indexValue}`"
|
||||||
|
class="rounded-lg border border-border/70 bg-muted/20 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<span class="font-mono font-medium">
|
||||||
|
{{ variable.key }}
|
||||||
|
</span>
|
||||||
|
<span class="mx-2 text-muted-foreground">
|
||||||
|
=
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="font-mono text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ variable.value }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p class="text-sm font-medium">
|
||||||
|
Dockerfile snapshot
|
||||||
|
</p>
|
||||||
|
<pre
|
||||||
|
class="max-h-56 overflow-auto rounded-lg border border-border/70 bg-muted/20 p-4 font-mono text-xs leading-6 whitespace-pre-wrap"
|
||||||
|
><code>{{ activeBuild.dockerfile_content }}</code></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between gap-3"
|
||||||
|
>
|
||||||
|
<p class="text-sm font-medium">Build log</p>
|
||||||
|
<span
|
||||||
|
v-if="activeBuild.status === 'running'"
|
||||||
|
class="text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
Updating every second
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref="logViewport"
|
||||||
|
class="max-h-[30rem] overflow-auto rounded-lg border border-border/70 bg-zinc-950 p-4 text-xs text-zinc-100"
|
||||||
|
>
|
||||||
|
<pre class="font-mono whitespace-pre-wrap">{{
|
||||||
|
buildLog(activeBuild)
|
||||||
|
}}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<Button variant="outline" as-child>
|
||||||
|
<Link :href="buildHistory()">
|
||||||
|
<History />
|
||||||
|
View full history
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="rounded-xl border border-dashed border-border/70 p-8 text-center text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
No saved builds yet. Queue a Docker build to start
|
||||||
|
capturing logs and history.
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\DockerImageBuilderController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use Laravel\Fortify\Features;
|
use Laravel\Fortify\Features;
|
||||||
|
|
||||||
@@ -9,6 +10,13 @@ Route::inertia('/', 'Welcome', [
|
|||||||
|
|
||||||
Route::middleware(['auth', 'verified'])->group(function () {
|
Route::middleware(['auth', 'verified'])->group(function () {
|
||||||
Route::inertia('dashboard', 'Dashboard')->name('dashboard');
|
Route::inertia('dashboard', 'Dashboard')->name('dashboard');
|
||||||
|
|
||||||
|
Route::prefix('docker-builder')->name('docker-builder.')->group(function () {
|
||||||
|
Route::get('/', [DockerImageBuilderController::class, 'index'])->name('index');
|
||||||
|
Route::post('/', [DockerImageBuilderController::class, 'store'])->name('store');
|
||||||
|
Route::get('history', [DockerImageBuilderController::class, 'history'])->name('history');
|
||||||
|
Route::get('builds/{dockerImageBuild}', [DockerImageBuilderController::class, 'show'])->name('builds.show');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
require __DIR__.'/settings.php';
|
require __DIR__.'/settings.php';
|
||||||
|
|||||||
128
tests/Feature/DockerImageBuilderTest.php
Normal file
128
tests/Feature/DockerImageBuilderTest.php
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Jobs\RunDockerImageBuild;
|
||||||
|
use App\Models\DockerImageBuild;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Inertia\Testing\AssertableInertia as Assert;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('guests are redirected to the login page', function () {
|
||||||
|
$this->get(route('docker-builder.index'))
|
||||||
|
->assertRedirect(route('login'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('authenticated users can visit the docker image builder page', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('docker-builder.index'))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('docker/ImageBuilder')
|
||||||
|
->where('build', null),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('authenticated users can queue a docker image build from editor input', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)
|
||||||
|
->post(route('docker-builder.store'), [
|
||||||
|
'dockerfile' => implode("\n", [
|
||||||
|
'FROM alpine:3.20',
|
||||||
|
'RUN echo "building"',
|
||||||
|
]),
|
||||||
|
'image_name' => 'ghcr.io/acme/example',
|
||||||
|
'image_tag' => 'latest',
|
||||||
|
'environment' => [
|
||||||
|
['key' => 'APP_ENV', 'value' => 'production'],
|
||||||
|
['key' => 'APP_NAME', 'value' => 'Docker Builder'],
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->assertSessionHasNoErrors();
|
||||||
|
|
||||||
|
$dockerImageBuild = DockerImageBuild::query()->sole();
|
||||||
|
|
||||||
|
$response->assertRedirect(route('docker-builder.index', [
|
||||||
|
'build' => $dockerImageBuild->id,
|
||||||
|
]));
|
||||||
|
|
||||||
|
expect($dockerImageBuild->user_id)->toBe($user->id)
|
||||||
|
->and($dockerImageBuild->image_name)->toBe('ghcr.io/acme/example')
|
||||||
|
->and($dockerImageBuild->image_tag)->toBe('latest')
|
||||||
|
->and($dockerImageBuild->dockerfile_content)->toContain('RUN echo "building"')
|
||||||
|
->and($dockerImageBuild->environment)->toBe([
|
||||||
|
['key' => 'APP_ENV', 'value' => 'production'],
|
||||||
|
['key' => 'APP_NAME', 'value' => 'Docker Builder'],
|
||||||
|
])
|
||||||
|
->and($dockerImageBuild->status)->toBe(DockerImageBuild::STATUS_QUEUED)
|
||||||
|
->and($dockerImageBuild->successful)->toBeNull()
|
||||||
|
->and($dockerImageBuild->queued_at)->not->toBeNull();
|
||||||
|
|
||||||
|
Queue::assertPushed(RunDockerImageBuild::class, function (RunDockerImageBuild $job) use ($dockerImageBuild): bool {
|
||||||
|
return $job->dockerImageBuildId === $dockerImageBuild->id;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('authenticated users can view their docker build history', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$olderBuild = DockerImageBuild::factory()->for($user)->create([
|
||||||
|
'status' => DockerImageBuild::STATUS_FAILED,
|
||||||
|
'build_output' => 'failed to solve',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$newerBuild = DockerImageBuild::factory()->for($user)->create([
|
||||||
|
'status' => DockerImageBuild::STATUS_SUCCEEDED,
|
||||||
|
'successful' => true,
|
||||||
|
'exit_code' => 0,
|
||||||
|
'build_output' => 'Successfully built image',
|
||||||
|
'started_at' => now()->subMinute(),
|
||||||
|
'finished_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
DockerImageBuild::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('docker-builder.history'))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('docker/BuildHistory')
|
||||||
|
->has('builds', 2)
|
||||||
|
->where('builds.0.id', $newerBuild->id)
|
||||||
|
->where('builds.1.id', $olderBuild->id),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('authenticated users can fetch their saved build status', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$dockerImageBuild = DockerImageBuild::factory()->for($user)->create([
|
||||||
|
'image_name' => 'ghcr.io/acme/example',
|
||||||
|
'image_tag' => 'stable',
|
||||||
|
'status' => DockerImageBuild::STATUS_RUNNING,
|
||||||
|
'build_output' => 'Step 1/3 : FROM alpine:3.20',
|
||||||
|
'started_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('docker-builder.builds.show', $dockerImageBuild))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('build.id', $dockerImageBuild->id)
|
||||||
|
->assertJsonPath('build.image', 'ghcr.io/acme/example:stable')
|
||||||
|
->assertJsonPath('build.status', DockerImageBuild::STATUS_RUNNING)
|
||||||
|
->assertJsonPath('build.build_output', 'Step 1/3 : FROM alpine:3.20');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('authenticated users cannot fetch builds that belong to someone else', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$dockerImageBuild = DockerImageBuild::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('docker-builder.builds.show', $dockerImageBuild))
|
||||||
|
->assertNotFound();
|
||||||
|
});
|
||||||
99
tests/Feature/RunDockerImageBuildJobTest.php
Normal file
99
tests/Feature/RunDockerImageBuildJobTest.php
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Jobs\RunDockerImageBuild;
|
||||||
|
use App\Models\DockerImageBuild;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Process\PendingProcess;
|
||||||
|
use Illuminate\Support\Facades\Process;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('run docker image build job marks successful builds as succeeded', function () {
|
||||||
|
$dockerImageBuild = DockerImageBuild::factory()->create([
|
||||||
|
'image_name' => 'ghcr.io/acme/example',
|
||||||
|
'image_tag' => 'latest',
|
||||||
|
'dockerfile_content' => implode("\n", [
|
||||||
|
'FROM alpine:3.20',
|
||||||
|
'RUN echo "building"',
|
||||||
|
]),
|
||||||
|
'environment' => [
|
||||||
|
['key' => 'APP_ENV', 'value' => 'production'],
|
||||||
|
['key' => 'APP_NAME', 'value' => 'Docker Builder'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Process::fake(function (PendingProcess $process) {
|
||||||
|
expect($process->path)->not->toBeNull();
|
||||||
|
expect($process->command)->toBe([
|
||||||
|
'docker',
|
||||||
|
'build',
|
||||||
|
'--tag',
|
||||||
|
'ghcr.io/acme/example:latest',
|
||||||
|
'.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$dockerfile = file_get_contents($process->path.'/Dockerfile');
|
||||||
|
|
||||||
|
expect($dockerfile)
|
||||||
|
->toContain('FROM alpine:3.20')
|
||||||
|
->toContain('RUN echo "building"')
|
||||||
|
->toContain('ENV APP_ENV="production"')
|
||||||
|
->toContain('ENV APP_NAME="Docker Builder"');
|
||||||
|
|
||||||
|
return Process::result(implode("\n", [
|
||||||
|
'Step 1/2 : FROM alpine:3.20',
|
||||||
|
'Successfully built ghcr.io/acme/example:latest',
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
|
(new RunDockerImageBuild($dockerImageBuild->id))->handle();
|
||||||
|
|
||||||
|
$dockerImageBuild->refresh();
|
||||||
|
|
||||||
|
expect($dockerImageBuild->status)->toBe(DockerImageBuild::STATUS_SUCCEEDED)
|
||||||
|
->and($dockerImageBuild->successful)->toBeTrue()
|
||||||
|
->and($dockerImageBuild->exit_code)->toBe(0)
|
||||||
|
->and($dockerImageBuild->build_output)->toContain('Successfully built ghcr.io/acme/example:latest')
|
||||||
|
->and($dockerImageBuild->started_at)->not->toBeNull()
|
||||||
|
->and($dockerImageBuild->finished_at)->not->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('run docker image build job marks non-zero docker exits as failed', function () {
|
||||||
|
$dockerImageBuild = DockerImageBuild::factory()->create([
|
||||||
|
'image_name' => 'ghcr.io/acme/example',
|
||||||
|
'image_tag' => 'broken',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Process::fake(fn (PendingProcess $process) => Process::result(
|
||||||
|
'Step 1/1 : FROM missing:latest',
|
||||||
|
'failed to solve: missing image',
|
||||||
|
1,
|
||||||
|
));
|
||||||
|
|
||||||
|
(new RunDockerImageBuild($dockerImageBuild->id))->handle();
|
||||||
|
|
||||||
|
$dockerImageBuild->refresh();
|
||||||
|
|
||||||
|
expect($dockerImageBuild->status)->toBe(DockerImageBuild::STATUS_FAILED)
|
||||||
|
->and($dockerImageBuild->successful)->toBeFalse()
|
||||||
|
->and($dockerImageBuild->exit_code)->toBe(1)
|
||||||
|
->and($dockerImageBuild->build_output)->toContain('failed to solve: missing image')
|
||||||
|
->and($dockerImageBuild->finished_at)->not->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('run docker image build job records unexpected process exceptions as failed', function () {
|
||||||
|
$dockerImageBuild = DockerImageBuild::factory()->create();
|
||||||
|
|
||||||
|
Process::fake(fn (PendingProcess $process) => throw new \RuntimeException('Docker is unavailable'));
|
||||||
|
|
||||||
|
expect(fn () => (new RunDockerImageBuild($dockerImageBuild->id))->handle())
|
||||||
|
->toThrow(\RuntimeException::class, 'Docker is unavailable');
|
||||||
|
|
||||||
|
$dockerImageBuild->refresh();
|
||||||
|
|
||||||
|
expect($dockerImageBuild->status)->toBe(DockerImageBuild::STATUS_FAILED)
|
||||||
|
->and($dockerImageBuild->successful)->toBeFalse()
|
||||||
|
->and($dockerImageBuild->exit_code)->toBe(1)
|
||||||
|
->and($dockerImageBuild->build_output)->toContain('Docker is unavailable')
|
||||||
|
->and($dockerImageBuild->finished_at)->not->toBeNull();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user