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>
511 lines
20 KiB
PHP
511 lines
20 KiB
PHP
<?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));
|
|
}
|
|
}
|