d2d.emegrendeles.hu/app/Services/Deployment/DeploymentPackageBuilder.php
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

355 lines
13 KiB
PHP

<?php
namespace App\Services\Deployment;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Carbon;
use RuntimeException;
/**
* 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, 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->writeInstructions($path, $manifest, $target);
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.',
];
if ($manifest['deleted'] !== []) {
$steps[] = sprintf('Töröld a `_TORLENDO.txt`-ben felsorolt %d fájlt a célgépen.', $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'], '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.';
}
}
$steps[] = 'Ürítsd a cache-t: `php artisan config:clear && php artisan view:clear && php artisan route:clear`';
$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");
}
/**
* 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));
}
}