build logs
This commit is contained in:
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\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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user