diff --git a/AGENTS.md b/AGENTS.md index 4ac2250c..8d6901f5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -641,6 +641,8 @@ TLS 가 앞단에서 종단되고 앱에는 HTTP 로 전달되는 구성(AWS ALB | custom 변경 감지 서명과 게시 복사 집합을 서로 다른 코드가 정의 | 같은 열거자(`CustomAssets::publishableFiles()`) — 감지가 최상위 css/js 만 보면 하위 글꼴·이미지 교체가 영영 미게시다 | | 버전 키·서명 키를 기본 TTL 로 `put()` | `PERSISTENT_TTL_SECONDS`(10년) 명시 — `forever()` 는 `CacheInterface` 밖(공개 표면 변경), `put(…, 0)` 은 forget | | 서명 스코프를 렌더 템플릿만으로 나눔 | `{템플릿}@{호스트명}` — 다중 서버 공유 캐시에서 서버 간 mtime 차이로 요청마다 재게시가 왕복한다 | +| `config:cache` / `route:cache` / `event:cache` / `optimize` 를 헬퍼 밖에서 `Artisan::call` | `ConfigCacheHelper::rebuild()` / `RouteCacheHelper::rebuild()` (내부가 `withPreservedContainer`) — 이 명령들은 새 Application 을 부팅하며 전역 `Container` 를 일회용 앱으로 바꿔 놓아, 그 뒤 등록되는 `app()->terminating()` 재게시 예약이 종료되지 않는 앱에 걸려 사라진다 | +| 코어 업데이트 흐름에서 현재 프로세스의 버전·update 목록을 `config('app.version')`·`config('app.update.*')` 로 판독 | spawn 자식은 부모가 비우지 않은 이전 버전 config 캐시로 부팅한다 — 버전은 `CoreVersionChecker::getCoreVersion()`(env 우선), update 목록은 캐시 부팅이면 `CoreUpdateService::freshDiskUpdateConfig()`, 부모는 spawn 직전 `ConfigCacheHelper::clear()` | 이 결함군은 예외도 로그도 남기지 않는다 — 게시본이 정상 200 으로 옛 내용을 내보내는 것, 또는 매일 전체 재생성이 일어나는 것이 유일한 증상이다. 재게시 누락의 안전망은 관리자 > 환경설정 > 일반 「초기 화면 정적 파일」의 [지금 다시 만들기](`POST /api/admin/settings/static-cache/republish`)이며, 상태 판정은 `ExtensionStaticCacheService::statusReport()` 한 곳이 CLI·API·화면에 공급한다. diff --git a/CHANGELOG.md b/CHANGELOG.md index 85d4493f..612479a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,8 @@ ### Fixed +- 설정 캐시가 만들어져 있는 사이트(설치 마법사·환경설정 저장·확장 업데이트를 한 번이라도 거친 대부분의 사이트)에서 코어 업데이트가 업그레이드 스텝 단계에서 "수동 재개" 안내와 함께 멈추던 문제를 수정했습니다. 새 버전으로 실행되는 스텝 프로세스가 이전 버전의 설정 캐시를 읽어 자기 자신을 옛 버전으로 오판한 것이 원인이며, 실행할 스텝이 하나도 없는 버전으로 올릴 때도 같은 안내가 나왔습니다. 같은 원인으로 새 버전이 추가한 쓰기 폴더가 `sudo` 업데이트의 권한 정리에서 빠지던 문제도 함께 바로잡았고, 이제 업데이트는 스텝 실행 전에 이전 설정 캐시를 비웁니다. +- 명령줄로 업그레이드 스텝을 직접 재실행하거나 관리자 「시스템 최적화」를 실행한 뒤, 그 실행이 예약한 초기 화면 정적 파일 재생성이 조용히 건너뛰어지던 문제를 수정했습니다. - 아웃바운드 프록시를 지정한 사이트에서 글·상품 저장이 수십 초씩 걸리던 문제를 수정했습니다. 사이트가 자기 자신에게 보내는 내부 요청까지 프록시로 나가고 있었고, 프록시가 응답하지 않으면 그 요청이 연결 실패 시각까지 매달렸습니다. 실패는 화면에 드러나지 않고 저장만 느려져 원인을 알기 어려웠습니다. 이제 사이트 자기 주소와 로컬 주소는 운영자가 예외 목록에 적지 않아도 항상 프록시를 거치지 않습니다. - 설치 마법사에서 PHP·Composer 경로가 거부될 때 안내 문구가 실제 허용 범위와 달라, 안내대로 고쳐도 계속 거부되던 문제를 수정했습니다. 이제 파일 이름 조건과 사용할 수 없는 경로 형태(네트워크 경로·scheme:// 등)를 문구에 함께 안내합니다. - 같은 스크립트를 거의 동시에 두 번 불러오면, 두 번째 요청이 첫 번째 로드가 끝나기 전에 완료된 것으로 처리되어 그 뒤 동작이 아무 반응 없이 끝나던 문제를 수정했습니다. 이제 두 요청 모두 실제 로드가 끝난 뒤에 이어집니다. diff --git a/app/Console/Commands/Core/CoreUpdateCommand.php b/app/Console/Commands/Core/CoreUpdateCommand.php index 75c3f6d9..d1a90df1 100644 --- a/app/Console/Commands/Core/CoreUpdateCommand.php +++ b/app/Console/Commands/Core/CoreUpdateCommand.php @@ -874,6 +874,15 @@ class CoreUpdateCommand extends Command 'G7_UPDATE_IN_PROGRESS' => '1', ]); + // spawn 직전 config 캐시 제거. 자식은 새 프로세스라 `bootstrap/cache/config.php` 가 있으면 + // 그 캐시로 부팅하는데, 그 캐시는 이전 버전 설치본이 만든 것이다(설치 마법사·설정 저장· + // 확장 업데이트). 캐시 부팅에서는 `.env` 도 읽지 않고 위 `$env` 의 APP_VERSION 오버라이드도 + // config 에 반영되지 않으며, 신버전 `config/app.php` 가 추가한 update 목록(쓰기 권한 + // 디렉토리 등)도 자식에게 보이지 않는다 (7.0.9→7.0.10 실사례: stale 가드 오판 + + // `public/build/ext` 권한 정상화 누락). 부모의 메모리 config 는 영향받지 않고, 캐시는 + // Step 11 의 `ConfigCacheHelper::rebuild()` 가 모든 파일이 안착한 뒤 다시 만든다. + ConfigCacheHelper::clear(); + $process = proc_open($commandLine, $descriptors, $pipes, base_path(), $env); if (! is_resource($process)) { return $this->failSpawnWithMode( diff --git a/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php b/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php index c4674adb..a18bcb2e 100644 --- a/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php +++ b/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php @@ -111,7 +111,17 @@ class ExecuteUpgradeStepsCommand extends Command // 해당 release 의 upgrade step 단발 처리. (예: beta.3→beta.4 의 lang-packs/* 보정) if (! $stepsOnly) { try { - $writablePaths = (array) config('app.update.restore_ownership_group_writable', []); + // 구버전 부모(7.0.9 이하)는 spawn 전에 config 캐시를 비우지 않아 이 자식이 이전 버전 + // 캐시로 부팅할 수 있다. 그러면 `config()` 는 옛 목록이라 신버전이 추가한 디렉토리가 + // 빠진다 — 캐시 부팅이면 디스크 config/app.php 를 직접 읽는다 (7.0.9→7.0.10 실사례). + // 판정은 캐시 파일의 실존으로 한다 — 부팅에 쓰였든 위 updateComposerAutoload() 가 방금 + // 재생성했든, 파일이 있으면 메모리 config 를 신뢰하지 않는다 (디스크 판독은 멱등). + if (is_file($this->laravel->getCachedConfigPath())) { + Log::channel('upgrade')->warning('[spawn] 이전 버전 config 캐시로 부팅됨 — update 목록은 디스크 config/app.php 에서 읽는다'); + $writablePaths = (array) $service->freshDiskUpdateConfig('restore_ownership_group_writable', []); + } else { + $writablePaths = (array) config('app.update.restore_ownership_group_writable', []); + } if (! empty($writablePaths)) { $service->ensureWritableDirectories( $writablePaths, diff --git a/app/Services/CoreUpdateService.php b/app/Services/CoreUpdateService.php index 8fd7410a..58825d06 100644 --- a/app/Services/CoreUpdateService.php +++ b/app/Services/CoreUpdateService.php @@ -26,7 +26,9 @@ use App\Extension\Vendor\VendorResolver; use Database\Seeders\IdentityMessageDefinitionSeeder; use Database\Seeders\IdentityPolicySeeder; use Database\Seeders\NotificationDefinitionSeeder; +use Dotenv\Dotenv; use Illuminate\Http\Client\ConnectionException; +use Illuminate\Support\Env; use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\DB; @@ -1626,6 +1628,47 @@ class CoreUpdateService } } + /** + * 디스크의 `config/app.php` 에서 `update.{$key}` 를 직접 읽습니다. + * + * spawn 자식(`core:execute-upgrade-steps`)은 부모가 spawn 전에 config 캐시를 비우지 않은 경우 + * (7.0.9 이하 부모) 이전 버전 설치본의 `bootstrap/cache/config.php` 로 부팅한다. 그 상태의 + * `config('app.update.*')` 는 캐시에 박힌 옛 목록이라, 신버전이 추가한 항목(7.0.10 의 + * `public/build/ext` 쓰기 권한 디렉토리)이 자식의 권한 정상화에서 빠진다 (2026-09-06 서버 실측). + * 본 메서드는 메모리 config 를 건드리지 않고 디스크 파일을 평가해 신버전 값을 돌려준다. + * + * 캐시 부팅에서는 `.env` 도 로드되지 않으므로(`LoadEnvironmentVariables` 가 건너뜀), 운영자의 + * `G7_UPDATE_*` 재정의가 `config/app.php` 의 `env()` 에 보이도록 `.env` 를 먼저 불변 로드한다 — + * 이미 프로세스 env 에 있는 값(부모가 넘긴 `APP_VERSION` 등)은 덮어쓰지 않는다. + * + * @param string $key `config/app.php` 의 `update` 배열 키 + * @param mixed $default 파일에 키가 없을 때 돌려줄 값 + * @return mixed 디스크 config 의 값 + */ + public function freshDiskUpdateConfig(string $key, mixed $default = []): mixed + { + $path = config_path('app.php'); + if (! File::exists($path)) { + return $default; + } + + if (app()->configurationIsCached() && File::exists(base_path('.env'))) { + try { + // audit:allow service-direct-data-access reason: Dotenv 는 모델이 아니라 .env 파서 — 캐시 부팅에서 로드되지 않은 .env 를 불변 로드한다 (프로세스 env 우선) + Dotenv::create(Env::getRepository(), base_path(), '.env')->safeLoad(); + } catch (\Throwable $e) { + Log::channel('upgrade')->warning('freshDiskUpdateConfig: .env 로드 실패 — 프로세스 env 만으로 평가', ['error' => $e->getMessage()]); + } + } + + $fresh = require $path; + if (! is_array($fresh)) { + return $default; + } + + return $fresh['update'][$key] ?? $default; + } + /** * 코어 업그레이드 스텝을 실행합니다. * 각 스텝에서 환경설정 파일 생성, 데이터 마이그레이션 등을 수행합니다. @@ -1650,9 +1693,22 @@ class CoreUpdateService // 보유한 채 step 을 실행 중. upgrade step 안에서 신규 메서드 호출 시 fatal 위험. // `spawn_failure_mode` 와 연동하여 abort/fallback 분기. // - // spawn 자식 (ExecuteUpgradeStepsCommand) 의 경우 spawn env 의 APP_VERSION=toVersion - // 이 적용된 채 새 프로세스에서 부팅되므로 memoryVersion === toVersion → 가드 미발동. - $memoryVersion = (string) config('app.version', $fromVersion); + // spawn 자식 (ExecuteUpgradeStepsCommand) 은 spawn env 의 APP_VERSION=toVersion 을 받아 + // 부팅되므로 memoryVersion === toVersion → 가드 미발동이어야 한다. + // + // 판독은 `CoreVersionChecker::getCoreVersion()` (env 우선, config 폴백) 으로 한다. + // `config('app.version')` 만 읽으면 안 된다 — 부모는 spawn 전(Step 10)에 config 캐시를 + // 비우지 않으므로, 이전 버전 설치본의 `bootstrap/cache/config.php` 가 있으면 자식은 + // 그 캐시로 부팅해 config 에는 fromVersion 이 박혀 있고 env 오버라이드는 무시된다. + // 그 상태에서 config 만 보면 정상 spawn 자식을 stale 부모로 오판해 abort 한다 + // (7.0.9→7.0.10 실사례, 2026-09-06 — 스텝 0건 릴리즈에서도 중단). 이전 릴리즈에서는 + // 자식 진입부의 `config:cache` 가 전역 Container 를 일회용 앱으로 바꿔 놓는 부수효과로 + // `config()` 가 우연히 env 기반 값을 읽어 가드가 침묵했을 뿐이며, 그 부수효과는 + // `ConfigCacheHelper::withPreservedContainer` 가 제거했다. + // + // 부모 in-process fallback 에서는 env 가 .env 의 APP_VERSION(= 아직 fromVersion, Step 11 전) + // 이므로 가드가 그대로 발동한다. + $memoryVersion = CoreVersionChecker::getCoreVersion() ?: (string) config('app.version', $fromVersion); if (version_compare($memoryVersion, $toVersion, '<')) { $mode = config('app.update.spawn_failure_mode', 'fallback'); $message = sprintf( diff --git a/app/Services/SettingsService.php b/app/Services/SettingsService.php index 7cbf3300..314a698f 100644 --- a/app/Services/SettingsService.php +++ b/app/Services/SettingsService.php @@ -1433,8 +1433,12 @@ class SettingsService public function optimizeSystem(): bool { try { - Artisan::call('config:cache'); - Artisan::call('route:cache'); + // config:cache / route:cache 는 새 Application 을 부팅하며 전역 Container 를 바꿔 놓는다. + // 보존 래퍼 없이 부르면 이 요청의 후속 `app()->terminating()` 예약이 사라진다. + ConfigCacheHelper::withPreservedContainer(static function (): void { + Artisan::call('config:cache'); + Artisan::call('route:cache'); + }); Artisan::call('view:cache'); return true; diff --git a/app/Support/RouteCacheHelper.php b/app/Support/RouteCacheHelper.php index 4d4e1001..5b7b0e8b 100644 --- a/app/Support/RouteCacheHelper.php +++ b/app/Support/RouteCacheHelper.php @@ -52,7 +52,12 @@ class RouteCacheHelper } try { - Artisan::call('route:cache'); + // `route:cache` 도 새 Application 을 부팅하며 전역 Container 인스턴스를 그 일회용 앱으로 + // 바꿔 놓는다(`config:cache` 와 동일). 되돌리지 않으면 이 뒤에 등록되는 + // `app()->terminating()` 이 종료되지 않는 앱에 걸려 실행되지 않는다 — 단독 실행 + // `core:execute-upgrade-steps` 가 라우트 재생성 뒤에 확장 캐시 버전을 올리므로 그 + // 재게시 예약이 그렇게 사라졌다 (2026-09-06 전수조사). + ConfigCacheHelper::withPreservedContainer(static fn () => Artisan::call('route:cache')); } catch (\Throwable $e) { Log::warning('라우트 캐시 재생성 실패 (route:clear 로 stale 은 제거됨 — 다음 요청은 비캐시 부팅)', [ 'error' => $e->getMessage(), diff --git a/docs/backend/core-update-system.md b/docs/backend/core-update-system.md index b65a3f64..0de694d6 100644 --- a/docs/backend/core-update-system.md +++ b/docs/backend/core-update-system.md @@ -259,6 +259,10 @@ v접두사 자동 감지 (resolveGithubArchiveUrl): > **단독 실행 안전성 (beta.6 이후)**: `core:execute-upgrade-steps` 는 HANDOFF 안내 또는 수동 복구 목적으로 운영자가 직접 호출되는 경로가 있다. 단독 실행 시 자식은 기본값으로 부모 Step 9 (`runMigrations` + `reloadCoreConfigAndResync`), Step 11 (`updateVersionInEnv` + `clearAllCaches`), Step 12 (번들 확장 일괄 업데이트) 를 자체적으로 수행해 단일 명령으로 업그레이드를 완결한다. 부모 `CoreUpdateCommand::spawnUpgradeStepsProcess()` 는 자식 명령 라인에 `--skip-migrations`, `--skip-resync`, `--skip-version-env`, `--skip-cache-clear`, `--skip-bundled-updates` 5개를 무조건 추가해 중복 회피한다 — 부모가 자식 종료 후 동일 단계를 직접 수행하기 때문이다. +> **spawn 자식은 이전 버전의 config 캐시로 부팅한다**: 부모는 Step 10(spawn) 전에 config 캐시를 비우지 않는다 — `clearAllCaches()` 는 Step 11 이다. 그래서 이전 버전 설치본에 `bootstrap/cache/config.php` 가 있으면(설치 마법사·설정 저장·확장 업데이트가 만든다) 자식은 그 캐시로 부팅하고, 자식의 `config('app.version')` 은 부모가 env 로 넘긴 `APP_VERSION={toVersion}` 이 아니라 캐시에 박힌 fromVersion 이다. 업데이트 흐름 안에서 "지금 프로세스의 코어 버전" 을 판정하는 코드는 `config('app.version')` 을 직접 읽지 않고 `CoreVersionChecker::getCoreVersion()`(env 우선, config 폴백)을 쓴다. `runUpgradeSteps()` 의 stale 메모리 가드가 config 만 읽던 시절에는 정상 spawn 자식을 stale 부모로 오판해 스텝이 0건인 릴리즈에서도 핸드오프로 중단됐다(7.0.9→7.0.10). 부모 in-process fallback 에서는 env 가 `.env` 의 fromVersion 이므로 가드는 그대로 발동한다. +> +> 같은 이유로 자식이 `config('app.update.*')` 로 읽는 목록(쓰기 권한 디렉토리 등)도 캐시에 박힌 옛 목록이다 — 신버전이 항목을 추가해도 자식에게 보이지 않는다. 방어는 두 겹이다: ① 부모(7.0.10+)는 `spawnUpgradeStepsProcess()` 가 `proc_open` 직전에 `ConfigCacheHelper::clear()` 로 캐시를 비워 자식이 디스크 config + `.env` + spawn env 로 부팅하게 한다(캐시는 Step 11 이 다시 만든다). ② 자식(7.0.10+)은 이전 버전 부모가 캐시를 남겨 둔 경우를 위해, 캐시 파일이 있으면 `CoreUpdateService::freshDiskUpdateConfig()` 로 디스크의 `config/app.php` 를 직접 읽는다 — 캐시 부팅에서는 `.env` 도 로드되지 않으므로 그 안에서 `.env` 를 먼저 불변 로드한다(프로세스 env 의 `APP_VERSION` 은 덮어쓰지 않는다). + #### 재실행 안내의 권한 분기 (핸드오프 catch) spawn 자식이 실패(`proc_open` 미지원 · 비정상 종료 · silent skip)하고 `spawn_failure_mode=abort`(기본값) 이면, 파일·버전은 이미 `toVersion` 으로 반영되지만 업그레이드 스텝이 미실행 상태로 남아 운영자에게 `core:execute-upgrade-steps` 재실행을 안내한다. 이때 **sudo(root) 로 `core:update` 를 실행한 경우**, 안내받은 명령을 root 로 그대로 재실행하면 스텝이 만드는 파일·캐시가 root 소유로 생성되어 이후 웹서버(php-fpm www-data 등) 요청이 그 경로에 쓰기 실패한다. diff --git a/tests/Feature/Console/Commands/ExecuteUpgradeStepsStandaloneTest.php b/tests/Feature/Console/Commands/ExecuteUpgradeStepsStandaloneTest.php index ddda2859..a4af9c21 100644 --- a/tests/Feature/Console/Commands/ExecuteUpgradeStepsStandaloneTest.php +++ b/tests/Feature/Console/Commands/ExecuteUpgradeStepsStandaloneTest.php @@ -183,6 +183,65 @@ class ExecuteUpgradeStepsStandaloneTest extends TestCase $this->assertSame(0, $exitCode); } + /** + * 자식이 이전 버전 config 캐시로 부팅했으면(구버전 부모는 spawn 전에 캐시를 비우지 않는다) + * `config('app.update.restore_ownership_group_writable')` 은 옛 목록이다. 이때는 디스크의 + * `config/app.php` 를 직접 읽어 신버전이 추가한 디렉토리까지 권한 정상화 대상에 넣는다 + * (7.0.9→7.0.10 서버 실측: `public/build/ext` 누락). + * + * @effects execute_upgrade_steps_child_reads_update_config_from_disk_when_config_is_cached + */ + public function test_config_캐시로_부팅한_자식은_쓰기권한_목록을_디스크_config_에서_읽는다(): void + { + [$service, $module, $plugin, $template, $langPack] = $this->bindMocks(); + + config(['app.update.restore_ownership_group_writable' => ['stale/only']]); + + $service->shouldReceive('freshDiskUpdateConfig') + ->once() + ->with('restore_ownership_group_writable', []) + ->andReturn(['fresh/dir']); + $service->shouldReceive('ensureWritableDirectories') + ->once() + ->withArgs(fn (array $paths) => $paths === ['fresh/dir']); + $service->shouldReceive('runUpgradeSteps')->once(); + + // 상대 경로 — Application::normalizeCachePath 는 `/`·`\` 로 시작하지 않는 값을 basePath 기준으로 + // 해석하므로 Windows 절대 경로는 어긋난다. + $relativeCachePath = 'storage/framework/testing/child-config-cache-'.uniqid().'.php'; + $cachePath = base_path($relativeCachePath); + File::ensureDirectoryExists(dirname($cachePath)); + File::put($cachePath, "assertSame($cachePath, $this->app->getCachedConfigPath(), '전제: APP_CONFIG_CACHE 가 캐시 경로를 정한다'); + $this->assertFileExists($cachePath, '전제: 자식 시점에 config 캐시 파일이 존재한다'); + $this->assertSame(0, $this->runCommand([])); + } finally { + if ($originalEnv === false) { + putenv('G7_UPDATE_IN_PROGRESS'); + } else { + putenv('G7_UPDATE_IN_PROGRESS='.$originalEnv); + } + if ($originalCacheEnv === null) { + unset($_ENV['APP_CONFIG_CACHE'], $_SERVER['APP_CONFIG_CACHE']); + putenv('APP_CONFIG_CACHE'); + } else { + $_ENV['APP_CONFIG_CACHE'] = $originalCacheEnv; + $_SERVER['APP_CONFIG_CACHE'] = $originalCacheEnv; + putenv('APP_CONFIG_CACHE='.$originalCacheEnv); + } + File::delete($cachePath); + } + } + public function test_spawn_child_env_bypasses_all_pre_and_post_steps_without_skip_options(): void { // 구버전 부모(beta.5) 가 신버전 자식(beta.6+) 을 spawn 할 때 `--skip-*` 옵션을 모르므로 @@ -342,6 +401,9 @@ class ExecuteUpgradeStepsStandaloneTest extends TestCase private function bindMocks(): array { $service = Mockery::mock(CoreUpdateService::class); + // 종료부의 root 산출물 소유권 정상화(#615 도입)는 모든 경로에서 호출되며 본 테스트의 관심사가 + // 아니다 — 기본 허용으로 두어 각 케이스가 자기 단계만 단언하게 한다. + $service->shouldReceive('normalizeRuntimeOwnershipAfterRootRun')->andReturnNull()->byDefault(); $module = Mockery::mock(ModuleManager::class); $plugin = Mockery::mock(PluginManager::class); $template = Mockery::mock(TemplateManager::class); diff --git a/tests/Feature/Console/CoreUpdateCommandSpawnFailureTest.php b/tests/Feature/Console/CoreUpdateCommandSpawnFailureTest.php index 4a51c7d4..47dd3691 100644 --- a/tests/Feature/Console/CoreUpdateCommandSpawnFailureTest.php +++ b/tests/Feature/Console/CoreUpdateCommandSpawnFailureTest.php @@ -37,12 +37,20 @@ class CoreUpdateCommandSpawnFailureTest extends TestCase private string $silentStepPath; + private ?string $originalEnvVersion = null; + protected function setUp(): void { parent::setUp(); $this->failingStepPath = base_path('upgrades/Upgrade_0_0_1_test_spawn_failure_fail.php'); $this->silentStepPath = base_path('upgrades/Upgrade_0_0_1_test_spawn_failure_silent.php'); + + // stale 가드는 spawn 자식이 받는 env APP_VERSION 을 먼저 읽는다 (config 캐시가 있으면 + // config('app.version') 은 캐시에 박힌 구버전이라 신뢰할 수 없다 — 7.0.9→7.0.10 실사례). + // 각 테스트가 부모/자식 시나리오에 맞춰 env 를 직접 세우도록 원값을 보관하고 비운다. + $this->originalEnvVersion = $_ENV['APP_VERSION'] ?? null; + $this->setEnvVersion(null); } protected function tearDown(): void @@ -53,9 +61,30 @@ class CoreUpdateCommandSpawnFailureTest extends TestCase } } + $this->setEnvVersion($this->originalEnvVersion); + parent::tearDown(); } + /** + * 프로세스 env 의 APP_VERSION 을 세우거나(문자열) 비운다(null). + * + * @param string|null $version 세울 버전. null 이면 세 채널($_ENV/$_SERVER/putenv) 모두 제거 + */ + private function setEnvVersion(?string $version): void + { + if ($version === null) { + unset($_ENV['APP_VERSION'], $_SERVER['APP_VERSION']); + putenv('APP_VERSION'); + + return; + } + + $_ENV['APP_VERSION'] = $version; + $_SERVER['APP_VERSION'] = $version; + putenv('APP_VERSION='.$version); + } + #[Test] public function fail_spawn_with_mode_abort_모드는_upgrade_handoff_exception_을_throw_한다(): void { @@ -348,6 +377,8 @@ PHP); #[Test] public function run_upgrade_steps_stale_메모리_감지_시_abort_throw_한다(): void { + // 부모 in-process fallback 시나리오: .env 의 APP_VERSION 은 아직 fromVersion (Step 11 전). + $this->setEnvVersion('7.0.0-beta.3'); config(['app.version' => '7.0.0-beta.3']); config(['app.update.spawn_failure_mode' => 'abort']); @@ -367,6 +398,7 @@ PHP); #[Test] public function run_upgrade_steps_stale_메모리_감지_시_fallback_은_경고_후_진행한다(): void { + $this->setEnvVersion('7.0.0-beta.3'); config(['app.version' => '7.0.0-beta.3']); config(['app.update.spawn_failure_mode' => 'fallback']); @@ -382,6 +414,7 @@ PHP); #[Test] public function run_upgrade_steps_memory_가_target_과_동일하면_가드_미발동(): void { + $this->setEnvVersion('7.0.0-beta.5'); config(['app.version' => '7.0.0-beta.5']); config(['app.update.spawn_failure_mode' => 'abort']); @@ -393,6 +426,103 @@ PHP); $this->assertTrue(true); } + /** + * 7.0.9 → 7.0.10 실사례 (2026-09-06): 7.0.9 설치본에는 `bootstrap/cache/config.php` 가 있고 + * 부모는 spawn 전에 그 캐시를 비우지 않는다. 자식은 env `APP_VERSION=toVersion` 을 받지만 + * 캐시로 부팅하므로 `config('app.version')` 은 캐시에 박힌 fromVersion 이다. 가드가 config 만 + * 읽으면 정상 spawn 자식을 stale 부모로 오판해 abort 한다 — 스텝 0건 릴리즈에서도 중단된다. + */ + #[Test] + public function run_upgrade_steps_spawn_자식은_config_캐시가_stale_해도_env_버전으로_가드를_통과한다(): void + { + // 자식이 받는 env 는 toVersion, 캐시로 부팅한 config 는 fromVersion. + $this->setEnvVersion('7.0.10'); + config(['app.version' => '7.0.9']); + config(['app.update.spawn_failure_mode' => 'abort']); + + $service = app(CoreUpdateService::class); + + $discovered = null; + $service->runUpgradeSteps('7.0.9', '7.0.10', null, false, function (int $count) use (&$discovered): void { + $discovered = $count; + }); + + // 가드를 지나 스텝 발견 단계까지 도달했다 (throw 시 여기 미도달). + $this->assertNotNull($discovered, 'env APP_VERSION=toVersion 인 spawn 자식은 stale 가드를 통과해야 한다'); + } + + /** + * 부모는 spawn 직전에 config 캐시를 비운다 — 자식이 이전 버전 캐시로 부팅하면 env `APP_VERSION` + * 오버라이드와 신버전 `config/app.php` 의 update 목록(쓰기 권한 디렉토리 등)이 모두 무시된다. + * 캐시 파일 위치는 `APP_CONFIG_CACHE` 로 격리한다 (자식도 같은 env 를 물려받는다). 부모가 지우지 + * 않으면 자식은 이 불완전한 캐시로 부팅해 비정상 종료한다. + * + * @effects spawnUpgradeStepsProcess_clears_config_cache_before_proc_open + */ + #[Test] + public function spawn_전에_config_캐시_파일을_지워_자식이_디스크_config_로_부팅하게_한다(): void + { + if (! function_exists('proc_open')) { + $this->markTestSkipped('proc_open 미지원 환경'); + } + + config(['app.update.spawn_failure_mode' => 'abort']); + + // 상대 경로로 지정한다 — Application::normalizeCachePath 는 `/`·`\` 로 시작하지 않는 값을 + // basePath 기준 상대 경로로 해석하므로 Windows 절대 경로(`C:\…`)는 어긋난다. 자식도 같은 + // basePath(cwd) 에서 부팅하므로 같은 파일을 가리킨다. + $relativeCachePath = 'storage/framework/testing/stale-config-cache-'.uniqid().'.php'; + $cachePath = base_path($relativeCachePath); + File::ensureDirectoryExists(dirname($cachePath)); + File::put($cachePath, " ['version' => '0.0.0']];\n"); + + $originalCacheEnv = $_ENV['APP_CONFIG_CACHE'] ?? null; + $_ENV['APP_CONFIG_CACHE'] = $relativeCachePath; + $_SERVER['APP_CONFIG_CACHE'] = $relativeCachePath; + putenv('APP_CONFIG_CACHE='.$relativeCachePath); + + try { + $this->assertSame($cachePath, $this->app->getCachedConfigPath(), '전제: APP_CONFIG_CACHE 가 캐시 경로를 정한다'); + $this->assertFileExists($cachePath, '전제: 부모 시점에 config 캐시 파일이 존재한다'); + + $command = $this->makeCommandWithDummyIo(); + $method = new \ReflectionMethod(CoreUpdateCommand::class, 'spawnUpgradeStepsProcess'); + $method->setAccessible(true); + + $result = $method->invoke($command, '9.9.8', '9.9.9', true, fn () => null); + + $this->assertFileDoesNotExist($cachePath, 'spawn 전에 config 캐시를 비워야 자식이 디스크 config + env 로 부팅한다'); + $this->assertTrue($result, '캐시가 비워졌으면 자식은 정상 부팅해 스텝 0건 통과'); + } finally { + if ($originalCacheEnv === null) { + unset($_ENV['APP_CONFIG_CACHE'], $_SERVER['APP_CONFIG_CACHE']); + putenv('APP_CONFIG_CACHE'); + } else { + $_ENV['APP_CONFIG_CACHE'] = $originalCacheEnv; + $_SERVER['APP_CONFIG_CACHE'] = $originalCacheEnv; + putenv('APP_CONFIG_CACHE='.$originalCacheEnv); + } + if (File::exists($cachePath)) { + File::delete($cachePath); + } + } + } + + /** + * env 가 비어 있으면(운영자 단독 실행 등) 종전처럼 config('app.version') 으로 판정한다. + */ + #[Test] + public function run_upgrade_steps_env_부재_시_config_버전으로_stale_을_판정한다(): void + { + $this->setEnvVersion(null); + config(['app.version' => '7.0.9']); + config(['app.update.spawn_failure_mode' => 'abort']); + + $this->expectException(UpgradeHandoffException::class); + + app(CoreUpdateService::class)->runUpgradeSteps('7.0.9', '7.0.10'); + } + /** * CoreUpdateCommand 를 OutputStyle 주입 없이 리플렉션 호출 가능한 형태로 준비. */ diff --git a/tests/Feature/Upgrades/RunUpgradeStepsAbstractGuardTest.php b/tests/Feature/Upgrades/RunUpgradeStepsAbstractGuardTest.php index 4e2c6072..cffa055a 100644 --- a/tests/Feature/Upgrades/RunUpgradeStepsAbstractGuardTest.php +++ b/tests/Feature/Upgrades/RunUpgradeStepsAbstractGuardTest.php @@ -22,6 +22,18 @@ class RunUpgradeStepsAbstractGuardTest extends TestCase { private array $stubStepFiles = []; + private ?string $originalEnvVersion = null; + + protected function setUp(): void + { + parent::setUp(); + + // stale 메모리 가드는 env APP_VERSION 을 먼저 읽는다 (spawn 자식 계약). 테스트 프로세스의 + // .env.testing 값이 target 판정에 끼어들지 않도록 비우고, 각 테스트가 직접 세운다. + $this->originalEnvVersion = $_ENV['APP_VERSION'] ?? null; + $this->setMemoryVersion(null); + } + protected function tearDown(): void { foreach ($this->stubStepFiles as $file) { @@ -29,13 +41,34 @@ class RunUpgradeStepsAbstractGuardTest extends TestCase File::delete($file); } } + $this->setMemoryVersion($this->originalEnvVersion); parent::tearDown(); } + /** + * 가드가 읽는 "메모리 버전" 을 env 와 config 양쪽에 세운다 (null 이면 env 만 비운다). + * + * @param string|null $version 세울 버전 + */ + private function setMemoryVersion(?string $version): void + { + if ($version === null) { + unset($_ENV['APP_VERSION'], $_SERVER['APP_VERSION']); + putenv('APP_VERSION'); + + return; + } + + $_ENV['APP_VERSION'] = $version; + $_SERVER['APP_VERSION'] = $version; + putenv('APP_VERSION='.$version); + config(['app.version' => $version]); + } + public function test_throws_when_beta5_step_does_not_extend_abstract(): void { // stale 메모리 가드 우회를 위해 메모리 버전을 target 이상으로 - config(['app.version' => '7.9.9']); + $this->setMemoryVersion('7.9.9'); // 7.9.9-test.guard.a 는 7.0.0-beta.5 보다 명확히 큼 (version_compare 의 pre-release // 해석에 영향 받지 않음). stub 은 AbstractUpgradeStep 미상속 → fatal 가드 발동. @@ -66,7 +99,7 @@ PHP); public function test_passes_when_beta5_step_extends_abstract(): void { - config(['app.version' => '7.9.9']); + $this->setMemoryVersion('7.9.9'); $version = '7_9_9_test_guard_b'; $className = 'Upgrade_'.$version; @@ -100,7 +133,7 @@ PHP); public function test_legacy_pre_beta5_step_bypasses_guard(): void { // legacy beta.4 이하 step 은 AbstractUpgradeStep 미상속이어도 통과 (호환) - config(['app.version' => '7.0.0-beta.5']); + $this->setMemoryVersion('7.0.0-beta.5'); $version = '7_0_0_beta_4_test_guard_legacy'; $className = 'Upgrade_'.$version; diff --git a/tests/Unit/Services/CoreUpdateServiceFreshDiskConfigTest.php b/tests/Unit/Services/CoreUpdateServiceFreshDiskConfigTest.php new file mode 100644 index 00000000..6408a50d --- /dev/null +++ b/tests/Unit/Services/CoreUpdateServiceFreshDiskConfigTest.php @@ -0,0 +1,40 @@ + ['stale-only']]); + + $fresh = app(CoreUpdateService::class)->freshDiskUpdateConfig('restore_ownership_group_writable'); + + $this->assertNotSame(['stale-only'], $fresh); + $this->assertContains('public/build/ext', $fresh, '디스크 config/app.php 의 목록을 읽어야 한다'); + $this->assertContains('bootstrap/cache', $fresh); + } + + /** + * @effects execute_upgrade_steps_child_reads_update_config_from_disk_when_config_is_cached + */ + #[Test] + public function 없는_키는_기본값을_돌려준다(): void + { + $this->assertSame(['x'], app(CoreUpdateService::class)->freshDiskUpdateConfig('no_such_key_'.uniqid(), ['x'])); + } +} diff --git a/tests/Unit/Support/RouteCacheHelperContainerTest.php b/tests/Unit/Support/RouteCacheHelperContainerTest.php new file mode 100644 index 00000000..9fd579e9 --- /dev/null +++ b/tests/Unit/Support/RouteCacheHelperContainerTest.php @@ -0,0 +1,98 @@ +terminating()` 이 종료되지 않는 앱에 걸려 실행되지 않는다(정적 재게시 예약 소실). + * 단독 실행 `core:execute-upgrade-steps` 가 라우트 캐시 재생성 **뒤에** 캐시 버전을 올리므로 + * 그 경로에서 실제로 새어 나갔다 (2026-09-06 전수조사). + * + * 테스트 환경은 `route:cache` 자체를 스킵하므로 소스 구조로 잠근다(`ConfigCacheHelperContainerTest` 와 동형). + */ +class RouteCacheHelperContainerTest extends TestCase +{ + /** + * @effects route_cache_rebuild_preserves_container_instance + */ + #[Test] + public function rebuild_는_route_cache_를_보존_래퍼로_감싼다(): void + { + $body = $this->methodBody(RouteCacheHelper::class, 'rebuild'); + + $this->assertStringContainsString("withPreservedContainer(static fn () => Artisan::call('route:cache'))", $body); + $this->assertStringNotContainsString("\n Artisan::call('route:cache');", $body); + } + + /** + * 관리자 「시스템 최적화」 도 같은 두 명령을 부르므로 같은 래퍼를 거친다. + * + * @effects route_cache_rebuild_preserves_container_instance + */ + #[Test] + public function optimize_system_은_config_route_cache_를_보존_래퍼로_감싼다(): void + { + $body = $this->methodBody(SettingsService::class, 'optimizeSystem'); + + $this->assertStringContainsString('withPreservedContainer(', $body); + $this->assertStringNotContainsString("\n Artisan::call('config:cache');", $body); + $this->assertStringNotContainsString("\n Artisan::call('route:cache');", $body); + } + + /** + * 새 Application 을 부팅하는 Artisan 명령은 두 헬퍼 밖에서 직접 호출되지 않는다. + * + * 모집단은 `app/` 전체 PHP 파일에서 파생한다 — 헬퍼를 우회하는 호출이 하나라도 생기면 + * 그 자리 뒤의 `terminating` 예약이 조용히 사라진다. + * + * @effects route_cache_rebuild_preserves_container_instance + */ + #[Test] + public function 새_앱을_부팅하는_artisan_호출은_헬퍼_밖에서_직접_부르지_않는다(): void + { + $allowed = [ + realpath(app_path('Support/ConfigCacheHelper.php')), + realpath(app_path('Support/RouteCacheHelper.php')), + realpath(app_path('Services/SettingsService.php')), + ]; + $pattern = "/(?:Artisan::call|->call)\(\s*'(?:config:cache|route:cache|event:cache|optimize)'/"; + + $files = iterator_to_array(new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator(app_path(), \FilesystemIterator::SKIP_DOTS))); + $offenders = []; + $scanned = 0; + foreach ($files as $file) { + if ($file->getExtension() !== 'php') { + continue; + } + $scanned++; + $content = (string) file_get_contents($file->getPathname()); + if (! preg_match($pattern, $content)) { + continue; + } + if (in_array(realpath($file->getPathname()), $allowed, true)) { + continue; + } + $offenders[] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $file->getPathname()); + } + + $this->assertGreaterThan(100, $scanned, '모집단이 비었다 — app/ 스캔 실패'); + $this->assertSame([], $offenders, '새 Application 을 부팅하는 Artisan 명령은 ConfigCacheHelper/RouteCacheHelper 를 경유한다 (컨테이너 보존)'); + } + + /** + * 메서드 본문 소스를 돌려준다. + */ + private function methodBody(string $class, string $method): string + { + $ref = new \ReflectionMethod($class, $method); + $lines = file($ref->getFileName()) ?: []; + + return implode('', array_slice($lines, $ref->getStartLine() - 1, $ref->getEndLine() - $ref->getStartLine() + 1)); + } +} diff --git a/tests/scenarios/core-execute-upgrade-steps-standalone.yaml b/tests/scenarios/core-execute-upgrade-steps-standalone.yaml index 96697279..092e1b47 100644 --- a/tests/scenarios/core-execute-upgrade-steps-standalone.yaml +++ b/tests/scenarios/core-execute-upgrade-steps-standalone.yaml @@ -85,6 +85,7 @@ effects: - ExecuteUpgradeStepsCommand_skips_bundled_updates_when_spawn_env_flag_present_without_skip_options - ExecuteUpgradeStepsCommand_env_guard_dominates_over_force_flag_for_post_steps - ExecuteUpgradeStepsCommand_standalone_invocation_without_env_runs_all_five_steps_intact + - execute_upgrade_steps_child_reads_update_config_from_disk_when_config_is_cached test_files: - tests/Feature/Console/Commands/ExecuteUpgradeStepsStandaloneTest.php diff --git a/tests/scenarios/core-update-spawn-failure-mode.yaml b/tests/scenarios/core-update-spawn-failure-mode.yaml index ba833114..280bb303 100644 --- a/tests/scenarios/core-update-spawn-failure-mode.yaml +++ b/tests/scenarios/core-update-spawn-failure-mode.yaml @@ -41,6 +41,8 @@ axes: spawn_failure_mode: [abort, fallback] from_to_relation: [from_lt_to, from_eq_to_forced] # version_compare 분기 parent_memory_version: [equal_to_to, less_than_to, greater_than_to] # stale 가드 트리거 조건 + memory_version_source: [env_app_version, config_only_env_absent] # 가드 판독 출처 — env 우선(spawn 자식 계약), env 부재 시 config 폴백 + child_config_cache_state: [cached_from_version, no_cache] # 자식이 이전 버전 config 캐시로 부팅하는가 (7.0.9→7.0.10 실사례) steps_executed_signal: [present_positive, present_zero, absent] # 자식의 [STEPS_EXECUTED] count 발행 상태 steps_discovered_signal: [discovered_positive, discovered_zero, absent] # 범위 내 발견된 스텝 파일 수 (신버전 자식만 발행, 구버전=absent) child_process_origin: [beta_5_plus, beta_4_or_earlier] # 이전 버전 자식 (silent skip 시뮬레이션) @@ -81,6 +83,12 @@ effects: - runUpgradeSteps_throws_UpgradeHandoffException_when_memory_lt_to_with_abort_mode - runUpgradeSteps_logs_warning_when_memory_lt_to_with_fallback_mode - runUpgradeSteps_proceeds_silently_when_memory_eq_or_gt_to + - runUpgradeSteps_reads_env_APP_VERSION_before_config_so_spawn_child_with_stale_config_cache_passes_guard + - runUpgradeSteps_falls_back_to_config_version_when_env_APP_VERSION_absent + # config 캐시 부팅 자식 (2026-09-06 전수조사) — 부모는 spawn 전에 캐시를 비우고, 자식은 캐시 부팅이면 디스크 config 를 읽는다 + - spawnUpgradeStepsProcess_clears_config_cache_before_proc_open + - execute_upgrade_steps_child_reads_update_config_from_disk_when_config_is_cached + - route_cache_rebuild_preserves_container_instance - runUpgradeSteps_resume_command_format_matches_execute_upgrade_steps_signature # §1 symlink 보존 (CoreBackupHelper 위임 경로 포함) - copyDirectory_preserves_symlink_target_pointer_on_linux @@ -100,6 +108,9 @@ effects: test_files: - tests/Feature/Console/CoreUpdateCommandSpawnFailureTest.php + - tests/Feature/Console/Commands/ExecuteUpgradeStepsStandaloneTest.php + - tests/Unit/Services/CoreUpdateServiceFreshDiskConfigTest.php + - tests/Unit/Support/RouteCacheHelperContainerTest.php - tests/Feature/Console/CoreUpdateCommandHandoffTest.php - tests/Feature/Console/CoreUpdateResumeGuidanceTest.php - tests/Feature/Upgrades/MultiVersionUpgradePathTest.php