Compare commits
15 Commits
8bcfeba0f9
...
3cfaa6c9d5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cfaa6c9d5 | ||
|
|
11a18eb1a6 | ||
|
|
98cf955354 | ||
|
|
a9758e8da7 | ||
|
|
947eccff90 | ||
|
|
11918b608d | ||
|
|
2965f231ce | ||
|
|
4bb89ae6a1 | ||
|
|
c8d5e01490 | ||
|
|
9bc723bd83 | ||
|
|
b8fd71ed23 | ||
|
|
bfc1484611 | ||
|
|
7ad9c6fea8 | ||
|
|
6027511585 | ||
|
|
09eed3eeb3 |
105
CLAUDE.md
Normal file
105
CLAUDE.md
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
Laravel 12 (PHP 8.4) + Filament 4 + Livewire 3, Pest 3 tesztekkel, MariaDB adatbázison.
|
||||||
|
Fejlesztés Windowson, egyetlen gépen — nincs csapat és nincs CI.
|
||||||
|
|
||||||
|
## Tesztek futtatása
|
||||||
|
|
||||||
|
Teljes suite (~77 mp, 6 processzen):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan test --parallel
|
||||||
|
```
|
||||||
|
|
||||||
|
Egy tesztre vagy egy fájlra **`--parallel` nélkül** — ott a hat processz bootja tiszta
|
||||||
|
veszteség, a párhuzamos futás lassabb:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan test --filter="részlet"
|
||||||
|
```
|
||||||
|
|
||||||
|
Hasznos még: `--retry` (előbb a bukott teszteket futtatja, első hibánál megáll) és
|
||||||
|
`--profile` (a 10 leglassabb teszt). A Pest 3.8-ban **nincs** `--dirty` kapcsoló.
|
||||||
|
|
||||||
|
A `tests/Feature/ExampleTest.php` (`GET /` → 302) **régóta bukik**, függetlenül minden
|
||||||
|
más munkától. Egy bukó teszt a teljes suite-on ez, nem regresszió.
|
||||||
|
|
||||||
|
### Adatbázis-védelem
|
||||||
|
|
||||||
|
A `tests/TestCase.php` `setUp()`-ja a `RefreshDatabase` **előtt** ellenőrzi a
|
||||||
|
konfigurált adatbázisnevet, mert a `RefreshDatabase` feltétel nélkül eldobja a
|
||||||
|
táblákat. Az éles `d2d` név azonnali `exit(1)`-et vált ki.
|
||||||
|
|
||||||
|
Elfogadott név: `/^d2dtest(_test_\d+)?$/`. A `_test_N` alak a `--parallel`
|
||||||
|
processzenkénti adatbázisa (a nevet a Laravel `TestDatabases::testDatabase()` adja).
|
||||||
|
**Ezt a mintát ne lazítsd `str_starts_with($db, 'd2d')`-re** — az átengedné az éles
|
||||||
|
adatbázist is.
|
||||||
|
|
||||||
|
A `setUp()` szándékosan hívja meg maga a `ParallelTesting::callSetUpTestCaseCallbacks()`-ot:
|
||||||
|
a Laravel ezt csak akkor futtatná, ha ő hozta létre az alkalmazást, mi viszont előbb
|
||||||
|
bootolunk az ellenőrzés miatt. Enélkül a processzenkénti adatbázis sosem jönne létre,
|
||||||
|
és minden processz ugyanazt az adatbázist migrálná.
|
||||||
|
|
||||||
|
## Gépfüggő beállítások
|
||||||
|
|
||||||
|
**A `mysql` kliensnek a PATH-on kell lennie**, különben az egész teszt-suite elszáll
|
||||||
|
(`MySqlSchemaState.php`). Binárisok: `C:\www\envkit\services\mysql\11.8.2\bin`.
|
||||||
|
|
||||||
|
**`bootstrap/cache/blade-icons.php`** — ez adja az app boot idejének a felét
|
||||||
|
(nélküle 8,2 mp, vele 3,8 mp minden `php artisan` hívás). Gitignore-olt, tehát
|
||||||
|
gépenként kell egyszer:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan icons:cache
|
||||||
|
```
|
||||||
|
|
||||||
|
Új ikonkészlet vagy ikonokat szállító csomag után újra kell generálni. A
|
||||||
|
`php artisan filament:optimize` **nem** hoz további nyereséget (mérve), viszont
|
||||||
|
elrejtené az újonnan létrehozott Filament resource-okat — ne használd fejlesztés közben.
|
||||||
|
|
||||||
|
## Migrációk és séma dump
|
||||||
|
|
||||||
|
`database/schema/mysql-schema.sql` váltja ki a 103 migráció lefuttatását a teszteknél.
|
||||||
|
**Minden új migráció után újra kell generálni:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan schema:dump
|
||||||
|
```
|
||||||
|
|
||||||
|
Ha elmarad, az nem törik el semmit — a Laravel betölti a dumpot, majd a dump utáni
|
||||||
|
migrációkat egyenként lefuttatja rá.
|
||||||
|
|
||||||
|
A dumpot a Laravel csak akkor tölti be, ha a `migrations` tábla **üres**. Az e2e/d2d
|
||||||
|
környezetek, ahol már vannak lefutott migrációk, változatlanul inkrementálisan
|
||||||
|
migrálnak. A `--prune` kapcsolót **ne** használd: az törölné a migrációs fájlokat.
|
||||||
|
|
||||||
|
## A deployment tesztek szerkezete
|
||||||
|
|
||||||
|
`tests/Feature/Deployment/` hat fájlból áll, mert a paratest **fájlonként** osztja szét
|
||||||
|
a munkát — egyetlen nagy fájlból nem tud párhuzamosítani. A felosztás mért időkre van
|
||||||
|
kalibrálva, a leghosszabb fájl ~35 mp.
|
||||||
|
|
||||||
|
Ha új tesztet írsz, a téma szerinti fájlba tedd — de **ha az a fájl 35 mp fölé nőne,
|
||||||
|
bontsd tovább**: a párhuzamos futás faliórája a leghosszabb fájlhoz van kötve.
|
||||||
|
|
||||||
|
A közös segédfüggvények a `DeploymentTestHelpers.php`-ban vannak (nem `*Test.php`, ezért
|
||||||
|
a phpunit nem szedi fel). A temp git repó **processzenként egyszer** épül fel, a tesztek
|
||||||
|
`File::copyDirectory`-val kapnak róla saját másolatot. A takarítás
|
||||||
|
`register_shutdown_function`-ben van, **nem `afterAll()`-ban**: az fájlonként futna le, és
|
||||||
|
a következő teszt-fájl alól húzná ki a mintát.
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
A deploy **kézi fájlmásolással** történik, nem gittel. Emiatt egy „benne van-e már élesben
|
||||||
|
ez a javítás" kérdés **nem válaszolható meg a git állapotából** — a deployolt fájl
|
||||||
|
tartalmát kell megnézni.
|
||||||
|
|
||||||
|
Queue-alapú funkcióknál a fájlmásolás önmagában nem elég: a `queue:work` hosszan futó
|
||||||
|
folyamat, a memóriájában tartja a régi kódot. A sorrend:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan config:clear && php artisan cache:clear
|
||||||
|
```
|
||||||
|
|
||||||
|
majd a workert futtató service force-restartja (d2d-n `d2d-environment_queue`). A restart
|
||||||
|
a cache ürítés **után** jön, különben a frissen induló worker a régi konfigot töltené be.
|
||||||
609
app/Filament/Pages/DeploymentPackage.php
Normal file
609
app/Filament/Pages/DeploymentPackage.php
Normal file
@ -0,0 +1,609 @@
|
|||||||
|
<?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\Cache;
|
||||||
|
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);
|
||||||
|
$this->fetchAllLiveVersions();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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, bool $force = false): void
|
||||||
|
{
|
||||||
|
app(DeploymentGuard::class)->ensureAllowed(auth()->user());
|
||||||
|
|
||||||
|
$target = app(DeploymentTargets::class)->find($targetKey);
|
||||||
|
|
||||||
|
if (! $target || ! $target['domain']) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cacheKey = 'deployment.live-version.'.$targetKey;
|
||||||
|
|
||||||
|
if ($force) {
|
||||||
|
Cache::forget($cacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rövid cache: az oldal minden Livewire körben újrarenderel, kérésenkénti
|
||||||
|
// HTTP hívás nélkül. A hibás választ is cache-eljük, hogy egy nem válaszoló
|
||||||
|
// környezet ne lassítsa minden kattintásnál a felületet.
|
||||||
|
$this->liveVersions[$targetKey] = Cache::remember($cacheKey, now()->addMinute(), function () use ($target): array {
|
||||||
|
try {
|
||||||
|
$response = Http::connectTimeout(2)->timeout(3)->get('https://'.$target['domain'].'/deploy-version.json');
|
||||||
|
|
||||||
|
return $response->successful()
|
||||||
|
? ['ok' => true, 'data' => $response->json()]
|
||||||
|
: ['ok' => false, 'error' => 'HTTP '.$response->status()];
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
return ['ok' => false, 'error' => $exception->getMessage()];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minden környezet állapotának lekérése (oldalbetöltéskor fut).
|
||||||
|
*/
|
||||||
|
public function fetchAllLiveVersions(bool $force = false): void
|
||||||
|
{
|
||||||
|
foreach (array_keys(app(DeploymentTargets::class)->all()) as $targetKey) {
|
||||||
|
$this->fetchLiveVersion($targetKey, $force);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Melyik commiton áll melyik környezet - a commitlista megjelöléséhez.
|
||||||
|
*
|
||||||
|
* Elsődlegesen a szerverről lekérdezett élő állapotot használjuk; ha az nem
|
||||||
|
* elérhető, a naplóban kirakottként megjelölt csomagot.
|
||||||
|
*
|
||||||
|
* @return array<string, array<int, array{key:string, label:string, live:bool}>>
|
||||||
|
*/
|
||||||
|
#[Computed]
|
||||||
|
public function commitMarkers(): array
|
||||||
|
{
|
||||||
|
$markers = [];
|
||||||
|
|
||||||
|
foreach ($this->environmentStatuses() as $status) {
|
||||||
|
$liveCommit = ($status['live']['ok'] ?? false) ? ($status['live']['data']['commit'] ?? null) : null;
|
||||||
|
$commit = $liveCommit ?: $status['package']?->to_commit;
|
||||||
|
|
||||||
|
if (! $commit) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$markers[$commit][] = [
|
||||||
|
'key' => $status['target']['key'],
|
||||||
|
'label' => $status['target']['label'],
|
||||||
|
'live' => (bool) $liveCommit,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $markers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -162,6 +162,30 @@ public function form(Schema $schema): Schema
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Egy payload mező szövegként, a nézet eltörése nélkül.
|
||||||
|
*
|
||||||
|
* A régebbi rekordokban a rich text cellák ÜRES TÖMBKÉNT landoltak a payloadban
|
||||||
|
* (a `json_encode` a RichText objektumot `{}`-ra serializálta). A TextColumn
|
||||||
|
* `description()`-je viszont csak stringet vagy Htmlable-t fogad el, tömbre
|
||||||
|
* TypeError-ral elszáll, és ilyenkor a teljes sorok-blokk nem jelenik meg.
|
||||||
|
* A beolvasás azóta normalizál (PricelistFileProcessService::normalizeCellValue),
|
||||||
|
* de a MÁR ELMENTETT sorok csak így maradnak megjeleníthetők.
|
||||||
|
*/
|
||||||
|
protected static function payloadText($record, int $headerIndex): string
|
||||||
|
{
|
||||||
|
$value = $record->payload[PriceListService::EXPECTED_HEADERS[$headerIndex]] ?? null;
|
||||||
|
|
||||||
|
if (is_array($value)) {
|
||||||
|
$value = implode(' ', array_filter(
|
||||||
|
\Illuminate\Support\Arr::flatten($value),
|
||||||
|
fn ($item) => is_scalar($item),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_scalar($value) ? trim((string) $value) : '';
|
||||||
|
}
|
||||||
|
|
||||||
public function table(Table $table): Table
|
public function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
@ -182,8 +206,8 @@ public function table(Table $table): Table
|
|||||||
|
|
||||||
TextColumn::make('product_info')
|
TextColumn::make('product_info')
|
||||||
->label('Termék')
|
->label('Termék')
|
||||||
->description(fn ($record) => $record->payload[PriceListService::EXPECTED_HEADERS[4]] ?? '')
|
->description(fn ($record) => self::payloadText($record, 4))
|
||||||
->getStateUsing(fn ($record) => $record->payload[PriceListService::EXPECTED_HEADERS[0]] ?? 'N/A')
|
->getStateUsing(fn ($record) => self::payloadText($record, 0) ?: 'N/A')
|
||||||
->color(fn ($record) => !empty($record->diff) ? 'info' : null)
|
->color(fn ($record) => !empty($record->diff) ? 'info' : null)
|
||||||
->wrap()
|
->wrap()
|
||||||
->searchable(query: function ($query, string $search) {
|
->searchable(query: function ($query, string $search) {
|
||||||
|
|||||||
48
app/Models/DeploymentPackage.php
Normal file
48
app/Models/DeploymentPackage.php
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Egy elkészített deployment csomag naplóbejegyzése.
|
||||||
|
*
|
||||||
|
* Nem csak audit: a legutóbb kirakott csomag `to_commit`-je adja a következő csomag
|
||||||
|
* alapértelmezett kezdő commitját, környezetenként külön - ez az, ami a "mi maradt ki
|
||||||
|
* a másolásból" hibát megelőzi.
|
||||||
|
*/
|
||||||
|
class DeploymentPackage extends BaseAuditable
|
||||||
|
{
|
||||||
|
use SoftDeletes;
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'deployed_at' => 'datetime',
|
||||||
|
'copied_count' => 'integer',
|
||||||
|
'deleted_count' => 'integer',
|
||||||
|
'skipped_count' => 'integer',
|
||||||
|
'missing_count' => 'integer',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function scopeForTarget(Builder $query, string $target): Builder
|
||||||
|
{
|
||||||
|
return $query->where('target', $target);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Az adott környezetre utoljára kirakottként megjelölt csomag.
|
||||||
|
*/
|
||||||
|
public static function lastDeployedFor(string $target): ?self
|
||||||
|
{
|
||||||
|
return static::query()
|
||||||
|
->forTarget($target)
|
||||||
|
->whereNotNull('deployed_at')
|
||||||
|
->latest('deployed_at')
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isDeployed(): bool
|
||||||
|
{
|
||||||
|
return $this->deployed_at !== null;
|
||||||
|
}
|
||||||
|
}
|
||||||
205
app/Services/Deployment/ChangeSetAnalyzer.php
Normal file
205
app/Services/Deployment/ChangeSetAnalyzer.php
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Deployment;
|
||||||
|
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A git diff nyers kimenetéből állítja elő a csomagolható halmazt és a figyelmeztetéseket.
|
||||||
|
*
|
||||||
|
* Szándékosan nem hív gitet és nem ír fájlt: tisztán a listát és a szabályokat kezeli,
|
||||||
|
* így a fázis 2 csomagolója ugyanezt az eredményt tudja majd felhasználni.
|
||||||
|
*/
|
||||||
|
class ChangeSetAnalyzer
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<int, array{status:string, path:string, old_path:?string}> $changedFiles
|
||||||
|
* @param array{key:string, label:string, domain:string, remote_path:string, expected_branch:?string}|null $target
|
||||||
|
* @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}>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function analyze(array $changedFiles, ?array $target = null, ?string $branch = null): array
|
||||||
|
{
|
||||||
|
/** @var array<int, string> $excludePatterns */
|
||||||
|
$excludePatterns = (array) config('deployment.exclude', []);
|
||||||
|
|
||||||
|
$files = [];
|
||||||
|
$copied = [];
|
||||||
|
$deleted = [];
|
||||||
|
$excludedCount = 0;
|
||||||
|
|
||||||
|
foreach ($changedFiles as $file) {
|
||||||
|
$excluded = Str::is($excludePatterns, $file['path']);
|
||||||
|
|
||||||
|
// Törlés esetén nincs mit másolni: a fájl a célgépen létezik, itt már nem.
|
||||||
|
// Átnevezésnél mindkettő kell - az új útvonal másolandó, a régi törlendő.
|
||||||
|
$action = $excluded
|
||||||
|
? 'skip'
|
||||||
|
: ($file['status'] === 'D' ? 'delete' : 'copy');
|
||||||
|
|
||||||
|
if ($excluded) {
|
||||||
|
$excludedCount++;
|
||||||
|
} elseif ($action === 'delete') {
|
||||||
|
$deleted[] = $file['path'];
|
||||||
|
} else {
|
||||||
|
$copied[] = $file['path'];
|
||||||
|
|
||||||
|
if ($file['old_path'] !== null && ! Str::is($excludePatterns, $file['old_path'])) {
|
||||||
|
$deleted[] = $file['old_path'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$files[] = array_merge($file, ['excluded' => $excluded, 'action' => $action]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'files' => $files,
|
||||||
|
'copied' => $copied,
|
||||||
|
'deleted' => $deleted,
|
||||||
|
'counts' => [
|
||||||
|
'total' => count($files),
|
||||||
|
'copied' => count($copied),
|
||||||
|
'deleted' => count($deleted),
|
||||||
|
'excluded' => $excludedCount,
|
||||||
|
],
|
||||||
|
'warnings' => $this->warnings($copied, $deleted, $excludedCount, $target, $branch),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $copied
|
||||||
|
* @param array<int, string> $deleted
|
||||||
|
* @param array{key:string, label:string, domain:string, remote_path:string, expected_branch:?string}|null $target
|
||||||
|
* @return array<int, array{level:string, title:string, body:string}>
|
||||||
|
*/
|
||||||
|
private function warnings(array $copied, array $deleted, int $excludedCount, ?array $target, ?string $branch): array
|
||||||
|
{
|
||||||
|
$warnings = [];
|
||||||
|
$maxFiles = (int) config('deployment.max_files', 500);
|
||||||
|
|
||||||
|
if ($copied === [] && $deleted === []) {
|
||||||
|
$warnings[] = [
|
||||||
|
'level' => 'info',
|
||||||
|
'title' => 'Nincs csomagolható változás',
|
||||||
|
'body' => 'A két commit között nincs olyan fájl, ami a célgépre kerülne.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($copied) > $maxFiles) {
|
||||||
|
$warnings[] = [
|
||||||
|
'level' => 'danger',
|
||||||
|
'title' => sprintf('Túl sok fájl (%d db, limit %d)', count($copied), $maxFiles),
|
||||||
|
'body' => 'Ekkora tartomány kézi másolással már nehezen ellenőrizhető. Szűkítsd a commit-tartományt, vagy bontsd több csomagra.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$migrations = $this->matching($copied, 'migration');
|
||||||
|
|
||||||
|
if ($migrations !== []) {
|
||||||
|
$warnings[] = [
|
||||||
|
'level' => 'warning',
|
||||||
|
'title' => sprintf('Migráció a csomagban (%d db)', count($migrations)),
|
||||||
|
'body' => 'A fájlok felmásolása után a célgépen le kell futtatni: php artisan migrate',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$seeders = $this->matching($copied, 'seeder');
|
||||||
|
|
||||||
|
if ($seeders !== []) {
|
||||||
|
$warnings[] = [
|
||||||
|
'level' => 'warning',
|
||||||
|
'title' => sprintf('Seeder a csomagban (%d db)', count($seeders)),
|
||||||
|
'body' => 'A seeder felmásolása önmagában nem csinál semmit - el kell dönteni, hogy le kell-e futtatni a célgépen. '
|
||||||
|
.'Ha feature flageket ír, a lefutása után azok azonnal hatnak.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->matching($copied, 'composer') !== []) {
|
||||||
|
$warnings[] = [
|
||||||
|
'level' => 'warning',
|
||||||
|
'title' => 'Függőség változott (composer)',
|
||||||
|
'body' => 'A vendor/ nincs verziókövetve, ezért nem kerül a csomagba - a célgépen composer install szükséges.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->matching($copied, 'asset') !== []) {
|
||||||
|
$warnings[] = [
|
||||||
|
'level' => 'warning',
|
||||||
|
'title' => 'Frontend forrás változott',
|
||||||
|
'body' => 'A fordított assetek (public/build, public/js, public/css, mix-manifest.json) a .gitignore miatt NEM kerülnek a csomagba. Futtass npm run build-ot, és a fázis 4-től külön csatolhatók lesznek.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->matching($copied, 'runtime') !== []) {
|
||||||
|
$jobs = count($this->matching($copied, 'job'));
|
||||||
|
$consoles = count($this->matching($copied, 'console'));
|
||||||
|
|
||||||
|
$detail = [];
|
||||||
|
|
||||||
|
if ($jobs > 0) {
|
||||||
|
$detail[] = $jobs.' job';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($consoles > 0) {
|
||||||
|
$detail[] = $consoles.' konzol parancs';
|
||||||
|
}
|
||||||
|
|
||||||
|
$warnings[] = [
|
||||||
|
'level' => 'warning',
|
||||||
|
'title' => 'Hosszan futó konténerek érintve'.($detail === [] ? '' : ' ('.implode(', ', $detail).')'),
|
||||||
|
'body' => 'A queue worker és a scheduler külön konténerben fut, és a betöltött osztályokat a memóriájában '
|
||||||
|
.'tartja - a fájlok felmásolása után is a RÉGI kóddal dolgozik, amíg újra nem indul. Ez némán rossz '
|
||||||
|
.'viselkedést okoz (a job "elkészül", de nem azt csinálja, amit az új kód). A konkrét restart parancsok '
|
||||||
|
.'a csomag _TEENDOK.md fájljában lesznek.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($deleted !== []) {
|
||||||
|
$warnings[] = [
|
||||||
|
'level' => 'warning',
|
||||||
|
'title' => sprintf('Törlendő fájl a célgépen (%d db)', count($deleted)),
|
||||||
|
'body' => 'Ezeket a másolás nem intézi el, kézzel kell törölni - a csomag _TORLENDO.txt fájlja fogja tartalmazni őket.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($excludedCount > 0) {
|
||||||
|
$warnings[] = [
|
||||||
|
'level' => 'info',
|
||||||
|
'title' => sprintf('Kihagyott fájl (%d db)', $excludedCount),
|
||||||
|
'body' => 'A kizárási lista (.env, storage/, vendor/, node_modules/, .git*) alapján ezek soha nem kerülnek csomagba.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($target && $target['expected_branch'] && $branch && $branch !== $target['expected_branch']) {
|
||||||
|
$warnings[] = [
|
||||||
|
'level' => 'warning',
|
||||||
|
'title' => 'Nem a szokásos branch',
|
||||||
|
'body' => sprintf(
|
||||||
|
'A(z) "%s" környezetre általában a %s branchről megy csomag, most viszont a %s van kiválasztva.',
|
||||||
|
$target['label'],
|
||||||
|
$target['expected_branch'],
|
||||||
|
$branch,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $warnings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $paths
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function matching(array $paths, string $group): array
|
||||||
|
{
|
||||||
|
/** @var array<int, string> $patterns */
|
||||||
|
$patterns = (array) config('deployment.attention.'.$group, []);
|
||||||
|
|
||||||
|
return array_values(array_filter($paths, fn (string $path): bool => Str::is($patterns, $path)));
|
||||||
|
}
|
||||||
|
}
|
||||||
92
app/Services/Deployment/DeploymentGuard.php
Normal file
92
app/Services/Deployment/DeploymentGuard.php
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Deployment;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Laravel\Pennant\Feature;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A deployment csomagoló hozzáférés-ellenőrzése egy helyen.
|
||||||
|
*
|
||||||
|
* Az alkalmazás kódja manuális fájlmásolással kerül a szerverekre, tehát ez az oldal
|
||||||
|
* fizikailag ott lesz az éles környezetben is. Ezért négy, egymástól független feltétel
|
||||||
|
* van, és mindet MINDEN belépési ponton ellenőrizni kell (oldal mount, akciók, később a
|
||||||
|
* csomagoló service) - ha csak a felületet védjük, az látszatvédelem.
|
||||||
|
*
|
||||||
|
* A négyből a tényleges védelmet az .env kapcsoló és a stage whitelist adja, a
|
||||||
|
* jogosultságot a szerepkör. A feature flag NEM biztonsági réteg: a menü-láthatóságot
|
||||||
|
* vezérli, és lehetővé teszi a deploy nélküli kikapcsolást, illetve a név szerinti
|
||||||
|
* átadást (feature_flag_overrides) developer szerep osztogatása nélkül.
|
||||||
|
*/
|
||||||
|
class DeploymentGuard
|
||||||
|
{
|
||||||
|
public function isAllowed(?User $user = null): bool
|
||||||
|
{
|
||||||
|
return $this->denialReason($user) === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Csak a környezeti feltételek (1-2. réteg), felhasználó nélkül.
|
||||||
|
*
|
||||||
|
* Ezt hívja a csomagoló service is: így akkor sem fut le, ha valaki a felületet
|
||||||
|
* megkerülve (konzolról, jobból) indítaná el egy szerveren.
|
||||||
|
*/
|
||||||
|
public function environmentDenialReason(): ?string
|
||||||
|
{
|
||||||
|
if (! config('deployment.enabled')) {
|
||||||
|
return 'A deployment csomagoló ki van kapcsolva (DEPLOYMENT_PACKAGE_ENABLED).';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var array<int, string> $stages */
|
||||||
|
$stages = (array) config('deployment.stages', []);
|
||||||
|
|
||||||
|
if (! in_array((string) config('app.stage'), $stages, true)) {
|
||||||
|
return sprintf(
|
||||||
|
'A deployment csomagoló csak fejlesztői környezetben használható (engedélyezett stage: %s, jelenlegi: %s).',
|
||||||
|
implode(', ', $stages),
|
||||||
|
(string) config('app.stage'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Az első nem teljesülő feltétel magyarázata, vagy null, ha minden rendben.
|
||||||
|
*/
|
||||||
|
public function denialReason(?User $user = null): ?string
|
||||||
|
{
|
||||||
|
if ($reason = $this->environmentDenialReason()) {
|
||||||
|
return $reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user) {
|
||||||
|
return 'Bejelentkezés szükséges.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->hasRole((string) config('deployment.role'))) {
|
||||||
|
return 'A deployment csomagoló csak fejlesztői szerepkörrel érhető el.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! Feature::for($user)->active((string) config('deployment.feature_flag'))) {
|
||||||
|
return 'A deployment csomagoló feature flag nincs bekapcsolva erre a felhasználóra.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ensureAllowed(?User $user = null): void
|
||||||
|
{
|
||||||
|
if ($reason = $this->denialReason($user)) {
|
||||||
|
abort(403, $reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ensureEnvironmentAllowed(): void
|
||||||
|
{
|
||||||
|
if ($reason = $this->environmentDenialReason()) {
|
||||||
|
throw new RuntimeException($reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
510
app/Services/Deployment/DeploymentPackageBuilder.php
Normal file
510
app/Services/Deployment/DeploymentPackageBuilder.php
Normal file
@ -0,0 +1,510 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Deployment;
|
||||||
|
|
||||||
|
use Illuminate\Filesystem\Filesystem;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use RuntimeException;
|
||||||
|
use ZipArchive;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A kijelölt commit-tartományból állítja elő a feltölthető csomagot.
|
||||||
|
*
|
||||||
|
* A kimenet egy mappa, amiben a fájlok EREDETI relatív útvonalon ülnek - így a
|
||||||
|
* feltöltés a célgépen sima mappa-összeolvasztás. A tartalom mindig a záró commitból
|
||||||
|
* jön (ld. GitRepository::archiveTo), nem a working tree-ből.
|
||||||
|
*
|
||||||
|
* A törölt fájlokat a másolás nem tudja elintézni, ezért azok külön listába kerülnek
|
||||||
|
* (_TORLENDO.txt) - ez szándékosan kézi lépés marad.
|
||||||
|
*/
|
||||||
|
class DeploymentPackageBuilder
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly GitRepository $repository,
|
||||||
|
private readonly ChangeSetAnalyzer $analyzer,
|
||||||
|
private readonly DeploymentTargets $targets,
|
||||||
|
private readonly DeploymentGuard $guard,
|
||||||
|
private readonly Filesystem $files,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $skippedPaths a felületen kézzel kivett fájlok
|
||||||
|
* @return array{
|
||||||
|
* name:string, path:string, zip:?string, target:string, branch:string,
|
||||||
|
* from:string, to:string, created_at:string, created_by:string,
|
||||||
|
* counts:array{copied:int, deleted:int, excluded:int, skipped:int, missing:int},
|
||||||
|
* files:array<int, array{path:string, status:string, size:int, sha1:string}>,
|
||||||
|
* deleted:array<int, string>, skipped:array<int, string>, missing:array<int, string>,
|
||||||
|
* warnings:array<int, array{level:string, title:string, body:string}>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function build(
|
||||||
|
string $targetKey,
|
||||||
|
string $branch,
|
||||||
|
string $fromCommit,
|
||||||
|
string $toCommit,
|
||||||
|
array $skippedPaths = [],
|
||||||
|
?string $createdBy = null,
|
||||||
|
): array {
|
||||||
|
$this->guard->ensureEnvironmentAllowed();
|
||||||
|
|
||||||
|
$target = $this->targets->find($targetKey);
|
||||||
|
|
||||||
|
if (! $target) {
|
||||||
|
throw new RuntimeException('Ismeretlen célkörnyezet.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$from = $this->repository->resolveCommit($fromCommit);
|
||||||
|
$to = $this->repository->resolveCommit($toCommit);
|
||||||
|
|
||||||
|
if (! $from || ! $to) {
|
||||||
|
throw new RuntimeException('Ismeretlen commit azonosító.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$changeSet = $this->analyzer->analyze(
|
||||||
|
$this->repository->changedFiles($from, $to),
|
||||||
|
$target,
|
||||||
|
$branch,
|
||||||
|
);
|
||||||
|
|
||||||
|
$statuses = [];
|
||||||
|
|
||||||
|
foreach ($changeSet['files'] as $file) {
|
||||||
|
$statuses[$file['path']] = $file['status'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$skipped = array_values(array_intersect($changeSet['copied'], $skippedPaths));
|
||||||
|
$copy = array_values(array_diff($changeSet['copied'], $skippedPaths));
|
||||||
|
$maxFiles = (int) config('deployment.max_files', 500);
|
||||||
|
|
||||||
|
if (count($copy) > $maxFiles) {
|
||||||
|
throw new RuntimeException(sprintf(
|
||||||
|
'A csomag %d fájlt tartalmazna, a limit %d. Szűkítsd a commit-tartományt.',
|
||||||
|
count($copy),
|
||||||
|
$maxFiles,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($copy === [] && $changeSet['deleted'] === []) {
|
||||||
|
throw new RuntimeException('Nincs mit csomagolni: a tartományban nincs a célgépre kerülő változás.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$createdAt = Carbon::now();
|
||||||
|
$path = $this->createDirectory($targetKey, $createdAt);
|
||||||
|
$written = $this->repository->archiveTo($to, $copy, $path);
|
||||||
|
|
||||||
|
// Ha a git archive kihagyott valamit (pl. .gitattributes export-ignore), az itt
|
||||||
|
// derül ki - csendben hiányzó fájl a manuális deploynál pont a legrosszabb hiba.
|
||||||
|
$missing = array_values(array_diff($copy, $written));
|
||||||
|
|
||||||
|
$manifest = [
|
||||||
|
'name' => basename($path),
|
||||||
|
'path' => $path,
|
||||||
|
'target' => $targetKey,
|
||||||
|
'branch' => $branch,
|
||||||
|
'from' => $from,
|
||||||
|
'to' => $to,
|
||||||
|
'created_at' => $createdAt->toIso8601String(),
|
||||||
|
'created_by' => $createdBy ?? 'ismeretlen',
|
||||||
|
'counts' => [
|
||||||
|
'copied' => count($written),
|
||||||
|
'deleted' => count($changeSet['deleted']),
|
||||||
|
'excluded' => $changeSet['counts']['excluded'],
|
||||||
|
'skipped' => count($skipped),
|
||||||
|
'missing' => count($missing),
|
||||||
|
],
|
||||||
|
'files' => $this->describeFiles($path, $written, $statuses),
|
||||||
|
'deleted' => $changeSet['deleted'],
|
||||||
|
'skipped' => $skipped,
|
||||||
|
'missing' => $missing,
|
||||||
|
'warnings' => $changeSet['warnings'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->writeVersionMarker($path, $manifest, $target);
|
||||||
|
$this->writeManifest($path, $manifest, $target);
|
||||||
|
$this->writeDeleteList($path, $manifest, $target);
|
||||||
|
$this->writeDeleteScript($path, $manifest, $target);
|
||||||
|
$this->writeInstructions($path, $manifest, $target);
|
||||||
|
|
||||||
|
// A ZIP készül utoljára, hogy a kísérő fájlokat is tartalmazza. A fájllistát
|
||||||
|
// előbb gyűjtjük össze, mint ahogy a zip létrejön, így nem tudja magát becsomagolni.
|
||||||
|
$manifest['zip'] = $this->createZip($path, $manifest['name']);
|
||||||
|
|
||||||
|
return $manifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $written
|
||||||
|
* @param array<string, string> $statuses
|
||||||
|
* @return array<int, array{path:string, status:string, size:int, sha1:string}>
|
||||||
|
*/
|
||||||
|
private function describeFiles(string $path, array $written, array $statuses): array
|
||||||
|
{
|
||||||
|
sort($written);
|
||||||
|
|
||||||
|
return array_map(function (string $relative) use ($path, $statuses): array {
|
||||||
|
$absolute = $path.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $relative);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'path' => $relative,
|
||||||
|
'status' => $statuses[$relative] ?? '?',
|
||||||
|
'size' => $this->files->exists($absolute) ? $this->files->size($absolute) : 0,
|
||||||
|
'sha1' => $this->files->exists($absolute) ? (string) sha1_file($absolute) : '',
|
||||||
|
];
|
||||||
|
}, $written);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createDirectory(string $targetKey, Carbon $createdAt): string
|
||||||
|
{
|
||||||
|
$base = rtrim((string) config('deployment.output_path'), '/\\');
|
||||||
|
$name = sprintf('deploy_%s_%s', $targetKey, $createdAt->format('Ymd_His'));
|
||||||
|
$path = $base.DIRECTORY_SEPARATOR.$name;
|
||||||
|
|
||||||
|
// Ugyanabban a másodpercben indított második csomag ne írja felül az elsőt.
|
||||||
|
$suffix = 2;
|
||||||
|
|
||||||
|
while ($this->files->exists($path)) {
|
||||||
|
$path = $base.DIRECTORY_SEPARATOR.$name.'-'.$suffix++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->files->makeDirectory($path, 0775, true);
|
||||||
|
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A kirakott verzió azonosítója - ettől kezdve a "melyik commit fut a szerveren?"
|
||||||
|
* kérdés egy HTTP kéréssel megválaszolható, nem kézi fájldiffel.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $manifest
|
||||||
|
* @param array<string, mixed> $target
|
||||||
|
*/
|
||||||
|
private function writeVersionMarker(string $path, array $manifest, array $target): void
|
||||||
|
{
|
||||||
|
$this->put($path.'/public/deploy-version.json', json_encode([
|
||||||
|
'target' => $target['key'],
|
||||||
|
'commit' => $manifest['to'],
|
||||||
|
'short' => substr((string) $manifest['to'], 0, 7),
|
||||||
|
'branch' => $manifest['branch'],
|
||||||
|
'packaged_at' => $manifest['created_at'],
|
||||||
|
'by' => $manifest['created_by'],
|
||||||
|
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)."\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $manifest
|
||||||
|
* @param array<string, mixed> $target
|
||||||
|
*/
|
||||||
|
private function writeManifest(string $path, array $manifest, array $target): void
|
||||||
|
{
|
||||||
|
$this->put(
|
||||||
|
$path.'/_MANIFEST.json',
|
||||||
|
json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)."\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
$lines = [
|
||||||
|
'DEPLOYMENT CSOMAG',
|
||||||
|
str_repeat('=', 60),
|
||||||
|
'Csomag: '.$manifest['name'],
|
||||||
|
'Célkörnyezet: '.$target['label'],
|
||||||
|
'Cél útvonal: '.$target['remote_path'],
|
||||||
|
'Branch: '.$manifest['branch'],
|
||||||
|
'Tartomány: '.substr((string) $manifest['from'], 0, 7).' (kizárva) -> '.substr((string) $manifest['to'], 0, 7).' (beleértve)',
|
||||||
|
'Készítette: '.$manifest['created_by'],
|
||||||
|
'Készült: '.$manifest['created_at'],
|
||||||
|
'',
|
||||||
|
sprintf('MÁSOLANDÓ FÁJLOK (%d)', $manifest['counts']['copied']),
|
||||||
|
str_repeat('-', 60),
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($manifest['files'] as $file) {
|
||||||
|
// mb_str_pad, mert a sprintf %-70s bájtban számol - egy ékezetes fájlnév
|
||||||
|
// elcsúsztatná az oszlopot.
|
||||||
|
$lines[] = sprintf(
|
||||||
|
'%-2s %s %8d %s',
|
||||||
|
$file['status'],
|
||||||
|
mb_str_pad($file['path'], 70),
|
||||||
|
$file['size'],
|
||||||
|
$file['sha1'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($manifest['deleted'] !== []) {
|
||||||
|
$lines[] = '';
|
||||||
|
$lines[] = sprintf('TÖRLENDŐ A CÉLGÉPEN (%d) - ld. _TORLENDO.txt', $manifest['counts']['deleted']);
|
||||||
|
$lines[] = str_repeat('-', 60);
|
||||||
|
|
||||||
|
foreach ($manifest['deleted'] as $deleted) {
|
||||||
|
$lines[] = ' '.$deleted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($manifest['skipped'] !== []) {
|
||||||
|
$lines[] = '';
|
||||||
|
$lines[] = sprintf('KÉZZEL KIVÉVE A CSOMAGBÓL (%d)', $manifest['counts']['skipped']);
|
||||||
|
$lines[] = str_repeat('-', 60);
|
||||||
|
|
||||||
|
foreach ($manifest['skipped'] as $skipped) {
|
||||||
|
$lines[] = ' '.$skipped;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($manifest['missing'] !== []) {
|
||||||
|
$lines[] = '';
|
||||||
|
$lines[] = sprintf('FIGYELEM - NEM KERÜLT A CSOMAGBA (%d)', $manifest['counts']['missing']);
|
||||||
|
$lines[] = str_repeat('-', 60);
|
||||||
|
|
||||||
|
foreach ($manifest['missing'] as $missing) {
|
||||||
|
$lines[] = ' '.$missing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->put($path.'/_MANIFEST.txt', implode("\n", $lines)."\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $manifest
|
||||||
|
* @param array<string, mixed> $target
|
||||||
|
*/
|
||||||
|
private function writeDeleteList(string $path, array $manifest, array $target): void
|
||||||
|
{
|
||||||
|
$lines = [
|
||||||
|
'# '.$manifest['name'].' - a célgépen TÖRLENDŐ fájlok',
|
||||||
|
'# Cél: '.$target['label'].' - '.$target['remote_path'],
|
||||||
|
'# A másolás ezeket nem intézi el, a törlés kézi lépés.',
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($manifest['deleted'] as $deleted) {
|
||||||
|
$lines[] = $deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($manifest['deleted'] === []) {
|
||||||
|
$lines[] = '# (nincs törlendő fájl ebben a csomagban)';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->put($path.'/_TORLENDO.txt', implode("\n", $lines)."\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $manifest
|
||||||
|
* @param array<string, mixed> $target
|
||||||
|
*/
|
||||||
|
private function writeInstructions(string $path, array $manifest, array $target): void
|
||||||
|
{
|
||||||
|
$steps = [
|
||||||
|
'A csomag mappájának **tartalmát** másold fel ide: `'.$target['remote_path'].'`'
|
||||||
|
.' — mappa-összeolvasztás, NEM a teljes app mappa cseréje. A `_` kezdetű fájlokat ne másold fel.',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @var array<int, string> $restartSteps */
|
||||||
|
$restartSteps = [];
|
||||||
|
|
||||||
|
if ($manifest['deleted'] !== []) {
|
||||||
|
$steps[] = sprintf(
|
||||||
|
'Töröld a `_TORLENDO.txt`-ben felsorolt %d fájlt a célgépen. Nagyobb mennyiségnél a mellékelt '
|
||||||
|
.'szkript is használható **átnézés után**: `bash _torles.sh` kilistázza, `bash _torles.sh --confirm` törli.',
|
||||||
|
$manifest['counts']['deleted'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($manifest['warnings'] as $warning) {
|
||||||
|
if (str_contains($warning['title'], 'Migráció')) {
|
||||||
|
$steps[] = 'Futtasd a célgépen: `php artisan migrate`';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($warning['title'], 'Seeder')) {
|
||||||
|
$seeders = $this->seederClasses($manifest);
|
||||||
|
|
||||||
|
$steps[] = sprintf(
|
||||||
|
'Seeder változott (%s). A felmásolás önmagában NEM futtatja le - ha idempotens '
|
||||||
|
.'(`updateOrCreate`/`firstOrCreate`), futtasd: %s',
|
||||||
|
implode(', ', $seeders),
|
||||||
|
implode(' ', array_map(fn (string $class): string => '`php artisan db:seed --class='.$class.'`', $seeders)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// A projekt FeatureFlagObserver-e Eloquent mentéskor magától purge-öl, ezért
|
||||||
|
// ezt nem kötelező lépésként, hanem feltételes ellenőrzésként írjuk ki.
|
||||||
|
$steps[] = 'Ha a seeder feature flageket írt **Eloquent modellen keresztül**, a Pennant cache-t a '
|
||||||
|
.'`FeatureFlagObserver` magától üríti. Query builderes írás (`->update()`) esetén viszont kell: '
|
||||||
|
.'`php artisan pennant:purge`. Ha szerepkör/jogosultság **hozzárendelés** változott, a laratrust '
|
||||||
|
.'cache-hez: `php artisan cache:clear`.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($warning['title'], 'composer')) {
|
||||||
|
$steps[] = 'Futtasd a célgépen: `composer install --no-dev` (a vendor/ nincs a csomagban)';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($warning['title'], 'Frontend')) {
|
||||||
|
$steps[] = 'A fordított assetek NINCSENEK a csomagban (`.gitignore`) — `npm run build` után kézzel másold fel a `public/build` tartalmát.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// A restart lépéseket külön gyűjtjük: azoknak a cache ürítés UTÁN kell jönniük,
|
||||||
|
// különben a frissen induló worker még a régi, cache-elt konfigot tölti be.
|
||||||
|
if (str_contains($warning['title'], 'Hosszan futó')) {
|
||||||
|
$commands = $this->targets->restartCommands($target['key']);
|
||||||
|
|
||||||
|
if ($commands !== []) {
|
||||||
|
$restartSteps[] = 'Utolsó lépésként indítsd újra a hosszan futó konténereket. A queue worker és a '
|
||||||
|
.'scheduler **a memóriájában tartja a betöltött kódot**, ezért a fájlmásolás önmagában NEM elég — '
|
||||||
|
."enélkül a háttérfeladatok némán a régi viselkedést hozzák:\n\n"
|
||||||
|
." ```bash\n ".implode("\n ", $commands)."\n ```";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($target['services_note']) {
|
||||||
|
$restartSteps[] = $target['services_note'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$steps[] = 'Ürítsd a cache-t: `php artisan config:clear && php artisan view:clear && php artisan route:clear`';
|
||||||
|
|
||||||
|
foreach ($restartSteps as $restartStep) {
|
||||||
|
$steps[] = $restartStep;
|
||||||
|
}
|
||||||
|
|
||||||
|
$steps[] = 'Ellenőrizd a kirakott verziót: `curl https://'.$target['domain'].'/deploy-version.json`'
|
||||||
|
.' — a commitnak `'.substr((string) $manifest['to'], 0, 7).'` kell lennie.';
|
||||||
|
|
||||||
|
$lines = [
|
||||||
|
'# Teendők — '.$manifest['name'],
|
||||||
|
'',
|
||||||
|
'| | |',
|
||||||
|
'|---|---|',
|
||||||
|
'| Célkörnyezet | '.$target['label'].' |',
|
||||||
|
'| Cél útvonal | `'.$target['remote_path'].'` |',
|
||||||
|
'| Tartomány | `'.substr((string) $manifest['from'], 0, 7).'` → `'.substr((string) $manifest['to'], 0, 7).'` |',
|
||||||
|
'| Fájlok | '.$manifest['counts']['copied'].' másolandó, '.$manifest['counts']['deleted'].' törlendő |',
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($steps as $index => $step) {
|
||||||
|
$lines[] = ($index + 1).'. '.$step;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($manifest['missing'] !== []) {
|
||||||
|
$lines[] = '';
|
||||||
|
$lines[] = '> **Figyelem:** '.$manifest['counts']['missing'].' fájl nem került a csomagba, '
|
||||||
|
.'a listájuk a `_MANIFEST.txt` végén van. Ezeket ellenőrizd, mielőtt feltöltesz.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->put($path.'/_TEENDOK.md', implode("\n", $lines)."\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Törlő szkript nagyobb mennyiséghez - a kézi törlés (_TORLENDO.txt) marad az alapeset.
|
||||||
|
*
|
||||||
|
* Ezért alapból csak kilistáz, a tényleges törléshez explicit --confirm kell; és mivel
|
||||||
|
* Windowson generáljuk Linux célgépre, LF sorvéggel és BOM nélkül kell kiírni, különben
|
||||||
|
* a shebang sor törik el.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $manifest
|
||||||
|
* @param array<string, mixed> $target
|
||||||
|
*/
|
||||||
|
private function writeDeleteScript(string $path, array $manifest, array $target): void
|
||||||
|
{
|
||||||
|
$lines = [
|
||||||
|
'#!/usr/bin/env bash',
|
||||||
|
'# '.$manifest['name'].' - a célgépen törlendő fájlok ('.$target['label'].')',
|
||||||
|
'# Futtatás az app gyökeréből:',
|
||||||
|
'# bash _torles.sh -> csak kilistázza (dry run)',
|
||||||
|
'# bash _torles.sh --confirm -> ténylegesen töröl',
|
||||||
|
'set -euo pipefail',
|
||||||
|
'',
|
||||||
|
'[[ -f artisan && -d app ]] || { echo "HIBA: nem az app gyökerében futsz"; exit 1; }',
|
||||||
|
'',
|
||||||
|
'CONFIRM=0',
|
||||||
|
'[[ "${1:-}" == "--confirm" ]] && CONFIRM=1',
|
||||||
|
'',
|
||||||
|
'FILES=(',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($manifest['deleted'] as $deleted) {
|
||||||
|
$lines[] = ' "'.$deleted.'"';
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines = array_merge($lines, [
|
||||||
|
')',
|
||||||
|
'',
|
||||||
|
'if (( ${#FILES[@]} == 0 )); then echo "Nincs törlendő fájl."; exit 0; fi',
|
||||||
|
'',
|
||||||
|
'for f in "${FILES[@]}"; do',
|
||||||
|
' case "$f" in /*|*..*) echo "KIHAGYVA (gyanús útvonal): $f"; continue ;; esac',
|
||||||
|
' [[ -f "$f" ]] || { echo "NINCS MEG: $f"; continue; }',
|
||||||
|
' if (( CONFIRM )); then rm -f -- "$f"; echo "TÖRÖLVE: $f"; else echo "TÖRÖLNÉ: $f"; fi',
|
||||||
|
'done',
|
||||||
|
'',
|
||||||
|
'if (( ! CONFIRM )); then echo; echo "Ez csak lista volt. Tényleges törlés: bash _torles.sh --confirm"; fi',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->put($path.'/_torles.sh', implode("\n", $lines)."\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A kész mappa ZIP-be csomagolása letöltéshez és archiváláshoz.
|
||||||
|
*/
|
||||||
|
private function createZip(string $path, string $name): ?string
|
||||||
|
{
|
||||||
|
$entries = [];
|
||||||
|
|
||||||
|
foreach ($this->files->allFiles($path, true) as $file) {
|
||||||
|
$entries[str_replace('\\', '/', $file->getRelativePathname())] = $file->getPathname();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($entries === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$zipPath = $path.DIRECTORY_SEPARATOR.$name.'.zip';
|
||||||
|
$zip = new ZipArchive;
|
||||||
|
|
||||||
|
if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||||
|
throw new RuntimeException('A ZIP fájl nem hozható létre.');
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($entries as $relative => $absolute) {
|
||||||
|
$zip->addFile($absolute, $relative);
|
||||||
|
}
|
||||||
|
|
||||||
|
$zip->close();
|
||||||
|
|
||||||
|
return $zipPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A csomagban lévő seeder fájlok osztálynevei.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $manifest
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function seederClasses(array $manifest): array
|
||||||
|
{
|
||||||
|
/** @var array<int, string> $patterns */
|
||||||
|
$patterns = (array) config('deployment.attention.seeder', []);
|
||||||
|
|
||||||
|
$classes = [];
|
||||||
|
|
||||||
|
foreach ($manifest['files'] as $file) {
|
||||||
|
if (Str::is($patterns, $file['path'])) {
|
||||||
|
$classes[] = pathinfo($file['path'], PATHINFO_FILENAME);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $classes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mindig LF sorvéggel írunk: a csomag Windowson készül, de Linux célgépre megy.
|
||||||
|
*/
|
||||||
|
private function put(string $path, string $contents): void
|
||||||
|
{
|
||||||
|
$directory = dirname($path);
|
||||||
|
|
||||||
|
if (! $this->files->isDirectory($directory)) {
|
||||||
|
$this->files->makeDirectory($directory, 0775, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->files->put($path, str_replace("\r\n", "\n", $contents));
|
||||||
|
}
|
||||||
|
}
|
||||||
88
app/Services/Deployment/DeploymentTargets.php
Normal file
88
app/Services/Deployment/DeploymentTargets.php
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Deployment;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A célkörnyezetek (e2e, d2d) feloldása a config/deployment.php-ból.
|
||||||
|
*
|
||||||
|
* A target nem utólagos címke: már a csomagoláskor rögzül, mert befolyásolja a
|
||||||
|
* mappanevet, a teendők listáját, a kezdő commit előtöltését és a naplót is.
|
||||||
|
*/
|
||||||
|
class DeploymentTargets
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, array{key:string, label:string, domain:string, remote_path:string, expected_branch:?string, services:array<string, string>, services_note:?string}>
|
||||||
|
*/
|
||||||
|
public function all(): array
|
||||||
|
{
|
||||||
|
/** @var array<string, array<string, mixed>> $targets */
|
||||||
|
$targets = (array) config('deployment.targets', []);
|
||||||
|
|
||||||
|
$resolved = [];
|
||||||
|
|
||||||
|
foreach ($targets as $key => $target) {
|
||||||
|
$resolved[$key] = array_merge(
|
||||||
|
[
|
||||||
|
'label' => $key,
|
||||||
|
'domain' => '',
|
||||||
|
'remote_path' => '',
|
||||||
|
'expected_branch' => null,
|
||||||
|
'services' => [],
|
||||||
|
'services_note' => null,
|
||||||
|
],
|
||||||
|
$target,
|
||||||
|
['key' => $key],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A célkörnyezet hosszan futó service-einek újraindító parancsai.
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public function restartCommands(?string $key): array
|
||||||
|
{
|
||||||
|
$target = $this->find($key);
|
||||||
|
|
||||||
|
if (! $target || $target['services'] === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = (string) config('deployment.swarm.user', 'root');
|
||||||
|
$host = (string) config('deployment.swarm.host');
|
||||||
|
|
||||||
|
return array_values(array_map(
|
||||||
|
fn (string $service): string => sprintf(
|
||||||
|
'ssh %s@%s "docker service update --force %s"',
|
||||||
|
$user,
|
||||||
|
$host,
|
||||||
|
$service,
|
||||||
|
),
|
||||||
|
$target['services'],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{key:string, label:string, domain:string, remote_path:string, expected_branch:?string, services:array<string, string>, services_note:?string}|null
|
||||||
|
*/
|
||||||
|
public function find(?string $key): ?array
|
||||||
|
{
|
||||||
|
return $key ? ($this->all()[$key] ?? null) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function options(): array
|
||||||
|
{
|
||||||
|
return array_map(fn (array $target): string => $target['label'], $this->all());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function defaultKey(): ?string
|
||||||
|
{
|
||||||
|
return array_key_first($this->all());
|
||||||
|
}
|
||||||
|
}
|
||||||
400
app/Services/Deployment/GitRepository.php
Normal file
400
app/Services/Deployment/GitRepository.php
Normal file
@ -0,0 +1,400 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Deployment;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
use ZipArchive;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Csak olvasó git wrapper a deployment csomagolóhoz.
|
||||||
|
*
|
||||||
|
* Minden hívás argumentum-tömbbel megy a Symfony Process-nek, soha nem shell-stringgel,
|
||||||
|
* így a felületről érkező érték nem tud parancsot injektálni. Ettől függetlenül minden
|
||||||
|
* bemenetet külön is validálunk: a git a kötőjellel kezdődő értéket kapcsolóként
|
||||||
|
* értelmezné, a `..` pedig commit-tartományt jelent, nem fájlnevet.
|
||||||
|
*/
|
||||||
|
class GitRepository
|
||||||
|
{
|
||||||
|
/** Mezőelválasztó a git log formátumban (ASCII unit separator). */
|
||||||
|
private const FIELD_SEPARATOR = "\x1f";
|
||||||
|
|
||||||
|
/** Rekordelválasztó a git log formátumban (ASCII record separator). */
|
||||||
|
private const RECORD_SEPARATOR = "\x1e";
|
||||||
|
|
||||||
|
private const COMMIT_PATTERN = '/^[0-9a-f]{7,40}$/';
|
||||||
|
|
||||||
|
private const REFERENCE_PATTERN = '#^[A-Za-z0-9][A-Za-z0-9._/-]*$#';
|
||||||
|
|
||||||
|
public function isAvailable(): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->run(['rev-parse', '--git-dir']);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (RuntimeException) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function currentBranch(): ?string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$branch = trim($this->run(['rev-parse', '--abbrev-ref', 'HEAD']));
|
||||||
|
} catch (RuntimeException) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ($branch === '' || $branch === 'HEAD') ? null : $branch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public function branches(): array
|
||||||
|
{
|
||||||
|
$output = $this->run([
|
||||||
|
'for-each-ref',
|
||||||
|
'--format=%(refname:short)',
|
||||||
|
'--sort=-committerdate',
|
||||||
|
'refs/heads',
|
||||||
|
'refs/remotes',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return collect(explode("\n", $output))
|
||||||
|
->map(fn (string $line): string => trim($line))
|
||||||
|
->filter()
|
||||||
|
// Az "origin/HEAD" csak egy mutató az alapértelmezett branchre, nem önálló ág.
|
||||||
|
->reject(fn (string $branch): bool => str_ends_with($branch, '/HEAD'))
|
||||||
|
->unique()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commitlista egy referenciáról, legfrissebbtől visszafelé.
|
||||||
|
*
|
||||||
|
* A törzset (%b) is elkérjük, mert a felületen erre is lehet keresni - a commit
|
||||||
|
* üzenet érdemi része nálunk gyakran a törzsben van, nem a tárgysorban.
|
||||||
|
*
|
||||||
|
* A --shortstat miatt minden commit után megjelenik a "N files changed" sor is,
|
||||||
|
* ezt az utolsó mezőből bányásszuk ki - így nem kell commitonként külön git hívás
|
||||||
|
* a fájlszámhoz.
|
||||||
|
*
|
||||||
|
* @return array<int, array{hash:string, short:string, author:string, date:string, subject:string, body:string, file_count:?int}>
|
||||||
|
*/
|
||||||
|
public function commits(string $reference, int $limit): array
|
||||||
|
{
|
||||||
|
$this->assertReference($reference);
|
||||||
|
|
||||||
|
$format = self::RECORD_SEPARATOR.implode(self::FIELD_SEPARATOR, ['%H', '%h', '%an', '%aI', '%s', '%b']);
|
||||||
|
|
||||||
|
$output = $this->run([
|
||||||
|
'log',
|
||||||
|
'--max-count='.max(1, min($limit, 500)),
|
||||||
|
'--no-merges',
|
||||||
|
'--shortstat',
|
||||||
|
'--pretty=format:'.$format,
|
||||||
|
$reference,
|
||||||
|
'--',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$commits = [];
|
||||||
|
|
||||||
|
foreach (explode(self::RECORD_SEPARATOR, $output) as $record) {
|
||||||
|
if (trim($record) === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fields = explode(self::FIELD_SEPARATOR, $record, 6);
|
||||||
|
|
||||||
|
if (count($fields) < 6) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$hash, $short, $author, $date, $subject, $tail] = $fields;
|
||||||
|
|
||||||
|
// A --shortstat sora a törzs UTÁN érkezik, ezért előbb leválasztjuk róla:
|
||||||
|
// enélkül a "3 files changed" szöveg a keresésbe és a törzsbe is beszivárogna.
|
||||||
|
$fileCount = null;
|
||||||
|
|
||||||
|
if (preg_match('/\n\s*(\d+)\s+files?\s+changed[^\n]*\n?$/', $tail, $matches) === 1) {
|
||||||
|
$fileCount = (int) $matches[1];
|
||||||
|
$tail = substr($tail, 0, -strlen($matches[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
$commits[] = [
|
||||||
|
'hash' => $hash,
|
||||||
|
'short' => $short,
|
||||||
|
'author' => $author,
|
||||||
|
'date' => $date,
|
||||||
|
'subject' => trim($subject),
|
||||||
|
'body' => trim($tail),
|
||||||
|
'file_count' => $fileCount,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $commits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Teljes hash, ha a megadott érték létező commitra mutat - különben null.
|
||||||
|
*/
|
||||||
|
public function resolveCommit(string $hash): ?string
|
||||||
|
{
|
||||||
|
if (preg_match(self::COMMIT_PATTERN, $hash) !== 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$resolved = trim($this->run(['rev-parse', '--verify', '--quiet', $hash.'^{commit}']));
|
||||||
|
} catch (RuntimeException) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $resolved === '' ? null : $resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Őse-e az első commit a másodiknak? (Fordított tartomány felismeréséhez.)
|
||||||
|
*/
|
||||||
|
public function isAncestor(string $ancestor, string $descendant): bool
|
||||||
|
{
|
||||||
|
if (! $this->resolveCommit($ancestor) || ! $this->resolveCommit($descendant)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A merge-base 1-es kilépési kóddal jelzi a "nem őse" esetet, ez nem hiba,
|
||||||
|
// ezért itt nem a run() dobó változatát használjuk.
|
||||||
|
return $this->process(['merge-base', '--is-ancestor', $ancestor, $descendant])->run() === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A két commit között változott fájlok (from kizárva, to beleértve).
|
||||||
|
*
|
||||||
|
* @return array<int, array{status:string, path:string, old_path:?string}>
|
||||||
|
*/
|
||||||
|
public function changedFiles(string $from, string $to): array
|
||||||
|
{
|
||||||
|
$fromHash = $this->resolveCommit($from);
|
||||||
|
$toHash = $this->resolveCommit($to);
|
||||||
|
|
||||||
|
if (! $fromHash || ! $toHash) {
|
||||||
|
throw new RuntimeException('Ismeretlen commit azonosító.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// A core.quotePath=false nélkül a git a nem-ASCII fájlneveket idézőjelbe teszi
|
||||||
|
// és oktálisan escape-eli ("app/\303\251kezet.php") - ékezetes útvonalaknál ez
|
||||||
|
// használhatatlan lenne.
|
||||||
|
$output = $this->run([
|
||||||
|
'-c', 'core.quotePath=false',
|
||||||
|
'diff',
|
||||||
|
'--name-status',
|
||||||
|
'--find-renames',
|
||||||
|
$fromHash.'..'.$toHash,
|
||||||
|
'--',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$files = [];
|
||||||
|
|
||||||
|
foreach (explode("\n", $output) as $line) {
|
||||||
|
$line = rtrim($line, "\r\n");
|
||||||
|
|
||||||
|
if ($line === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = explode("\t", $line);
|
||||||
|
$status = strtoupper(substr($parts[0], 0, 1));
|
||||||
|
|
||||||
|
// Átnevezés (R) és másolás (C) esetén két útvonal jön: régi és új.
|
||||||
|
$isTwoPath = in_array($status, ['R', 'C'], true) && count($parts) >= 3;
|
||||||
|
$path = $isTwoPath ? $parts[2] : ($parts[1] ?? '');
|
||||||
|
$oldPath = $isTwoPath ? $parts[1] : null;
|
||||||
|
|
||||||
|
if (! $this->isSafePath($path) || ($oldPath !== null && ! $this->isSafePath($oldPath))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$files[] = [
|
||||||
|
'status' => $status,
|
||||||
|
'path' => $path,
|
||||||
|
'old_path' => $oldPath,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($files, fn (array $a, array $b): int => strcmp($a['path'], $b['path']));
|
||||||
|
|
||||||
|
return $files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hány commit van a kettő között (from kizárva, to beleértve).
|
||||||
|
*
|
||||||
|
* A környezetek lemaradásának kimutatásához: "az e2e 7 committal van elmaradva".
|
||||||
|
*/
|
||||||
|
public function countCommitsBetween(string $from, string $to): ?int
|
||||||
|
{
|
||||||
|
$fromHash = $this->resolveCommit($from);
|
||||||
|
|
||||||
|
if (! $fromHash) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->assertReference($to);
|
||||||
|
$count = trim($this->run(['rev-list', '--count', $fromHash.'..'.$to, '--']));
|
||||||
|
} catch (RuntimeException) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_numeric($count) ? (int) $count : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A megadott commit szerinti fájltartalmat írja ki a célmappába, relatív útvonalakat megőrizve.
|
||||||
|
*
|
||||||
|
* Szándékosan a git objektumtárból dolgozunk (git archive) és nem a working tree-ből:
|
||||||
|
* így garantáltan a commitolt állapot kerül a csomagba (nem szivárog ki félkész
|
||||||
|
* módosítás), és a .gitattributes `text=auto eol=lf` miatt LF sorvégekkel, ami a
|
||||||
|
* Linux célgépeknek kell - a working tree másolása ezt egyik esetben sem garantálná.
|
||||||
|
*
|
||||||
|
* @param array<int, string> $paths
|
||||||
|
* @return array<int, string> a ténylegesen kiírt útvonalak
|
||||||
|
*/
|
||||||
|
public function archiveTo(string $commit, array $paths, string $targetDirectory): array
|
||||||
|
{
|
||||||
|
// Pathspec nélkül a git archive a TELJES fát csomagolná - üres listánál ezért
|
||||||
|
// nem hívhatjuk meg egyáltalán.
|
||||||
|
if ($paths === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolved = $this->resolveCommit($commit);
|
||||||
|
|
||||||
|
if (! $resolved) {
|
||||||
|
throw new RuntimeException('Ismeretlen commit azonosító.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$written = [];
|
||||||
|
|
||||||
|
// Az útvonalak argumentumként mennek: Windowson a CreateProcess parancssora
|
||||||
|
// ~32 000 karakter, ezért darabolunk.
|
||||||
|
foreach (array_chunk($paths, 100) as $chunk) {
|
||||||
|
$archivePath = tempnam(sys_get_temp_dir(), 'deployment-archive-');
|
||||||
|
|
||||||
|
if ($archivePath === false) {
|
||||||
|
throw new RuntimeException('Nem hozható létre ideiglenes fájl a csomagoláshoz.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->run(array_merge(
|
||||||
|
['archive', '--format=zip', '--output='.$archivePath, $resolved, '--'],
|
||||||
|
$chunk,
|
||||||
|
));
|
||||||
|
|
||||||
|
$written = array_merge($written, $this->extract($archivePath, $targetDirectory));
|
||||||
|
} finally {
|
||||||
|
@unlink($archivePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $written;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function extract(string $archivePath, string $targetDirectory): array
|
||||||
|
{
|
||||||
|
$zip = new ZipArchive;
|
||||||
|
|
||||||
|
if ($zip->open($archivePath) !== true) {
|
||||||
|
throw new RuntimeException('A git archive kimenete nem nyitható meg.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$entries = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
for ($index = 0; $index < $zip->numFiles; $index++) {
|
||||||
|
$name = $zip->getNameIndex($index);
|
||||||
|
|
||||||
|
if ($name === false || str_ends_with($name, '/')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A célmappából kimutató bejegyzés nem fordulhat elő valódi git archive-ban,
|
||||||
|
// de a kiírás előtti ellenőrzés olcsó - és ez az egyetlen pont, ahol az
|
||||||
|
// alkalmazás a repón kívülre írhatna.
|
||||||
|
if (! $this->isSafePath($name)) {
|
||||||
|
throw new RuntimeException(sprintf('Gyanús útvonal a csomagban: %s', $name));
|
||||||
|
}
|
||||||
|
|
||||||
|
$entries[] = $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($entries !== [] && ! $zip->extractTo($targetDirectory, $entries)) {
|
||||||
|
throw new RuntimeException('A fájlok kicsomagolása nem sikerült.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
$zip->close();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A repo gyökeréből ki nem mutató, relatív útvonal-e.
|
||||||
|
*/
|
||||||
|
private function isSafePath(string $path): bool
|
||||||
|
{
|
||||||
|
if ($path === '' || str_starts_with($path, '/') || str_contains($path, "\0")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Windows meghajtó-előtag (C:/...) és szülőkönyvtár-hivatkozás sem fordulhat elő
|
||||||
|
// valódi git útvonalban, viszont fájlkiírásnál kitörhetne a célmappából.
|
||||||
|
return preg_match('#^[A-Za-z]:#', $path) !== 1
|
||||||
|
&& preg_match('#(^|/)\.\.(/|$)#', $path) !== 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertReference(string $reference): void
|
||||||
|
{
|
||||||
|
if (preg_match(self::REFERENCE_PATTERN, $reference) !== 1 || str_contains($reference, '..')) {
|
||||||
|
throw new RuntimeException(sprintf('Érvénytelen git referencia: %s', $reference));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $arguments
|
||||||
|
*/
|
||||||
|
private function process(array $arguments): Process
|
||||||
|
{
|
||||||
|
return new Process(
|
||||||
|
array_merge([(string) config('deployment.git_binary') ?: 'git'], $arguments),
|
||||||
|
(string) config('deployment.repo_path') ?: base_path(),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
(float) config('deployment.timeout', 60),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $arguments
|
||||||
|
*/
|
||||||
|
private function run(array $arguments): string
|
||||||
|
{
|
||||||
|
$process = $this->process($arguments);
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
if (! $process->isSuccessful()) {
|
||||||
|
throw new RuntimeException(sprintf(
|
||||||
|
'A git parancs hibára futott (%s): %s',
|
||||||
|
implode(' ', $arguments),
|
||||||
|
trim($process->getErrorOutput()) ?: trim($process->getOutput()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $process->getOutput();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -740,8 +740,8 @@ public function validate(PricelistFile $pricelistFile): bool
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$rowData[$label] = $value;
|
$rowData[$label] = $this->normalizeCellValue($value);
|
||||||
if (!empty(trim((string)$value))) {
|
if (!empty(trim((string)$rowData[$label]))) {
|
||||||
$hasData = true;
|
$hasData = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1074,7 +1074,7 @@ protected function runBusinessValidation(PricelistFile $pricelistFile, bool $has
|
|||||||
foreach ($mandatoryIndexes as $mIndex) {
|
foreach ($mandatoryIndexes as $mIndex) {
|
||||||
$mLabel = PriceListService::EXPECTED_HEADERS[$mIndex];
|
$mLabel = PriceListService::EXPECTED_HEADERS[$mIndex];
|
||||||
$mValue = $payload[$mLabel] ?? null;
|
$mValue = $payload[$mLabel] ?? null;
|
||||||
if ($mValue === null || (is_string($mValue) && trim($mValue) === '')) {
|
if ($this->isBlankPayloadValue($mValue)) {
|
||||||
// Megfelelő kulcsot választunk, hogy a specifikus validátorok felülírhassák
|
// Megfelelő kulcsot választunk, hogy a specifikus validátorok felülírhassák
|
||||||
$mKey = match($mIndex) {
|
$mKey = match($mIndex) {
|
||||||
7 => 'product_unit',
|
7 => 'product_unit',
|
||||||
@ -1095,7 +1095,7 @@ protected function runBusinessValidation(PricelistFile $pricelistFile, bool $has
|
|||||||
foreach ($hooreycaIndexes as $hIndex) {
|
foreach ($hooreycaIndexes as $hIndex) {
|
||||||
$hLabel = PriceListService::EXPECTED_HEADERS[$hIndex];
|
$hLabel = PriceListService::EXPECTED_HEADERS[$hIndex];
|
||||||
$hValue = $payload[$hLabel] ?? null;
|
$hValue = $payload[$hLabel] ?? null;
|
||||||
if ($hValue !== null && !(is_string($hValue) && trim($hValue) === '')) {
|
if (! $this->isBlankPayloadValue($hValue)) {
|
||||||
$hasAnyHooreyca = true;
|
$hasAnyHooreyca = true;
|
||||||
$hooreycaData[$hIndex] = $hValue;
|
$hooreycaData[$hIndex] = $hValue;
|
||||||
} else {
|
} else {
|
||||||
@ -1702,7 +1702,7 @@ protected function castExecutionValue(mixed $value, string $type, string $field)
|
|||||||
'int' => (int) $this->castExecutionFloat($value, $field),
|
'int' => (int) $this->castExecutionFloat($value, $field),
|
||||||
// booleanCustom: üres érték => false, bármilyen más érték (pl. "X") => true
|
// booleanCustom: üres érték => false, bármilyen más érték (pl. "X") => true
|
||||||
'bool' => strlen(trim((string) $value)) > 0,
|
'bool' => strlen(trim((string) $value)) > 0,
|
||||||
default => $value === null ? null : trim((string) $value),
|
default => $this->isBlankPayloadValue($value) ? null : trim((string) $this->normalizeCellValue($value)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1724,6 +1724,69 @@ protected function castExecutionFloat(mixed $value, string $field = ''): float
|
|||||||
return $number;
|
return $number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Egy Excel cella értékének normalizálása skalárrá, MÉG a payloadba kerülés előtt.
|
||||||
|
*
|
||||||
|
* A `Cell::getValue()` nem csak skalárt adhat vissza. A gyakorlatban a `RichText`
|
||||||
|
* okozott adatvesztést: ha egy cellán belül több formázású szövegrész van (pl. a
|
||||||
|
* terméknév egy része félkövér), az érték egy RichText objektum, amit a `json_encode`
|
||||||
|
* ÜRESRE serializál - a szövegrészek protected propertyben vannak -, így a payloadba
|
||||||
|
* `{}` kerül, ami visszaolvasva üres tömb. A cella szövege ilyenkor nyomtalanul
|
||||||
|
* elveszik, a felület pedig eltörik, mert tömböt kap string helyett.
|
||||||
|
*/
|
||||||
|
protected function normalizeCellValue(mixed $value): string|int|float|bool|null
|
||||||
|
{
|
||||||
|
if ($value === null || is_scalar($value)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($value instanceof \PhpOffice\PhpSpreadsheet\RichText\RichText) {
|
||||||
|
return $value->getPlainText();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($value instanceof \DateTimeInterface) {
|
||||||
|
return $value->format('Y-m-d H:i:s');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($value)) {
|
||||||
|
$flat = array_filter(
|
||||||
|
\Illuminate\Support\Arr::flatten($value),
|
||||||
|
fn ($item) => $item !== null && $item !== '',
|
||||||
|
);
|
||||||
|
|
||||||
|
return $flat === [] ? null : trim(implode(' ', array_map(
|
||||||
|
fn ($item) => is_scalar($item) ? (string) $item : '',
|
||||||
|
$flat,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_object($value) && method_exists($value, '__toString')) {
|
||||||
|
return (string) $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Üresnek számít-e egy payload érték. A tömböt külön kell kezelni: a fenti RichText
|
||||||
|
* hiba miatt a régebbi rekordokban üres tömbök is előfordulnak, azokat pedig a
|
||||||
|
* korábbi `is_string()` alapú vizsgálat KITÖLTÖTTNEK látta - egy elveszett terméknév
|
||||||
|
* így némán átment a kötelező mező ellenőrzésén.
|
||||||
|
*/
|
||||||
|
protected function isBlankPayloadValue(mixed $value): bool
|
||||||
|
{
|
||||||
|
if ($value === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($value) || is_object($value)) {
|
||||||
|
return $this->normalizeCellValue($value) === null
|
||||||
|
|| trim((string) $this->normalizeCellValue($value)) === '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim((string) $value) === '';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Termékcsoport ID => típus ('F' / 'N' / 'X'). A termék `type` mezője a
|
* Termékcsoport ID => típus ('F' / 'N' / 'X'). A termék `type` mezője a
|
||||||
* termékcsoportjától öröklődik, ahogy a legacy importban is.
|
* termékcsoportjától öröklődik, ahogy a legacy importban is.
|
||||||
|
|||||||
179
config/deployment.php
Normal file
179
config/deployment.php
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Fő kapcsoló (1. védelmi réteg)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| A deployment csomagoló KIZÁRÓLAG fejlesztői gépen futhat. Mivel az
|
||||||
|
| alkalmazás kódja manuális fájlmásolással kerül a szerverekre, ez az
|
||||||
|
| oldal fizikailag ott lesz minden környezeten - ezért a bekapcsolás
|
||||||
|
| .env-ből történik, ami viszont nincs verziókövetve és nem másoljuk.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'enabled' => env('DEPLOYMENT_PACKAGE_ENABLED', false),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Stage whitelist (2. védelmi réteg)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Szándékosan hardkódolt lista, nem env-ből jön: ez fogja meg azt az esetet,
|
||||||
|
| ha valaki a fenti kapcsolót tévedésből bemásolja egy szerver .env-jébe.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'stages' => ['local'],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Jogosultság (3. réteg) és láthatóság (4. réteg)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| A feature flag NEM biztonsági réteg - a védelmet a fenti kettő és a
|
||||||
|
| szerepkör adja. A flag a menü-láthatóságot vezérli, illetve deploy nélküli
|
||||||
|
| kikapcsolást és név szerinti átadást tesz lehetővé (feature_flag_overrides).
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'role' => 'developer',
|
||||||
|
|
||||||
|
'feature_flag' => 'DeploymentPackage',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Git
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
'repo_path' => base_path(),
|
||||||
|
|
||||||
|
'git_binary' => env('DEPLOYMENT_GIT_BINARY', 'git'),
|
||||||
|
|
||||||
|
'timeout' => 60,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Csomagolás
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
'output_path' => env('DEPLOYMENT_OUTPUT_PATH', storage_path('app/private/deployments')),
|
||||||
|
|
||||||
|
'max_files' => 500,
|
||||||
|
|
||||||
|
'commit_limits' => [50, 100, 200],
|
||||||
|
|
||||||
|
/*
|
||||||
|
| Ezek soha nem kerülnek csomagba, akkor sem, ha a diffben szerepelnének.
|
||||||
|
| A minták Str::is() szintaxisúak, a repo gyökeréhez képest relatív úton.
|
||||||
|
*/
|
||||||
|
'exclude' => [
|
||||||
|
'.env',
|
||||||
|
'.env.*',
|
||||||
|
'auth.json',
|
||||||
|
'.git*',
|
||||||
|
'storage/*',
|
||||||
|
'vendor/*',
|
||||||
|
'node_modules/*',
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
| Fájlok, amik csomagolhatók, de külön figyelmeztetést érdemelnek, mert
|
||||||
|
| önmagukban nem elegendők (kell melléjük egy parancs a célgépen).
|
||||||
|
*/
|
||||||
|
'attention' => [
|
||||||
|
'migration' => ['database/migrations/*'],
|
||||||
|
'seeder' => ['database/seeders/*'],
|
||||||
|
'composer' => ['composer.json', 'composer.lock'],
|
||||||
|
'asset' => [
|
||||||
|
'resources/js/*',
|
||||||
|
'resources/css/*',
|
||||||
|
'resources/modern/*',
|
||||||
|
'vite.config.js',
|
||||||
|
'webpack.mix.js',
|
||||||
|
'package.json',
|
||||||
|
'package-lock.json',
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
| A queue worker és a scheduler hosszan futó PHP folyamat: a már betöltött
|
||||||
|
| osztályokat a memóriájában tartja, ezért a fájlok felmásolása után is a RÉGI
|
||||||
|
| kóddal dolgozik, amíg újra nem indul. Ez némán rossz viselkedést okoz (2026-08-17,
|
||||||
|
| árlista végrehajtás: a job "elkészült" státuszt adott anélkül, hogy bármit írt volna),
|
||||||
|
| ezért minden háttérben is futó kód változása restartot igényel - nem csak a jobok.
|
||||||
|
*/
|
||||||
|
'runtime' => [
|
||||||
|
'app/*',
|
||||||
|
'config/*',
|
||||||
|
'database/*',
|
||||||
|
'routes/*',
|
||||||
|
'bootstrap/*',
|
||||||
|
'composer.lock',
|
||||||
|
],
|
||||||
|
|
||||||
|
/* Csak a figyelmeztetés szövegének pontosításához, a restart ettől függetlenül kell. */
|
||||||
|
'job' => ['app/Jobs/*'],
|
||||||
|
'console' => ['app/Console/*'],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Docker Swarm
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| A restart parancsokat a Swarm leader node-on keresztül kell kiadni
|
||||||
|
| (ld. docs/cicd-context.md).
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'swarm' => [
|
||||||
|
'host' => env('DEPLOYMENT_SWARM_HOST', '46.29.140.234'),
|
||||||
|
'user' => env('DEPLOYMENT_SWARM_USER', 'root'),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Célkörnyezetek
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Az expected_branch csak figyelmeztetést vezérel, nem tiltást.
|
||||||
|
| A remote_path a shared storage útvonala (ld. docs/cicd-context.md).
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
| A sorrend számít: az első az alapértelmezett célkörnyezet a felületen
|
||||||
|
| (DeploymentTargets::defaultKey()). A d2d azért van elöl, mert azt frissítjük
|
||||||
|
| sűrűbben, tehát a gyakoribb eset legyen az alapértelmezés.
|
||||||
|
*/
|
||||||
|
'targets' => [
|
||||||
|
'd2d' => [
|
||||||
|
'label' => 'd2d — fejlesztői',
|
||||||
|
'domain' => 'd2d.emegrendeles.hu',
|
||||||
|
'remote_path' => '/delirest/d2d.emegrendeles.hu/app/',
|
||||||
|
'expected_branch' => 'test',
|
||||||
|
// Ugyanabból az imageből, közös volume-ról több service is futtatja a kódot.
|
||||||
|
// A web konténer (d2d-environment_app) kérésenként újratölti, ezért az nem kell ide.
|
||||||
|
'services' => [
|
||||||
|
'queue' => 'd2d-environment_queue',
|
||||||
|
'scheduler' => 'd2d-environment_scheduler',
|
||||||
|
],
|
||||||
|
'services_note' => 'A d2d scheduler jelenleg 0 replikán fut — ha leskálázva marad, a restartja üresjárat.',
|
||||||
|
],
|
||||||
|
'e2e' => [
|
||||||
|
'label' => 'e2e — t2t éles',
|
||||||
|
'domain' => 'e2e.emegrendeles.hu',
|
||||||
|
'remote_path' => '/delirest/test.t2t.emegrendeles.hu/app/',
|
||||||
|
'expected_branch' => 'main',
|
||||||
|
'services' => [
|
||||||
|
'scheduler' => 't2t_scheduler',
|
||||||
|
],
|
||||||
|
'services_note' => 'A t2t-n még nincs queue worker service; ha létrejön, a névkonvenció alapján t2t_queue lesz, és azt is újra kell majd indítani.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
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('deployment_packages', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name');
|
||||||
|
$table->string('target', 32);
|
||||||
|
$table->string('branch')->nullable();
|
||||||
|
$table->string('from_commit', 40);
|
||||||
|
$table->string('to_commit', 40);
|
||||||
|
$table->string('folder', 1024);
|
||||||
|
$table->string('zip_path', 1024)->nullable();
|
||||||
|
$table->unsignedInteger('copied_count')->default(0);
|
||||||
|
$table->unsignedInteger('deleted_count')->default(0);
|
||||||
|
$table->unsignedInteger('skipped_count')->default(0);
|
||||||
|
$table->unsignedInteger('missing_count')->default(0);
|
||||||
|
$table->text('note')->nullable();
|
||||||
|
// A csomagolás és a tényleges kirakás két külön esemény: a deployed_at csak
|
||||||
|
// akkor kap értéket, amikor a fejlesztő megjelöli, hogy fel is töltötte.
|
||||||
|
$table->timestamp('deployed_at')->nullable();
|
||||||
|
$table->userIdFields();
|
||||||
|
$table->timestamps();
|
||||||
|
$table->softDeletes();
|
||||||
|
|
||||||
|
$table->index(['target', 'deployed_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('deployment_packages');
|
||||||
|
}
|
||||||
|
};
|
||||||
1125
database/schema/mysql-schema.sql
Normal file
1125
database/schema/mysql-schema.sql
Normal file
File diff suppressed because it is too large
Load Diff
BIN
public/images/icons/ico_git.png
Normal file
BIN
public/images/icons/ico_git.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.5 KiB |
@ -40,6 +40,13 @@
|
|||||||
['displayName' => 'Egyéni felülbírálások', 'link' => \App\Filament\Resources\FeatureFlagOverrides\FeatureFlagOverrideResource::getUrl()],
|
['displayName' => 'Egyéni felülbírálások', 'link' => \App\Filament\Resources\FeatureFlagOverrides\FeatureFlagOverrideResource::getUrl()],
|
||||||
]];
|
]];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A deployment csomagoló kizárólag fejlesztői gépen érhető el (DeploymentGuard:
|
||||||
|
// .env kapcsoló + stage whitelist + developer szerep + feature flag), ezért a
|
||||||
|
// menüpont a szervereken akkor sem jelenik meg, ha a kód oda is felmásolódik.
|
||||||
|
if (\App\Filament\Pages\DeploymentPackage::canAccess()) {
|
||||||
|
$modules[] = ['name' => 'deploymentPackage', 'displayName' => 'Deployment', 'icon' => 'git', 'link' => \App\Filament\Pages\DeploymentPackage::getUrl(), 'roles' => ['root','developer']];
|
||||||
|
}
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<nav class="fi-speed-button-nav flex items-center h-full ml-4 w-full">
|
<nav class="fi-speed-button-nav flex items-center h-full ml-4 w-full">
|
||||||
|
|||||||
437
resources/views/filament/pages/deployment-package.blade.php
Normal file
437
resources/views/filament/pages/deployment-package.blade.php
Normal file
@ -0,0 +1,437 @@
|
|||||||
|
@php
|
||||||
|
// A fázis 1 felülete csak olvas: a git történetet listázza és a kijelölt tartomány
|
||||||
|
// diffjét mutatja. A számított property-ket egy helyen kérjük le, hogy a hibabanner
|
||||||
|
// (gitError) már a renderelés elején beállított állapotot lássa.
|
||||||
|
$commits = $this->commits;
|
||||||
|
$changeSet = $this->changeSet;
|
||||||
|
$highlighted = $this->highlightedHashes;
|
||||||
|
$target = $this->target();
|
||||||
|
$environments = $this->environmentStatuses;
|
||||||
|
$recentPackages = $this->recentPackages;
|
||||||
|
|
||||||
|
// Filament saját szín-nevei (badge/button komponensek), nem nyers Tailwind osztályok:
|
||||||
|
// egy új blade-ben használt utility class csak npm run build után kerül a CSS-be, a
|
||||||
|
// Filament komponensek stílusa viszont már benne van a lefordított bundle-ben.
|
||||||
|
$statusStyles = [
|
||||||
|
'A' => ['label' => 'új', 'color' => 'success'],
|
||||||
|
'M' => ['label' => 'módosult', 'color' => 'warning'],
|
||||||
|
'D' => ['label' => 'törölt', 'color' => 'danger'],
|
||||||
|
'R' => ['label' => 'átnevezett', 'color' => 'info'],
|
||||||
|
'C' => ['label' => 'másolt', 'color' => 'info'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$warningStyles = [
|
||||||
|
'danger' => 'border-red-300 bg-red-50 text-red-900 dark:border-red-800 dark:bg-red-950 dark:text-red-200',
|
||||||
|
'warning' => 'border-amber-300 bg-amber-50 text-amber-900 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-200',
|
||||||
|
'info' => 'border-gray-300 bg-gray-50 text-gray-800 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300',
|
||||||
|
];
|
||||||
|
|
||||||
|
$cardClass = 'p-6 bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700';
|
||||||
|
$commitMarkers = $this->commitMarkers;
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<x-filament-panels::page>
|
||||||
|
{{--
|
||||||
|
Saját stílus, nem Tailwind utility: egy új blade-ben használt osztály csak
|
||||||
|
npm run build után kerül a bundle-be (a md:grid-cols-2 pl. jelenleg nincs benne),
|
||||||
|
így a kétoszlopos elrendezés build nélkül is működik.
|
||||||
|
--}}
|
||||||
|
<style>
|
||||||
|
.fi-deployment-env-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.fi-deployment-env-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
|
||||||
|
<div class="{{ $cardClass }}">
|
||||||
|
<h5 class="mb-2 text-2xl font-bold tracking-tight text-gray-900 dark:text-white">Deployment csomagoló</h5>
|
||||||
|
<p class="font-normal text-gray-700 dark:text-gray-400">
|
||||||
|
Válaszd ki a célkörnyezetet, majd két commitot: a köztük változott fájlok kerülnek majd a csomagba
|
||||||
|
(a kezdő commit kizárva, a záró beleértve).
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
A csomag ide készül: <code>{{ config('deployment.output_path') }}</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="{{ $cardClass }}">
|
||||||
|
<div class="flex items-center justify-between gap-3 mb-3">
|
||||||
|
<h6 class="text-lg font-semibold text-gray-900 dark:text-white">Környezetek állapota</h6>
|
||||||
|
<x-filament::button size="xs" color="gray" wire:click="fetchAllLiveVersions(true)">
|
||||||
|
Frissítés
|
||||||
|
</x-filament::button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fi-deployment-env-grid">
|
||||||
|
@foreach ($environments as $environment)
|
||||||
|
<div class="p-3 border rounded-lg {{ $environment['mismatch'] ? $warningStyles['danger'] : 'border-gray-200 dark:border-gray-700' }}">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="font-semibold text-gray-900 dark:text-white">{{ $environment['target']['label'] }}</span>
|
||||||
|
|
||||||
|
@if ($environment['live'] && $environment['live']['ok'])
|
||||||
|
<span class="font-mono text-xs text-gray-600 dark:text-gray-300">
|
||||||
|
élő: {{ substr($environment['live']['data']['commit'] ?? '?', 0, 7) }}
|
||||||
|
</span>
|
||||||
|
@elseif ($environment['live'])
|
||||||
|
<span class="text-xs text-gray-400">élő: nem elérhető</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
@if ($environment['package'])
|
||||||
|
<span>napló: <code>{{ substr($environment['package']->to_commit, 0, 7) }}</code></span>
|
||||||
|
<span class="text-gray-400">·</span>
|
||||||
|
<span>{{ $environment['package']->deployed_at?->format('Y.m.d H:i') }}</span>
|
||||||
|
@if ($environment['behind'] !== null)
|
||||||
|
<span class="text-gray-400">·</span>
|
||||||
|
<span>{{ $environment['behind'] }} commit lemaradás</span>
|
||||||
|
@endif
|
||||||
|
@else
|
||||||
|
<span class="text-gray-400">Még nincs kirakottként megjelölt csomag.</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($environment['mismatch'])
|
||||||
|
<p class="mt-1 text-sm font-semibold">
|
||||||
|
A szerveren futó commit eltér a naplózottól — elmaradt vagy félbemaradt feltöltés.
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($this->lastPackage)
|
||||||
|
<div class="p-4 border rounded-lg {{ $this->lastPackage['counts']['missing'] > 0 ? $warningStyles['warning'] : 'border-emerald-300 bg-emerald-50 text-emerald-900 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-200' }}">
|
||||||
|
<p class="font-semibold">Elkészült: {{ $this->lastPackage['name'] }}</p>
|
||||||
|
<p class="mt-1 text-sm">
|
||||||
|
{{ $this->lastPackage['counts']['copied'] }} fájl másolva,
|
||||||
|
{{ $this->lastPackage['counts']['deleted'] }} törlendő,
|
||||||
|
{{ $this->lastPackage['counts']['skipped'] }} kézzel kivéve.
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-xs font-mono break-all">{{ $this->lastPackage['path'] }}</p>
|
||||||
|
|
||||||
|
@if ($this->lastPackage['counts']['missing'] > 0)
|
||||||
|
<p class="mt-2 text-sm font-semibold">
|
||||||
|
Figyelem: {{ $this->lastPackage['counts']['missing'] }} fájl nem került a csomagba,
|
||||||
|
a listájuk a _MANIFEST.txt végén van.
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<p class="mt-2 text-sm">
|
||||||
|
A lépéseket a csomagban lévő <code>_TEENDOK.md</code> tartalmazza.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($this->gitError)
|
||||||
|
<div class="p-4 border rounded-lg {{ $warningStyles['danger'] }}">
|
||||||
|
<p class="font-semibold">Git hiba</p>
|
||||||
|
<p class="mt-1 text-sm">{{ $this->gitError }}</p>
|
||||||
|
</div>
|
||||||
|
@elseif (! $this->isRepositoryAvailable())
|
||||||
|
<div class="p-4 border rounded-lg {{ $warningStyles['danger'] }}">
|
||||||
|
<p class="font-semibold">Nem érhető el git repó</p>
|
||||||
|
<p class="mt-1 text-sm">
|
||||||
|
A(z) <code>{{ config('deployment.repo_path') }}</code> útvonalon nincs git working copy,
|
||||||
|
vagy a git parancs nem található.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="{{ $cardClass }}">
|
||||||
|
{{ $this->form }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="{{ $cardClass }}">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div class="flex flex-wrap items-center gap-3 text-sm">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">Tartomány:</span>
|
||||||
|
|
||||||
|
@if ($this->fromCommit)
|
||||||
|
<code class="px-2 py-1 rounded bg-gray-100 dark:bg-gray-900">{{ substr($this->fromCommit, 0, 7) }}</code>
|
||||||
|
@else
|
||||||
|
<span class="text-gray-400">nincs kezdő commit</span>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<span class="text-gray-400">→</span>
|
||||||
|
|
||||||
|
@if ($this->toCommit)
|
||||||
|
<code class="px-2 py-1 rounded bg-gray-100 dark:bg-gray-900">{{ substr($this->toCommit, 0, 7) }}</code>
|
||||||
|
@else
|
||||||
|
<span class="text-gray-400">nincs záró commit</span>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($changeSet)
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">
|
||||||
|
· {{ $changeSet['counts']['copied'] }} másolandó
|
||||||
|
@if ($changeSet['counts']['deleted'] > 0)
|
||||||
|
, {{ $changeSet['counts']['deleted'] }} törlendő
|
||||||
|
@endif
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<x-filament::button size="xs" color="gray" wire:click="swapRange">Csere</x-filament::button>
|
||||||
|
<x-filament::button size="xs" color="gray" wire:click="clearRange">Törlés</x-filament::button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($target)
|
||||||
|
<p class="mt-3 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Cél: <strong>{{ $target['label'] }}</strong> — a fájlok ide kerülnek majd feltöltésre:
|
||||||
|
<code>{{ $target['remote_path'] }}</code>
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="{{ $cardClass }}">
|
||||||
|
<h6 class="mb-1 text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
Commitok
|
||||||
|
<span class="ml-1 text-sm font-normal text-gray-500 dark:text-gray-400">({{ count($commits) }} db)</span>
|
||||||
|
</h6>
|
||||||
|
<p class="mb-4 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
A hash melletti jelölés mutatja, melyik környezet áll azon a commiton —
|
||||||
|
zöld: a szervertől lekérdezve, szürke: a napló szerint.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@if ($commits === [])
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Nincs megjeleníthető commit.</p>
|
||||||
|
@else
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm text-left">
|
||||||
|
<thead class="text-xs uppercase text-gray-600 border-b border-gray-200 dark:text-gray-300 dark:border-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th class="py-2 pr-4">Kijelölés</th>
|
||||||
|
<th class="py-2 pr-4">Hash</th>
|
||||||
|
<th class="py-2 pr-4">Dátum</th>
|
||||||
|
<th class="py-2 pr-4">Szerző</th>
|
||||||
|
<th class="py-2 pr-4">Üzenet</th>
|
||||||
|
<th class="py-2 text-right">Fájl</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach ($commits as $commit)
|
||||||
|
@php
|
||||||
|
$isFrom = $this->fromCommit === $commit['hash'];
|
||||||
|
$isTo = $this->toCommit === $commit['hash'];
|
||||||
|
$inRange = in_array($commit['hash'], $highlighted, true);
|
||||||
|
@endphp
|
||||||
|
<tr wire:key="commit-{{ $commit['hash'] }}"
|
||||||
|
class="border-b border-gray-100 dark:border-gray-700 {{ $inRange ? 'bg-primary-50 dark:bg-gray-900' : '' }}">
|
||||||
|
<td class="py-2 pr-4 whitespace-nowrap">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<x-filament::button size="xs" :color="$isFrom ? 'primary' : 'gray'"
|
||||||
|
wire:click="selectFrom('{{ $commit['hash'] }}')">
|
||||||
|
Kezdő
|
||||||
|
</x-filament::button>
|
||||||
|
<x-filament::button size="xs" :color="$isTo ? 'primary' : 'gray'"
|
||||||
|
wire:click="selectTo('{{ $commit['hash'] }}')">
|
||||||
|
Záró
|
||||||
|
</x-filament::button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 pr-4 whitespace-nowrap">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<span class="font-mono text-xs">{{ $commit['short'] }}</span>
|
||||||
|
|
||||||
|
{{-- Melyik környezet áll ezen a commiton (élő állapot, vagy napló szerint). --}}
|
||||||
|
@foreach ($commitMarkers[$commit['hash']] ?? [] as $marker)
|
||||||
|
<x-filament::badge size="xs" :color="$marker['live'] ? 'success' : 'gray'">
|
||||||
|
{{ $marker['key'] }}
|
||||||
|
</x-filament::badge>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 pr-4 whitespace-nowrap text-gray-600 dark:text-gray-300">
|
||||||
|
{{ \Illuminate\Support\Carbon::parse($commit['date'])->format('Y.m.d H:i') }}
|
||||||
|
</td>
|
||||||
|
<td class="py-2 pr-4 whitespace-nowrap text-gray-600 dark:text-gray-300">{{ $commit['author'] }}</td>
|
||||||
|
<td class="py-2 pr-4 text-gray-800 dark:text-gray-100">{{ $commit['subject'] }}</td>
|
||||||
|
<td class="py-2 text-right text-gray-500 dark:text-gray-400">{{ $commit['file_count'] ?? '–' }}</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($changeSet)
|
||||||
|
<div class="{{ $cardClass }}">
|
||||||
|
<h6 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
Változott fájlok
|
||||||
|
<span class="ml-1 text-sm font-normal text-gray-500 dark:text-gray-400">
|
||||||
|
({{ $changeSet['counts']['total'] }} db)
|
||||||
|
</span>
|
||||||
|
</h6>
|
||||||
|
|
||||||
|
@foreach ($changeSet['warnings'] as $warning)
|
||||||
|
<div class="p-4 mb-3 border rounded-lg {{ $warningStyles[$warning['level']] ?? $warningStyles['info'] }}">
|
||||||
|
<p class="font-semibold">{{ $warning['title'] }}</p>
|
||||||
|
<p class="mt-1 text-sm">{{ $warning['body'] }}</p>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
|
||||||
|
@if ($changeSet['files'] !== [])
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm text-left">
|
||||||
|
<thead class="text-xs uppercase text-gray-600 border-b border-gray-200 dark:text-gray-300 dark:border-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th class="py-2 pr-4">Csomagba</th>
|
||||||
|
<th class="py-2 pr-4">Állapot</th>
|
||||||
|
<th class="py-2 pr-4">Útvonal</th>
|
||||||
|
<th class="py-2">Művelet</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach ($changeSet['files'] as $file)
|
||||||
|
@php
|
||||||
|
$style = $statusStyles[$file['status']] ?? ['label' => $file['status'], 'color' => 'gray'];
|
||||||
|
$isSkipped = in_array($file['path'], $this->skippedPaths, true);
|
||||||
|
@endphp
|
||||||
|
<tr wire:key="file-{{ $loop->index }}" class="border-b border-gray-100 dark:border-gray-700 {{ $file['excluded'] || $isSkipped ? 'opacity-50' : '' }}">
|
||||||
|
<td class="py-2 pr-4 whitespace-nowrap">
|
||||||
|
@if ($file['action'] === 'copy')
|
||||||
|
<input type="checkbox"
|
||||||
|
wire:click="toggleFile(@js($file['path']))"
|
||||||
|
@checked(! $isSkipped)
|
||||||
|
class="rounded border-gray-300 dark:border-gray-600">
|
||||||
|
@else
|
||||||
|
<span class="text-gray-300 dark:text-gray-600">–</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="py-2 pr-4 whitespace-nowrap">
|
||||||
|
<x-filament::badge :color="$style['color']">
|
||||||
|
{{ $file['status'] }} · {{ $style['label'] }}
|
||||||
|
</x-filament::badge>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 pr-4 font-mono text-xs break-all text-gray-800 dark:text-gray-100">
|
||||||
|
{{ $file['path'] }}
|
||||||
|
@if ($file['old_path'])
|
||||||
|
<span class="block text-gray-400">← {{ $file['old_path'] }}</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="py-2 whitespace-nowrap text-gray-600 dark:text-gray-300">
|
||||||
|
@switch($file['action'])
|
||||||
|
@case('copy') {{ $isSkipped ? 'kézzel kivéve' : 'másolandó' }} @break
|
||||||
|
@case('delete') törlendő a célgépen @break
|
||||||
|
@default kihagyva (kizárási lista)
|
||||||
|
@endswitch
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@php
|
||||||
|
$packageCount = count(array_diff($changeSet['copied'], $this->skippedPaths));
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-4 pt-4 mt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
A csomagba <strong>{{ $packageCount }}</strong> fájl kerül
|
||||||
|
@if ($changeSet['counts']['deleted'] > 0)
|
||||||
|
, és {{ $changeSet['counts']['deleted'] }} fájl a törlendők listájára
|
||||||
|
@endif
|
||||||
|
@if ($target)
|
||||||
|
· cél: <strong>{{ $target['label'] }}</strong>
|
||||||
|
@endif
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<x-filament::button
|
||||||
|
icon="heroicon-m-archive-box-arrow-down"
|
||||||
|
wire:click="createPackage"
|
||||||
|
wire:confirm="Elkészítjük a csomagot: {{ $packageCount }} fájl{{ $target ? ' a(z) '.$target['label'].' környezethez' : '' }}. Folytatod?"
|
||||||
|
:disabled="$packageCount === 0 && $changeSet['counts']['deleted'] === 0">
|
||||||
|
Csomag készítése
|
||||||
|
</x-filament::button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($recentPackages->isNotEmpty())
|
||||||
|
<div class="{{ $cardClass }}">
|
||||||
|
<h6 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
Korábbi csomagok
|
||||||
|
<span class="ml-1 text-sm font-normal text-gray-500 dark:text-gray-400">
|
||||||
|
({{ $target ? $target['label'] : 'összes' }})
|
||||||
|
</span>
|
||||||
|
</h6>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm text-left">
|
||||||
|
<thead class="text-xs uppercase text-gray-600 border-b border-gray-200 dark:text-gray-300 dark:border-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th class="py-2 pr-4">Csomag</th>
|
||||||
|
<th class="py-2 pr-4">Tartomány</th>
|
||||||
|
<th class="py-2 pr-4">Fájlok</th>
|
||||||
|
<th class="py-2 pr-4">Készült</th>
|
||||||
|
<th class="py-2 pr-4">Kirakva</th>
|
||||||
|
<th class="py-2 text-right">Műveletek</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach ($recentPackages as $package)
|
||||||
|
<tr wire:key="package-{{ $package->id }}" class="border-b border-gray-100 dark:border-gray-700">
|
||||||
|
<td class="py-2 pr-4 font-mono text-xs break-all text-gray-800 dark:text-gray-100">{{ $package->name }}</td>
|
||||||
|
<td class="py-2 pr-4 font-mono text-xs whitespace-nowrap text-gray-600 dark:text-gray-300">
|
||||||
|
{{ substr($package->from_commit, 0, 7) }} → {{ substr($package->to_commit, 0, 7) }}
|
||||||
|
</td>
|
||||||
|
<td class="py-2 pr-4 whitespace-nowrap text-gray-600 dark:text-gray-300">
|
||||||
|
{{ $package->copied_count }}
|
||||||
|
@if ($package->deleted_count > 0)
|
||||||
|
<span class="text-gray-400">/ {{ $package->deleted_count }} törl.</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="py-2 pr-4 whitespace-nowrap text-gray-600 dark:text-gray-300">
|
||||||
|
{{ $package->created_at?->format('Y.m.d H:i') }}
|
||||||
|
</td>
|
||||||
|
<td class="py-2 pr-4 whitespace-nowrap">
|
||||||
|
@if ($package->isDeployed())
|
||||||
|
<span class="px-2 py-0.5 text-xs font-semibold rounded bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200">
|
||||||
|
{{ $package->deployed_at?->format('Y.m.d H:i') }}
|
||||||
|
</span>
|
||||||
|
@else
|
||||||
|
<span class="text-gray-400">–</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="py-2 whitespace-nowrap">
|
||||||
|
<div class="flex items-center justify-end gap-1">
|
||||||
|
@if ($package->zip_path)
|
||||||
|
<x-filament::button size="xs" color="gray"
|
||||||
|
wire:click="downloadPackage({{ $package->id }})">
|
||||||
|
ZIP
|
||||||
|
</x-filament::button>
|
||||||
|
@endif
|
||||||
|
@unless ($package->isDeployed())
|
||||||
|
<x-filament::button size="xs" color="gray"
|
||||||
|
wire:click="markDeployed({{ $package->id }})"
|
||||||
|
wire:confirm="Megjelöljük kirakottként? Innentől ez lesz a következő csomag kezdő commitja ezen a környezeten.">
|
||||||
|
Kirakva
|
||||||
|
</x-filament::button>
|
||||||
|
@endunless
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</x-filament-panels::page>
|
||||||
@ -173,6 +173,18 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
];
|
];
|
||||||
|
// A deployment csomagoló kizárólag fejlesztői gépen érhető el (DeploymentGuard) -
|
||||||
|
// ugyanaz a canAccess() hívás, mint a modern speed-button-nav-bar.blade.php-ban.
|
||||||
|
if (\App\Filament\Pages\DeploymentPackage::canAccess()) {
|
||||||
|
$Modules['deploymentPackage'] = [
|
||||||
|
'name' => 'deploymentPackage',
|
||||||
|
'DisplayName' => 'Deployment',
|
||||||
|
'icon' => 'git',
|
||||||
|
'link' => \App\Filament\Pages\DeploymentPackage::getUrl(),
|
||||||
|
'noAjax' => true,
|
||||||
|
'roles' => ['root', 'developer'],
|
||||||
|
];
|
||||||
|
}
|
||||||
$Modules=json_decode(json_encode($Modules));
|
$Modules=json_decode(json_encode($Modules));
|
||||||
?>
|
?>
|
||||||
{{--
|
{{--
|
||||||
|
|||||||
92
tests/Feature/Deployment/DeploymentPackageAccessTest.php
Normal file
92
tests/Feature/Deployment/DeploymentPackageAccessTest.php
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Pages\DeploymentPackage;
|
||||||
|
use App\Models\FeatureFlag;
|
||||||
|
use App\Models\Role;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Deployment\DeploymentGuard;
|
||||||
|
use App\Services\FeatureFlagRegistrar;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Config;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
require_once __DIR__.'/DeploymentTestHelpers.php';
|
||||||
|
|
||||||
|
uses(TestCase::class, RefreshDatabase::class);
|
||||||
|
|
||||||
|
// Az oldal betöltéskor lekérdezi a környezetek deploy-version.json-ját - a tesztek
|
||||||
|
// nem indíthatnak valódi kimenő kérést.
|
||||||
|
beforeEach(function () {
|
||||||
|
Http::fake();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a kikapcsolt env kapcsoló mindenkitől elveszi a hozzáférést', function () {
|
||||||
|
deploymentAllowEnvironment();
|
||||||
|
Config::set('deployment.enabled', false);
|
||||||
|
|
||||||
|
$developer = deploymentDeveloper();
|
||||||
|
|
||||||
|
expect(app(DeploymentGuard::class)->isAllowed($developer))->toBeFalse()
|
||||||
|
->and(app(DeploymentGuard::class)->denialReason($developer))->toContain('DEPLOYMENT_PACKAGE_ENABLED');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a nem fejlesztői stage-en akkor sem érhető el, ha a kapcsoló be van kapcsolva', function () {
|
||||||
|
deploymentAllowEnvironment();
|
||||||
|
Config::set('app.stage', 'PROD');
|
||||||
|
|
||||||
|
$developer = deploymentDeveloper();
|
||||||
|
|
||||||
|
expect(app(DeploymentGuard::class)->isAllowed($developer))->toBeFalse()
|
||||||
|
->and(app(DeploymentGuard::class)->denialReason($developer))->toContain('fejlesztői környezetben');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a developer szerep nélküli felhasználó nem éri el', function () {
|
||||||
|
deploymentAllowEnvironment();
|
||||||
|
|
||||||
|
Role::create(['name' => 'admin', 'display_name' => 'Admin']);
|
||||||
|
$admin = User::factory()->create();
|
||||||
|
$admin->addRole(Role::where('name', 'admin')->first());
|
||||||
|
|
||||||
|
FeatureFlag::create([
|
||||||
|
'name' => 'DeploymentPackage',
|
||||||
|
'label' => 'Deployment csomagoló',
|
||||||
|
'enabled' => true,
|
||||||
|
'stages' => null,
|
||||||
|
'roles' => null,
|
||||||
|
]);
|
||||||
|
app(FeatureFlagRegistrar::class)->registerAll();
|
||||||
|
|
||||||
|
expect(app(DeploymentGuard::class)->isAllowed($admin))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a kikapcsolt feature flag elrejti a felületet a fejlesztő elől is', function () {
|
||||||
|
deploymentAllowEnvironment();
|
||||||
|
|
||||||
|
$developer = deploymentDeveloper(flagEnabled: false);
|
||||||
|
|
||||||
|
expect(app(DeploymentGuard::class)->isAllowed($developer))->toBeFalse()
|
||||||
|
->and(DeploymentPackage::canAccess())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('minden feltétel teljesülése esetén engedélyezett', function () {
|
||||||
|
deploymentAllowEnvironment();
|
||||||
|
|
||||||
|
$developer = deploymentDeveloper();
|
||||||
|
|
||||||
|
expect(app(DeploymentGuard::class)->isAllowed($developer))->toBeTrue()
|
||||||
|
->and(app(DeploymentGuard::class)->denialReason($developer))->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a jogosulatlan felhasználó 403-at kap az oldal betöltésekor', function () {
|
||||||
|
deploymentAllowEnvironment();
|
||||||
|
|
||||||
|
$developer = deploymentDeveloper(flagEnabled: false);
|
||||||
|
|
||||||
|
// Szándékosan valódi HTTP kérés: a Livewire::test() a HttpException-t válasszá
|
||||||
|
// alakítja (RequestBroker: withoutExceptionHandling([HttpException::class])), így
|
||||||
|
// ott nem a tényleges belépési pontot mérnénk.
|
||||||
|
$this->actingAs($developer)
|
||||||
|
->get(DeploymentPackage::getUrl())
|
||||||
|
->assertForbidden();
|
||||||
|
});
|
||||||
168
tests/Feature/Deployment/DeploymentPackageBuilderTest.php
Normal file
168
tests/Feature/Deployment/DeploymentPackageBuilderTest.php
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Services\Deployment\DeploymentPackageBuilder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Config;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
require_once __DIR__.'/DeploymentTestHelpers.php';
|
||||||
|
|
||||||
|
uses(TestCase::class, RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
Http::fake();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a teendők kérik a queue worker és a scheduler újraindítását, a cache ürítés után', function () {
|
||||||
|
deploymentAllowEnvironment();
|
||||||
|
$repository = deploymentTestRepository();
|
||||||
|
[$first, , $third] = $repository['commits'];
|
||||||
|
|
||||||
|
$package = app(DeploymentPackageBuilder::class)->build('d2d', 'test', $first, $third);
|
||||||
|
$teendok = File::get($package['path'].'/_TEENDOK.md');
|
||||||
|
|
||||||
|
expect($teendok)->toContain('docker service update --force d2d-environment_queue')
|
||||||
|
->and($teendok)->toContain('docker service update --force d2d-environment_scheduler')
|
||||||
|
// A 0 replikás scheduler miatt tudni kell, hogy a restart üresjárat lehet.
|
||||||
|
->and($teendok)->toContain('0 replikán fut');
|
||||||
|
|
||||||
|
// A restartnak a cache ürítés UTÁN kell jönnie: a frissen induló worker különben
|
||||||
|
// a régi, cache-elt konfigot töltené be.
|
||||||
|
expect(strpos($teendok, 'docker service update'))->toBeGreaterThan(strpos($teendok, 'config:clear'));
|
||||||
|
|
||||||
|
deploymentCleanup($repository['path']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a queue worker nélküli környezetnél is kiderül, mit kell majd újraindítani', function () {
|
||||||
|
deploymentAllowEnvironment();
|
||||||
|
$repository = deploymentTestRepository();
|
||||||
|
[$first, , $third] = $repository['commits'];
|
||||||
|
|
||||||
|
$package = app(DeploymentPackageBuilder::class)->build('e2e', 'main', $first, $third);
|
||||||
|
$teendok = File::get($package['path'].'/_TEENDOK.md');
|
||||||
|
|
||||||
|
expect($teendok)->toContain('docker service update --force t2t_scheduler')
|
||||||
|
->and($teendok)->toContain('t2t_queue');
|
||||||
|
|
||||||
|
deploymentCleanup($repository['path']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a teendők között szerepel a seeder futtatása a konkrét osztálynévvel', function () {
|
||||||
|
deploymentAllowEnvironment();
|
||||||
|
$repository = deploymentTestRepository();
|
||||||
|
[$first, , $third] = $repository['commits'];
|
||||||
|
|
||||||
|
$package = app(DeploymentPackageBuilder::class)->build('e2e', 'main', $first, $third);
|
||||||
|
$teendok = File::get($package['path'].'/_TEENDOK.md');
|
||||||
|
|
||||||
|
expect($teendok)->toContain('php artisan db:seed --class=TesztSeeder')
|
||||||
|
// A seeder puszta felmásolása nem futtatja le - ezt ki kell mondani.
|
||||||
|
->and($teendok)->toContain('NEM futtatja le')
|
||||||
|
// A Pennant purge feltételes: Eloquent mentésnél a FeatureFlagObserver elvégzi.
|
||||||
|
->and($teendok)->toContain('pennant:purge')
|
||||||
|
->and($teendok)->toContain('cache:clear');
|
||||||
|
|
||||||
|
deploymentCleanup($repository['path']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a csomag a commitolt tartalmat viszi, nem a working tree állapotát', function () {
|
||||||
|
deploymentAllowEnvironment();
|
||||||
|
$repository = deploymentTestRepository();
|
||||||
|
[$first, , $third] = $repository['commits'];
|
||||||
|
|
||||||
|
// A working tree szándékos elrontása a commit után: ez nem kerülhet a csomagba.
|
||||||
|
File::put($repository['path'].'/app/Árlista.php', "<?php\n// EZ NEM KERÜLHET A CSOMAGBA\n");
|
||||||
|
|
||||||
|
$package = app(DeploymentPackageBuilder::class)->build('e2e', 'main', $first, $third);
|
||||||
|
|
||||||
|
expect(File::get($package['path'].'/app/Árlista.php'))->not->toContain('NEM KERÜLHET')
|
||||||
|
// A .gitattributes eol=lf miatt a git archive LF-fel ír, Windowson is.
|
||||||
|
->and(File::get($package['path'].'/app/Árlista.php'))->not->toContain("\r\n");
|
||||||
|
|
||||||
|
deploymentCleanup($repository['path']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a csomagból kimarad a kizárt és a kézzel kivett fájl, a törlendők külön listára kerülnek', function () {
|
||||||
|
deploymentAllowEnvironment();
|
||||||
|
$repository = deploymentTestRepository();
|
||||||
|
[$first, , $third] = $repository['commits'];
|
||||||
|
|
||||||
|
$package = app(DeploymentPackageBuilder::class)->build('e2e', 'main', $first, $third, ['app/Elso.php']);
|
||||||
|
|
||||||
|
expect(File::exists($package['path'].'/app/Atnevezett.php'))->toBeTrue()
|
||||||
|
->and(File::exists($package['path'].'/app/Elso.php'))->toBeFalse()
|
||||||
|
->and(File::exists($package['path'].'/.env'))->toBeFalse()
|
||||||
|
->and(File::exists($package['path'].'/app/Regi.php'))->toBeFalse()
|
||||||
|
->and($package['counts']['skipped'])->toBe(1)
|
||||||
|
->and($package['counts']['missing'])->toBe(0);
|
||||||
|
|
||||||
|
$torlendo = File::get($package['path'].'/_TORLENDO.txt');
|
||||||
|
|
||||||
|
expect($torlendo)->toContain('app/Regi.php')
|
||||||
|
->and($torlendo)->toContain('app/Atnevezendo.php');
|
||||||
|
|
||||||
|
deploymentCleanup($repository['path']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a csomag rögzíti a kirakott verziót', function () {
|
||||||
|
deploymentAllowEnvironment();
|
||||||
|
$repository = deploymentTestRepository();
|
||||||
|
[$first, , $third] = $repository['commits'];
|
||||||
|
|
||||||
|
$package = app(DeploymentPackageBuilder::class)->build('e2e', 'main', $first, $third);
|
||||||
|
|
||||||
|
$version = json_decode(File::get($package['path'].'/public/deploy-version.json'), true);
|
||||||
|
|
||||||
|
expect($version['commit'])->toBe($third)
|
||||||
|
->and($version['target'])->toBe('e2e')
|
||||||
|
->and($version['branch'])->toBe('main');
|
||||||
|
|
||||||
|
deploymentCleanup($repository['path']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a limit fölötti fájlszámnál nem készül csomag', function () {
|
||||||
|
deploymentAllowEnvironment();
|
||||||
|
$repository = deploymentTestRepository();
|
||||||
|
[$first, , $third] = $repository['commits'];
|
||||||
|
|
||||||
|
Config::set('deployment.max_files', 1);
|
||||||
|
|
||||||
|
expect(fn () => app(DeploymentPackageBuilder::class)->build('e2e', 'main', $first, $third))
|
||||||
|
->toThrow(RuntimeException::class);
|
||||||
|
|
||||||
|
expect(File::exists(config('deployment.output_path')))->toBeFalse();
|
||||||
|
|
||||||
|
deploymentCleanup($repository['path']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a csomagoló szerveren akkor sem indul el, ha közvetlenül hívják', function () {
|
||||||
|
deploymentAllowEnvironment();
|
||||||
|
$repository = deploymentTestRepository();
|
||||||
|
[$first, , $third] = $repository['commits'];
|
||||||
|
|
||||||
|
Config::set('app.stage', 'PROD');
|
||||||
|
|
||||||
|
expect(fn () => app(DeploymentPackageBuilder::class)->build('e2e', 'main', $first, $third))
|
||||||
|
->toThrow(RuntimeException::class);
|
||||||
|
|
||||||
|
deploymentCleanup($repository['path']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a törlő szkript dry runban fut és ellenőrzi az app gyökeret', function () {
|
||||||
|
deploymentAllowEnvironment();
|
||||||
|
$repository = deploymentTestRepository();
|
||||||
|
[$first, , $third] = $repository['commits'];
|
||||||
|
|
||||||
|
$package = app(DeploymentPackageBuilder::class)->build('e2e', 'main', $first, $third);
|
||||||
|
$script = File::get($package['path'].'/_torles.sh');
|
||||||
|
|
||||||
|
expect($script)->toStartWith('#!/usr/bin/env bash')
|
||||||
|
->and($script)->toContain('[[ -f artisan && -d app ]]')
|
||||||
|
->and($script)->toContain('"app/Regi.php"')
|
||||||
|
// Windowson generáljuk, Linuxon fut: CRLF-fel a shebang sor törne el.
|
||||||
|
->and($script)->not->toContain("\r\n");
|
||||||
|
|
||||||
|
deploymentCleanup($repository['path']);
|
||||||
|
});
|
||||||
70
tests/Feature/Deployment/DeploymentPackageCreationTest.php
Normal file
70
tests/Feature/Deployment/DeploymentPackageCreationTest.php
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Pages\DeploymentPackage;
|
||||||
|
use App\Models\DeploymentPackage as DeploymentPackageRecord;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
require_once __DIR__.'/DeploymentTestHelpers.php';
|
||||||
|
|
||||||
|
uses(TestCase::class, RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
Http::fake();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a felületről indított csomagolás létrehozza a mappát', function () {
|
||||||
|
deploymentAllowEnvironment();
|
||||||
|
$repository = deploymentTestRepository();
|
||||||
|
[$first, , $third] = $repository['commits'];
|
||||||
|
|
||||||
|
$this->actingAs(deploymentDeveloper());
|
||||||
|
|
||||||
|
$component = Livewire::test(DeploymentPackage::class)
|
||||||
|
->call('selectFrom', $first)
|
||||||
|
->call('selectTo', $third)
|
||||||
|
->call('toggleFile', 'app/Elso.php')
|
||||||
|
->call('createPackage');
|
||||||
|
|
||||||
|
$package = $component->get('lastPackage');
|
||||||
|
|
||||||
|
// Alapértelmezett célkörnyezet a d2d: azt frissítjük sűrűbben.
|
||||||
|
expect($package)->not->toBeNull()
|
||||||
|
->and($package['name'])->toStartWith('deploy_d2d_')
|
||||||
|
->and(File::isDirectory($package['path']))->toBeTrue()
|
||||||
|
->and($package['skipped'])->toBe(['app/Elso.php']);
|
||||||
|
|
||||||
|
deploymentCleanup($repository['path']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a kirakottként megjelölt csomag adja a következő tartomány kezdetét', function () {
|
||||||
|
deploymentAllowEnvironment();
|
||||||
|
$repository = deploymentTestRepository();
|
||||||
|
[$first, $second] = $repository['commits'];
|
||||||
|
|
||||||
|
$this->actingAs(deploymentDeveloper());
|
||||||
|
|
||||||
|
// Az alapértelmezett célkörnyezetre készül, hogy a második oldalbetöltés is ezt töltse elő.
|
||||||
|
$record = DeploymentPackageRecord::create([
|
||||||
|
'name' => 'deploy_d2d_teszt',
|
||||||
|
'target' => 'd2d',
|
||||||
|
'branch' => 'main',
|
||||||
|
'from_commit' => $first,
|
||||||
|
'to_commit' => $second,
|
||||||
|
'folder' => $repository['path'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(DeploymentPackage::class)
|
||||||
|
->call('markDeployed', $record->id)
|
||||||
|
->assertSet('fromCommit', $second);
|
||||||
|
|
||||||
|
expect($record->refresh()->deployed_at)->not->toBeNull();
|
||||||
|
|
||||||
|
// Új oldalbetöltésnél is a kirakott commitról indul a tartomány.
|
||||||
|
Livewire::test(DeploymentPackage::class)->assertSet('fromCommit', $second);
|
||||||
|
|
||||||
|
deploymentCleanup($repository['path']);
|
||||||
|
});
|
||||||
70
tests/Feature/Deployment/DeploymentPackageDownloadTest.php
Normal file
70
tests/Feature/Deployment/DeploymentPackageDownloadTest.php
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Pages\DeploymentPackage;
|
||||||
|
use App\Models\DeploymentPackage as DeploymentPackageRecord;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
require_once __DIR__.'/DeploymentTestHelpers.php';
|
||||||
|
|
||||||
|
uses(TestCase::class, RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
Http::fake();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a csomagolás naplóbejegyzést és letölthető ZIP-et hagy maga után', function () {
|
||||||
|
deploymentAllowEnvironment();
|
||||||
|
$repository = deploymentTestRepository();
|
||||||
|
[$first, , $third] = $repository['commits'];
|
||||||
|
|
||||||
|
$this->actingAs(deploymentDeveloper());
|
||||||
|
|
||||||
|
Livewire::test(DeploymentPackage::class)
|
||||||
|
->call('selectFrom', $first)
|
||||||
|
->call('selectTo', $third)
|
||||||
|
->call('createPackage');
|
||||||
|
|
||||||
|
$record = DeploymentPackageRecord::query()->latest('id')->first();
|
||||||
|
|
||||||
|
expect($record)->not->toBeNull()
|
||||||
|
->and($record->target)->toBe('d2d')
|
||||||
|
->and($record->to_commit)->toBe($third)
|
||||||
|
->and($record->deployed_at)->toBeNull()
|
||||||
|
->and(File::exists($record->zip_path))->toBeTrue();
|
||||||
|
|
||||||
|
Livewire::test(DeploymentPackage::class)
|
||||||
|
->call('downloadPackage', $record->id)
|
||||||
|
->assertFileDownloaded();
|
||||||
|
|
||||||
|
deploymentCleanup($repository['path']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a kimeneti mappán kívüli ZIP útvonal nem tölthető le', function () {
|
||||||
|
deploymentAllowEnvironment();
|
||||||
|
$repository = deploymentTestRepository();
|
||||||
|
[$first, , $third] = $repository['commits'];
|
||||||
|
|
||||||
|
$this->actingAs(deploymentDeveloper());
|
||||||
|
|
||||||
|
// Elrontott (vagy szándékosan átírt) rekord: a fájl létezik, de a csomagoló
|
||||||
|
// kimeneti mappáján kívül van.
|
||||||
|
$record = DeploymentPackageRecord::create([
|
||||||
|
'name' => 'deploy_e2e_hamis',
|
||||||
|
'target' => 'e2e',
|
||||||
|
'branch' => 'main',
|
||||||
|
'from_commit' => $first,
|
||||||
|
'to_commit' => $third,
|
||||||
|
'folder' => $repository['path'],
|
||||||
|
'zip_path' => base_path('composer.json'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(DeploymentPackage::class)
|
||||||
|
->call('downloadPackage', $record->id)
|
||||||
|
->assertNoFileDownloaded();
|
||||||
|
|
||||||
|
deploymentCleanup($repository['path']);
|
||||||
|
});
|
||||||
95
tests/Feature/Deployment/DeploymentPackageGitTest.php
Normal file
95
tests/Feature/Deployment/DeploymentPackageGitTest.php
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Services\Deployment\ChangeSetAnalyzer;
|
||||||
|
use App\Services\Deployment\GitRepository;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
require_once __DIR__.'/DeploymentTestHelpers.php';
|
||||||
|
|
||||||
|
uses(TestCase::class, RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
Http::fake();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a commitlista a legfrissebbtől visszafelé, fájlszámmal együtt jön', function () {
|
||||||
|
$repository = deploymentTestRepository();
|
||||||
|
|
||||||
|
$commits = app(GitRepository::class)->commits('main', 10);
|
||||||
|
|
||||||
|
expect($commits)->toHaveCount(3)
|
||||||
|
->and($commits[0]['subject'])->toBe('harmadik commit')
|
||||||
|
->and($commits[2]['subject'])->toBe('első commit')
|
||||||
|
->and($commits[0]['hash'])->toHaveLength(40)
|
||||||
|
->and($commits[0]['file_count'])->toBe(2)
|
||||||
|
// A törzs a tárgysor nélkül jön, és nem tapad hozzá a --shortstat sora sem.
|
||||||
|
->and($commits[1]['body'])->toBe('A törzsben említett árlista részlet.')
|
||||||
|
->and($commits[0]['body'])->toBe('');
|
||||||
|
|
||||||
|
deploymentCleanup($repository['path']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a diff felismeri az új, módosult, törölt és átnevezett fájlokat', function () {
|
||||||
|
$repository = deploymentTestRepository();
|
||||||
|
[$first, , $third] = $repository['commits'];
|
||||||
|
|
||||||
|
$files = collect(app(GitRepository::class)->changedFiles($first, $third))->keyBy('path');
|
||||||
|
|
||||||
|
expect($files->get('app/Elso.php')['status'])->toBe('M')
|
||||||
|
->and($files->get('app/Regi.php')['status'])->toBe('D')
|
||||||
|
->and($files->get('app/Atnevezett.php')['status'])->toBe('R')
|
||||||
|
->and($files->get('app/Atnevezett.php')['old_path'])->toBe('app/Atnevezendo.php')
|
||||||
|
// Ékezetes útvonal: core.quotePath=false nélkül "app/\303\201rlista.php" jönne vissza.
|
||||||
|
->and($files->has('app/Árlista.php'))->toBeTrue();
|
||||||
|
|
||||||
|
deploymentCleanup($repository['path']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('az elemzés kizárja a tiltott fájlokat és külön kezeli a törlendőket', function () {
|
||||||
|
$repository = deploymentTestRepository();
|
||||||
|
[$first, , $third] = $repository['commits'];
|
||||||
|
|
||||||
|
$changedFiles = app(GitRepository::class)->changedFiles($first, $third);
|
||||||
|
$changeSet = app(ChangeSetAnalyzer::class)->analyze($changedFiles);
|
||||||
|
|
||||||
|
expect($changeSet['copied'])->toContain('app/Árlista.php', 'app/Atnevezett.php', 'app/Elso.php')
|
||||||
|
->and($changeSet['copied'])->not->toContain('.env')
|
||||||
|
->and($changeSet['counts']['excluded'])->toBe(1)
|
||||||
|
// A törölt fájl mellett az átnevezés régi útvonala is törlendő a célgépen.
|
||||||
|
->and($changeSet['deleted'])->toContain('app/Regi.php', 'app/Atnevezendo.php');
|
||||||
|
|
||||||
|
$titles = collect($changeSet['warnings'])->pluck('title')->implode(' | ');
|
||||||
|
|
||||||
|
expect($titles)->toContain('Migráció')
|
||||||
|
->and($titles)->toContain('Seeder')
|
||||||
|
->and($titles)->toContain('Törlendő fájl');
|
||||||
|
|
||||||
|
deploymentCleanup($repository['path']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('az érvénytelen commit azonosítót nem adja tovább a gitnek', function () {
|
||||||
|
$repository = deploymentTestRepository();
|
||||||
|
|
||||||
|
$git = app(GitRepository::class);
|
||||||
|
|
||||||
|
expect($git->resolveCommit('../../etc/passwd'))->toBeNull()
|
||||||
|
->and($git->resolveCommit('main; rm -rf /'))->toBeNull()
|
||||||
|
->and($git->resolveCommit('HEAD'))->toBeNull()
|
||||||
|
->and($git->resolveCommit($repository['commits'][0]))->toBe($repository['commits'][0]);
|
||||||
|
|
||||||
|
deploymentCleanup($repository['path']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a fordított tartomány felismerhető', function () {
|
||||||
|
$repository = deploymentTestRepository();
|
||||||
|
[$first, , $third] = $repository['commits'];
|
||||||
|
|
||||||
|
$git = app(GitRepository::class);
|
||||||
|
|
||||||
|
expect($git->isAncestor($first, $third))->toBeTrue()
|
||||||
|
->and($git->isAncestor($third, $first))->toBeFalse();
|
||||||
|
|
||||||
|
deploymentCleanup($repository['path']);
|
||||||
|
});
|
||||||
90
tests/Feature/Deployment/DeploymentPackagePageTest.php
Normal file
90
tests/Feature/Deployment/DeploymentPackagePageTest.php
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Pages\DeploymentPackage;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Http\Client\Factory;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
require_once __DIR__.'/DeploymentTestHelpers.php';
|
||||||
|
|
||||||
|
uses(TestCase::class, RefreshDatabase::class);
|
||||||
|
|
||||||
|
// Az oldal betöltéskor lekérdezi a környezetek deploy-version.json-ját - a tesztek
|
||||||
|
// nem indíthatnak valódi kimenő kérést.
|
||||||
|
beforeEach(function () {
|
||||||
|
Http::fake();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a keresés a commit törzsében is talál, nem csak a tárgysorban', function () {
|
||||||
|
deploymentAllowEnvironment();
|
||||||
|
$repository = deploymentTestRepository();
|
||||||
|
|
||||||
|
$this->actingAs(deploymentDeveloper());
|
||||||
|
|
||||||
|
Livewire::test(DeploymentPackage::class)
|
||||||
|
->set('data.search', 'árlista részlet')
|
||||||
|
->assertSee('második commit')
|
||||||
|
->assertDontSee('harmadik commit');
|
||||||
|
|
||||||
|
deploymentCleanup($repository['path']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('az oldal kilistázza a commitokat és a kijelölt tartomány fájljait', function () {
|
||||||
|
deploymentAllowEnvironment();
|
||||||
|
$repository = deploymentTestRepository();
|
||||||
|
[$first, , $third] = $repository['commits'];
|
||||||
|
|
||||||
|
$this->actingAs(deploymentDeveloper());
|
||||||
|
|
||||||
|
Livewire::test(DeploymentPackage::class)
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('harmadik commit')
|
||||||
|
->call('selectFrom', $first)
|
||||||
|
->call('selectTo', $third)
|
||||||
|
->assertSet('fromCommit', $first)
|
||||||
|
->assertSet('toCommit', $third)
|
||||||
|
->assertSee('app/Atnevezett.php')
|
||||||
|
->assertSee('app/Regi.php');
|
||||||
|
|
||||||
|
deploymentCleanup($repository['path']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('az oldal alapból a d2d környezetet ajánlja és betöltéskor lekérdezi az élő állapotot', function () {
|
||||||
|
deploymentAllowEnvironment();
|
||||||
|
$repository = deploymentTestRepository();
|
||||||
|
[, , $third] = $repository['commits'];
|
||||||
|
|
||||||
|
// A beforeEach-ben regisztrált catch-all stub minden URL-re illeszkedik, és a
|
||||||
|
// Http factory az ELSŐ találatot adja vissza - ezért itt új factory kell, különben
|
||||||
|
// az üres törzsű alapértelmezett válasz nyerne a konkrét stub helyett.
|
||||||
|
Http::swap(new Factory);
|
||||||
|
Http::fake([
|
||||||
|
'*deploy-version.json' => Http::response(['target' => 'd2d', 'commit' => $third, 'branch' => 'main']),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs(deploymentDeveloper());
|
||||||
|
|
||||||
|
$component = Livewire::test(DeploymentPackage::class);
|
||||||
|
|
||||||
|
expect($component->get('data')['target'])->toBe('d2d')
|
||||||
|
// A lekérdezés gomb nélkül, magától megtörténik.
|
||||||
|
->and($component->get('liveVersions')['d2d']['ok'])->toBeTrue()
|
||||||
|
->and($component->get('liveVersions')['d2d']['data']['commit'])->toBe($third);
|
||||||
|
|
||||||
|
deploymentCleanup($repository['path']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('az oldal nem fogad el kamu commit azonosítót', function () {
|
||||||
|
deploymentAllowEnvironment();
|
||||||
|
$repository = deploymentTestRepository();
|
||||||
|
|
||||||
|
$this->actingAs(deploymentDeveloper());
|
||||||
|
|
||||||
|
Livewire::test(DeploymentPackage::class)
|
||||||
|
->call('selectFrom', '../../etc/passwd')
|
||||||
|
->assertSet('fromCommit', null);
|
||||||
|
|
||||||
|
deploymentCleanup($repository['path']);
|
||||||
|
});
|
||||||
181
tests/Feature/Deployment/DeploymentTestHelpers.php
Normal file
181
tests/Feature/Deployment/DeploymentTestHelpers.php
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A deployment csomagoló tesztjeinek közös segédfüggvényei.
|
||||||
|
*
|
||||||
|
* A tesztek több fájlra vannak bontva, hogy a `--parallel` szét tudja osztani őket
|
||||||
|
* (a paratest FÁJLONKÉNT oszt, egyetlen nagy fájlból nem tud párhuzamosítani).
|
||||||
|
* Ez a fájl nem `*Test.php`, ezért a phpunit nem szedi fel teszt-fájlként.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Models\FeatureFlag;
|
||||||
|
use App\Models\Role;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\FeatureFlagRegistrar;
|
||||||
|
use Illuminate\Support\Facades\Config;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fejlesztői gépet szimuláló környezet: az .env kapcsoló és a stage whitelist rendben.
|
||||||
|
*/
|
||||||
|
function deploymentAllowEnvironment(): void
|
||||||
|
{
|
||||||
|
Config::set('deployment.enabled', true);
|
||||||
|
Config::set('app.stage', 'local');
|
||||||
|
}
|
||||||
|
|
||||||
|
function deploymentDeveloper(bool $flagEnabled = true): User
|
||||||
|
{
|
||||||
|
$role = Role::firstOrCreate(['name' => 'developer'], ['display_name' => 'Developer']);
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$user->addRole($role);
|
||||||
|
|
||||||
|
FeatureFlag::create([
|
||||||
|
'name' => 'DeploymentPackage',
|
||||||
|
'label' => 'Deployment csomagoló',
|
||||||
|
'enabled' => $flagEnabled,
|
||||||
|
'stages' => null,
|
||||||
|
'roles' => ['developer'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(FeatureFlagRegistrar::class)->registerAll();
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $arguments
|
||||||
|
*/
|
||||||
|
function deploymentGit(string $path, array $arguments): string
|
||||||
|
{
|
||||||
|
$process = new Process(array_merge(['git'], $arguments), $path);
|
||||||
|
$process->mustRun();
|
||||||
|
|
||||||
|
return $process->getOutput();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A minta-repó útvonala. A PID azért van benne, hogy párhuzamos futásnál (paratest)
|
||||||
|
* minden processznek saját mintája legyen.
|
||||||
|
*/
|
||||||
|
function deploymentTemplatePath(): string
|
||||||
|
{
|
||||||
|
return sys_get_temp_dir().DIRECTORY_SEPARATOR.'deployment-package-minta-'.getmypid();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determinisztikus minta-repó: átnevezés, törlés, ékezetes fájlnév és migráció is van benne.
|
||||||
|
*
|
||||||
|
* A 12 git parancs processzenként EGYSZER fut le - Windowson a processzindítás olyan drága,
|
||||||
|
* hogy tesztenként újraépítve ez önmagában ~4 mp lenne. A tesztek másolatot kapnak.
|
||||||
|
*
|
||||||
|
* @return array{path:string, commits:array<int, string>}
|
||||||
|
*/
|
||||||
|
function deploymentRepositoryTemplate(): array
|
||||||
|
{
|
||||||
|
static $template = null;
|
||||||
|
|
||||||
|
// Ha a minta időközben eltűnt (kézi takarítás, megszakadt futás), újraépítjük.
|
||||||
|
if ($template !== null && is_dir($template['path'])) {
|
||||||
|
return $template;
|
||||||
|
}
|
||||||
|
|
||||||
|
$elsoFelepites = $template === null;
|
||||||
|
$path = deploymentTemplatePath();
|
||||||
|
|
||||||
|
// Egy korábbi, megszakadt futás azonos PID-ű maradványa nem szennyezheti a mintát.
|
||||||
|
deploymentDeleteDirectory($path);
|
||||||
|
|
||||||
|
File::makeDirectory($path.'/app', 0777, true);
|
||||||
|
File::makeDirectory($path.'/database/migrations', 0777, true);
|
||||||
|
File::makeDirectory($path.'/database/seeders', 0777, true);
|
||||||
|
|
||||||
|
deploymentGit($path, ['init']);
|
||||||
|
deploymentGit($path, ['config', 'user.email', 'teszt@example.com']);
|
||||||
|
deploymentGit($path, ['config', 'user.name', 'Teszt']);
|
||||||
|
deploymentGit($path, ['config', 'commit.gpgsign', 'false']);
|
||||||
|
|
||||||
|
File::put($path.'/app/Elso.php', "<?php\n// elso\n");
|
||||||
|
File::put($path.'/app/Regi.php', "<?php\n// regi\n");
|
||||||
|
File::put($path.'/app/Atnevezendo.php', "<?php\n// atnevezendo\n");
|
||||||
|
deploymentGit($path, ['add', '-A']);
|
||||||
|
deploymentGit($path, ['commit', '-m', 'első commit']);
|
||||||
|
deploymentGit($path, ['branch', '-M', 'main']);
|
||||||
|
|
||||||
|
File::put($path.'/app/Elso.php', "<?php\n// elso modositva\n");
|
||||||
|
File::put($path.'/app/Árlista.php', "<?php\n// ekezetes\n");
|
||||||
|
File::put($path.'/database/migrations/2026_01_01_000000_teszt.php', "<?php\n// migracio\n");
|
||||||
|
File::put($path.'/database/seeders/TesztSeeder.php', "<?php\n// seeder\n");
|
||||||
|
File::put($path.'/.env', "APP_KEY=titok\n");
|
||||||
|
deploymentGit($path, ['add', '-A', '-f']);
|
||||||
|
deploymentGit($path, ['commit', '-m', "második commit\n\nA törzsben említett árlista részlet."]);
|
||||||
|
|
||||||
|
deploymentGit($path, ['mv', 'app/Atnevezendo.php', 'app/Atnevezett.php']);
|
||||||
|
deploymentGit($path, ['rm', 'app/Regi.php']);
|
||||||
|
deploymentGit($path, ['commit', '-m', 'harmadik commit']);
|
||||||
|
|
||||||
|
$log = trim(deploymentGit($path, ['log', '--reverse', '--pretty=format:%H']));
|
||||||
|
|
||||||
|
// A mintából minden teszt másol, tehát tilos beleírni - a sample hookokat viszont
|
||||||
|
// felesleges körbemásolni, azokra a git nem támaszkodik.
|
||||||
|
deploymentDeleteDirectory($path.'/.git/hooks');
|
||||||
|
|
||||||
|
// A takarítás a PROCESSZ végén fut. Pest afterAll() nem jó rá: az fájlonként futna
|
||||||
|
// le, és a következő teszt-fájl alól húzná ki a mintát.
|
||||||
|
if ($elsoFelepites) {
|
||||||
|
register_shutdown_function(fn () => deploymentDeleteDirectory(deploymentTemplatePath()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $template = ['path' => $path, 'commits' => explode("\n", $log)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A teszt saját, írható másolata a mintáról: a builder és a "working tree elrontása"
|
||||||
|
* tesztek beleírnak a repóba, ezért nem oszthatnak közös példányt.
|
||||||
|
*
|
||||||
|
* @return array{path:string, commits:array<int, string>}
|
||||||
|
*/
|
||||||
|
function deploymentTestRepository(): array
|
||||||
|
{
|
||||||
|
$template = deploymentRepositoryTemplate();
|
||||||
|
|
||||||
|
$path = sys_get_temp_dir().DIRECTORY_SEPARATOR.'deployment-package-test-'.uniqid();
|
||||||
|
|
||||||
|
File::copyDirectory($template['path'], $path);
|
||||||
|
|
||||||
|
Config::set('deployment.repo_path', $path);
|
||||||
|
Config::set('deployment.output_path', $path.DIRECTORY_SEPARATOR.'_kimenet');
|
||||||
|
|
||||||
|
return ['path' => $path, 'commits' => $template['commits']];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Facade nélküli törlés: a shutdown függvény a booteolt alkalmazáson kívül is fut.
|
||||||
|
*/
|
||||||
|
function deploymentDeleteDirectory(string $path): void
|
||||||
|
{
|
||||||
|
if (! is_dir($path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = new RecursiveIteratorIterator(
|
||||||
|
new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
|
||||||
|
RecursiveIteratorIterator::CHILD_FIRST
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($items as $item) {
|
||||||
|
// A .git/objects tartalma Windowson csak olvasható, ezért törlés előtt fel kell oldani.
|
||||||
|
@chmod($item->getPathname(), 0777);
|
||||||
|
|
||||||
|
$item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname());
|
||||||
|
}
|
||||||
|
|
||||||
|
@rmdir($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deploymentCleanup(string $path): void
|
||||||
|
{
|
||||||
|
deploymentDeleteDirectory($path);
|
||||||
|
}
|
||||||
108
tests/Feature/PricelistRichTextTest.php
Normal file
108
tests/Feature/PricelistRichTextTest.php
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Enums\PricelistFileLineStatusEnum;
|
||||||
|
use App\Enums\PricelistFileStatusEnum;
|
||||||
|
use App\Filament\Resources\PricelistFiles\RelationManagers\LinesRelationManager;
|
||||||
|
use App\Filament\Resources\PricelistFiles\Pages\ViewPricelistFile;
|
||||||
|
use App\Models\PricelistFile;
|
||||||
|
use App\Models\PricelistFileLine;
|
||||||
|
use App\Models\Role;
|
||||||
|
use App\Models\Supplier;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\PriceListService;
|
||||||
|
use App\Services\PricelistFileProcessService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use PhpOffice\PhpSpreadsheet\RichText\RichText;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
uses(TestCase::class, RefreshDatabase::class);
|
||||||
|
|
||||||
|
function invokeProtected(string $method, mixed ...$args): mixed
|
||||||
|
{
|
||||||
|
$service = app(PricelistFileProcessService::class);
|
||||||
|
$reflection = new ReflectionMethod($service, $method);
|
||||||
|
$reflection->setAccessible(true);
|
||||||
|
|
||||||
|
return $reflection->invoke($service, ...$args);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formázott (rich text) cella: a terméknév egy része félkövér. A PhpSpreadsheet ilyenkor
|
||||||
|
* nem stringet, hanem RichText objektumot ad vissza a getValue()-ból.
|
||||||
|
*/
|
||||||
|
function richTextCellValue(): RichText
|
||||||
|
{
|
||||||
|
$richText = new RichText();
|
||||||
|
$richText->createText('Trappista sajt ');
|
||||||
|
$richText->createTextRun('PRÉMIUM')->getFont()->setBold(true);
|
||||||
|
|
||||||
|
return $richText;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a rich text cella értéke sima szövegként normalizálódik', function () {
|
||||||
|
// json_encode a nyers objektumot üresre serializálná (a szövegrészek protected
|
||||||
|
// propertyben vannak), így a payloadba üres tömb kerülne és a szöveg elveszne.
|
||||||
|
expect(json_encode(['x' => richTextCellValue()]))->toBe('{"x":{}}');
|
||||||
|
|
||||||
|
expect(invokeProtected('normalizeCellValue', richTextCellValue()))
|
||||||
|
->toBe('Trappista sajt PRÉMIUM');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a normalizálás a skalárokat változatlanul hagyja', function () {
|
||||||
|
expect(invokeProtected('normalizeCellValue', 'Sajt'))->toBe('Sajt')
|
||||||
|
->and(invokeProtected('normalizeCellValue', 1250.5))->toBe(1250.5)
|
||||||
|
->and(invokeProtected('normalizeCellValue', null))->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a dátum cella szöveggé alakul, nem tömbbé', function () {
|
||||||
|
expect(invokeProtected('normalizeCellValue', new DateTimeImmutable('2026-08-24 10:30:00')))
|
||||||
|
->toBe('2026-08-24 10:30:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('az üres tömb hiányzó kötelező értéknek számít', function () {
|
||||||
|
// A régi rekordokban a rich text cellák üres tömbként landoltak. A korábbi,
|
||||||
|
// is_string() alapú vizsgálat ezeket KITÖLTÖTTNEK látta, így egy elveszett
|
||||||
|
// terméknév némán átment a kötelező mező ellenőrzésén.
|
||||||
|
expect(invokeProtected('isBlankPayloadValue', []))->toBeTrue()
|
||||||
|
->and(invokeProtected('isBlankPayloadValue', null))->toBeTrue()
|
||||||
|
->and(invokeProtected('isBlankPayloadValue', ' '))->toBeTrue()
|
||||||
|
->and(invokeProtected('isBlankPayloadValue', 'Sajt'))->toBeFalse()
|
||||||
|
->and(invokeProtected('isBlankPayloadValue', 0))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a már elmentett tömbös payload nem töri el a sorok táblát', function () {
|
||||||
|
$supplier = Supplier::factory()->create();
|
||||||
|
|
||||||
|
$file = PricelistFile::create([
|
||||||
|
'filename' => 'arlista.xlsx',
|
||||||
|
'supplier_id' => $supplier->id,
|
||||||
|
'available_date' => now()->addWeek()->toDateString(),
|
||||||
|
'status' => PricelistFileStatusEnum::waiting_for_approval,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Pontosan az az állapot, ami a d2d-n a nézetet eltörte:
|
||||||
|
// TextColumn::getDescriptionBelow(): Return value must be of type
|
||||||
|
// Htmlable|string|null, array returned
|
||||||
|
PricelistFileLine::create([
|
||||||
|
'pricelist_file_id' => $file->id,
|
||||||
|
'row_number' => 5,
|
||||||
|
'status' => PricelistFileLineStatusEnum::error,
|
||||||
|
'payload' => [
|
||||||
|
PriceListService::EXPECTED_HEADERS[0] => 'SKU-1',
|
||||||
|
PriceListService::EXPECTED_HEADERS[4] => [],
|
||||||
|
],
|
||||||
|
'validation_messages' => ['name' => 'Hiányzó kötelező mező'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$user->addRole(Role::create(['name' => 'admin', 'display_name' => 'Admin']));
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
Livewire::test(LinesRelationManager::class, [
|
||||||
|
'ownerRecord' => $file,
|
||||||
|
'pageClass' => ViewPricelistFile::class,
|
||||||
|
])
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('SKU-1');
|
||||||
|
});
|
||||||
@ -3,6 +3,7 @@
|
|||||||
namespace Tests;
|
namespace Tests;
|
||||||
|
|
||||||
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
||||||
|
use Illuminate\Support\Facades\ParallelTesting;
|
||||||
|
|
||||||
abstract class TestCase extends BaseTestCase
|
abstract class TestCase extends BaseTestCase
|
||||||
{
|
{
|
||||||
@ -10,14 +11,34 @@ protected function setUp(): void
|
|||||||
{
|
{
|
||||||
if (! $this->app) {
|
if (! $this->app) {
|
||||||
$this->refreshApplication();
|
$this->refreshApplication();
|
||||||
|
|
||||||
|
// A Laravel ezt a saját setUp()-jában csak akkor hívja meg, ha ŐT hozta létre az
|
||||||
|
// alkalmazást (InteractsWithTestCaseLifecycle: "if (! $this->app)"). Mi viszont
|
||||||
|
// előbb bootolunk - hogy a DB nevét a RefreshDatabase ELŐTT ellenőrizhessük -,
|
||||||
|
// ezért ott az az ág kimaradna, és a --parallel processzenkénti adatbázisa sosem
|
||||||
|
// jönne létre: minden processz a közös d2dTest-et migrálná egyszerre.
|
||||||
|
// Nem párhuzamos futásnál ez no-op (whenRunningInParallel).
|
||||||
|
ParallelTesting::callSetUpTestCaseCallbacks($this);
|
||||||
}
|
}
|
||||||
|
|
||||||
$currentDb = config('database.connections.mysql.database');
|
$this->guardTestDatabase();
|
||||||
$currentEnv = env('APP_ENV');
|
|
||||||
|
parent::setUp();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Megakadályozza, hogy a tesztek az éles adatbázison fussanak.
|
||||||
|
*
|
||||||
|
* A parent::setUp() ELŐTT kell lefutnia: a RefreshDatabase feltétel nélkül eldobja
|
||||||
|
* a táblákat, tehát utólag ellenőrizni már késő lenne.
|
||||||
|
*/
|
||||||
|
private function guardTestDatabase(): void
|
||||||
|
{
|
||||||
|
$database = (string) config('database.connections.mysql.database');
|
||||||
|
|
||||||
// SZIGORÚ ELLENŐRZÉS: A 'd2d' adatbázist SOHA nem szabad teszthez használni!
|
// SZIGORÚ ELLENŐRZÉS: A 'd2d' adatbázist SOHA nem szabad teszthez használni!
|
||||||
// Akkor is védünk, ha a config cache-elve van vagy az APP_ENV nem 'testing'.
|
// Akkor is védünk, ha a config cache-elve van vagy az APP_ENV nem 'testing'.
|
||||||
if (strtolower($currentDb) === 'd2d') {
|
if (strtolower($database) === 'd2d') {
|
||||||
echo "\n\n!!! KRITIKUS BIZTONSÁGI HIBA !!!\n";
|
echo "\n\n!!! KRITIKUS BIZTONSÁGI HIBA !!!\n";
|
||||||
echo "A tesztek az ÉLŐ (d2d) adatbázishoz kapcsolódnak!\n";
|
echo "A tesztek az ÉLŐ (d2d) adatbázishoz kapcsolódnak!\n";
|
||||||
echo "Ez az adatbázis törléséhez (RefreshDatabase) vezetne.\n";
|
echo "Ez az adatbázis törléséhez (RefreshDatabase) vezetne.\n";
|
||||||
@ -26,17 +47,31 @@ protected function setUp(): void
|
|||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (env('APP_ENV') !== 'testing') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Kényszerítsük a teszt adatbázist, ha testing környezetben vagyunk és nem d2d
|
// Kényszerítsük a teszt adatbázist, ha testing környezetben vagyunk és nem d2d
|
||||||
if ($currentEnv === 'testing' && strtolower($currentDb) !== 'd2dtest') {
|
if (! $this->isTestDatabase($database)) {
|
||||||
config(['database.connections.mysql.database' => 'd2dTest']);
|
config(['database.connections.mysql.database' => 'd2dTest']);
|
||||||
$currentDb = 'd2dTest';
|
$database = (string) config('database.connections.mysql.database');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Általános ellenőrzés, hogy a teszt adatbázis legyen használva
|
// Általános ellenőrzés, hogy a teszt adatbázis legyen használva
|
||||||
if (strtolower($currentDb) !== 'd2dtest' && $currentEnv === 'testing') {
|
if (! $this->isTestDatabase($database)) {
|
||||||
throw new \Exception('HIBA: A teszteknek a d2dTest adatbázison kellene futniuk. Aktuális konfiguráció: ' . $currentDb . '. Kérlek futtasd a "php artisan config:clear" parancsot!');
|
throw new \Exception('HIBA: A teszteknek a d2dTest adatbázison kellene futniuk. Aktuális konfiguráció: '.$database.'. Kérlek futtasd a "php artisan config:clear" parancsot!');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
parent::setUp();
|
/**
|
||||||
|
* Elfogadható a 'd2dTest', illetve a --parallel processzenkénti 'd2dTest_test_1' alakja
|
||||||
|
* (a nevet a Laravel adja: TestDatabases::testDatabase() -> "{$database}_test_{$token}").
|
||||||
|
*
|
||||||
|
* Szándékosan NEM egyszerű prefix-vizsgálat: a kézenfekvő str_starts_with($db, 'd2d')
|
||||||
|
* átengedné az éles 'd2d' nevet is.
|
||||||
|
*/
|
||||||
|
private function isTestDatabase(string $database): bool
|
||||||
|
{
|
||||||
|
return preg_match('/^d2dtest(_test_\d+)?$/', strtolower($database)) === 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user