fix(core): 업데이트 트리 argv 판정을 명령줄 SAPI 로 한정하고 매니페스트 삭제 실패를 로그에 남김

7.0.11 인스톨러·코어 업데이트 변경(e60a82d73)에 대해 과거 회귀 22건을 부류별로 대조한
결과, 신규 노출면 1건과 테스트 위생 1건이 나와 인터뷰 결정대로 조치했다.

1. argv 채널의 SAPI 게이트 — CGI/FPM 은 register_argc_argv=On 이면 $_SERVER['argv'] 를
 쿼리스트링을 '+' 로 쪼개 채우므로(`GET /?x+core:update` → argv[1]==='core:update', php-cgi
 실측) 비인증 웹 요청이 업데이트 트리로 판정되어 bootstrap/app.php 자가 치유가 요청마다
 패키지 매니페스트를 지우고 다시 만들었다. CoreUpdateContext 와 bootstrap/app.php 복제본
 모두 argv 를 cli·phpdbg 에서만 읽는다. env 플래그 채널은 웹에서 주입할 수 없으므로 그대로
 두어 웹 요청 안에서 시작하는 업데이트 흐름(7.1.0)에 영향이 없다. 동형성 테스트에 SAPI 축을
 더했다.

2. 매니페스트 삭제 실패 기록 — PackageManifestCacheHelper::clear 가 지우지 못한 파일의
 경로를 돌려주고, spawn 직전 호출부가 업그레이드 로그·콘솔에 경고로 남긴다. 권한·소유권
 불일치면 자식의 자가 치유도 같은 이유로 실패해 증상은 제보와 같은 「Class not found」 인데,
 이 경고가 원인이 권한이라는 유일한 흔적이다.

3. 테스트 격리 — tests/bootstrap.php 가 APP_PACKAGES_CACHE/APP_SERVICES_CACHE 를 테스트
 전용 경로로 돌린다. proc_open 으로 자식을 띄우는 기존 테스트 2종의 자식이 개발 클론의
 실제 bootstrap/cache 매니페스트를 지우고 다시 쓰던 것(stat 실측)을 부모·자식 함께 막는다.

관리자 [시스템 최적화] 경로(withPreservedContainer 파사드 복원의 미실측 형제 호출처)는
임시 설치본에서 API 로 실측했다 — 200, 설정·라우트 캐시 재생성, 후속 요청 200, 로그 오류 0.

4. 트러블슈팅 사례 ↔ 회귀 테스트 앵커 계약 — 신규 사례는 헤딩에 <!-- case:{영역}-{번호} -->
 앵커를 달고 같은 문자열을 그 사례를 잠그는 회귀 테스트에도 남겨야 한다. 사례 번호는
 문서마다 1부터 재시작하고 병합으로 중복되므로(이번 리베이스에서도 우리 사례가 develop 과
 같은 29 였다가 31 로 밀렸다), 개수만 대조하면 다른 사례를 덮는 테스트도 초록이 된다.

 그런데 판정기 check-troubleshooting-test-coverage.cjs 를 부르는 지점이 저장소에 하나도
 없었다 — 스크립트 자체 주석에만 실행법이 적혀 있어 아무도 부르지 않으면 영원히 돌지
 않았고, 그 사이 위반이 9건 쌓였다(backend 26~31, cache 17~19). 돌지 않는 대조는 아무것도
 잠그지 못하므로 위반 해소와 실행 지점 부여를 함께 한다.

 9건 전부에 앵커를 부착하고(각 사례가 선언한 회귀 테스트 중 가장 구체적인 파일에 배치,
 한 파일이 두 사례에 선언된 경우는 갈라 배치), stop-guard 7.2 에 앵커 계약 + 미커버
 baseline ratchet 두 축으로 등록했다. 트러블슈팅 사례 추가 프로토콜에 6단계를
 더하고 coverage 에 troubleshooting-case-anchor-contract(manual-only, 전용 판정기 위임)를
 등재했다. 판정기 종료코드 1 → 0, 미커버 건수는 전 문서 baseline 그대로다.
This commit is contained in:
HeuJung
2026-09-08 10:42:19 +09:00
parent ffab451b4a
commit 0c4f52fc96
21 changed files with 339 additions and 31 deletions
+2
View File
@@ -692,6 +692,8 @@ Laravel 은 `bootstrap/cache/packages.php` 가 있으면 stale 여부를 검사
| 정리 로직을 호출부마다 `@unlink` 로 복제 | 헬퍼 단일 지점 — `clearAllCaches()` 도 같은 헬퍼를 쓴다 |
| 자식 프로세스 보호를 부모 코드에만 두기 | 부모는 이미 배포된 옛 코드일 수 있다 — 새 버전의 `bootstrap/app.php` 가 `G7_UPDATE_IN_PROGRESS` 를 보고 스스로 비운다(App\ 클래스 미참조·실패 무시) |
| 코어 버전 판정에서 프로세스 env `APP_VERSION` 을 무조건 우선 | env 우선은 `CoreUpdateContext::isInProgress()` 인 프로세스 트리 안에서만 — 업데이트 전에 뜬 `artisan serve`·큐 워커는 옛 값을 물고 있다 |
| 업데이트 커맨드 argv 판정을 SAPI 게이트 없이 두기 | argv 는 명령줄 SAPI(`cli`·`phpdbg`)에서만 읽는다 — CGI/FPM 은 `register_argc_argv=On` 이면 `$_SERVER['argv']` 를 쿼리스트링에서 채워(`?x+core:update`) 비인증 웹 요청이 업데이트 트리로 판정되고, 자가 치유가 요청마다 매니페스트를 지운다. env 플래그 채널은 웹에서 주입할 수 없으므로 그대로 둔다 |
| 매니페스트 삭제 실패를 `@unlink` 로 삼키기 | `clear()` 가 지우지 못한 경로를 돌려주고 spawn 직전 호출부가 업그레이드 로그에 경고로 남긴다 — 권한·소유권 불일치면 자식도 같은 이유로 실패해 증상은 제보와 같고, 이 경고가 원인을 가리키는 유일한 흔적이다 |
| 업데이트 트리 판정을 지점마다 다시 작성 | `App\Support\CoreUpdateContext` 단일 SSoT — `CoreServiceProvider::isCoreUpdateInProgress()` 도 위임이다. `bootstrap/app.php` 의 복제본은 부팅 전이라 불가피한 예외이며 주석으로 상호 참조한다 |
| 자동 비활성화 로그의 `core_version` 을 `config('app.version')` 으로 적기 | 판정과 같은 `CoreVersionChecker::getCoreVersion()` — 로그와 판정 근거가 갈리면 운영자가 원인을 특정할 수 없다 |
| "이미 있으니 건너뛴다" 분기(인스톨러 vendor 재사용)가 산출물의 출처를 보지 않음 | `installed.json` 의 `dev`/`dev-package-names` 로 출처를 보고 경고 카드·로그를 남기며, 재사용 경로에서도 컴파일 캐시를 정리한다 |
+2 -1
View File
@@ -32,10 +32,11 @@
- 검색엔진 봇에게 대신 그려 주는 페이지가 주소의 물음표 뒤 값만 바꿔 계속 요청하면 매번 새로 그려지고 그 결과가 무한정 저장되던 문제를 수정했습니다. 봇으로 위장한 요청이 서버 부하와 저장 공간 증가로 이어질 수 있었습니다. 이제 한 IP 가 분당 일정 횟수를 넘겨 새 페이지를 요청하면 그 초과분에는 일반 페이지를 주고, 저장 개수에도 상한을 둡니다. 상한값은 서버 설정으로 바꿀 수 있습니다.
- 관리자 SEO 통계와 `seo:stats` 명령이 항상 0 으로 표시되던 문제를 수정했습니다. 캐시 적중·미적중이 기록되지 않고 있었습니다.
- 확장의 스크립트·스타일 파일을 읽지 못하는 상태가 되면 그 확장의 자산이 빠진 결과가 저장되어, 파일 문제가 풀린 뒤에도 확장을 다시 설치하거나 설정을 바꿀 때까지 계속 빠진 채로 남던 문제를 수정했습니다. 이제 읽지 못한 확장이 있으면 그 결과를 저장하지 않아 원인이 사라지는 즉시 정상으로 돌아오며, 어느 확장을 읽지 못했는지 서버 기록에 남습니다.
- 개발용 패키지가 포함된 상태로 설치된 사이트에서 코어 업데이트가 업그레이드 스텝 단계에서 「Class ... not found」 오류와 「수동 재개」 안내로 멈추던 문제를 수정했습니다. 이전 설치본이 만든 패키지 목록 캐시가 새 `vendor/` 로 교체된 뒤에도 남아 있던 것이 원인이며, 이제 업데이트는 스텝 실행 전에 그 캐시를 함께 비웁니다. (sir.kr 커뮤니티에서 제보해주신 내용입니다.)
- 개발용 패키지가 포함된 상태로 설치된 사이트에서 코어 업데이트가 업그레이드 스텝 단계에서 「Class ... not found」 오류와 「수동 재개」 안내로 멈추던 문제를 수정했습니다. 이전 설치본이 만든 패키지 목록 캐시가 새 `vendor/` 로 교체된 뒤에도 남아 있던 것이 원인이며, 이제 업데이트는 스텝 실행 전에 그 캐시를 함께 비웁니다. 비우지 못한 파일이 있으면 업데이트 로그에 남겨 권한 문제를 바로 확인할 수 있습니다. (sir.kr 커뮤니티에서 제보해주신 내용입니다.)
- 이미 배포된 이전 버전(7.0.9·7.0.10)에서 업데이트를 시작하는 경우에도 같은 중단이 나지 않도록, 새 버전의 스텝 프로세스가 시작할 때 이전 패키지 목록 캐시를 스스로 정리합니다.
- `php artisan serve` 나 큐 워커처럼 오래 떠 있는 프로세스가 기동 시점의 버전 값을 계속 쓰면서, 업데이트 직후 확장이 「코어 버전 미달」로 잘못 비활성화되던 문제를 수정했습니다. 업데이트 진행 중이 아닐 때는 프로세스 환경값이 아니라 설정의 버전을 기준으로 판정합니다.
- 코어 업데이트가 마지막 정리 단계에서 「Target class [...] does not exist」 오류로 실패하고 백업으로 되돌아가던 문제를 수정했습니다. 설정 캐시를 다시 만드는 과정이 내부적으로 띄우는 일회용 애플리케이션이 그 뒤의 모든 작업까지 넘겨받은 채로 남아 있던 것이 원인입니다.
- 업데이트 진행 중 판정이 명령줄에서 실행될 때만 커맨드 이름을 참고하도록 좁혔습니다. 일부 PHP 설정(`register_argc_argv` 활성)에서는 웹 주소의 쿼리만으로 업데이트가 진행 중인 것처럼 보이게 할 수 있었습니다.
## [7.0.10] - 2026-09-06
@@ -919,7 +919,17 @@ class CoreUpdateCommand extends Command
// dev composer(require-dev 전이 의존성 laravel/mcp 의 McpServiceProvider 포함)로 깔렸다면
// 자식은 새 vendor 에 없는 provider 를 new 하다 부팅 단계에서 죽는다 (7.0.9→7.0.10 실사례).
// 부모 메모리의 매니페스트는 영향받지 않고, Step 11 이 package:discover 로 다시 만든다.
PackageManifestCacheHelper::clear();
//
// 지우지 못한 파일(권한·소유권 불일치)은 로그에 남긴다. 그 상태면 자식의 자가 치유도 같은
// 권한으로 실패하므로 증상은 이전 설치본 provider 의 「Class not found」 그대로이고, 이 기록이
// 권한이 원인이라는 유일한 흔적이다.
$remainingManifests = PackageManifestCacheHelper::clear();
if ($remainingManifests !== []) {
$manifestWarning = 'spawn 직전 패키지 매니페스트 삭제 실패 — 자식이 이전 설치본의 provider 목록으로 부팅할 수 있습니다 (권한·소유권 확인): '
.implode(', ', $remainingManifests);
$log($manifestWarning);
$this->warn($manifestWarning);
}
$process = proc_open($commandLine, $descriptors, $pipes, base_path(), $env);
if (! is_resource($process)) {
+19 -3
View File
@@ -15,6 +15,12 @@ namespace App\Support;
* `$env` 로 전파되지만, `variables_order` 에 `E` 가 없는 호스팅에서는 `$_ENV` 가 비어 있을 수
* 있어 argv 보조 판정을 함께 둔다.
*
* argv 채널은 명령줄 SAPI 에서만 읽는다. CGI/FPM 은 `register_argc_argv=On` 이면 `$_SERVER['argv']`
* 를 쿼리스트링을 `+` 로 쪼갠 값으로 채우므로(`GET /?x+core:update` → `argv[1] === 'core:update'`),
* 그 SAPI 에서 argv 를 믿으면 비인증 웹 요청이 업데이트 트리로 판정되어 `bootstrap/app.php` 의
* 자가 치유가 요청마다 매니페스트를 지운다. env 플래그 채널은 웹 요청으로 주입할 수 없어 SAPI 와
* 무관하게 인정한다 — 웹 요청 안에서 시작하는 업데이트 흐름은 그 플래그를 프로세스 안에서 세운다.
*
* 주의: `bootstrap/app.php` 의 자가 치유 블록은 부팅 전이라 이 클래스를 참조할 수 없어
* 같은 판정을 순수 PHP 로 복제한다. 조건을 바꾸면 그쪽도 함께 고친다.
*/
@@ -25,22 +31,32 @@ final class CoreUpdateContext
*/
private const UPDATE_COMMANDS = ['core:update', 'core:execute-upgrade-steps'];
/**
* argv 보조 판정을 신뢰하는 SAPI — 명령줄 프로세스만. 웹 SAPI 의 argv 는 쿼리스트링에서 채워질 수 있다.
*/
private const CONSOLE_SAPIS = ['cli', 'phpdbg'];
/**
* 현재 프로세스가 코어 업데이트 트리(부모 core:update 또는 그 spawn 자식) 안에 있는지 판정합니다.
*
* 판정 조건 (OR):
* 1. 환경변수 `G7_UPDATE_IN_PROGRESS=1` — 부모가 시작 시 설정하고 spawn 자식에 전파
* 2. artisan 커맨드 이름이 `core:update` / `core:execute-upgrade-steps` — 1 이 전파되지
* 않은 극단 상황 대비 보조 판정
* 2. 명령줄 SAPI 에서 artisan 커맨드 이름이 `core:update` / `core:execute-upgrade-steps` —
* 1 이 전파되지 않은 극단 상황 대비 보조 판정. 웹 SAPI 에서는 읽지 않는다
*
* @param string|null $sapi 판정에 쓸 SAPI 이름. 기본은 `PHP_SAPI` 이며, 테스트가 웹 SAPI 를 주입할 때만 지정
* @return bool 업데이트 트리 안이면 true
*/
public static function isInProgress(): bool
public static function isInProgress(?string $sapi = null): bool
{
if (self::hasEnvFlag()) {
return true;
}
if (! in_array($sapi ?? PHP_SAPI, self::CONSOLE_SAPIS, true)) {
return false;
}
$argv = $_SERVER['argv'] ?? [];
return in_array($argv[1] ?? '', self::UPDATE_COMMANDS, true);
+18 -5
View File
@@ -25,22 +25,35 @@ class PackageManifestCacheHelper
* 경로는 `Application` 의 게터로 읽어 `APP_PACKAGES_CACHE`/`APP_SERVICES_CACHE` 환경변수
* 재지정을 존중한다(테스트 격리가 이 경로 재지정에 의존한다).
*
* 삭제 실패(권한 · Windows 파일 핸들 점유)는 치명적이지 않다 — 종전 상태로 남을 뿐이며
* 코어 업데이트의 마지막 단계(`clearAllCaches()`)가 다시 시도한다.
* 삭제 실패(권한 · 소유권 불일치 · Windows 파일 핸들 점유)는 예외로 올리지 않는다 — 부팅 직전에
* 불리므로 실패가 흐름을 막으면 안 되고, 코어 업데이트의 마지막 단계(`clearAllCaches()`)가 다시
* 시도한다. 대신 지우지 못한 파일의 경로를 돌려준다. spawn 직전 호출부는 그 목록을 업그레이드
* 로그에 남긴다 — 그 상태면 자식의 자가 치유도 같은 권한으로 같은 이유로 실패해 증상은 이전
* 설치본 provider 의 「Class not found」 그대로인데, 이 기록이 권한이 원인이라는 유일한 흔적이다.
*
* @return void
* @return array<int, string> 삭제하지 못하고 남은 파일의 절대 경로. 전부 지웠거나 원래 없었으면 빈 배열
*/
public static function clear(): void
public static function clear(): array
{
$app = app();
$remaining = [];
foreach ([$app->getCachedServicesPath(), $app->getCachedPackagesPath()] as $path) {
if (! is_file($path)) {
continue;
}
if (! @unlink($path)) {
clearstatcache(true, $path);
if (is_file($path)) {
@unlink($path);
$remaining[] = $path;
}
}
}
clearstatcache();
return $remaining;
}
/**
+5 -1
View File
@@ -225,9 +225,13 @@ $app = Application::configure(basePath: dirname(__DIR__))
| 규칙: App\ 클래스를 참조하지 않는다 (부팅 전이라 오토로드를 신뢰할 수 없고, 자가 치유가 자기
| 실패로 부팅을 막아서는 안 된다). 판정은 App\Support\CoreUpdateContext::isInProgress() 와 동일 —
| 조건을 바꾸면 양쪽을 함께 고친다.
| argv 채널은 명령줄 SAPI 에서만 읽는다. CGI/FPM 은 register_argc_argv=On 이면 $_SERVER['argv'] 를
| 쿼리스트링을 '+' 로 쪼갠 값으로 채우므로(`GET /?x+core:update` → argv[1] === 'core:update'), 이 게이트가
| 없으면 비인증 웹 요청이 요청마다 매니페스트를 지우고 다시 만들게 한다. env 플래그 채널은 웹 요청으로
| 주입할 수 없어 그대로 두며, 웹 요청 안에서 시작하는 업데이트 흐름은 그 플래그를 프로세스 안에서 세운다.
*/
$g7UpdateFlag = $_ENV['G7_UPDATE_IN_PROGRESS'] ?? $_SERVER['G7_UPDATE_IN_PROGRESS'] ?? getenv('G7_UPDATE_IN_PROGRESS');
$g7UpdateArgv = $_SERVER['argv'][1] ?? '';
$g7UpdateArgv = in_array(PHP_SAPI, ['cli', 'phpdbg'], true) ? ($_SERVER['argv'][1] ?? '') : '';
if ($g7UpdateFlag === '1' || $g7UpdateFlag === 1 || $g7UpdateFlag === true
|| in_array($g7UpdateArgv, ['core:update', 'core:execute-upgrade-steps'], true)) {
+5 -3
View File
@@ -280,11 +280,13 @@ config 캐시와 같은 문제가 **패키지 매니페스트**(`bootstrap/cache
| 계층 | 위치 | 막는 실패 | 잠그는 테스트 |
|------|------|-----------|--------------|
| ① 부모 선정리 | `CoreUpdateCommand::spawnUpgradeStepsProcess()` 가 `proc_open` 직전 `PackageManifestCacheHelper::clear()` | 7.0.11+ 부모가 띄우는 자식의 부팅 실패 | `CoreUpdateCommandStalePackageManifestTest` |
| ② 자식 자가 치유 | `bootstrap/app.php` 가 `G7_UPDATE_IN_PROGRESS=1`(또는 업데이트 argv)이면 두 파일을 스스로 삭제 | **이미 배포된** 7.0.9·7.0.10 부모 아래에서 도는 신버전 자식 — 그 부모 코드는 고칠 수 없다 | 같은 테스트 (플래그 유·무 대조군 포함) |
| ① 부모 선정리 | `CoreUpdateCommand::spawnUpgradeStepsProcess()` 가 `proc_open` 직전 `PackageManifestCacheHelper::clear()`. 지우지 못한 파일이 있으면 그 경로를 업그레이드 로그에 경고로 남긴다 — 권한·소유권 불일치면 자식의 계층 ② 도 같은 이유로 실패해 증상은 제보와 같은 「Class not found」 인데, 이 경고가 원인을 가리키는 유일한 흔적이다 | 7.0.11+ 부모가 띄우는 자식의 부팅 실패 | `CoreUpdateCommandStalePackageManifestTest` |
| ② 자식 자가 치유 | `bootstrap/app.php` 가 `G7_UPDATE_IN_PROGRESS=1`(또는 명령줄 SAPI 에서의 업데이트 argv)이면 두 파일을 스스로 삭제 | **이미 배포된** 7.0.9·7.0.10 부모 아래에서 도는 신버전 자식 — 그 부모 코드는 고칠 수 없다 | 같은 테스트 (플래그 유·무 대조군 포함) |
| ③ 버전 판독 범위 | `CoreVersionChecker::getCoreVersion()` 의 env 우선은 `CoreUpdateContext::isInProgress()` 트리 안에서만 | 업데이트 **전에** 뜬 `php artisan serve`·큐 워커가 옛 `APP_VERSION` 을 물고 확장을 `incompatible_core` 로 끄는 것 | `CoreVersionCheckerEnvPriorityTest` · `CoreUpdateContextTest` |
계층 ②는 `config:cache`/`route:cache` 가 만드는 in-process 일회용 앱에도 발동한다 — 그 부팅도 `bootstrap/app.php` 를 다시 require 하고 플래그를 상속하기 때문이다. 웹 요청·`queue:work`·운영자 셸은 플래그도 argv 도 없어 no-op 이다.
계층 ②는 `config:cache`/`route:cache` 가 만드는 in-process 일회용 앱에도 발동한다 — 그 부팅도 `bootstrap/app.php` 를 다시 require 하고 플래그를 상속하기 때문이다. 웹 요청·`queue:work`·운영자 셸은 플래그가 없어 no-op 이다.
argv 채널은 명령줄 SAPI(`cli`·`phpdbg`)에서만 읽는다. CGI/FPM 은 `register_argc_argv=On` 이면 `$_SERVER['argv']` 를 쿼리스트링을 `+` 로 쪼갠 값으로 채우므로(`GET /?x+core:update` → `argv[1] === 'core:update'`), 그 게이트가 없으면 비인증 웹 요청이 요청마다 매니페스트를 지우고 다시 만들게 된다. env 플래그 채널은 웹 요청으로 주입할 수 없어 그대로 두며, 웹 요청 안에서 시작되는 업데이트 흐름은 그 플래그를 프로세스 안에서 세워 판정된다.
계층 ③의 판정은 `App\Support\CoreUpdateContext` 가 단독으로 소유하고 `CoreServiceProvider::isCoreUpdateInProgress()` 가 그리로 위임한다. 자동 비활성화 로그의 `core_version` 도 같은 게터를 쓴다 — 로그가 `config('app.version')` 을 적고 판정은 env 로 하면 운영자가 보는 근거와 실제 판정이 어긋난다.
@@ -238,6 +238,8 @@ $process = proc_open($cmd, $descriptors, $pipes, $cwd, $env);
`isCoreUpdateInProgress()` 는 env 외에 `$argv[1]` 이 `core:update` / `core:execute-upgrade-steps` 인 경우도 true 판정 — env 전파 실패 극단 상황 방어용. 하지만 `php -r '...'` 로 기동되는 spawn (inline 스크립트) 은 argv 판정이 되지 않으므로 env 전파가 유일한 수단입니다.
argv 판정은 명령줄 SAPI(`cli`·`phpdbg`)에서만 유효합니다. 웹 요청의 `$_SERVER['argv']` 는 `register_argc_argv` 설정에 따라 쿼리스트링에서 채워지므로 신뢰하지 않으며, 웹 요청 안에서 업데이트 흐름을 시작하는 코드는 env 플래그를 프로세스 안에서 세워 판정을 받습니다.
#### 판정의 단일 출처와 이 플래그가 게이트하는 것
`CoreServiceProvider::isCoreUpdateInProgress()` 는 `App\Support\CoreUpdateContext::isInProgress()` 위임입니다. 같은 플래그가 서로 다른 계층에서 셋을 게이트하므로 판정이 갈라지면 그중 한 경로만 조용히 다르게 동작합니다.
@@ -9,7 +9,7 @@ use Mockery;
use Tests\TestCase;
/**
* 확장 프론트엔드 병합 번들 서빙 엔드포인트 Feature 테스트
* [case:backend-27] 확장 프론트엔드 병합 번들 서빙 엔드포인트 Feature 테스트
*
* /api/{modules,plugins}/bundle.{js,css} 의 응답 계약(Content-Type, ETag,
* 304 Not Modified, 빈 번들 처리)을 검증한다. ExtensionBundleService 를
+1 -1
View File
@@ -19,7 +19,7 @@ use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* 로그인 2단계 인증 테스트
* [case:backend-29] 로그인 2단계 인증 테스트
*
* `security.two_factor_auth` 는 설정 화면에만 있고 구현이 없어, 켜도 아무 일도 일어나지
* 않았습니다. 관리자는 2단계 인증이 걸린 줄 알지만 실제로는 비밀번호 하나로 로그인됩니다.
@@ -12,7 +12,7 @@ use Symfony\Component\Console\Output\BufferedOutput;
use Tests\TestCase;
/**
* stale 패키지 매니페스트로 인한 spawn 자식 부팅 실패 회귀 테스트 (dev-g7 #658).
* [case:backend-31] stale 패키지 매니페스트로 인한 spawn 자식 부팅 실패 회귀 테스트 (dev-g7 #658).
*
* 회귀 시나리오 (sir.kr 제보, 7.0.9 → 7.0.10):
* 운영 사이트가 `composer install`(옵션 없음)로 깔린 dev 설치본이면
@@ -202,6 +202,88 @@ class CoreUpdateCommandStalePackageManifestTest extends TestCase
$this->assertSame($before, md5_file($this->packagesPath), '매니페스트 내용이 변경되지 않아야 한다');
}
/**
* 계층 ①: spawn 직전에 매니페스트를 지우지 못하면 그 파일을 업그레이드 로그에 남긴다.
*
* 권한·소유권 불일치로 삭제가 막히면 자식의 자가 치유도 같은 권한으로 같은 이유로 실패해
* 증상은 이전 설치본 provider 의 「Class not found」 그대로다 — 이 경고가 원인이 권한이라는
* 유일한 흔적이다. 삭제 실패는 실제로 만든다(Windows: 열린 핸들 / POSIX: 부모 디렉토리 쓰기
* 권한 제거). 자식은 존재하지 않는 php 바이너리로 부팅 불가하게 두어 `proc_open` 앞의 동작만
* 관측한다.
*
* @effects spawnUpgradeStepsProcess_logs_manifest_files_it_could_not_remove
*/
#[Test]
public function spawn_직전_매니페스트를_지우지_못하면_업그레이드_로그에_남은_파일을_경고한다(): void
{
if (! function_exists('proc_open')) {
$this->markTestSkipped('proc_open 미지원 환경');
}
config(['app.update.spawn_failure_mode' => 'abort']);
config(['process.php_binary' => base_path('storage/framework/testing/no-such-php-binary')]);
$this->writeStaleManifests();
$release = $this->makeUndeletable($this->packagesPath);
$logged = [];
try {
try {
$this->invokeSpawn(function (string $line) use (&$logged): void {
$logged[] = $line;
});
$this->fail('자식이 부팅할 수 없으므로 abort 모드에서 UpgradeHandoffException 이 발생해야 한다');
} catch (UpgradeHandoffException) {
// 기대된 경로 — 아래 단언이 본 검증이다.
}
} finally {
$release();
}
$this->assertFileExists($this->packagesPath, '삭제가 막힌 packages.php 는 남는다');
$this->assertFileDoesNotExist($this->servicesPath, '지울 수 있는 services.php 는 지운다');
$warnings = array_values(array_filter($logged, static fn (string $line): bool => str_contains($line, '패키지 매니페스트 삭제 실패')));
$this->assertCount(1, $warnings, '지우지 못한 파일이 있으면 업그레이드 로그에 경고 한 줄을 남긴다');
$this->assertStringContainsString($this->packagesPath, $warnings[0], '경고는 지우지 못한 파일의 경로를 지목한다');
$this->assertStringNotContainsString($this->servicesPath, $warnings[0], '지운 파일은 경고에 싣지 않는다');
}
/**
* 파일을 현재 프로세스가 삭제할 수 없는 상태로 만들고, 되돌리는 클로저를 반환합니다.
*
* Windows 는 읽기 전용 속성(0444)이 삭제를 막고(PHP 7.3+ 는 파일을 `FILE_SHARE_DELETE` 로 열어
* 열린 핸들로는 막히지 않는다 — 실측), POSIX 는 부모 디렉토리의 쓰기 권한이 삭제를 막는다(root 는
* 권한을 우회하므로 이 방법으로 실패를 만들 수 없다).
*
* @param string $path 삭제를 막을 파일
* @return \Closure 원상 복구 클로저
*/
private function makeUndeletable(string $path): \Closure
{
if (PHP_OS_FAMILY === 'Windows') {
$this->assertTrue(chmod($path, 0444), '전제: 읽기 전용 속성을 건다');
return static function () use ($path): void {
if (is_file($path)) {
chmod($path, 0644);
}
};
}
if (function_exists('posix_geteuid') && posix_geteuid() === 0) {
$this->markTestSkipped('root 는 디렉토리 권한으로 삭제 실패를 만들 수 없다 — 비-root 로 실행해야 이 축이 측정된다');
}
$dir = dirname($path);
$this->assertTrue(chmod($dir, 0555), '전제: 부모 디렉토리의 쓰기 권한을 뺀다');
return static function () use ($dir): void {
chmod($dir, 0755);
};
}
/**
* 존재하지 않는 provider 를 등재한 stale 매니페스트 두 개를 만든다.
*/
@@ -218,9 +300,10 @@ class CoreUpdateCommandStalePackageManifestTest extends TestCase
/**
* `spawnUpgradeStepsProcess` 를 리플렉션으로 호출합니다.
*
* @param \Closure|null $log 업그레이드 로그 클로저. 생략하면 버린다
* @return bool spawn 성공 여부
*/
private function invokeSpawn(): bool
private function invokeSpawn(?\Closure $log = null): bool
{
$command = app(CoreUpdateCommand::class);
@@ -242,7 +325,7 @@ class CoreUpdateCommandStalePackageManifestTest extends TestCase
$method = new \ReflectionMethod(CoreUpdateCommand::class, 'spawnUpgradeStepsProcess');
$method->setAccessible(true);
return (bool) $method->invoke($command, '9.9.8', '9.9.9', true, fn () => null);
return (bool) $method->invoke($command, '9.9.8', '9.9.9', true, $log ?? fn () => null);
}
/**
@@ -6,7 +6,7 @@ use App\Support\EnvPriority;
use Tests\TestCase;
/**
* `.env` 키 단위 우선 규약의 계약 테스트.
* [case:backend-26] `.env` 키 단위 우선 규약의 계약 테스트.
*
* 이 결함군은 오류를 남기지 않는다 — 매핑이 뒤처지거나 배선이 빠지면 잠금이 조용히
* 미발동하고, 그 상태는 "그 키가 `.env` 에 없다"와 화면상 구분되지 않는다. 그래서
@@ -6,7 +6,7 @@ use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* 템플릿 업데이트 성공 cleanup 의 config 캐시 무효화 구조 테스트 (#588, 공개 #119)
* [case:cache-17] 템플릿 업데이트 성공 cleanup 의 config 캐시 무효화 구조 테스트 (#588, 공개 #119)
*
* `template.config.{identifier}` 는 버전 접미사 없는 고정 키라 캐시 버전 bump 로
* 무효화되지 않는다. updateTemplate() 성공 cleanup 이 clearTemplateCache() 를
@@ -8,7 +8,7 @@ use Illuminate\Support\Facades\File;
use Tests\TestCase;
/**
* SettingsMigrator owner 상속 회귀 테스트.
* [case:backend-28] SettingsMigrator owner 상속 회귀 테스트.
*
* sudo update 흐름에서 모듈/플러그인 upgrade step 이 root 로 실행될 때
* `SettingsMigrator::writeJsonFile` 가 만드는 *.json 파일이 root 소유로 영구 잔존하는
@@ -13,7 +13,7 @@ use Mockery;
use Tests\TestCase;
/**
* ExtensionBundleService 단위 테스트
* [case:backend-30] ExtensionBundleService 단위 테스트
*
* 활성 확장 IIFE/CSS 의 priority 정렬, `\n;\n` 구분자 병합, sourceMappingURL
* 처리, 확장별 fault tolerance, 캐시 파일 생성/정리를 검증한다.
@@ -13,7 +13,7 @@ use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
/**
* 코어 단건 설정 저장(setSetting)의 부수효과 정합 테스트 (공개 #114 동종, B-3)
* [case:cache-18] 코어 단건 설정 저장(setSetting)의 부수효과 정합 테스트 (공개 #114 동종, B-3)
*
* 단건 저장은 `PUT /api/admin/settings/{key}` 로 실제 도달 가능한 경로인데, 벌크 저장이
* 수행하는 부수효과 중 일부를 건너뛰었다:
+73 -3
View File
@@ -121,6 +121,48 @@ class CoreUpdateContextTest extends TestCase
}
}
/**
* 웹 SAPI 에서는 argv 를 인정하지 않는다.
*
* CGI/FPM 은 `register_argc_argv=On` 이면 `$_SERVER['argv']` 를 쿼리스트링을 `+` 로 쪼갠 값으로
* 채운다 — `GET /?x+core:update` 가 `argv[1] === 'core:update'` 를 만든다(2026-09-08 php-cgi 실측).
* 그 SAPI 에서 argv 를 믿으면 비인증 요청이 업데이트 트리로 판정되어 `bootstrap/app.php` 의
* 자가 치유가 요청마다 매니페스트를 지운다.
*
* @effects CoreUpdateContext_ignores_argv_outside_console_sapi
*/
#[Test]
public function 웹_sapi_에서는_위조_가능한_argv_를_인정하지_않는다(): void
{
// register_argc_argv=On 인 FPM 이 `?x+core:update` 로 만드는 형태.
$_SERVER['argv'] = ['x', 'core:update'];
foreach (['fpm-fcgi', 'cgi-fcgi', 'apache2handler', 'litespeed', 'cli-server'] as $sapi) {
$this->assertFalse(CoreUpdateContext::isInProgress($sapi), "{$sapi} 에서는 argv 로 트리 안이 되면 안 된다");
}
foreach (['cli', 'phpdbg'] as $sapi) {
$this->assertTrue(CoreUpdateContext::isInProgress($sapi), "{$sapi} 에서는 argv 보조 판정이 유효하다");
}
}
/**
* env 플래그 채널은 SAPI 와 무관하다 — 웹 요청으로는 주입할 수 없고, 웹 요청 안에서 시작하는
* 업데이트 흐름이 프로세스 안에서 세우는 채널이다.
*
* @effects CoreUpdateContext_env_flag_is_honored_regardless_of_sapi
*/
#[Test]
public function env_플래그는_웹_sapi_에서도_인정한다(): void
{
$_ENV['G7_UPDATE_IN_PROGRESS'] = '1';
$_SERVER['argv'] = ['index.php'];
foreach (['fpm-fcgi', 'cgi-fcgi', 'cli'] as $sapi) {
$this->assertTrue(CoreUpdateContext::isInProgress($sapi), "{$sapi} 에서도 env 플래그는 트리 안이다");
}
}
/**
* `hasEnvFlag()` 는 argv 보조 판정을 섞지 않는다 — 섞으면 단독 실행이 spawn 자식으로
* 오판되어 사전·사후 단계를 통째로 건너뛴다.
@@ -143,9 +185,9 @@ class CoreUpdateContextTest extends TestCase
* 드러나지 않는다. 그래서 두 조건이 같은지를 여기서 잠근다 — 종전에는 양쪽 주석의 상호
* 참조가 유일한 방어였다.
*
* 대조 항목은 판정을 이루는 네 축 전부다: 환경변수 이름 · 읽는 채널 3종 · 참으로 받는 값
* 3종 · argv 로 인정하는 커맨드 목록(리플렉션으로 이 클래스에서 파생 — 손으로 적으면
* 커맨드가 하나 늘어도 통과한다).
* 대조 항목은 판정을 이루는 다섯 축 전부다: 환경변수 이름 · 읽는 채널 3종 · 참으로 받는 값
* 3종 · argv 로 인정하는 커맨드 목록 · argv 를 신뢰하는 SAPI 목록(뒤의 둘은 리플렉션으로 이
* 클래스에서 파생 — 손으로 적으면 항목이 하나 늘어도 통과한다).
*/
#[Test]
public function bootstrap_자가치유_조건이_이_판정기와_동형이다(): void
@@ -196,6 +238,34 @@ class CoreUpdateContextTest extends TestCase
"자가 치유 블록의 argv 판정에 `{$command}` 가 없다 — 그 커맨드로 도는 자식은 방어를 받지 못한다"
);
}
// ⑤ argv 를 신뢰하는 SAPI — 목록은 이 클래스에서 파생한다. 게이트가 빠지면 register_argc_argv=On 인
// CGI/FPM 에서 `?x+core:update` 한 번에 자가 치유가 켜진다.
$sapis = $reflection->getConstant('CONSOLE_SAPIS');
$this->assertIsArray($sapis);
$this->assertNotEmpty($sapis, 'SAPI 목록이 비면 이 대조는 공허하게 통과한다');
$this->assertStringContainsString(
'PHP_SAPI',
$block,
'자가 치유 블록의 argv 판정에 SAPI 게이트가 없다 — 웹 요청이 쿼리스트링으로 매니페스트 삭제를 켤 수 있다'
);
foreach ($sapis as $sapi) {
$this->assertStringContainsString(
"'{$sapi}'",
$block,
"자가 치유 블록의 SAPI 게이트에 `{$sapi}` 가 없다 — 그 SAPI 로 도는 명령줄 자식은 argv 방어를 받지 못한다"
);
}
foreach (['fpm-fcgi', 'cgi-fcgi', 'apache2handler'] as $webSapi) {
$this->assertStringNotContainsString(
"'{$webSapi}'",
$block,
"자가 치유 블록이 웹 SAPI `{$webSapi}` 의 argv 를 신뢰한다"
);
}
}
/**
@@ -8,7 +8,7 @@ use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* `CustomAssets::publishableFiles()` — 게시·변경 감지가 공유하는 열거자 테스트 (#651 F7).
* [case:cache-19] `CustomAssets::publishableFiles()` — 게시·변경 감지가 공유하는 열거자 테스트 (#651 F7).
*
* 정적 게시는 `custom/**` 를 재귀로 복사하고, 변경 감지는 종전에 최상위 css/js 의 mtime 만
* 서명했다. 두 범위를 서로 다른 코드가 정의하면 어긋나고, 그 어긋남은 "글꼴을 바꿨는데
@@ -79,10 +79,11 @@ class PackageManifestCacheHelperTest extends TestCase
$this->assertSame($this->packagesPath, $this->app->getCachedPackagesPath(), '전제: env 재지정이 경로를 정한다');
$this->assertSame($this->servicesPath, $this->app->getCachedServicesPath(), '전제: env 재지정이 경로를 정한다');
PackageManifestCacheHelper::clear();
$remaining = PackageManifestCacheHelper::clear();
$this->assertFileDoesNotExist($this->packagesPath);
$this->assertFileDoesNotExist($this->servicesPath);
$this->assertSame([], $remaining, '전부 지웠으면 남은 파일이 없다');
}
/**
@@ -93,10 +94,74 @@ class PackageManifestCacheHelperTest extends TestCase
{
$this->assertFileDoesNotExist($this->packagesPath, '전제: 파일이 없다');
PackageManifestCacheHelper::clear();
$remaining = PackageManifestCacheHelper::clear();
$this->assertFileDoesNotExist($this->packagesPath);
$this->assertFileDoesNotExist($this->servicesPath);
$this->assertSame([], $remaining, '원래 없던 파일은 "지우지 못한 파일" 이 아니다');
}
/**
* 지우지 못한 파일은 경로로 돌려주고, 지울 수 있는 형제 파일은 그대로 지운다.
*
* 호출부(spawn 직전)가 이 목록을 업그레이드 로그에 남긴다 — 권한·소유권 불일치면 자식의 자가
* 치유도 같은 이유로 실패해 증상은 「Class not found」 그대로인데, 그 기록이 원인을 가리키는
* 유일한 흔적이다. 삭제 실패는 플랫폼마다 실제로 만든다: Windows 는 열린 핸들이, POSIX 는 부모
* 디렉토리의 쓰기 권한이 삭제를 막는다.
*
* @effects PackageManifestCacheHelper_clear_returns_paths_it_could_not_remove
*/
#[Test]
public function clear_는_지우지_못한_파일의_경로를_돌려준다(): void
{
File::put($this->packagesPath, "<?php return [];\n");
File::put($this->servicesPath, "<?php return [];\n");
$release = $this->makeUndeletable($this->packagesPath);
try {
$remaining = PackageManifestCacheHelper::clear();
} finally {
$release();
}
$this->assertSame([$this->packagesPath], $remaining, '지우지 못한 파일만 경로로 돌려준다');
$this->assertFileExists($this->packagesPath, '삭제가 막힌 파일은 그대로 남는다');
$this->assertFileDoesNotExist($this->servicesPath, '지울 수 있는 형제 파일은 지운다');
}
/**
* 파일을 현재 프로세스가 삭제할 수 없는 상태로 만들고, 되돌리는 클로저를 반환합니다.
*
* Windows 는 읽기 전용 속성(0444)이 삭제를 막고(PHP 7.3+ 는 파일을 `FILE_SHARE_DELETE` 로 열어
* 열린 핸들로는 막히지 않는다 — 실측), POSIX 는 부모 디렉토리의 쓰기 권한이 삭제를 막는다(root 는
* 권한을 우회하므로 이 방법으로 실패를 만들 수 없다).
*
* @param string $path 삭제를 막을 파일
* @return \Closure 원상 복구 클로저
*/
private function makeUndeletable(string $path): \Closure
{
if (PHP_OS_FAMILY === 'Windows') {
$this->assertTrue(chmod($path, 0444), '전제: 읽기 전용 속성을 건다');
return static function () use ($path): void {
if (is_file($path)) {
chmod($path, 0644);
}
};
}
if (function_exists('posix_geteuid') && posix_geteuid() === 0) {
$this->markTestSkipped('root 는 디렉토리 권한으로 삭제 실패를 만들 수 없다 — 비-root 로 실행해야 이 축이 측정된다');
}
$dir = dirname($path);
$this->assertTrue(chmod($dir, 0555), '전제: 부모 디렉토리의 쓰기 권한을 뺀다');
return static function () use ($dir): void {
chmod($dir, 0755);
};
}
/**
+33
View File
@@ -174,6 +174,39 @@ putenv('APP_ROUTES_CACHE='.$testingRoutesCache);
$_ENV['APP_ROUTES_CACHE'] = $testingRoutesCache;
$_SERVER['APP_ROUTES_CACHE'] = $testingRoutesCache;
/*
|--------------------------------------------------------------------------
| 패키지 매니페스트 캐시 격리 (테스트 환경 보장)
|--------------------------------------------------------------------------
|
| `bootstrap/cache/packages.php` · `services.php` 도 라우트 캐시와 같이 **경로를 돌린다.**
| 코어 업데이트 흐름은 이 두 파일을 지우고 다시 만드는 코드를 여럿 갖고 있고(spawn 직전 선정리,
| `bootstrap/app.php` 의 업데이트 트리 자가 치유, `clearAllCaches()` 의 `package:discover`), 그
| 코드를 태우는 테스트 가운데 몇은 `proc_open` 으로 **진짜 자식 프로세스**를 띄운다. 자식은 부모의
| 테스트 격리(임시 base path·목)를 하나도 물려받지 못하고 env 만 물려받으므로, 경로를 env 로
| 돌려 두는 것이 부모와 자식을 한 번에 격리하는 유일한 수단이다. 돌리지 않으면 테스트를 한 번
| 돌릴 때마다 개발 클론의 실제 매니페스트가 지워지고 다시 만들어진다 — 오늘은 재생성 내용이 같아
| 무해하지만, 그 사이 다른 프로세스가 부팅하면 매번 재빌드를 겪고, 테스트가 실제 파일을 건드리는
| 구조 자체가 `.env` 삭제 사고와 같은 부류다.
|
| 자기 경로를 따로 쓰는 테스트(`CoreUpdateCommandStalePackageManifestTest` 등)는 여기 값을
| 원값으로 보관·복원하므로 그대로 동작한다.
|
*/
$testingManifestDir = __DIR__.'/../storage/framework/testing';
if (! is_dir($testingManifestDir)) {
@mkdir($testingManifestDir, 0777, true);
}
foreach ([
'APP_PACKAGES_CACHE' => 'storage/framework/testing/packages.php',
'APP_SERVICES_CACHE' => 'storage/framework/testing/services.php',
] as $manifestEnvKey => $manifestRelativePath) {
putenv($manifestEnvKey.'='.$manifestRelativePath);
$_ENV[$manifestEnvKey] = $manifestRelativePath;
$_SERVER[$manifestEnvKey] = $manifestRelativePath;
}
unset($testingManifestDir, $manifestEnvKey, $manifestRelativePath);
// Composer 오토로더 로드
$loader = require __DIR__.'/../vendor/autoload.php';
@@ -56,6 +56,8 @@ axes:
parent_generation: [pre_7_0_11, 7_0_11_plus] # 부모가 spawn 직전 매니페스트를 비우는 세대인가 (이미 배포된 7.0.9·7.0.10 은 비우지 않는다)
child_self_heal_flag: [present, absent] # 자식이 G7_UPDATE_IN_PROGRESS 를 물려받았는가 (자가 치유 게이트)
process_context: [update_tree, long_lived_outside_update] # 버전 판독 범위 — 상주 프로세스(artisan serve·큐 워커)는 트리 밖이다
argv_sapi: [console, web_with_forged_query_argv] # argv 보조 판정을 읽는 SAPI — CGI/FPM 은 register_argc_argv=On 이면 `?x+core:update` 로 argv 를 위조할 수 있다
manifest_unlink_result: [removed, left_by_permission] # spawn 직전 매니페스트 삭제 결과 — 지우지 못한 파일은 업그레이드 로그에 남긴다
vendor_dev_packages: [present, absent, unknown] # 운영 vendor 에 require-dev 가 섞여 있는가 (installed.json 판정)
composer_step_branch: [reinstalled, skipped_unchanged] # Step 6 이 vendor 를 교체했는가 스킵했는가
@@ -70,6 +72,7 @@ exclusions:
- { parent_generation: 7_0_11_plus, package_manifest_state: stale_dev_provider, reason: "7.0.11+ 부모는 spawn 직전에 매니페스트를 비우므로 자식이 stale 을 보는 상태 자체가 성립하지 않는다 — 계층 ② 검증은 pre_7_0_11 부모에서만 의미가 있다" }
- { process_context: long_lived_outside_update, child_self_heal_flag: present, reason: "플래그를 물고 있으면 정의상 업데이트 트리 안이다 — 모순" }
- { vendor_dev_packages: unknown, composer_step_branch: skipped_unchanged, reason: "판정 불가(installed.json 부재)에서는 감지 로그 자체가 출력되지 않아 분기가 구분되지 않는다" }
- { argv_sapi: web_with_forged_query_argv, child_self_heal_flag: present, reason: "env 플래그는 웹 요청으로 주입할 수 없다 — 위조 argv 축은 플래그 부재 상태에서만 의미가 있다" }
effects:
# §2 spawn_failure_mode + failSpawnWithMode
@@ -130,6 +133,10 @@ effects:
- PackageManifestCacheHelper_clear_unlinks_packages_and_services_at_configured_paths
- clearAllCaches_rebuilds_package_manifest_via_helper
- CoreUpdateContext_isInProgress_detects_env_flag_or_update_argv
- CoreUpdateContext_ignores_argv_outside_console_sapi
- CoreUpdateContext_env_flag_is_honored_regardless_of_sapi
- PackageManifestCacheHelper_clear_returns_paths_it_could_not_remove
- spawnUpgradeStepsProcess_logs_manifest_files_it_could_not_remove
- getCoreVersion_prefers_env_APP_VERSION_only_inside_update_tree
- getCoreVersion_ignores_env_APP_VERSION_outside_update_tree
- getCoreVersion_falls_back_to_config_when_env_absent_inside_update_tree