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>
This commit is contained in:
parent
6027511585
commit
7ad9c6fea8
@ -4,6 +4,7 @@
|
||||
|
||||
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;
|
||||
@ -18,10 +19,10 @@
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Deployment csomagoló - fázis 1: célkörnyezet, commitlista és diff-előnézet.
|
||||
* Deployment csomagoló: célkörnyezet, commit-tartomány, diff-előnézet és csomagolás.
|
||||
*
|
||||
* Ez az oldal (a fázis 1-ben) semmit nem ír a fájlrendszerre, csak olvassa a git
|
||||
* történetet. A tényleges csomagolás a fázis 2-ben érkezik.
|
||||
* 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
|
||||
@ -50,6 +51,12 @@ class DeploymentPackage extends Page implements HasForms
|
||||
|
||||
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;
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
return app(DeploymentGuard::class)->isAllowed(auth()->user());
|
||||
@ -132,6 +139,67 @@ 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;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
use App\Models\User;
|
||||
use Laravel\Pennant\Feature;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* A deployment csomagoló hozzáférés-ellenőrzése egy helyen.
|
||||
@ -26,9 +27,12 @@ public function isAllowed(?User $user = null): bool
|
||||
}
|
||||
|
||||
/**
|
||||
* Az első nem teljesülő feltétel magyarázata, vagy null, ha minden rendben.
|
||||
* 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 denialReason(?User $user = null): ?string
|
||||
public function environmentDenialReason(): ?string
|
||||
{
|
||||
if (! config('deployment.enabled')) {
|
||||
return 'A deployment csomagoló ki van kapcsolva (DEPLOYMENT_PACKAGE_ENABLED).';
|
||||
@ -45,6 +49,18 @@ public function denialReason(?User $user = null): ?string
|
||||
);
|
||||
}
|
||||
|
||||
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.';
|
||||
}
|
||||
@ -66,4 +82,11 @@ public function ensureAllowed(?User $user = null): void
|
||||
abort(403, $reason);
|
||||
}
|
||||
}
|
||||
|
||||
public function ensureEnvironmentAllowed(): void
|
||||
{
|
||||
if ($reason = $this->environmentDenialReason()) {
|
||||
throw new RuntimeException($reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
354
app/Services/Deployment/DeploymentPackageBuilder.php
Normal file
354
app/Services/Deployment/DeploymentPackageBuilder.php
Normal file
@ -0,0 +1,354 @@
|
||||
<?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));
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Process\Process;
|
||||
use ZipArchive;
|
||||
|
||||
/**
|
||||
* Csak olvasó git wrapper a deployment csomagolóhoz.
|
||||
@ -227,6 +228,98 @@ public function changedFiles(string $from, string $to): array
|
||||
return $files;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@ -35,10 +35,33 @@
|
||||
(a kezdő commit kizárva, a záró beleértve).
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Ez a felület jelenleg <strong>csak olvas</strong> — a csomag előállítása a következő fázisban készül el.
|
||||
A csomag ide készül: <code>{{ config('deployment.output_path') }}</code>
|
||||
</p>
|
||||
</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>
|
||||
@ -183,6 +206,7 @@ class="{{ $buttonClass }} ml-1 {{ $isTo ? 'border-primary-600 bg-primary-600 tex
|
||||
<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>
|
||||
@ -192,8 +216,19 @@ class="{{ $buttonClass }} ml-1 {{ $isTo ? 'border-primary-600 bg-primary-600 tex
|
||||
@foreach ($changeSet['files'] as $file)
|
||||
@php
|
||||
$style = $statusStyles[$file['status']] ?? ['label' => $file['status'], 'class' => 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'];
|
||||
$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'] ? 'opacity-50' : '' }}">
|
||||
<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">
|
||||
<span class="px-2 py-0.5 text-xs font-semibold rounded {{ $style['class'] }}">
|
||||
{{ $file['status'] }} · {{ $style['label'] }}
|
||||
@ -207,7 +242,7 @@ class="{{ $buttonClass }} ml-1 {{ $isTo ? 'border-primary-600 bg-primary-600 tex
|
||||
</td>
|
||||
<td class="py-2 whitespace-nowrap text-gray-600 dark:text-gray-300">
|
||||
@switch($file['action'])
|
||||
@case('copy') másolandó @break
|
||||
@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
|
||||
@ -218,6 +253,32 @@ class="{{ $buttonClass }} ml-1 {{ $isTo ? 'border-primary-600 bg-primary-600 tex
|
||||
</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>
|
||||
|
||||
<button type="button"
|
||||
wire:click="createPackage"
|
||||
wire:confirm="Elkészítjük a csomagot: {{ $packageCount }} fájl{{ $target ? ' a(z) '.$target['label'].' környezethez' : '' }}. Folytatod?"
|
||||
wire:loading.attr="disabled"
|
||||
@disabled($packageCount === 0 && $changeSet['counts']['deleted'] === 0)
|
||||
class="px-4 py-2 text-sm font-semibold text-white rounded bg-primary-600 hover:bg-primary-500 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span wire:loading.remove wire:target="createPackage">Csomag készítése</span>
|
||||
<span wire:loading wire:target="createPackage">Csomagolás…</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
use App\Models\User;
|
||||
use App\Services\Deployment\ChangeSetAnalyzer;
|
||||
use App\Services\Deployment\DeploymentGuard;
|
||||
use App\Services\Deployment\DeploymentPackageBuilder;
|
||||
use App\Services\Deployment\GitRepository;
|
||||
use App\Services\FeatureFlagRegistrar;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@ -96,6 +97,7 @@ function deploymentTestRepository(): array
|
||||
$log = trim(deploymentGit($path, ['log', '--reverse', '--pretty=format:%H']));
|
||||
|
||||
Config::set('deployment.repo_path', $path);
|
||||
Config::set('deployment.output_path', $path.DIRECTORY_SEPARATOR.'_kimenet');
|
||||
|
||||
return ['path' => $path, 'commits' => explode("\n", $log)];
|
||||
}
|
||||
@ -293,6 +295,112 @@ function deploymentCleanup(string $path): void
|
||||
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 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');
|
||||
|
||||
expect($package)->not->toBeNull()
|
||||
->and($package['name'])->toStartWith('deploy_e2e_')
|
||||
->and(File::isDirectory($package['path']))->toBeTrue()
|
||||
->and($package['skipped'])->toBe(['app/Elso.php']);
|
||||
|
||||
deploymentCleanup($repository['path']);
|
||||
});
|
||||
|
||||
test('az oldal nem fogad el kamu commit azonosítót', function () {
|
||||
deploymentAllowEnvironment();
|
||||
$repository = deploymentTestRepository();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user