Files
Gnuboard7/upgrades/data/7.0.0-beta.6/migrations/01_BackfillNewFilesManifest.php
T
2026-05-14 11:13:08 +09:00

310 lines
11 KiB
PHP

<?php
namespace App\Upgrades\Data\V7_0_0_beta_6\Migrations;
use App\Extension\Upgrade\DataMigration;
use App\Extension\UpgradeContext;
use FilesystemIterator;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use SplFileInfo;
/**
* beta.5 사용자의 사후 manifest 작성 — 자동 롤백 신규 파일 prune 의 한계 보완.
*
* 배경: beta.5 → beta.6 업그레이드 시 부모 프로세스(beta.5 CoreUpdateCommand) 는 본
* 브랜치의 Step 6.5 / restoreFromBackup 확장 로직을 모른다. 따라서 부모 catch 가
* 트리거되어도 manifest 가 없어 자동 prune 이 동작하지 않는다.
*
* 다만 Step 10 의 spawn 자식은 beta.6 메모리로 실행되므로, 그 시점에 사후 manifest 를
* 백업 디렉토리에 작성할 수 있다. 부모 catch 의 즉시 자동 prune 효과는 없지만:
*
* - 백업 디렉토리가 보존된 채로 fatal 이 발생한 경우 (Step 10 이후 ~ Step 11 cleanup
* 직전 구간), 운영자 수동 복구 / `hotfix:rollback-stale-files` 가 manifest 를 활용
* 가능
* - beta.6 이후의 업그레이드 사이클부터는 Step 6.5 가 정상 동작하므로 이중 보장
*
* 격리 원칙 (docs/extension/upgrade-step-guide.md §4, §13):
* - `CoreBackupHelper::writeNewFilesManifest()` 등 신 코드 직접 호출 금지
* - 모든 비교/직렬화 로직을 본 클래스의 private 메서드로 중복 구현
* - manifest JSON 스키마는 `CoreBackupHelper::MANIFEST_SCHEMA_VERSION = 1` 과 바이트
* 단위 호환 (회귀 테스트 Beta6BackfillManifestTest 시나리오 4 가 invariant 강제)
*
* 멱등성: manifest 가 이미 존재하면 noop (created_at 변동 없음). 백업 디렉토리 부재
* (예: --no-backup 모드로 부모가 진행) 시 정상 종료 + log 기록.
*/
final class BackfillNewFilesManifest implements DataMigration
{
private const MANIFEST_FILENAME = '_new_files_manifest.json';
private const MANIFEST_SCHEMA_VERSION = 1;
public function name(): string
{
return 'BackfillNewFilesManifest';
}
public function run(UpgradeContext $context): void
{
try {
$this->runInternal($context);
} catch (\Throwable $e) {
// 본 보완 로직은 실패해도 업그레이드 본체를 중단하지 않는다.
$context->logger->warning(sprintf(
'[7.0.0-beta.6] BackfillNewFilesManifest 실패 (계속 진행): %s',
$e->getMessage(),
));
Log::warning('beta.6 BackfillNewFilesManifest 실패', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
}
}
private function runInternal(UpgradeContext $context): void
{
$backupsDir = storage_path('app'.DIRECTORY_SEPARATOR.'core_backups');
if (! is_dir($backupsDir)) {
$context->logger->info('[7.0.0-beta.6] core_backups 디렉토리 부재 — manifest 사후 작성 skip');
return;
}
$latestBackup = $this->findLatestBackupDir($backupsDir);
if ($latestBackup === null) {
$context->logger->info('[7.0.0-beta.6] core_backups 비어 있음 — manifest 사후 작성 skip');
return;
}
$manifestPath = $latestBackup.DIRECTORY_SEPARATOR.self::MANIFEST_FILENAME;
if (file_exists($manifestPath)) {
$context->logger->info(sprintf(
'[7.0.0-beta.6] manifest 이미 존재 — noop (path=%s)',
$manifestPath,
));
return;
}
// 신 버전 디스크의 config 를 require — 부모 메모리의 stale config 와 무관한 SSoT
$config = $this->loadCoreUpdateConfigFromDisk();
$targets = $config['targets'];
$protectedPaths = $config['protected_paths'];
$excludes = $config['excludes'];
$activeRoot = base_path();
$newFiles = [];
$newDirs = [];
$protectedSet = $this->normalizeProtectedSet($protectedPaths);
$excludeSet = array_values(array_filter(array_map('trim', $excludes)));
foreach ($targets as $target) {
$target = trim((string) $target);
if ($target === '') {
continue;
}
if ($this->isWithinProtectedPath($target, $protectedSet)) {
continue;
}
$activeTargetPath = $activeRoot.DIRECTORY_SEPARATOR.$target;
if (! file_exists($activeTargetPath)) {
continue;
}
if (is_file($activeTargetPath) && ! is_link($activeTargetPath)) {
$backupItem = $latestBackup.DIRECTORY_SEPARATOR.$target;
if (! file_exists($backupItem)) {
$newFiles[] = $this->normalizeRelative($target);
}
continue;
}
if (! is_dir($activeTargetPath)) {
continue;
}
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($activeTargetPath, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST,
);
foreach ($iterator as $item) {
/** @var SplFileInfo $item */
$absolute = $item->getPathname();
$relative = $this->normalizeRelative(
$target.'/'.ltrim(substr($absolute, strlen($activeTargetPath)), DIRECTORY_SEPARATOR.'/'),
);
if ($this->matchesExcludes($relative, $excludeSet)) {
continue;
}
if ($this->isWithinProtectedPath($relative, $protectedSet)) {
continue;
}
$backupItemPath = $latestBackup.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $relative);
if ($item->isDir() && ! $item->isLink()) {
if (! is_dir($backupItemPath)) {
$newDirs[] = $relative;
}
} elseif ($item->isFile()) {
if (! file_exists($backupItemPath)) {
$newFiles[] = $relative;
}
}
}
}
sort($newFiles, SORT_STRING);
sort($newDirs, SORT_STRING);
$manifest = [
'version' => self::MANIFEST_SCHEMA_VERSION,
'created_at' => date('c'),
'from_version' => $context->fromVersion,
'to_version' => $context->toVersion,
'new_files' => $newFiles,
'new_dirs' => $newDirs,
];
File::put(
$manifestPath,
json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
);
$context->logger->info(sprintf(
'[7.0.0-beta.6] manifest 사후 작성 완료 — backup=%s, new_files=%d, new_dirs=%d',
$latestBackup,
count($newFiles),
count($newDirs),
));
}
/**
* `storage/app/core_backups/` 의 가장 최근 백업 디렉토리를 식별.
*
* CoreBackupHelper::listBackups() 직접 호출 금지 — 로컬 scandir 로 동일 결과 산출.
*/
private function findLatestBackupDir(string $backupsDir): ?string
{
$entries = @scandir($backupsDir);
if ($entries === false) {
return null;
}
$candidates = [];
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$full = $backupsDir.DIRECTORY_SEPARATOR.$entry;
if (! is_dir($full)) {
continue;
}
$candidates[$full] = (int) @filemtime($full);
}
if ($candidates === []) {
return null;
}
arsort($candidates, SORT_NUMERIC);
return (string) array_key_first($candidates);
}
/**
* 신 버전 디스크의 `config/app.php` 에서 update 관련 설정 추출.
*
* 부모 메모리의 stale `config()` 캐시를 우회하기 위해 디스크의 config 를 직접 require.
* env() 호출이 안전한 default 값을 반환하므로 신 버전 적용 후의 fresh 값을 얻을 수 있다.
*
* @return array{targets:array<int,string>, protected_paths:array<int,string>, excludes:array<int,string>}
*/
private function loadCoreUpdateConfigFromDisk(): array
{
// 부모 메모리의 config 사용을 우회 — 디스크 SSoT 의 신 버전 config 로드
$appConfig = require base_path('config'.DIRECTORY_SEPARATOR.'app.php');
$update = $appConfig['update'] ?? [];
return [
'targets' => (array) ($update['targets'] ?? []),
'protected_paths' => (array) ($update['protected_paths'] ?? []),
'excludes' => (array) ($update['excludes'] ?? []),
];
}
private function normalizeProtectedSet(array $paths): array
{
$out = [];
foreach ($paths as $p) {
$p = trim((string) $p);
if ($p === '') {
continue;
}
$out[] = $this->normalizeRelative($p);
}
return $out;
}
private function isWithinProtectedPath(string $relative, array $protectedSet): bool
{
$relative = $this->normalizeRelative($relative);
foreach ($protectedSet as $p) {
if ($p === '') {
continue;
}
if ($relative === $p) {
return true;
}
if (str_starts_with($relative, $p.'/')) {
return true;
}
}
return false;
}
private function matchesExcludes(string $relative, array $excludes): bool
{
$segments = explode('/', $relative);
foreach ($excludes as $exclude) {
if ($exclude === '') {
continue;
}
if (str_contains($exclude, '/')) {
if ($relative === $exclude || str_starts_with($relative, $exclude.'/')) {
return true;
}
} else {
if (in_array($exclude, $segments, true)) {
return true;
}
}
}
return false;
}
private function normalizeRelative(string $path): string
{
$p = str_replace('\\', '/', $path);
$p = ltrim($p, '/');
while (str_contains($p, '//')) {
$p = str_replace('//', '/', $p);
}
return $p;
}
}