fix(core): 코어 업데이트 spawn 자식의 config 캐시 오판 3건 수정 + 컨테이너 보존 래퍼 전수
7.0.9 클린 설치본에서 7.0.10 으로 올리자 업그레이드 스텝 단계가 "부모 메모리 stale" 로 중단됐다. 스텝 자식은 부모가 비우지 않은 이전 버전 config 캐시로 부팅해 env APP_VERSION 오버라이드와 신버전 config/app.php 의 update 목록을 모두 보지 못한다. 이전 릴리즈에서는 자식 진입부의 config:cache 가 전역 Container 를 일회용 앱으로 바꿔 놓는 부수효과로 가드가 우연히 침묵했고, 그 부수효과를 걷어낸 withPreservedContainer 가 잠복 결함을 드러냈다. - stale 가드는 CoreVersionChecker::getCoreVersion(env 우선) 으로 판독 - 부모는 spawn 직전 config 캐시를 비우고, 자식은 캐시 파일이 있으면 디스크 config 를 직접 읽는다 (7.0.10 이 추가한 public/build/ext 쓰기 권한 정상화 누락 차단) - route:cache 도 같은 부수효과를 남기므로 RouteCacheHelper·optimizeSystem 을 보존 래퍼로 감쌌다 (단독 스텝 재실행·시스템 최적화 뒤 정적 재게시 예약 소실) - 과거 업그레이드 사고 14부류를 7.0.10 변경과 대조 — 재발 조건은 위 3건뿐 - audit 룰 fresh-app-artisan-call-outside-cache-helper + 픽스처, 회귀 테스트 6건(수정 전 red)
This commit is contained in:
@@ -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·화면에 공급한다.
|
||||
|
||||
|
||||
@@ -52,6 +52,8 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- 설정 캐시가 만들어져 있는 사이트(설치 마법사·환경설정 저장·확장 업데이트를 한 번이라도 거친 대부분의 사이트)에서 코어 업데이트가 업그레이드 스텝 단계에서 "수동 재개" 안내와 함께 멈추던 문제를 수정했습니다. 새 버전으로 실행되는 스텝 프로세스가 이전 버전의 설정 캐시를 읽어 자기 자신을 옛 버전으로 오판한 것이 원인이며, 실행할 스텝이 하나도 없는 버전으로 올릴 때도 같은 안내가 나왔습니다. 같은 원인으로 새 버전이 추가한 쓰기 폴더가 `sudo` 업데이트의 권한 정리에서 빠지던 문제도 함께 바로잡았고, 이제 업데이트는 스텝 실행 전에 이전 설정 캐시를 비웁니다.
|
||||
- 명령줄로 업그레이드 스텝을 직접 재실행하거나 관리자 「시스템 최적화」를 실행한 뒤, 그 실행이 예약한 초기 화면 정적 파일 재생성이 조용히 건너뛰어지던 문제를 수정했습니다.
|
||||
- 아웃바운드 프록시를 지정한 사이트에서 글·상품 저장이 수십 초씩 걸리던 문제를 수정했습니다. 사이트가 자기 자신에게 보내는 내부 요청까지 프록시로 나가고 있었고, 프록시가 응답하지 않으면 그 요청이 연결 실패 시각까지 매달렸습니다. 실패는 화면에 드러나지 않고 저장만 느려져 원인을 알기 어려웠습니다. 이제 사이트 자기 주소와 로컬 주소는 운영자가 예외 목록에 적지 않아도 항상 프록시를 거치지 않습니다.
|
||||
- 설치 마법사에서 PHP·Composer 경로가 거부될 때 안내 문구가 실제 허용 범위와 달라, 안내대로 고쳐도 계속 거부되던 문제를 수정했습니다. 이제 파일 이름 조건과 사용할 수 없는 경로 형태(네트워크 경로·scheme:// 등)를 문구에 함께 안내합니다.
|
||||
- 같은 스크립트를 거의 동시에 두 번 불러오면, 두 번째 요청이 첫 번째 로드가 끝나기 전에 완료된 것으로 처리되어 그 뒤 동작이 아무 반응 없이 끝나던 문제를 수정했습니다. 이제 두 요청 모두 실제 로드가 끝난 뒤에 이어집니다.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 등) 요청이 그 경로에 쓰기 실패한다.
|
||||
|
||||
@@ -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, "<?php return [];\n");
|
||||
|
||||
$originalCacheEnv = $_ENV['APP_CONFIG_CACHE'] ?? null;
|
||||
$_ENV['APP_CONFIG_CACHE'] = $relativeCachePath;
|
||||
$_SERVER['APP_CONFIG_CACHE'] = $relativeCachePath;
|
||||
putenv('APP_CONFIG_CACHE='.$relativeCachePath);
|
||||
$originalEnv = getenv('G7_UPDATE_IN_PROGRESS');
|
||||
putenv('G7_UPDATE_IN_PROGRESS=1');
|
||||
|
||||
try {
|
||||
$this->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);
|
||||
|
||||
@@ -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, "<?php return ['app' => ['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 주입 없이 리플렉션 호출 가능한 형태로 준비.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Services;
|
||||
|
||||
use App\Services\CoreUpdateService;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* spawn 자식은 부모가 비우지 않은 이전 버전 config 캐시로 부팅할 수 있다. 그 상태에서
|
||||
* `config('app.update.*')` 는 캐시에 박힌 옛 목록이라, 신버전이 추가한 쓰기 권한 디렉토리
|
||||
* (7.0.10 의 `public/build/ext`) 가 자식의 권한 정상화에서 빠진다 (2026-09-06 서버 실측).
|
||||
* `freshDiskUpdateConfig()` 는 디스크의 `config/app.php` 를 직접 읽어 그 목록을 돌려준다.
|
||||
*/
|
||||
class CoreUpdateServiceFreshDiskConfigTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @effects execute_upgrade_steps_child_reads_update_config_from_disk_when_config_is_cached
|
||||
*/
|
||||
#[Test]
|
||||
public function 메모리_config_가_stale_해도_디스크의_update_목록을_돌려준다(): void
|
||||
{
|
||||
config(['app.update.restore_ownership_group_writable' => ['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']));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Support;
|
||||
|
||||
use App\Services\SettingsService;
|
||||
use App\Support\RouteCacheHelper;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* `route:cache` 도 `config:cache` 와 같은 부수효과를 남긴다 — 새 Application 을 부팅하며 전역
|
||||
* `Container` 인스턴스를 일회용 앱으로 바꿔 놓는다. 되돌리지 않으면 그 뒤에 등록되는
|
||||
* `app()->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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user