d2d.emegrendeles.hu/app/Services/Deployment/GitRepository.php
E98Developer bfc1484611 ADD Deployment csomagoló phase3 napló, környezetkövetés, ZIP letöltés és törlő szkript
A csomagolás mostantól nyomot hagy: melyik környezetre mi készült, mi lett kirakva, és
mennyi a lemaradás - ez az, ami a "melyik verzió fut az e2e-n" kérdést kiveszi a fejből.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 07:20:34 +02:00

401 lines
13 KiB
PHP

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