v7.0.0-beta.6 release

This commit is contained in:
HeuJung
2026-05-14 11:13:08 +09:00
parent cc45c0b84c
commit 61f5874fd2
51 changed files with 4151 additions and 106 deletions
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Upgrades;
use App\Extension\AbstractUpgradeStep;
/**
* 코어 7.0.0-beta.6 업그레이드 스텝
*
* 모든 비즈니스 로직은 본 클래스 파일이 아닌 `upgrades/data/7.0.0-beta.6/` 안에 격리된다:
*
* - migrations/
* 01_BackfillNewFilesManifest.php — beta.5 사용자의 사후 manifest 작성
* 02_LogStaleServiceProviders.php — 부팅 부정합 ServiceProvider 진단 로그
*
* 본 클래스는 `AbstractUpgradeStep` 의 default `run()` 에 위임 — 별도 override 없음.
*
* @upgrade-path B (spawn 자식 = 최신 beta.6 코어 메모리)
*
* 의존성 제약: 본 스텝은 변환/핫픽스를 `data/7.0.0-beta.6/migrations/` 의 버전 namespace
* 클래스에 위임한다. 미래 버전에서 *그 디렉토리는 동결* (수정 금지) 되어 "각 스텝별 동작
* 100% 동일 보장" invariant 가 성립.
*
* 상세: docs/extension/upgrade-step-guide.md §12 "버전별 데이터 스냅샷"
*/
class Upgrade_7_0_0_beta_6 extends AbstractUpgradeStep
{
// 모든 로직 위임 — data/7.0.0-beta.6/ 가 SSoT.
}
@@ -0,0 +1,309 @@
<?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;
}
}
@@ -0,0 +1,182 @@
<?php
namespace App\Upgrades\Data\V7_0_0_beta_6\Migrations;
use App\Extension\Upgrade\DataMigration;
use App\Extension\UpgradeContext;
use Illuminate\Support\Facades\Log;
/**
* 부팅 부정합 ServiceProvider 진단 로그 — 자동 삭제 없음.
*
* beta.5 이전의 자동 롤백 결함으로 인해 활성 디렉토리 `app/Providers/` 또는 `app/Services/`
* 등에 신 버전 ServiceProvider 파일이 잔존했을 수 있다. 본 진단은 다음 부정합을 식별한다:
*
* 1. `bootstrap/providers.php` 가 register 하는 클래스 FQCN 배열 추출
* 2. `app/Providers/` 의 모든 *.php 파일 스캔 + 클래스명 변환
* 3. 디스크에 있지만 register 되지 않은 ServiceProvider 후보 목록 산출
* 4. `storage/logs/core_upgrade_beta_6_stale_providers.log` 로 기록 (자동 삭제 없음)
*
* 자동 삭제하지 않는 이유: 운영자가 직접 추가한 커스텀 ServiceProvider 가 같은 패턴으로
* 식별되므로 안전 우선. 운영자 검토 후 수동 정리.
*
* 격리 원칙: 코어의 신 메서드 (CoreBackupHelper, ExtensionManager 등) 호출 금지. 표준
* 파일 시스템 API (scandir / file_get_contents) + `Illuminate\Support\Facades\Log` 만 사용.
*/
final class LogStaleServiceProviders implements DataMigration
{
private const LOG_FILENAME = 'core_upgrade_beta_6_stale_providers.log';
public function name(): string
{
return 'LogStaleServiceProviders';
}
public function run(UpgradeContext $context): void
{
try {
$this->runInternal($context);
} catch (\Throwable $e) {
$context->logger->warning(sprintf(
'[7.0.0-beta.6] LogStaleServiceProviders 실패 (계속 진행): %s',
$e->getMessage(),
));
Log::warning('beta.6 LogStaleServiceProviders 실패', [
'error' => $e->getMessage(),
]);
}
}
private function runInternal(UpgradeContext $context): void
{
$providersFile = base_path('bootstrap'.DIRECTORY_SEPARATOR.'providers.php');
$providersDir = base_path('app'.DIRECTORY_SEPARATOR.'Providers');
if (! file_exists($providersFile)) {
$context->logger->info('[7.0.0-beta.6] bootstrap/providers.php 부재 — stale providers 진단 skip');
return;
}
if (! is_dir($providersDir)) {
$context->logger->info('[7.0.0-beta.6] app/Providers 부재 — stale providers 진단 skip');
return;
}
$registered = $this->loadRegisteredProviders($providersFile);
$registeredSet = [];
foreach ($registered as $fqcn) {
$registeredSet[strtolower($fqcn)] = true;
}
$stale = [];
foreach ($this->collectProviderFiles($providersDir) as $file) {
$relative = ltrim(substr($file, strlen($providersDir)), DIRECTORY_SEPARATOR.'/');
$relative = str_replace('\\', '/', $relative);
// app/Providers/Sub/Foo.php → App\Providers\Sub\Foo
$classRelative = substr($relative, 0, -4); // .php strip
$fqcn = 'App\\Providers\\'.str_replace('/', '\\', $classRelative);
if (isset($registeredSet[strtolower($fqcn)])) {
continue;
}
$stale[] = [
'fqcn' => $fqcn,
'file' => 'app/Providers/'.$relative,
];
}
if ($stale === []) {
$context->logger->info('[7.0.0-beta.6] 부팅 부정합 ServiceProvider 후보 없음');
return;
}
$logPath = storage_path('logs'.DIRECTORY_SEPARATOR.self::LOG_FILENAME);
$header = implode("\n", [
'=== beta.6 부팅 부정합 ServiceProvider 진단 로그 ===',
'날짜: '.date('Y-m-d H:i:s'),
sprintf('업그레이드: %s → %s', $context->fromVersion, $context->toVersion),
'',
'아래 ServiceProvider 클래스 파일이 디스크에는 존재하지만 bootstrap/providers.php',
'의 등록 목록에 없습니다. beta.5 이전의 자동 롤백 결함으로 인해 잔존한 신 버전',
'파일이거나, 운영자가 직접 추가한 커스텀 ServiceProvider 일 수 있습니다.',
'',
'자동 삭제는 수행하지 않습니다. 운영자가 검토 후 수동 정리하세요:',
' - 신 버전 잔존 파일 → 삭제',
' - 커스텀 ServiceProvider → bootstrap/providers.php 에 등록',
'',
'=== 후보 목록 ===',
'',
]);
$body = '';
foreach ($stale as $entry) {
$body .= " - {$entry['fqcn']}\n";
$body .= " 파일: {$entry['file']}\n";
}
@file_put_contents($logPath, $header.$body);
$context->logger->warning(sprintf(
'[7.0.0-beta.6] 부팅 부정합 ServiceProvider %d개 진단 — 운영자 검토 필요. 로그: %s',
count($stale),
$logPath,
));
}
/**
* bootstrap/providers.php 를 require 하여 등록된 ServiceProvider FQCN 배열 반환.
*
* @return array<int, string>
*/
private function loadRegisteredProviders(string $providersFile): array
{
$data = require $providersFile;
if (! is_array($data)) {
return [];
}
return array_values(array_filter(
$data,
fn ($v) => is_string($v) && $v !== '',
));
}
/**
* app/Providers/ 의 모든 *.php 파일을 재귀 수집.
*
* @return array<int, string>
*/
private function collectProviderFiles(string $providersDir): array
{
$files = [];
$stack = [$providersDir];
while ($stack !== []) {
$cur = array_pop($stack);
$entries = @scandir($cur);
if ($entries === false) {
continue;
}
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$full = $cur.DIRECTORY_SEPARATOR.$entry;
if (is_dir($full) && ! is_link($full)) {
$stack[] = $full;
} elseif (is_file($full) && str_ends_with($entry, '.php')) {
$files[] = $full;
}
}
}
sort($files, SORT_STRING);
return $files;
}
}