A csomagolás mostantól nyomot hagy: melyik környezetre mi készült, mi lett kirakva, és mennyi a lemaradás - ez az, ami a "melyik verzió fut az e2e-n" kérdést kiveszi a fejből. - deployment_packages tábla + modell (BaseAuditable): target, tartomány, darabszámok, mappa/ZIP útvonal, deployed_at. A csomagolás és a tényleges kirakás két külön esemény. - Targetenkénti előtöltés: a kezdő commit az adott környezetre utoljára kirakottként megjelölt csomag to_commit-je. Rebase után nem létező hash esetén inkább üres marad, mint hogy hamis tartományt mutasson. - "Környezetek állapota" panel: napló szerinti állapot + commit-lemaradás (git rev-list --count), és gombra a szerver deploy-version.json-jának lekérdezése. Ha a kettő eltér, az elmaradt vagy félbemaradt feltöltés jele. Szándékosan gombra fut, nem minden rendereléskor: így nem indul kimenő kérés magától. - ZIP a kész mappából (a fájllista a zip létrejötte előtt készül, így nem csomagolja magát), letöltés a naplóból. A DB-ből jövő útvonalat kiírás előtt a kimeneti mappához kötjük, hogy egy módosított rekord se tehessen letölthetővé tetszőleges fájlt. - _torles.sh: alapból dry run, --confirm kell a törléshez, app-gyökér ellenőrzéssel, abszolút útvonal és .. kiszűrésével, LF sorvéggel és BOM nélkül. Lefuttatva ellenőrizve. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
556 lines
19 KiB
PHP
556 lines
19 KiB
PHP
<?php
|
|
|
|
namespace App\Filament\Pages;
|
|
|
|
use App\Models\DeploymentPackage as DeploymentPackageRecord;
|
|
use App\Services\Deployment\ChangeSetAnalyzer;
|
|
use App\Services\Deployment\DeploymentGuard;
|
|
use App\Services\Deployment\DeploymentPackageBuilder;
|
|
use App\Services\Deployment\DeploymentTargets;
|
|
use App\Services\Deployment\GitRepository;
|
|
use Filament\Forms\Components\Select;
|
|
use Filament\Forms\Components\TextInput;
|
|
use Filament\Forms\Concerns\InteractsWithForms;
|
|
use Filament\Forms\Contracts\HasForms;
|
|
use Filament\Notifications\Notification;
|
|
use Filament\Pages\Page;
|
|
use Filament\Schemas\Components\Grid;
|
|
use Filament\Schemas\Schema;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Livewire\Attributes\Computed;
|
|
use RuntimeException;
|
|
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
|
use Throwable;
|
|
|
|
/**
|
|
* Deployment csomagoló: célkörnyezet, commit-tartomány, diff-előnézet és csomagolás.
|
|
*
|
|
* Az oldal a git történetet olvassa, és a kijelölt tartományból egy időbélyeges mappát
|
|
* állít elő a config('deployment.output_path') alatt - a repón kívülre nem ír.
|
|
*
|
|
* A hozzáférést a DeploymentGuard dönti el (env kapcsoló + stage whitelist +
|
|
* developer szerep + feature flag), és a mount()-on kívül minden akció elején
|
|
* újra lefut - a felület elrejtése önmagában nem védelem.
|
|
*/
|
|
class DeploymentPackage extends Page implements HasForms
|
|
{
|
|
use InteractsWithForms;
|
|
|
|
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-rocket-launch';
|
|
|
|
protected string $view = 'filament.pages.deployment-package';
|
|
|
|
protected static ?string $title = 'Deployment csomagoló';
|
|
|
|
protected static ?string $navigationLabel = 'Deployment csomagoló';
|
|
|
|
protected static bool $shouldRegisterNavigation = false;
|
|
|
|
/** @var array<string, mixed>|null */
|
|
public ?array $data = [];
|
|
|
|
public ?string $fromCommit = null;
|
|
|
|
public ?string $toCommit = null;
|
|
|
|
public ?string $gitError = null;
|
|
|
|
/** @var array<int, string> a felületen kézzel kivett fájlok */
|
|
public array $skippedPaths = [];
|
|
|
|
/** @var array<string, mixed>|null az utoljára elkészített csomag adatai */
|
|
public ?array $lastPackage = null;
|
|
|
|
/** @var array<string, array<string, mixed>> targetenként a szerverről lekérdezett élő verzió */
|
|
public array $liveVersions = [];
|
|
|
|
public static function canAccess(): bool
|
|
{
|
|
return app(DeploymentGuard::class)->isAllowed(auth()->user());
|
|
}
|
|
|
|
public function mount(): void
|
|
{
|
|
app(DeploymentGuard::class)->ensureAllowed(auth()->user());
|
|
|
|
/** @var array<int, int> $limits */
|
|
$limits = (array) config('deployment.commit_limits', [50]);
|
|
|
|
$target = app(DeploymentTargets::class)->defaultKey();
|
|
|
|
$this->form->fill([
|
|
'target' => $target,
|
|
'branch' => $this->defaultBranch(),
|
|
'limit' => $limits[0] ?? 50,
|
|
'search' => null,
|
|
]);
|
|
|
|
$this->preloadFromCommit($target);
|
|
}
|
|
|
|
/**
|
|
* A kezdő commit előtöltése az adott környezetre utoljára kirakott csomagból.
|
|
*
|
|
* Ez a funkció lényegi része: a "hol tart ez a környezet" kérdésre nem emlékezetből
|
|
* kell válaszolni, így nem marad ki fájl a következő csomagból.
|
|
*/
|
|
private function preloadFromCommit(?string $targetKey): void
|
|
{
|
|
if (! $targetKey) {
|
|
return;
|
|
}
|
|
|
|
$lastDeployed = DeploymentPackageRecord::lastDeployedFor($targetKey);
|
|
|
|
// Rebase/force push után a korábban rögzített hash már nem létezik - ilyenkor
|
|
// inkább nem töltünk elő semmit, mint hogy hamis tartományt mutassunk.
|
|
$this->fromCommit = $lastDeployed
|
|
? app(GitRepository::class)->resolveCommit($lastDeployed->to_commit)
|
|
: null;
|
|
}
|
|
|
|
public function form(Schema $form): Schema
|
|
{
|
|
/** @var array<int, int> $limits */
|
|
$limits = (array) config('deployment.commit_limits', [50]);
|
|
|
|
return $form
|
|
->schema([
|
|
Grid::make(4)
|
|
->schema([
|
|
Select::make('target')
|
|
->label('Célkörnyezet')
|
|
->options(app(DeploymentTargets::class)->options())
|
|
->required()
|
|
->live()
|
|
->afterStateUpdated(fn (?string $state) => $this->preloadFromCommit($state))
|
|
->helperText(fn (): ?string => $this->target()['remote_path'] ?? null),
|
|
Select::make('branch')
|
|
->label('Branch')
|
|
->options($this->branchOptions())
|
|
->required()
|
|
->searchable()
|
|
->live()
|
|
->afterStateUpdated(fn () => $this->clearRange()),
|
|
Select::make('limit')
|
|
->label('Commitok száma')
|
|
->options(array_combine($limits, $limits))
|
|
->required()
|
|
->live(),
|
|
TextInput::make('search')
|
|
->label('Keresés')
|
|
->placeholder('pl. árlista, migráció, szerző')
|
|
->helperText('A commit üzenetében (a törzsben is), a szerzőben és a hashben keres, a betöltött listán belül.')
|
|
->live(debounce: 400),
|
|
]),
|
|
])
|
|
->statePath('data');
|
|
}
|
|
|
|
public function selectFrom(string $hash): void
|
|
{
|
|
app(DeploymentGuard::class)->ensureAllowed(auth()->user());
|
|
|
|
$this->fromCommit = $this->resolveOrWarn($hash);
|
|
}
|
|
|
|
public function selectTo(string $hash): void
|
|
{
|
|
app(DeploymentGuard::class)->ensureAllowed(auth()->user());
|
|
|
|
$this->toCommit = $this->resolveOrWarn($hash);
|
|
}
|
|
|
|
public function swapRange(): void
|
|
{
|
|
app(DeploymentGuard::class)->ensureAllowed(auth()->user());
|
|
|
|
[$this->fromCommit, $this->toCommit] = [$this->toCommit, $this->fromCommit];
|
|
}
|
|
|
|
public function clearRange(): void
|
|
{
|
|
$this->fromCommit = null;
|
|
$this->toCommit = null;
|
|
$this->skippedPaths = [];
|
|
}
|
|
|
|
/**
|
|
* Egy fájl kézi ki-/visszavétele a csomagból.
|
|
*/
|
|
public function toggleFile(string $path): void
|
|
{
|
|
app(DeploymentGuard::class)->ensureAllowed(auth()->user());
|
|
|
|
$this->skippedPaths = in_array($path, $this->skippedPaths, true)
|
|
? array_values(array_diff($this->skippedPaths, [$path]))
|
|
: array_merge($this->skippedPaths, [$path]);
|
|
}
|
|
|
|
/**
|
|
* A csomag előállítása a kijelölt tartományból.
|
|
*/
|
|
public function createPackage(): void
|
|
{
|
|
app(DeploymentGuard::class)->ensureAllowed(auth()->user());
|
|
|
|
if (! $this->fromCommit || ! $this->toCommit) {
|
|
Notification::make()
|
|
->title('Nincs kijelölt tartomány')
|
|
->body('Válassz egy kezdő és egy záró commitot.')
|
|
->danger()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$this->lastPackage = app(DeploymentPackageBuilder::class)->build(
|
|
(string) ($this->data['target'] ?? ''),
|
|
(string) ($this->data['branch'] ?? ''),
|
|
$this->fromCommit,
|
|
$this->toCommit,
|
|
$this->skippedPaths,
|
|
auth()->user()?->email,
|
|
);
|
|
} catch (RuntimeException $exception) {
|
|
Notification::make()
|
|
->title('A csomag nem készült el')
|
|
->body($exception->getMessage())
|
|
->danger()
|
|
->persistent()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
DeploymentPackageRecord::create([
|
|
'name' => $this->lastPackage['name'],
|
|
'target' => $this->lastPackage['target'],
|
|
'branch' => $this->lastPackage['branch'],
|
|
'from_commit' => $this->lastPackage['from'],
|
|
'to_commit' => $this->lastPackage['to'],
|
|
'folder' => $this->lastPackage['path'],
|
|
'zip_path' => $this->lastPackage['zip'],
|
|
'copied_count' => $this->lastPackage['counts']['copied'],
|
|
'deleted_count' => $this->lastPackage['counts']['deleted'],
|
|
'skipped_count' => $this->lastPackage['counts']['skipped'],
|
|
'missing_count' => $this->lastPackage['counts']['missing'],
|
|
]);
|
|
|
|
Notification::make()
|
|
->title('A csomag elkészült')
|
|
->body(sprintf(
|
|
'%d fájl a(z) %s mappában.',
|
|
$this->lastPackage['counts']['copied'],
|
|
$this->lastPackage['name'],
|
|
))
|
|
->success()
|
|
->send();
|
|
}
|
|
|
|
/**
|
|
* A csomag ZIP-jének letöltése.
|
|
*/
|
|
public function downloadPackage(int $id): ?BinaryFileResponse
|
|
{
|
|
app(DeploymentGuard::class)->ensureAllowed(auth()->user());
|
|
|
|
$package = DeploymentPackageRecord::find($id);
|
|
$zipPath = $package?->zip_path;
|
|
|
|
// Az útvonal adatbázisból jön, ezért kiírás előtt ellenőrizzük, hogy a csomagoló
|
|
// saját kimeneti mappáján belül van - egy elrontott vagy módosított rekord így sem
|
|
// tud tetszőleges fájlt letölthetővé tenni.
|
|
if (! $zipPath || ! is_file($zipPath) || ! $this->isInsideOutputPath($zipPath)) {
|
|
Notification::make()
|
|
->title('A ZIP nem érhető el')
|
|
->body('A csomag ZIP fájlja már nincs a lemezen.')
|
|
->danger()
|
|
->send();
|
|
|
|
return null;
|
|
}
|
|
|
|
return response()->download($zipPath);
|
|
}
|
|
|
|
/**
|
|
* A csomag megjelölése kirakottként - innentől ez lesz a következő tartomány kezdete.
|
|
*/
|
|
public function markDeployed(int $id): void
|
|
{
|
|
app(DeploymentGuard::class)->ensureAllowed(auth()->user());
|
|
|
|
$package = DeploymentPackageRecord::find($id);
|
|
|
|
if (! $package) {
|
|
return;
|
|
}
|
|
|
|
$package->deployed_at = now();
|
|
$package->save();
|
|
|
|
$this->preloadFromCommit($package->target);
|
|
|
|
Notification::make()
|
|
->title('Megjelölve kirakottként')
|
|
->body(sprintf('A(z) %s környezet innentől a %s commiton áll.', $package->target, substr((string) $package->to_commit, 0, 7)))
|
|
->success()
|
|
->send();
|
|
}
|
|
|
|
/**
|
|
* Az élő állapot lekérdezése a célkörnyezetről (deploy-version.json).
|
|
*
|
|
* Szándékosan gombra fut és nem minden rendereléskor: így nem indul kimenő kérés
|
|
* magától, és nem lassítja az oldalt, ha egy környezet nem válaszol.
|
|
*/
|
|
public function fetchLiveVersion(string $targetKey): void
|
|
{
|
|
app(DeploymentGuard::class)->ensureAllowed(auth()->user());
|
|
|
|
$target = app(DeploymentTargets::class)->find($targetKey);
|
|
|
|
if (! $target || ! $target['domain']) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$response = Http::connectTimeout(3)->timeout(5)->get('https://'.$target['domain'].'/deploy-version.json');
|
|
|
|
$this->liveVersions[$targetKey] = $response->successful()
|
|
? ['ok' => true, 'data' => $response->json()]
|
|
: ['ok' => false, 'error' => 'HTTP '.$response->status()];
|
|
} catch (Throwable $exception) {
|
|
$this->liveVersions[$targetKey] = ['ok' => false, 'error' => $exception->getMessage()];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Környezetenkénti állapot: mi van kirakva és mennyi a lemaradás.
|
|
*
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
#[Computed]
|
|
public function environmentStatuses(): array
|
|
{
|
|
$branch = $this->data['branch'] ?? null;
|
|
$statuses = [];
|
|
|
|
foreach (app(DeploymentTargets::class)->all() as $key => $target) {
|
|
$lastDeployed = DeploymentPackageRecord::lastDeployedFor($key);
|
|
$live = $this->liveVersions[$key] ?? null;
|
|
$liveCommit = $live['ok'] ?? false ? ($live['data']['commit'] ?? null) : null;
|
|
|
|
$statuses[] = [
|
|
'target' => $target,
|
|
'package' => $lastDeployed,
|
|
'behind' => $lastDeployed && $branch
|
|
? app(GitRepository::class)->countCommitsBetween($lastDeployed->to_commit, $branch)
|
|
: null,
|
|
'live' => $live,
|
|
// A napló azt mondja meg, mit jelöltünk kirakottnak; a szerver azt, mi fut
|
|
// valójában. Ha a kettő eltér, az elmaradt vagy félbemaradt feltöltés jele.
|
|
'mismatch' => $liveCommit && $lastDeployed && $liveCommit !== $lastDeployed->to_commit,
|
|
];
|
|
}
|
|
|
|
return $statuses;
|
|
}
|
|
|
|
/**
|
|
* @return \Illuminate\Support\Collection<int, DeploymentPackageRecord>
|
|
*/
|
|
#[Computed]
|
|
public function recentPackages(): Collection
|
|
{
|
|
return DeploymentPackageRecord::query()
|
|
->when($this->data['target'] ?? null, fn ($query, $target) => $query->forTarget($target))
|
|
->latest('id')
|
|
->limit(10)
|
|
->get();
|
|
}
|
|
|
|
private function isInsideOutputPath(string $path): bool
|
|
{
|
|
$base = realpath((string) config('deployment.output_path'));
|
|
$real = realpath($path);
|
|
|
|
return $base !== false && $real !== false && str_starts_with($real, $base);
|
|
}
|
|
|
|
/**
|
|
* @return array{key:string, label:string, domain:string, remote_path:string, expected_branch:?string}|null
|
|
*/
|
|
public function target(): ?array
|
|
{
|
|
return app(DeploymentTargets::class)->find($this->data['target'] ?? null);
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array{hash:string, short:string, author:string, date:string, subject:string, body:string, file_count:?int}>
|
|
*/
|
|
#[Computed]
|
|
public function commits(): array
|
|
{
|
|
$branch = $this->data['branch'] ?? null;
|
|
|
|
if (! $branch) {
|
|
return [];
|
|
}
|
|
|
|
try {
|
|
$commits = app(GitRepository::class)->commits($branch, (int) ($this->data['limit'] ?? 50));
|
|
} catch (RuntimeException $exception) {
|
|
$this->gitError = $exception->getMessage();
|
|
|
|
return [];
|
|
}
|
|
|
|
$search = trim((string) ($this->data['search'] ?? ''));
|
|
|
|
if ($search === '') {
|
|
return $commits;
|
|
}
|
|
|
|
$needle = mb_strtolower($search);
|
|
|
|
return array_values(array_filter(
|
|
$commits,
|
|
fn (array $commit): bool => str_contains(
|
|
mb_strtolower(implode(' ', [$commit['subject'], $commit['body'], $commit['author'], $commit['hash']])),
|
|
$needle,
|
|
),
|
|
));
|
|
}
|
|
|
|
/**
|
|
* A kijelölt tartomány elemzése, vagy null, ha még nincs két commit kiválasztva.
|
|
*
|
|
* @return array{
|
|
* files: array<int, array{status:string, path:string, old_path:?string, excluded:bool, action:string}>,
|
|
* copied: array<int, string>,
|
|
* deleted: array<int, string>,
|
|
* counts: array{total:int, copied:int, deleted:int, excluded:int},
|
|
* warnings: array<int, array{level:string, title:string, body:string}>
|
|
* }|null
|
|
*/
|
|
#[Computed]
|
|
public function changeSet(): ?array
|
|
{
|
|
if (! $this->fromCommit || ! $this->toCommit) {
|
|
return null;
|
|
}
|
|
|
|
$repository = app(GitRepository::class);
|
|
|
|
try {
|
|
$changedFiles = $repository->changedFiles($this->fromCommit, $this->toCommit);
|
|
} catch (RuntimeException $exception) {
|
|
$this->gitError = $exception->getMessage();
|
|
|
|
return null;
|
|
}
|
|
|
|
$changeSet = app(ChangeSetAnalyzer::class)->analyze(
|
|
$changedFiles,
|
|
$this->target(),
|
|
$this->data['branch'] ?? null,
|
|
);
|
|
|
|
if ($this->fromCommit === $this->toCommit) {
|
|
array_unshift($changeSet['warnings'], [
|
|
'level' => 'warning',
|
|
'title' => 'Azonos commitok',
|
|
'body' => 'A kezdő és a záró commit ugyanaz, így a tartomány üres.',
|
|
]);
|
|
} elseif (! $repository->isAncestor($this->fromCommit, $this->toCommit)) {
|
|
array_unshift($changeSet['warnings'], [
|
|
'level' => 'danger',
|
|
'title' => 'Fordított vagy szétágazó tartomány',
|
|
'body' => 'A kezdő commit nem őse a zárónak, ezért a lista nem a "mi került bele azóta" kérdésre válaszol. Ellenőrizd a sorrendet (Csere gomb), vagy azt, hogy ugyanazon az ágon vagy-e.',
|
|
]);
|
|
}
|
|
|
|
return $changeSet;
|
|
}
|
|
|
|
/**
|
|
* A kijelölt tartományba eső commitok - csak a lista kiemeléséhez.
|
|
*
|
|
* @return array<int, string>
|
|
*/
|
|
#[Computed]
|
|
public function highlightedHashes(): array
|
|
{
|
|
if (! $this->fromCommit || ! $this->toCommit) {
|
|
return array_values(array_filter([$this->fromCommit, $this->toCommit]));
|
|
}
|
|
|
|
$hashes = array_column($this->commits(), 'hash');
|
|
$fromIndex = array_search($this->fromCommit, $hashes, true);
|
|
$toIndex = array_search($this->toCommit, $hashes, true);
|
|
|
|
if ($fromIndex === false || $toIndex === false) {
|
|
return array_values(array_filter([$this->fromCommit, $this->toCommit]));
|
|
}
|
|
|
|
// A lista fentről lefelé a legfrissebbtől halad, tehát a záró commit van előrébb.
|
|
[$start, $end] = $fromIndex <= $toIndex ? [$fromIndex, $toIndex] : [$toIndex, $fromIndex];
|
|
|
|
return array_slice($hashes, $start, $end - $start + 1);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
public function branchOptions(): array
|
|
{
|
|
try {
|
|
$branches = app(GitRepository::class)->branches();
|
|
} catch (RuntimeException $exception) {
|
|
$this->gitError = $exception->getMessage();
|
|
|
|
return [];
|
|
}
|
|
|
|
return array_combine($branches, $branches) ?: [];
|
|
}
|
|
|
|
public function isRepositoryAvailable(): bool
|
|
{
|
|
return app(GitRepository::class)->isAvailable();
|
|
}
|
|
|
|
private function defaultBranch(): ?string
|
|
{
|
|
$repository = app(GitRepository::class);
|
|
$current = $repository->currentBranch();
|
|
|
|
if ($current) {
|
|
return $current;
|
|
}
|
|
|
|
return $this->branchOptions() === [] ? null : array_key_first($this->branchOptions());
|
|
}
|
|
|
|
private function resolveOrWarn(string $hash): ?string
|
|
{
|
|
$resolved = app(GitRepository::class)->resolveCommit($hash);
|
|
|
|
if (! $resolved) {
|
|
Notification::make()
|
|
->title('Ismeretlen commit')
|
|
->body('A kiválasztott commit nem található a repóban.')
|
|
->danger()
|
|
->send();
|
|
|
|
return null;
|
|
}
|
|
|
|
return $resolved;
|
|
}
|
|
}
|