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>
401 lines
13 KiB
PHP
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();
|
|
}
|
|
}
|