v7.0.0-beta.6 release
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user