d2d.emegrendeles.hu/app/Filament/Pages/DeploymentPackage.php
E98Developer bfc1484611 ADD Deployment csomagoló phase3 napló, környezetkövetés, ZIP letöltés és törlő szkript
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>
2026-08-16 07:20:34 +02:00

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;
}
}