Compare commits

...

15 Commits

Author SHA1 Message Date
E98Developer
3cfaa6c9d5 FIX EV3-357 rich text cellák kezelése az árlista beolvasásban
A d2d-n a megrendelő fájljának megnyitásakor a sorok táblája eltört:
TextColumn::getDescriptionBelow(): Return value must be of type
Htmlable|string|null, array returned.

Nem UI-hiba volt, hanem adatvesztés. 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), a PhpSpreadsheet
getValue()-ja RichText objektumot ad vissza. A json_encode ezt ÜRESRE
serializálja (a szövegrészek protected propertyben vannak), így a payloadba
{} kerül, ami visszaolvasva üres tömb - a cella tartalma nyomtalanul eltűnt,
a felület pedig tömböt kapott string helyett.

- normalizeCellValue(): a cella értéke a payloadba kerülés ELŐTT skalárrá
  alakul (RichText -> getPlainText, dátum -> szöveg, tömb -> összefűzött
  szöveg, __toString-es objektum -> string)
- LinesRelationManager::payloadText(): a MÁR ELMENTETT rekordokban maradt
  tömbök nem törhetik el a nézetet - a beolvasási javítás azokat
  visszamenőleg nem gyógyítja meg
- isBlankPayloadValue(): a kötelező mező ellenőrzése is_string() alapú volt,
  ezért az üres tömböt KITÖLTÖTTNEK látta - egy elveszett terméknév némán
  átment a validáláson. Ugyanez a védelem a Hooreyca-blokk vizsgálatában és a
  végrehajtás mezőkonverziójában is (utóbbi különben szó szerint "Array"
  néven hozta volna létre a terméket).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 13:51:11 +02:00
E98Developer
11a18eb1a6 ADD CLAUDE.md a fejlesztői munkafolyamat rögzítésére
A teszt-optimalizálás során több olyan szabály és csapda derült ki, ami a kódból
nem olvasható ki, és amit egy új session vagy a jövőbeli önmagunk könnyen
elrontana:

- A teljes suite --parallel-lel fut, EGY tesztre viszont ne: ott a hat processz
  bootja tiszta veszteség.
- A tests/TestCase.php DB-név mintáját nem szabad str_starts_with($db, 'd2d')-re
  lazítani, mert az átengedné az éles adatbázist.
- A ParallelTesting::callSetUpTestCaseCallbacks() hívás nem felesleges: enélkül
  a processzenkénti adatbázis sosem jön létre.
- A mysql kliens PATH-on léte nélkül az egész suite elszáll, nem csak lassabb.
- A bootstrap/cache/blade-icons.php adja a boot idejének a felét.
- Új migráció után schema:dump kell, --prune viszont soha.
- A deployment tesztek fájljai ne nőjenek 35 mp fölé.

A deploy szakasz azért került bele, mert a kézi fájlmásolás egyszer valódi éles
incidenst okozott, és a queue worker újraindításának sorrendje sem magától
értetődő.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 12:22:18 +02:00
E98Developer
98cf955354 FIX DeploymentPackageTest felbontása hat fájlra a párhuzamos futtatáshoz
A paratest FÁJLONKÉNT osztja szét a munkát, ezért egyetlen 28 tesztes fájlból
nem tudott párhuzamosítani: egy processz vitte a teljes 137 mp-et, a másik öt
üresjáratban állt. A --parallel emiatt korábban egyenesen lassabb volt
(136 -> 143 mp), a teljes suite-on pedig csak 11%-ot hozott.

A felosztás témák szerint történt, de mért időkre kalibrálva, hogy egyik fájl
se domináljon (a leghosszabb a Builder, ~35 mp):

  AccessTest    6 teszt  ~7 mp   jogosultság, feature flag, 403
  GitTest       5 teszt ~18 mp   GitRepository, ChangeSetAnalyzer
  BuilderTest   9 teszt ~35 mp   csomagépítés, _TEENDOK, _torles.sh, verzió
  PageTest      4 teszt ~21 mp   commitlista, keresés, kiválasztás
  CreationTest  2 teszt ~32 mp   felületről indított csomagolás, kirakottnak jelölés
  DownloadTest  2 teszt ~25 mp   naplóbejegyzés, ZIP letöltés, útvonal-ellenőrzés

A két legdrágább teszt (21,7 és 20,4 mp) szándékosan külön fájlba került,
együtt hagyva ők adnák a padlót.

A közös segédfüggvények a DeploymentTestHelpers.php-ba kerültek. Ez nem
*Test.php, ezért a phpunit nem szedi fel teszt-fájlként. A minta-repót takarító
afterAll() helyére register_shutdown_function lépett: az afterAll fájlonként
futna le, és a következő fájl alól húzná ki a mintát.

Mért eredmény (6 mag): ezek a tesztek sorosan 137,00 mp, párhuzamosan
67,88 mp. Teljes suite párhuzamosan 149,78 -> 77,52 mp.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 10:48:31 +02:00
E98Developer
a9758e8da7 ADD Séma dump a 103 migráció kiváltására a teszteknél
A migrate:fresh eddig 103 migrációt futtatott le minden teszt-futásnál. A dump
egyetlen SQL fájlként tölti be ugyanazt a 48 táblát: migrate:fresh 21,09 ->
12,07 mp, egy teszt --filter-rel 27,80 -> 20,88 mp.

A dumpot a Laravel csak akkor tölti be, ha a migrations tábla ÜRES
(MigrateCommand::prepareDatabase), tehát az e2e/d2d környezetek - ahol már
vannak lefutott migrációk - változatlanul inkrementálisan migrálnak. Ezért
--prune NÉLKÜL készült: a 103 migrációs fájl a helyén marad.

Karbantartás: minden új migráció után újra kell generálni (php artisan
schema:dump). Ha elmarad, az nem törik el semmit, csak a dump utáni
migrációkat egyenként futtatja le.

A betöltéshez a mysql kliens kell a PATH-on
(C:\www\envkit\services\mysql\11.8.2\bin).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 10:34:26 +02:00
E98Developer
947eccff90 FIX Teszt párhuzamos futtatás processzenkénti adatbázissal
A --parallel eddig két okból nem működött volna:

1. A setUp() a parent::setUp() ELŐTT hívja a refreshApplication()-t, hogy a DB
   nevét a RefreshDatabase előtt ellenőrizhesse. A Laravel viszont a
   ParallelTesting::callSetUpTestCaseCallbacks()-ot csak az "if (! $this->app)"
   ágban futtatja (InteractsWithTestCaseLifecycle), ami így kimaradt - a
   processzenkénti adatbázis sosem jött létre, minden processz a közös
   d2dTest-et migrálta volna egyszerre. A callbacket most magunk hívjuk meg;
   nem párhuzamos futásnál no-op.

2. A névellenőrzés pontos "d2dtest" egyezést várt, és eltérésnél nem hibát
   dobott, hanem VISSZAÍRTA a configot d2dTest-re - vagyis csendben közös
   adatbázisra terelte volna a processzeket.

A név mostantól a /^d2dtest(_test_\d+)?$/ mintát követi, ami a Laravel
TestDatabases::testDatabase() elnevezését fedi le. Szándékosan nem
str_starts_with($db, 'd2d'): az átengedné az éles adatbázist is. Az éles d2d
elleni azonnali exit(1) változatlan.

Ellenőrizve: tiszta lapról a párhuzamos futás létrehozta a d2dtest_test_1..6
adatbázisokat, a sima futás változatlanul működik.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 10:34:13 +02:00
E98Developer
11918b608d FIX DeploymentPackageTest git fixture újrahasznosítás és temp takarítás
A temp git repó eddig tesztenként épült újra 12 git processzel, ami Windowson
önmagában ~4 mp volt tesztenként. Mostantól futásonként EGYSZER épül fel, a
tesztek pedig másolatot kapnak róla (0,138 mp másolás + 0,177 mp takarítás).
Minden teszt saját, írható példányon dolgozik, így a "working tree elrontása"
és a builder-tesztek továbbra is írhatnak a repóba.

Mérés a teljes fájlon, azonos gépen, 28/28 zölddel: 384,05 mp -> 268,38 mp.

A régi deploymentCleanup a Windows read-only .git/objects fájljain elbukott és
a hibát csendben elnyelte, ezért árva mappákat hagyott a temp-ben (164 gyűlt
össze). Az új, facade nélküli deploymentDeleteDirectory nem hagy maga után
semmit, és az afterAll a booteolt alkalmazáson kívül is tud takarítani.

A minta útvonalában getmypid() van, hogy a tervezett --parallel futásnál is
processzenként külön mintát használjunk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 08:45:05 +02:00
E98Developer
2965f231ce ADD Deployment csomagoló queue worker és scheduler újraindítás a teendők közé
A 2026-08-17-i eset általánosítása: a d2d/e2e Docker Swarmban ugyanazt a kódot több
service futtatja közös volume-ról, és a queue:work hosszan futó folyamat a betöltött
osztályokat a memóriájában tartja. A fájlmásolás után a worker RÉGI kóddal dolgozta fel
a jobot - a felület jónak látszott, a háttérfeladat némán a régi viselkedést hozta.

- config: 'runtime' minta (app/, config/, database/, routes/, bootstrap/, composer.lock),
  mert a restart nem csak a jobok változásakor kell; targetenként a service nevek és a
  Swarm manager elérése
- a felületen figyelmeztetés, a teendőkben a konkrét ssh + docker service update parancs
- a restart szándékosan a cache ürítés UTÁN: a frissen induló worker különben a régi,
  cache-elt konfigot töltené be
- a d2d schedulernél jelezzük, hogy 0 replikán fut (a restart üresjárat lehet), a t2t-nél
  pedig azt, hogy queue service még nincs, de a névkonvenció alapján t2t_queue lesz

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 06:41:19 +02:00
E98Developer
4bb89ae6a1 FIX Deployment csomagoló d2d alapértelmezés és automatikus környezet-állapot
- A célkörnyezet alapértelmezése a d2d (azt frissítjük sűrűbben). A config targets
  tömbjének sorrendje adja az alapértelmezést, ezért csak a sorrend cserélődött.
- A "Környezetek állapota" panel betöltéskor magától lekérdezi a deploy-version.json-t,
  percenkénti cache-sel (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). A Frissítés gomb kényszerít.
- A panel tömörebb, és valóban két hasábos: a md:grid-cols-2 nincs benne a lefordított
  CSS-ben, ezért saját, media query-s stílussal oldjuk meg - így build nélkül is működik.
- A commitlistában a hash mellett jelöljük, melyik környezet áll azon a commiton:
  zöld a szervertől lekérdezett, szürke a napló szerinti állapot.

Teszt: a beforeEach Http::fake() catch-all stubja minden URL-re illeszkedik és a factory
az első találatot adja vissza, ezért a konkrét választ váró teszt új factory-t kap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 22:48:09 +02:00
E98Developer
c8d5e01490 ADD Deployment csomagoló seeder-felismerés a teendők közé
Éles használat közben derült ki: a csomagba került egy seeder, de a _TEENDOK.md nem
szólt róla. A seeder felmásolása önmagában nem csinál semmit, tehát csendben kimaradt
egy lépés - pont az a hibatípus, ami ellen az egész eszköz készült.

- config: új attention csoport a database/seeders/* útvonalakra
- a felületen figyelmeztetés, a teendőkben a konkrét osztálynévvel:
  php artisan db:seed --class=<Osztály>
- mellé egy feltételes cache-lépés: a Pennant purge Eloquent mentésnél NEM kell (a
  FeatureFlagObserver::saved() elvégzi), csak query builderes írás után; a laratrust
  cache pedig csak szerepkör/jogosultság hozzárendelés változásakor, mert a saját
  cache-ét a felhasználóhoz kötve tárolja

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 22:23:11 +02:00
E98Developer
9bc723bd83 FIX Deployment csomagoló a gombok és badge-ek Filament komponensekre cserélése
A "Csomag készítése" gomb a felületen láthatatlan volt: a text-white benne van a
lefordított CSS-ben, a bg-primary-600 viszont nincs, így fehér szöveg került fehér
kártyára. Ugyanezért jelentek meg a státusz-badge-ek is színtelen szövegként.

Ok: egy új blade fájlban használt Tailwind utility class csak npm run build után kerül
be a bundle-be (a theme.css scanneli a resources/views/filament mappát, de a CSS-t
nem generálja újra magától). Ezért a kézzel stílusozott elemek helyett a Filament saját
button/badge komponenseit használjuk, amiknek a stílusa már a lefordított CSS-ben van -
így a felület build nélkül is helyesen jelenik meg.

A színes figyelmeztető dobozok maradtak nyers utility class-okkal: azok build hiányában
is olvashatók (keretes doboz), csak a színüket kapják meg npm run build után.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 08:23:43 +02:00
E98Developer
b8fd71ed23 FIX Deployment csomagoló a 403-teszt valódi HTTP kérésre váltása
A Livewire::test() a HttpException-t válasszá alakítja és nem dobja tovább
(RequestBroker: withoutExceptionHandling([HttpException::class])), ezért a toThrow()
sosem teljesült volna. A jogosultság-ellenőrzés valódi belépési pontja az oldal URL-je,
így a teszt most GET-tel kéri le és assertForbidden()-nel ellenőriz.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 07:34:29 +02:00
E98Developer
bfc1484611 ADD Deployment csomagoló phase3 napló, környezetkövetés, ZIP letöltés és törlő szkript
A csomagolás mostantól nyomot hagy: melyik környezetre mi készült, mi lett kirakva, és
mennyi a lemaradás - ez az, ami a "melyik verzió fut az e2e-n" kérdést kiveszi a fejből.

- deployment_packages tábla + modell (BaseAuditable): target, tartomány, darabszámok,
  mappa/ZIP útvonal, deployed_at. A csomagolás és a tényleges kirakás két külön esemény.
- Targetenkénti előtöltés: a kezdő commit az adott környezetre utoljára kirakottként
  megjelölt csomag to_commit-je. Rebase után nem létező hash esetén inkább üres marad,
  mint hogy hamis tartományt mutasson.
- "Környezetek állapota" panel: napló szerinti állapot + commit-lemaradás
  (git rev-list --count), és gombra a szerver deploy-version.json-jának lekérdezése.
  Ha a kettő eltér, az elmaradt vagy félbemaradt feltöltés jele. Szándékosan gombra fut,
  nem minden rendereléskor: így nem indul kimenő kérés magától.
- ZIP a kész mappából (a fájllista a zip létrejötte előtt készül, így nem csomagolja
  magát), letöltés a naplóból. A DB-ből jövő útvonalat kiírás előtt a kimeneti mappához
  kötjük, hogy egy módosított rekord se tehessen letölthetővé tetszőleges fájlt.
- _torles.sh: alapból dry run, --confirm kell a törléshez, app-gyökér ellenőrzéssel,
  abszolút útvonal és .. kiszűrésével, LF sorvéggel és BOM nélkül. Lefuttatva ellenőrizve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 07:20:34 +02:00
E98Developer
7ad9c6fea8 ADD Deployment csomagoló phase2 csomag előállítása (mappa, manifest, törlendők, verziójelölő)
A kijelölt tartományból elkészül a feltölthető mappa: deploy_<target>_<időbélyeg>, benne
a fájlok eredeti relatív útvonalon, hogy a feltöltés mappa-összeolvasztás legyen.

- GitRepository::archiveTo(): git archive --format=zip + ZipArchive kicsomagolás, ~100
  fájlonként darabolva a parancssor-limit miatt. A tartalom a git objektumtárból jön, nem a
  working tree-ből: így nem szivárog ki commitolatlan módosítás, és a .gitattributes eol=lf
  miatt LF sorvéggel kerül a Linux célgépre. Üres pathspec esetén nem hívunk gitet, mert az
  a TELJES fát csomagolná.
- DeploymentPackageBuilder: _MANIFEST.json/.txt (sha1 + méret fájlonként), _TORLENDO.txt a
  törölt és az átnevezett fájlok régi útvonalával, _TEENDOK.md a diffből származó lépésekkel
  (migrate, composer install, asset-figyelmeztetés, verzió-ellenőrzés), és public/
  deploy-version.json a kirakott commit hashével.
- A git archive által kihagyott fájlok (pl. .gitattributes export-ignore) külön "missing"
  listára kerülnek - csendben hiányzó fájl a manuális deploynál a legrosszabb hiba.
- Felületen fájlonkénti kézi kivétel, megerősítéses csomagolás gomb, eredmény-kártya.
- DeploymentGuard::ensureEnvironmentAllowed(): a builder felhasználó nélkül is ellenőrzi az
  env kapcsolót és a stage whitelistet, így konzolról indítva sem fut le egy szerveren.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 07:09:05 +02:00
E98Developer
6027511585 FIX Deployment csomagoló phase1 git ikon és a commit törzsére kiterjesztett keresés
- a topbar menüpont a most bemásolt git ikont használja; a fájl ico_gitpng néven
  érkezett, átnevezve ico_git.png-re, hogy illeszkedjen az ico_*.png konvencióhoz,
  amiből a nav az útvonalat építi
- a git log a törzset (%b) is elkéri, és a keresés a tárgysor mellett ebben is keres:
  a hash-re ritkán keres ember, az érdemi leírás viszont gyakran a törzsben van
- a --shortstat sora a törzs után érkezik, ezért a parse leválasztja róla, különben
  a "N files changed" szöveg a törzsbe és a keresésbe is beszivárogna

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 06:59:04 +02:00
E98Developer
09eed3eeb3 ADD Deployment csomagoló phase1 védelem, git olvasó és read-only commit/diff felület
Két commit közötti fájlok összegyűjtését előkészítő felület első fázisa: célkörnyezet
választás (e2e/d2d), commitlista, diff-előnézet figyelmeztetésekkel. Ez a fázis még
semmit nem ír a fájlrendszerre, a csomagolás a phase2-ben érkezik.

- DeploymentGuard: .env kapcsoló + hardkódolt stage whitelist + developer szerep +
  feature flag; a flag szándékosan nem biztonsági réteg, csak láthatóság-vezérlés
- GitRepository: csak olvasó wrapper, argumentum-tömbös Symfony Process (nincs shell),
  hash- és referencia-validáció, core.quotePath=false az ékezetes útvonalakhoz
- ChangeSetAnalyzer: kizárási lista, törlendő/másolandó szétválasztás (átnevezésnél
  mindkettő), figyelmeztetések migrációra, composer.lock-ra és a nem verziókövetett
  fordított assetekre

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 06:45:09 +02:00
26 changed files with 4874 additions and 15 deletions

105
CLAUDE.md Normal file
View 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.

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

View File

@ -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) {

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

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

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

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

View 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());
}
}

View 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();
}
}

View File

@ -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
View File

@ -0,0 +1,179 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| 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.',
],
],
];

View File

@ -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');
}
};

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -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">

View 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>

View File

@ -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));
?> ?>
{{-- {{--

View 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();
});

View 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']);
});

View 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']);
});

View 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']);
});

View 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']);
});

View 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']);
});

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

View 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');
});

View File

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