Merge pull request from gnuboard:HeuJung/issue658
HeuJung/issue658
This commit is contained in:
@@ -42,7 +42,7 @@
|
|||||||
| [service-provider.md](docs/backend/service-provider.md) | 서비스 프로바이더 안전성 | DB 접근 전 .env 파일 존재 확인 필수 |
|
| [service-provider.md](docs/backend/service-provider.md) | 서비스 프로바이더 안전성 | DB 접근 전 .env 파일 존재 확인 필수 |
|
||||||
| [service-repository.md](docs/backend/service-repository.md) | Service-Repository 패턴 | RepositoryInterface 주입 필수 (구체 클래스 직접 주입 금지) |
|
| [service-repository.md](docs/backend/service-repository.md) | Service-Repository 패턴 | RepositoryInterface 주입 필수 (구체 클래스 직접 주입 금지) |
|
||||||
| [settings-multilingual-enrichment.md](docs/backend/settings-multilingual-enrichment.md) | Settings 카탈로그 다국어 자동 보강 | settings JSON 의 다국어 카탈로그 라벨(_cached_name 등)은 카탈로그 빌드 시점에 보강 |
|
| [settings-multilingual-enrichment.md](docs/backend/settings-multilingual-enrichment.md) | Settings 카탈로그 다국어 자동 보강 | settings JSON 의 다국어 카탈로그 라벨(_cached_name 등)은 카탈로그 빌드 시점에 보강 |
|
||||||
| [static-asset-publishing.md](docs/backend/static-asset-publishing.md) | 부트스트랩 리소스 정적 게시 (Static Asset Publishing) | 게시물: public/build/ext/{cache_version}/ — 수명주기 이벤트와 운영자 cu... |
|
| [static-asset-publishing.md](docs/backend/static-asset-publishing.md) | 부트스트랩 리소스 정적 게시 (Static Asset Publishing) | 게시물: public/build/ext/{cache_version}/ — 수명주기 이벤트·자산 URL ... |
|
||||||
| [translatable-seeders.md](docs/backend/translatable-seeders.md) | 다국어 시더 인터페이스 (Translatable Seeders) | 다국어 JSON 컬럼(name 등)을 시드하는 확장 entity 시더는 TranslatableSeede... |
|
| [translatable-seeders.md](docs/backend/translatable-seeders.md) | 다국어 시더 인터페이스 (Translatable Seeders) | 다국어 JSON 컬럼(name 등)을 시드하는 확장 entity 시더는 TranslatableSeede... |
|
||||||
| [user-overrides.md](docs/backend/user-overrides.md) | 사용자 수정 보존 (HasUserOverrides Trait) | 모델에 `use HasUserOverrides;` + `protected array $trackable... |
|
| [user-overrides.md](docs/backend/user-overrides.md) | 사용자 수정 보존 (HasUserOverrides Trait) | 모델에 `use HasUserOverrides;` + `protected array $trackable... |
|
||||||
| [validation.md](docs/backend/validation.md) | 검증 (Validation) | 필수: FormRequest에서 검증 (Service에 검증 로직 배치 금지) |
|
| [validation.md](docs/backend/validation.md) | 검증 (Validation) | 필수: FormRequest에서 검증 (Service에 검증 로직 배치 금지) |
|
||||||
@@ -198,7 +198,7 @@
|
|||||||
| `sirsoft-verification_nhnkcp` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-verification_nhnkcp/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-verification_nhnkcp/docs/README.md) | 훅 0 · 라우트 2 · 모델 2 · 레이아웃 1 |
|
| `sirsoft-verification_nhnkcp` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-verification_nhnkcp/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-verification_nhnkcp/docs/README.md) | 훅 0 · 라우트 2 · 모델 2 · 레이아웃 1 |
|
||||||
| `gnuboard7-hello_admin_template` | 템플릿 | [AGENTS.md](templates/_bundled/gnuboard7-hello_admin_template/AGENTS.md) | [docs/](templates/_bundled/gnuboard7-hello_admin_template/docs/README.md) | 훅 0 · 라우트 1 · 모델 0 · 레이아웃 8 |
|
| `gnuboard7-hello_admin_template` | 템플릿 | [AGENTS.md](templates/_bundled/gnuboard7-hello_admin_template/AGENTS.md) | [docs/](templates/_bundled/gnuboard7-hello_admin_template/docs/README.md) | 훅 0 · 라우트 1 · 모델 0 · 레이아웃 8 |
|
||||||
| `gnuboard7-hello_user_template` | 템플릿 | [AGENTS.md](templates/_bundled/gnuboard7-hello_user_template/AGENTS.md) | [docs/](templates/_bundled/gnuboard7-hello_user_template/docs/README.md) | 훅 0 · 라우트 1 · 모델 0 · 레이아웃 8 |
|
| `gnuboard7-hello_user_template` | 템플릿 | [AGENTS.md](templates/_bundled/gnuboard7-hello_user_template/AGENTS.md) | [docs/](templates/_bundled/gnuboard7-hello_user_template/docs/README.md) | 훅 0 · 라우트 1 · 모델 0 · 레이아웃 8 |
|
||||||
| `sirsoft-admin_basic` | 템플릿 | [AGENTS.md](templates/_bundled/sirsoft-admin_basic/AGENTS.md) | [docs/](templates/_bundled/sirsoft-admin_basic/docs/README.md) | 훅 0 · 라우트 29 · 모델 0 · 레이아웃 145 |
|
| `sirsoft-admin_basic` | 템플릿 | [AGENTS.md](templates/_bundled/sirsoft-admin_basic/AGENTS.md) | [docs/](templates/_bundled/sirsoft-admin_basic/docs/README.md) | 훅 0 · 라우트 29 · 모델 0 · 레이아웃 146 |
|
||||||
| `sirsoft-basic` | 템플릿 | [AGENTS.md](templates/_bundled/sirsoft-basic/AGENTS.md) | [docs/](templates/_bundled/sirsoft-basic/docs/README.md) | 훅 0 · 라우트 40 · 모델 0 · 레이아웃 166 |
|
| `sirsoft-basic` | 템플릿 | [AGENTS.md](templates/_bundled/sirsoft-basic/AGENTS.md) | [docs/](templates/_bundled/sirsoft-basic/docs/README.md) | 훅 0 · 라우트 40 · 모델 0 · 레이아웃 166 |
|
||||||
|
|
||||||
|
|
||||||
@@ -660,7 +660,7 @@ TLS 가 앞단에서 종단되고 앱에는 HTTP 로 전달되는 구성(AWS ALB
|
|||||||
| 버전 키·서명 키를 기본 TTL 로 `put()` | `PERSISTENT_TTL_SECONDS`(10년) 명시 — `forever()` 는 `CacheInterface` 밖(공개 표면 변경), `put(…, 0)` 은 forget |
|
| 버전 키·서명 키를 기본 TTL 로 `put()` | `PERSISTENT_TTL_SECONDS`(10년) 명시 — `forever()` 는 `CacheInterface` 밖(공개 표면 변경), `put(…, 0)` 은 forget |
|
||||||
| 서명 스코프를 렌더 템플릿만으로 나눔 | `{템플릿}@{호스트명}` — 다중 서버 공유 캐시에서 서버 간 mtime 차이로 요청마다 재게시가 왕복한다 |
|
| 서명 스코프를 렌더 템플릿만으로 나눔 | `{템플릿}@{호스트명}` — 다중 서버 공유 캐시에서 서버 간 mtime 차이로 요청마다 재게시가 왕복한다 |
|
||||||
| `config:cache` / `route:cache` / `event:cache` / `optimize` 를 헬퍼 밖에서 `Artisan::call` | `ConfigCacheHelper::rebuild()` / `RouteCacheHelper::rebuild()` (내부가 `withPreservedContainer`) — 이 명령들은 새 Application 을 부팅하며 전역 `Container` 를 일회용 앱으로 바꿔 놓아, 그 뒤 등록되는 `app()->terminating()` 재게시 예약이 종료되지 않는 앱에 걸려 사라진다 |
|
| `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()` |
|
| 코어 업데이트 흐름에서 현재 프로세스의 버전·update 목록을 `config('app.version')`·`config('app.update.*')` 로 판독 | spawn 자식은 부모가 비우지 않은 이전 버전 config 캐시로 부팅한다 — 버전은 `CoreVersionChecker::getCoreVersion()`(업데이트 트리 안에서만 env 우선), update 목록은 캐시 부팅이면 `CoreUpdateService::freshDiskUpdateConfig()`, 부모는 spawn 직전 `ConfigCacheHelper::clear()` + `PackageManifestCacheHelper::clear()` |
|
||||||
|
|
||||||
이 결함군은 예외도 로그도 남기지 않는다 — 게시본이 정상 200 으로 옛 내용을 내보내는 것, 또는 매일 전체 재생성이 일어나는 것이 유일한 증상이다. 재게시 누락의 안전망은 관리자 > 환경설정 > 일반 「초기 화면 정적 파일」의 [지금 다시 만들기](`POST /api/admin/settings/static-cache/republish`)이며, 상태 판정은 `ExtensionStaticCacheService::statusReport()` 한 곳이 CLI·API·화면에 공급한다.
|
이 결함군은 예외도 로그도 남기지 않는다 — 게시본이 정상 200 으로 옛 내용을 내보내는 것, 또는 매일 전체 재생성이 일어나는 것이 유일한 증상이다. 재게시 누락의 안전망은 관리자 > 환경설정 > 일반 「초기 화면 정적 파일」의 [지금 다시 만들기](`POST /api/admin/settings/static-cache/republish`)이며, 상태 판정은 `ExtensionStaticCacheService::statusReport()` 한 곳이 CLI·API·화면에 공급한다.
|
||||||
|
|
||||||
@@ -682,6 +682,27 @@ TLS 가 앞단에서 종단되고 앱에는 HTTP 로 전달되는 구성(AWS ALB
|
|||||||
|
|
||||||
> 상세: [module-settings.md](docs/extension/module-settings.md) "카탈로그 병합 설정의 공개 응답"
|
> 상세: [module-settings.md](docs/extension/module-settings.md) "카탈로그 병합 설정의 공개 응답"
|
||||||
|
|
||||||
|
### vendor 를 교체한 뒤 새 PHP 프로세스를 띄우기 전에는 패키지 매니페스트를 비운다
|
||||||
|
|
||||||
|
Laravel 은 `bootstrap/cache/packages.php` 가 있으면 stale 여부를 검사하지 않고 그대로 읽어 provider 를 `new` 한다. 그래서 vendor 를 바꾼 뒤 그 파일을 남겨 두면 다음에 부팅하는 프로세스가 새 vendor 에 없는 클래스를 찾다 부팅 단계에서 죽고, 예외는 부팅 전이라 앱 로그에 남지 않는다.
|
||||||
|
|
||||||
|
| 금지 | 올바른 사용 |
|
||||||
|
|------|------------|
|
||||||
|
| vendor 교체 뒤 `proc_open`·`config:cache`·`route:cache` 등 새 부팅을 `bootstrap/cache/{packages,services}.php` 정리 없이 실행 | 부팅 직전 `PackageManifestCacheHelper::clear()`(재생성까지 필요하면 `rebuild()`) — `ConfigCacheHelper::clear()` 와 짝으로 |
|
||||||
|
| 정리 로직을 호출부마다 `@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` 로 출처를 보고 경고 카드·로그를 남기며, 재사용 경로에서도 컴파일 캐시를 정리한다 |
|
||||||
|
| 코어 업데이트를 마치고 상주 워커에 신호를 보내지 않음 | Step 11·핸드오프·단독 재개 사후 단계에서 `signalQueueRestart()` |
|
||||||
|
|
||||||
|
이 결함군은 예외도 로그도 남기지 않는다 — 자식 프로세스가 부팅 단계에서 죽어 부모가 핸드오프로 멈추는 것, 또는 업데이트 직후 확장이 `incompatible_core` 로 꺼지는 것이 유일한 증상이다. 후자는 관리자 템플릿이 대상이면 복구 UI 자체에 도달할 수 없어 자가 회복 경로가 없다.
|
||||||
|
|
||||||
|
> 상세: [core-update-system.md](docs/backend/core-update-system.md) "spawn 전 캐시 정리 계약(3계층)" · [extension-update-system.md](docs/extension/extension-update-system.md) "판정의 단일 출처와 이 플래그가 게이트하는 것"
|
||||||
|
|
||||||
### 설정 주입과 `.env` 우선
|
### 설정 주입과 `.env` 우선
|
||||||
|
|
||||||
관리자 환경설정(`storage/app/settings/*.json`)은 `SettingsServiceProvider` 를 거쳐 `config()` 에 주입되어 `.env` 유래 값을 덮는다. `.env` 를 배포 기준값으로 관리하는 설치(컨테이너·IaC·다중 서버)를 위해 그 소유권을 **키 단위**로 되돌리는 옵트인 스위치(`G7_ENV_PRIORITY`)가 있고, 판정은 `App\Support\EnvPriority` 가 단독으로 소유한다.
|
관리자 환경설정(`storage/app/settings/*.json`)은 `SettingsServiceProvider` 를 거쳐 `config()` 에 주입되어 `.env` 유래 값을 덮는다. `.env` 를 배포 기준값으로 관리하는 설치(컨테이너·IaC·다중 서버)를 위해 그 소유권을 **키 단위**로 되돌리는 옵트인 스위치(`G7_ENV_PRIORITY`)가 있고, 판정은 `App\Support\EnvPriority` 가 단독으로 소유한다.
|
||||||
|
|||||||
@@ -13,6 +13,11 @@
|
|||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- 인증번호를 보내지 못해 로그인을 마칠 수 없을 때의 응답이 「인증에 실패했습니다」(401)에서 「인증번호를 보내지 못했습니다」(503)로 바뀌었습니다. 자격 증명은 올바른데도 로그인 실패로 안내되어 사용자는 비밀번호를 의심하며 같은 시도를 반복하고, 운영자는 메일 설정이 깨진 사실을 알 방법이 없었습니다. (#133 @keidichoi-gif 님께서 제보해주셨습니다.)
|
- 인증번호를 보내지 못해 로그인을 마칠 수 없을 때의 응답이 「인증에 실패했습니다」(401)에서 「인증번호를 보내지 못했습니다」(503)로 바뀌었습니다. 자격 증명은 올바른데도 로그인 실패로 안내되어 사용자는 비밀번호를 의심하며 같은 시도를 반복하고, 운영자는 메일 설정이 깨진 사실을 알 방법이 없었습니다. (#133 @keidichoi-gif 님께서 제보해주셨습니다.)
|
||||||
|
- 설치 마법사가 이미 준비된 `vendor/` 에 개발용 패키지가 섞여 있으면 설치 환경 확인 단계와 설치 로그에서 이를 알리고 정리 명령을 안내합니다. 설치는 그대로 진행되며, 종전에는 아무 표시 없이 그대로 사용해 이후 코어 업데이트 중단의 원인이 되었습니다.
|
||||||
|
- 설치 안내서의 Composer 설치 명령이 운영용 구성(`--no-dev`)으로 바뀌었고, `vendor/` 가 없으면 설치 마법사가 자동 설치하므로 이 단계를 생략해도 된다는 안내가 추가되었습니다.
|
||||||
|
- 설치 중 Composer 단계가 실패했을 때 안내하는 수동 명령도 운영용 구성(`--no-dev`)으로 바뀌었습니다. 종전 안내를 그대로 따르면 개발용 패키지가 섞인 상태로 설치되어 이후 코어 업데이트가 중단될 수 있었습니다.
|
||||||
|
- 코어 업데이트가 운영 `vendor/` 에 개발용 패키지가 있는지 확인해 로그에 남깁니다. 의존성 변경이 없어 재설치를 건너뛰는 경우에는 개발용 패키지가 그대로 남는다는 경고와 정리 명령을 함께 안내합니다.
|
||||||
|
- 코어 업데이트가 끝나면 실행 중인 큐 워커에 재시작 신호를 보내, 워커를 손수 재시작하지 않아도 새 코드가 반영됩니다.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
@@ -27,6 +32,11 @@
|
|||||||
- 검색엔진 봇에게 대신 그려 주는 페이지가 주소의 물음표 뒤 값만 바꿔 계속 요청하면 매번 새로 그려지고 그 결과가 무한정 저장되던 문제를 수정했습니다. 봇으로 위장한 요청이 서버 부하와 저장 공간 증가로 이어질 수 있었습니다. 이제 한 IP 가 분당 일정 횟수를 넘겨 새 페이지를 요청하면 그 초과분에는 일반 페이지를 주고, 저장 개수에도 상한을 둡니다. 상한값은 서버 설정으로 바꿀 수 있습니다.
|
- 검색엔진 봇에게 대신 그려 주는 페이지가 주소의 물음표 뒤 값만 바꿔 계속 요청하면 매번 새로 그려지고 그 결과가 무한정 저장되던 문제를 수정했습니다. 봇으로 위장한 요청이 서버 부하와 저장 공간 증가로 이어질 수 있었습니다. 이제 한 IP 가 분당 일정 횟수를 넘겨 새 페이지를 요청하면 그 초과분에는 일반 페이지를 주고, 저장 개수에도 상한을 둡니다. 상한값은 서버 설정으로 바꿀 수 있습니다.
|
||||||
- 관리자 SEO 통계와 `seo:stats` 명령이 항상 0 으로 표시되던 문제를 수정했습니다. 캐시 적중·미적중이 기록되지 않고 있었습니다.
|
- 관리자 SEO 통계와 `seo:stats` 명령이 항상 0 으로 표시되던 문제를 수정했습니다. 캐시 적중·미적중이 기록되지 않고 있었습니다.
|
||||||
- 확장의 스크립트·스타일 파일을 읽지 못하는 상태가 되면 그 확장의 자산이 빠진 결과가 저장되어, 파일 문제가 풀린 뒤에도 확장을 다시 설치하거나 설정을 바꿀 때까지 계속 빠진 채로 남던 문제를 수정했습니다. 이제 읽지 못한 확장이 있으면 그 결과를 저장하지 않아 원인이 사라지는 즉시 정상으로 돌아오며, 어느 확장을 읽지 못했는지 서버 기록에 남습니다.
|
- 확장의 스크립트·스타일 파일을 읽지 못하는 상태가 되면 그 확장의 자산이 빠진 결과가 저장되어, 파일 문제가 풀린 뒤에도 확장을 다시 설치하거나 설정을 바꿀 때까지 계속 빠진 채로 남던 문제를 수정했습니다. 이제 읽지 못한 확장이 있으면 그 결과를 저장하지 않아 원인이 사라지는 즉시 정상으로 돌아오며, 어느 확장을 읽지 못했는지 서버 기록에 남습니다.
|
||||||
|
- 개발용 패키지가 포함된 상태로 설치된 사이트에서 코어 업데이트가 업그레이드 스텝 단계에서 「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
|
## [7.0.10] - 2026-09-06
|
||||||
|
|
||||||
|
|||||||
+22
-6
@@ -148,12 +148,16 @@ git clone https://github.com/gnuboard/g7.git
|
|||||||
cd g7
|
cd g7
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3단계: Composer 의존성 설치
|
### 3단계: Composer 의존성 설치 (선택)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
composer install
|
composer install --no-dev --optimize-autoloader
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 이 단계는 생략해도 됩니다 — `vendor/` 가 없으면 설치 마법사가 운영용 구성(`--no-dev`)으로 자동 설치합니다.
|
||||||
|
>
|
||||||
|
> 개발용 패키지가 포함되는 `composer install`(옵션 없음)의 결과를 운영 사이트에 그대로 쓰지 마세요. 이미 그렇게 설치했다면 위 명령을 한 번 더 실행하면 개발용 패키지만 제거됩니다.
|
||||||
|
|
||||||
### 4단계: 환경 설정 파일 생성
|
### 4단계: 환경 설정 파일 생성
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -200,15 +204,17 @@ https://github.com/gnuboard/g7
|
|||||||
|
|
||||||
다운로드한 ZIP 파일을 원하는 위치에 압축 해제합니다.
|
다운로드한 ZIP 파일을 원하는 위치에 압축 해제합니다.
|
||||||
|
|
||||||
### 4단계: Composer 의존성 설치
|
### 4단계: Composer 의존성 설치 (선택)
|
||||||
|
|
||||||
터미널에서 압축 해제된 디렉토리로 이동한 후 실행합니다.
|
터미널에서 압축 해제된 디렉토리로 이동한 후 실행합니다. 이 단계는 생략해도 됩니다 — `vendor/` 가 없으면 설치 마법사가 운영용 구성(`--no-dev`)으로 자동 설치합니다.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd g7-버전명
|
cd g7-버전명
|
||||||
composer install
|
composer install --no-dev --optimize-autoloader
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 개발용 패키지가 포함되는 `composer install`(옵션 없음)의 결과를 운영 사이트에 그대로 쓰지 마세요. 이미 그렇게 설치했다면 위 명령을 한 번 더 실행하면 개발용 패키지만 제거됩니다.
|
||||||
|
|
||||||
### 5단계: 환경 설정 파일 생성
|
### 5단계: 환경 설정 파일 생성
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -343,13 +349,23 @@ http://도메인/install
|
|||||||
|------|------|
|
|------|------|
|
||||||
| 0. 환영 | 언어 선택 및 `storage` 권한 검증 — 권한 부족 시 인스톨러가 상대경로 기반 명령을 자동 안내 |
|
| 0. 환영 | 언어 선택 및 `storage` 권한 검증 — 권한 부족 시 인스톨러가 상대경로 기반 명령을 자동 안내 |
|
||||||
| 1. 라이선스 | 동의 |
|
| 1. 라이선스 | 동의 |
|
||||||
| 2. 요구사항 | PHP 8.2+ 및 필수 확장 확인 |
|
| 2. 요구사항 | PHP 8.2+ 및 필수 확장 확인 + 권장 항목(OPcache · Composer 의존성 구성) 안내 |
|
||||||
| 3. 환경 설정 | DB 정보 + 관리자 계정 + **Vendor 설치 방식** 선택 |
|
| 3. 환경 설정 | DB 정보 + 관리자 계정 + **Vendor 설치 방식** 선택 |
|
||||||
| 4. 확장 선택 | 템플릿/모듈/플러그인 선택 (의존성 자동 해결) |
|
| 4. 확장 선택 | 템플릿/모듈/플러그인 선택 (의존성 자동 해결) |
|
||||||
| 5. 설치 실행 | "설치 시작" 버튼 클릭 후 진행 상황 모니터링 |
|
| 5. 설치 실행 | "설치 시작" 버튼 클릭 후 진행 상황 모니터링 |
|
||||||
|
|
||||||
`.env` 파일 생성과 `storage` / `bootstrap/cache` / `public/build` 쓰기 권한 부여는 인스톨러가 환경(소유자 일치 여부 등)에 맞춰 Step 0 및 이후 단계에서 자동 안내하므로, 사전 SSH 작업으로 수행할 필요 없습니다.
|
`.env` 파일 생성과 `storage` / `bootstrap/cache` / `public/build` 쓰기 권한 부여는 인스톨러가 환경(소유자 일치 여부 등)에 맞춰 Step 0 및 이후 단계에서 자동 안내하므로, 사전 SSH 작업으로 수행할 필요 없습니다.
|
||||||
|
|
||||||
|
**Step 2 — Composer 의존성 구성 (선택 항목):**
|
||||||
|
|
||||||
|
이미 `vendor/` 가 준비되어 있으면 설치 마법사는 그것을 그대로 씁니다. 이때 그 `vendor/` 가 개발용 패키지까지 포함한 설치(`composer install`, 옵션 없음)인지 확인해 알려 줍니다.
|
||||||
|
|
||||||
|
- **운영용 구성 (개발용 패키지 없음)**: 권장 상태입니다. 조치가 필요 없습니다.
|
||||||
|
- **개발용 패키지 N개 포함**: 설치는 그대로 진행됩니다. 운영에 사용할 서버라면 설치를 마친 뒤 프로젝트 루트에서 `composer install --no-dev --optimize-autoloader` 를 한 번 실행하세요. 그대로 두면 이후 코어 업데이트가 `vendor/` 를 교체할 때 이전 패키지 목록 캐시와 어긋나 업데이트가 중단될 수 있습니다.
|
||||||
|
- **vendor 없음**: 설치 마법사가 운영용 구성으로 자동 설치하므로 조치가 필요 없습니다.
|
||||||
|
|
||||||
|
이 항목은 설치를 차단하지 않습니다.
|
||||||
|
|
||||||
**Step 3 — Vendor 설치 방식 선택:**
|
**Step 3 — Vendor 설치 방식 선택:**
|
||||||
|
|
||||||
- **자동 (권장)**: 환경을 자동 감지하여 번들 모드로 폴백합니다.
|
- **자동 (권장)**: 환경을 자동 감지하여 번들 모드로 폴백합니다.
|
||||||
|
|||||||
@@ -369,6 +369,8 @@ cp .env.example .env
|
|||||||
# 3. 브라우저에서 /install 접속 → 설치 마법사 진행
|
# 3. 브라우저에서 /install 접속 → 설치 마법사 진행
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> Composer 의존성은 설치 마법사가 운영용 구성(`--no-dev`)으로 자동 설치합니다. 직접 설치하려면 `composer install --no-dev --optimize-autoloader` 를 사용하세요.
|
||||||
|
|
||||||
> 상세 설치 가이드는 [INSTALL.md](INSTALL.md)를 참조하세요.
|
> 상세 설치 가이드는 [INSTALL.md](INSTALL.md)를 참조하세요.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -369,8 +369,11 @@ Every verification point — signup, password reset, sensitive operations, the m
|
|||||||
git clone https://github.com/gnuboard/g7.git
|
git clone https://github.com/gnuboard/g7.git
|
||||||
cd g7
|
cd g7
|
||||||
|
|
||||||
# 2. Install PHP dependencies
|
# 2. (Optional) Install PHP dependencies — you can skip this step:
|
||||||
composer install
|
# the setup wizard installs them automatically with the production
|
||||||
|
# configuration when vendor/ is absent. Do not use a plain composer
|
||||||
|
# install on a production site; it pulls in development packages.
|
||||||
|
composer install --no-dev --optimize-autoloader
|
||||||
|
|
||||||
# 3. Copy the environment file
|
# 3. Copy the environment file
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
|
|||||||
@@ -330,6 +330,10 @@ trait BundledExtensionUpdatePrompt
|
|||||||
// ENV 합집합 (G7_UPDATE_IN_PROGRESS 등 핵심 플래그 자식에 전달)
|
// ENV 합집합 (G7_UPDATE_IN_PROGRESS 등 핵심 플래그 자식에 전달)
|
||||||
$env = array_merge(getenv(), $_ENV);
|
$env = array_merge(getenv(), $_ENV);
|
||||||
|
|
||||||
|
// audit:allow spawn-after-vendor-swap-clears-package-manifest 이 spawn 은 vendor 를 바꾸지 않고,
|
||||||
|
// 두 호출처가 모두 clearAllCaches() 뒤다 — CoreUpdateCommand Step 11(패키지 매니페스트 재생성)
|
||||||
|
// 다음의 Step 12, ExecuteUpgradeStepsCommand 단독 실행의 캐시 정리 다음 단계. 따라서 자식이
|
||||||
|
// 읽는 매니페스트는 이미 현재 vendor 기준으로 다시 만들어진 것이다.
|
||||||
$process = proc_open($commandLine, $descriptors, $pipes, base_path(), $env);
|
$process = proc_open($commandLine, $descriptors, $pipes, base_path(), $env);
|
||||||
if (! is_resource($process)) {
|
if (! is_resource($process)) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ use App\Extension\Traits\ClearsTemplateCaches;
|
|||||||
use App\Extension\Vendor\Exceptions\VendorInstallException;
|
use App\Extension\Vendor\Exceptions\VendorInstallException;
|
||||||
use App\Extension\Vendor\VendorMode;
|
use App\Extension\Vendor\VendorMode;
|
||||||
use App\Services\CoreUpdateService;
|
use App\Services\CoreUpdateService;
|
||||||
|
use App\Support\ComposerInstallInfo;
|
||||||
use App\Support\ConfigCacheHelper;
|
use App\Support\ConfigCacheHelper;
|
||||||
|
use App\Support\PackageManifestCacheHelper;
|
||||||
use App\Support\RouteCacheHelper;
|
use App\Support\RouteCacheHelper;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
@@ -320,6 +322,22 @@ class CoreUpdateCommand extends Command
|
|||||||
$composerSkipped = $vendorMode !== VendorMode::Bundled
|
$composerSkipped = $vendorMode !== VendorMode::Bundled
|
||||||
&& $service->isComposerUnchangedForCore($pendingPath);
|
&& $service->isComposerUnchangedForCore($pendingPath);
|
||||||
|
|
||||||
|
// 운영 vendor 에 개발용(require-dev) 패키지가 섞여 있는지 알린다. 그대로 두면
|
||||||
|
// 이전 설치본이 만든 패키지 매니페스트가 새 vendor 와 어긋나 이후 부팅이 깨지고,
|
||||||
|
// 그 사실은 오류 메시지 어디에도 "dev 설치본" 으로 드러나지 않는다.
|
||||||
|
$devPackages = ComposerInstallInfo::devPackageNames(base_path('vendor'));
|
||||||
|
if ($devPackages !== []) {
|
||||||
|
$devCount = count($devPackages);
|
||||||
|
$devSample = implode(', ', array_slice($devPackages, 0, 5)).($devCount > 5 ? ' …' : '');
|
||||||
|
if ($composerSkipped) {
|
||||||
|
$this->warn("운영 vendor 에 개발용 패키지 {$devCount}개 감지 — composer.json/lock 변경이 없어 vendor 를 재설치하지 않으므로 그대로 남습니다. 업데이트 후 'composer install --no-dev --optimize-autoloader' 실행을 권장합니다.");
|
||||||
|
$log("운영 vendor 개발용 패키지 {$devCount}개 잔존 (composer 스킵): {$devSample}");
|
||||||
|
} else {
|
||||||
|
$this->line("운영 vendor 에 개발용 패키지 {$devCount}개 감지 — 이번 업데이트가 --no-dev vendor 로 교체합니다.");
|
||||||
|
$log("운영 vendor 개발용 패키지 {$devCount}개 감지 — --no-dev vendor 로 교체: {$devSample}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ($composerSkipped) {
|
if ($composerSkipped) {
|
||||||
$bar->setMessage('Composer 의존성 변경 없음 — 스킵');
|
$bar->setMessage('Composer 의존성 변경 없음 — 스킵');
|
||||||
$bar->advance();
|
$bar->advance();
|
||||||
@@ -511,6 +529,11 @@ class CoreUpdateCommand extends Command
|
|||||||
$service->updateVersionInEnv($toVersion);
|
$service->updateVersionInEnv($toVersion);
|
||||||
$service->clearAllCaches();
|
$service->clearAllCaches();
|
||||||
|
|
||||||
|
// 상주 큐 워커는 부팅이 한 번뿐이라 옛 코드를 물고 있다 — 캐시가 새 코드 기준으로
|
||||||
|
// 정리된 직후 재시작 신호를 보낸다.
|
||||||
|
$service->signalQueueRestart();
|
||||||
|
$log('큐 워커 재시작 신호 전송 (queue:restart)');
|
||||||
|
|
||||||
// 코어 업데이트 후 프론트엔드가 새 lang/routes/layout 자원으로 fetch 하도록
|
// 코어 업데이트 후 프론트엔드가 새 lang/routes/layout 자원으로 fetch 하도록
|
||||||
// `ext.cache_version` bump. 코어 lang JSON 변경이 프론트엔드 캐시에
|
// `ext.cache_version` bump. 코어 lang JSON 변경이 프론트엔드 캐시에
|
||||||
// 반영되지 않는 회귀를 차단한다 (core-frontend-i18n-infrastructure 계획서).
|
// 반영되지 않는 회귀를 차단한다 (core-frontend-i18n-infrastructure 계획서).
|
||||||
@@ -636,6 +659,9 @@ class CoreUpdateCommand extends Command
|
|||||||
try {
|
try {
|
||||||
$service->updateVersionInEnv($toVersion);
|
$service->updateVersionInEnv($toVersion);
|
||||||
$service->clearAllCaches();
|
$service->clearAllCaches();
|
||||||
|
// 핸드오프로 멈춰도 파일은 이미 toVersion 이므로 워커는 새 코드로 재기동해야 한다.
|
||||||
|
$service->signalQueueRestart();
|
||||||
|
$log('큐 워커 재시작 신호 전송 (queue:restart)');
|
||||||
// 핸드오프 cleanup 에서도 프론트엔드 캐시 버전 bump — 사용자가 resume 명령
|
// 핸드오프 cleanup 에서도 프론트엔드 캐시 버전 bump — 사용자가 resume 명령
|
||||||
// (execute-upgrade-steps) 을 실행하기 전이라도 이미 toVersion 으로 반영된
|
// (execute-upgrade-steps) 을 실행하기 전이라도 이미 toVersion 으로 반영된
|
||||||
// 코어 lang/routes/layout 자원이 프론트엔드 캐시 stale 로 가려지지 않도록.
|
// 코어 lang/routes/layout 자원이 프론트엔드 캐시 stale 로 가려지지 않도록.
|
||||||
@@ -887,6 +913,24 @@ class CoreUpdateCommand extends Command
|
|||||||
// Step 11 의 `ConfigCacheHelper::rebuild()` 가 모든 파일이 안착한 뒤 다시 만든다.
|
// Step 11 의 `ConfigCacheHelper::rebuild()` 가 모든 파일이 안착한 뒤 다시 만든다.
|
||||||
ConfigCacheHelper::clear();
|
ConfigCacheHelper::clear();
|
||||||
|
|
||||||
|
// 같은 이유로 패키지 매니페스트(bootstrap/cache/packages.php · services.php)도 비운다.
|
||||||
|
// vendor 는 Step 6/8 에서 이미 교체됐지만 두 파일은 Step 11 clearAllCaches 까지 이전
|
||||||
|
// 설치본의 것이 남고, Laravel 은 packages.php 가 "없을 때만" 다시 만든다. 이전 설치본이
|
||||||
|
// dev composer(require-dev 전이 의존성 laravel/mcp 의 McpServiceProvider 포함)로 깔렸다면
|
||||||
|
// 자식은 새 vendor 에 없는 provider 를 new 하다 부팅 단계에서 죽는다 (7.0.9→7.0.10 실사례).
|
||||||
|
// 부모 메모리의 매니페스트는 영향받지 않고, Step 11 이 package:discover 로 다시 만든다.
|
||||||
|
//
|
||||||
|
// 지우지 못한 파일(권한·소유권 불일치)은 로그에 남긴다. 그 상태면 자식의 자가 치유도 같은
|
||||||
|
// 권한으로 실패하므로 증상은 이전 설치본 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);
|
$process = proc_open($commandLine, $descriptors, $pipes, base_path(), $env);
|
||||||
if (! is_resource($process)) {
|
if (! is_resource($process)) {
|
||||||
return $this->failSpawnWithMode(
|
return $this->failSpawnWithMode(
|
||||||
|
|||||||
@@ -261,6 +261,10 @@ class ExecuteUpgradeStepsCommand extends Command
|
|||||||
if (! $this->option('skip-cache-clear') && ! $isSpawnChild && ! $stepsOnly) {
|
if (! $this->option('skip-cache-clear') && ! $isSpawnChild && ! $stepsOnly) {
|
||||||
$this->info('캐시 정리 (config/route/view/services/packages)');
|
$this->info('캐시 정리 (config/route/view/services/packages)');
|
||||||
$service->clearAllCaches();
|
$service->clearAllCaches();
|
||||||
|
// 상주 큐 워커는 부팅이 한 번뿐이라 옛 코드를 물고 있다 — 캐시 정리 직후 재시작 신호.
|
||||||
|
// spawn 자식·steps-only 는 이 블록을 스킵하고 부모가 보낸다.
|
||||||
|
$service->signalQueueRestart();
|
||||||
|
$this->info('큐 워커 재시작 신호 전송 (queue:restart)');
|
||||||
// clearAllCaches() 는 config:clear 만 하므로, 단독 실행 흐름에서는 config 캐시가
|
// clearAllCaches() 는 config:clear 만 하므로, 단독 실행 흐름에서는 config 캐시가
|
||||||
// 비활성 상태로 남는다. 모든 upgrade step + 번들 확장 업데이트가 config 소스를
|
// 비활성 상태로 남는다. 모든 upgrade step + 번들 확장 업데이트가 config 소스를
|
||||||
// 변경했을 수 있으니, 캐시 정리 세트의 마지막에 config 캐시를 재생성한다.
|
// 변경했을 수 있으니, 캐시 정리 세트의 마지막에 config 캐시를 재생성한다.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Extension;
|
|||||||
use App\Contracts\Extension\CacheInterface;
|
use App\Contracts\Extension\CacheInterface;
|
||||||
use App\Exceptions\CoreVersionMismatchException;
|
use App\Exceptions\CoreVersionMismatchException;
|
||||||
use App\Extension\Cache\CoreCacheDriver;
|
use App\Extension\Cache\CoreCacheDriver;
|
||||||
|
use App\Support\CoreUpdateContext;
|
||||||
use Composer\Semver\Semver;
|
use Composer\Semver\Semver;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
@@ -31,10 +32,11 @@ class CoreVersionChecker
|
|||||||
* 현재 설치된 그누보드7 코어 버전 반환
|
* 현재 설치된 그누보드7 코어 버전 반환
|
||||||
*
|
*
|
||||||
* 반환 우선순위:
|
* 반환 우선순위:
|
||||||
* 1. 환경변수 `APP_VERSION` (getenv / $_ENV / $_SERVER)
|
* 1. 코어 업데이트 프로세스 트리 안(`CoreUpdateContext::isInProgress()`)이면
|
||||||
|
* 환경변수 `APP_VERSION` (getenv / $_ENV / $_SERVER)
|
||||||
* 2. `config('app.version')`
|
* 2. `config('app.version')`
|
||||||
*
|
*
|
||||||
* 왜 env 를 우선 읽는가:
|
* 왜 업데이트 트리 안에서만 env 를 우선 읽는가:
|
||||||
* 코어 업그레이드 중 `core:update` 는 `core:execute-upgrade-steps` / 각 업그레이드 스텝의
|
* 코어 업그레이드 중 `core:update` 는 `core:execute-upgrade-steps` / 각 업그레이드 스텝의
|
||||||
* inline 스크립트를 `proc_open` 으로 spawn 한다. 디스크 `.env` 의 `APP_VERSION` 은
|
* inline 스크립트를 `proc_open` 으로 spawn 한다. 디스크 `.env` 의 `APP_VERSION` 은
|
||||||
* `updateVersionInEnv()` 가 최종 단계(Step 11)에서 기록하므로, spawn 이 부팅되는
|
* `updateVersionInEnv()` 가 최종 단계(Step 11)에서 기록하므로, spawn 이 부팅되는
|
||||||
@@ -44,23 +46,28 @@ class CoreVersionChecker
|
|||||||
* env 오버라이드가 반영되지 않는 회귀가 있었다 (확장이 `>= 신버전` 요구 시
|
* env 오버라이드가 반영되지 않는 회귀가 있었다 (확장이 `>= 신버전` 요구 시
|
||||||
* `validateAndDeactivateIncompatibleExtensions` 가 전 확장을 자동 비활성화).
|
* `validateAndDeactivateIncompatibleExtensions` 가 전 확장을 자동 비활성화).
|
||||||
*
|
*
|
||||||
* env 를 우선 읽는 본 구현은 config cache 유무와 무관하게 spawn 이 전달한 버전을 그대로
|
* 왜 트리 밖에서는 env 를 읽지 않는가:
|
||||||
* 신뢰한다. 일반 요청 경로에서는 `APP_VERSION` 이 `.env` 에 기록된 값 그대로이므로
|
* `php artisan serve` · 큐 워커 · Horizon 처럼 오래 사는 프로세스는 기동 시점의
|
||||||
* 동작에 차이가 없다.
|
* `APP_VERSION` 을 프로세스 환경 테이블에 물고 있다. 업데이트가 끝나 `.env` 와 config 가
|
||||||
|
* 새 버전이 되어도 그 프로세스만 옛 버전으로 판정해, 새 코어를 요구하는 확장을
|
||||||
|
* `incompatible_core` 로 자동 비활성화한다 (관리자 템플릿이 꺼지면 복구 UI 에도 도달할 수
|
||||||
|
* 없다 — 2026-09-07 실측). 업데이트 트리 밖에서는 config 가 유일한 근거다.
|
||||||
*
|
*
|
||||||
* 규정 예외: "env() 는 config 파일에서만 사용" 규칙의 본문 예외. 정당성은 버전 판정이
|
* 규정 예외: "env() 는 config 파일에서만 사용" 규칙의 본문 예외. 정당성은 업데이트 중
|
||||||
* config cache 우회를 요구하기 때문이다.
|
* 버전 판정이 config cache 우회를 요구하기 때문이다.
|
||||||
*
|
*
|
||||||
* @return string 코어 버전 문자열 (예: "7.0.0-beta.4")
|
* @return string 코어 버전 문자열 (예: "7.0.0-beta.4")
|
||||||
*/
|
*/
|
||||||
public static function getCoreVersion(): string
|
public static function getCoreVersion(): string
|
||||||
{
|
{
|
||||||
$envVersion = $_ENV['APP_VERSION'] ?? $_SERVER['APP_VERSION'] ?? getenv('APP_VERSION');
|
if (CoreUpdateContext::isInProgress()) {
|
||||||
if (is_string($envVersion) && $envVersion !== '') {
|
$envVersion = $_ENV['APP_VERSION'] ?? $_SERVER['APP_VERSION'] ?? getenv('APP_VERSION');
|
||||||
return $envVersion;
|
if (is_string($envVersion) && $envVersion !== '') {
|
||||||
|
return $envVersion;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return config('app.version');
|
return (string) config('app.version');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ use App\Services\LayoutExtensionService;
|
|||||||
use App\Services\TemplateLayoutAttachmentService;
|
use App\Services\TemplateLayoutAttachmentService;
|
||||||
use App\Services\TemplateService;
|
use App\Services\TemplateService;
|
||||||
use App\Services\UniqueIdService;
|
use App\Services\UniqueIdService;
|
||||||
|
use App\Support\CoreUpdateContext;
|
||||||
use App\Support\ExtensionSettingsMirror;
|
use App\Support\ExtensionSettingsMirror;
|
||||||
use App\Support\PrivilegedDatabaseAccounts;
|
use App\Support\PrivilegedDatabaseAccounts;
|
||||||
use Illuminate\Support\Facades\Config;
|
use Illuminate\Support\Facades\Config;
|
||||||
@@ -562,7 +563,7 @@ class CoreServiceProvider extends ServiceProvider
|
|||||||
'type' => $type,
|
'type' => $type,
|
||||||
'identifier' => $identifier,
|
'identifier' => $identifier,
|
||||||
'required_version' => $requiredVersion,
|
'required_version' => $requiredVersion,
|
||||||
'core_version' => config('app.version'),
|
'core_version' => CoreVersionChecker::getCoreVersion(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -626,6 +627,21 @@ class CoreServiceProvider extends ServiceProvider
|
|||||||
$cache->put($cacheKey, $recovered, CoreVersionChecker::getCacheTtl());
|
$cache->put($cacheKey, $recovered, CoreVersionChecker::getCacheTtl());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 프로세스가 코어 업데이트 중인지 판정합니다.
|
||||||
|
*
|
||||||
|
* 판정은 `App\Support\CoreUpdateContext::isInProgress()` 가 단독으로 소유한다 — 같은
|
||||||
|
* 플래그가 확장 자동 비활성화 스킵 · 코어 버전의 env 우선 판독 · `bootstrap/app.php` 의
|
||||||
|
* 패키지 매니페스트 자가 치유를 함께 게이트하므로, 판정이 갈라지면 한 경로만 조용히
|
||||||
|
* 다르게 동작한다. 이 메서드는 기존 호출처(각 Manager)를 위한 위임으로 남는다.
|
||||||
|
*
|
||||||
|
* @return bool 업데이트 트리 안이면 true (version-based 자동 비활성화 스킵)
|
||||||
|
*/
|
||||||
|
public static function isCoreUpdateInProgress(): bool
|
||||||
|
{
|
||||||
|
return CoreUpdateContext::isInProgress();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 호환되지 않는 템플릿을 자동 비활성화합니다.
|
* 호환되지 않는 템플릿을 자동 비활성화합니다.
|
||||||
*
|
*
|
||||||
@@ -634,30 +650,6 @@ class CoreServiceProvider extends ServiceProvider
|
|||||||
*
|
*
|
||||||
* @param TemplateManager $templateManager 템플릿 매니저
|
* @param TemplateManager $templateManager 템플릿 매니저
|
||||||
*/
|
*/
|
||||||
/**
|
|
||||||
* 현재 프로세스가 코어 업데이트 중인지 판정합니다.
|
|
||||||
*
|
|
||||||
* 판정 조건 (OR):
|
|
||||||
* 1. 환경변수 `G7_UPDATE_IN_PROGRESS=1` — 부모 CoreUpdateCommand 가 시작 시 설정,
|
|
||||||
* spawn 자식에도 `$env` 로 전파
|
|
||||||
* 2. artisan command 이름이 `core:update` / `core:execute-upgrade-steps` — 1 이 전파되지
|
|
||||||
* 않은 극단 상황 대비 보조 판정
|
|
||||||
*
|
|
||||||
* 둘 중 하나라도 true 면 업데이트 컨텍스트로 간주하여 version-based 자동 비활성화 스킵.
|
|
||||||
*/
|
|
||||||
public static function isCoreUpdateInProgress(): bool
|
|
||||||
{
|
|
||||||
$envFlag = $_ENV['G7_UPDATE_IN_PROGRESS'] ?? $_SERVER['G7_UPDATE_IN_PROGRESS'] ?? getenv('G7_UPDATE_IN_PROGRESS');
|
|
||||||
if ($envFlag === '1' || $envFlag === 1 || $envFlag === true) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$argv = $_SERVER['argv'] ?? [];
|
|
||||||
$command = $argv[1] ?? '';
|
|
||||||
|
|
||||||
return in_array($command, ['core:update', 'core:execute-upgrade-steps'], true);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function validateAndDeactivateIncompatibleTemplates(TemplateManager $templateManager): void
|
protected function validateAndDeactivateIncompatibleTemplates(TemplateManager $templateManager): void
|
||||||
{
|
{
|
||||||
// 업데이트 중 자동 비활성화 스킵 (validateAndDeactivateIncompatibleExtensions 와 동일 사유)
|
// 업데이트 중 자동 비활성화 스킵 (validateAndDeactivateIncompatibleExtensions 와 동일 사유)
|
||||||
@@ -702,7 +694,7 @@ class CoreServiceProvider extends ServiceProvider
|
|||||||
'type' => 'templates',
|
'type' => 'templates',
|
||||||
'identifier' => $identifier,
|
'identifier' => $identifier,
|
||||||
'required_version' => $requiredVersion,
|
'required_version' => $requiredVersion,
|
||||||
'core_version' => config('app.version'),
|
'core_version' => CoreVersionChecker::getCoreVersion(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -730,7 +722,7 @@ class CoreServiceProvider extends ServiceProvider
|
|||||||
$alerts = $cache->get('ext.compatibility_alerts', []);
|
$alerts = $cache->get('ext.compatibility_alerts', []);
|
||||||
$alerts[$type] = [
|
$alerts[$type] = [
|
||||||
'deactivated' => $deactivated,
|
'deactivated' => $deactivated,
|
||||||
'core_version' => config('app.version'),
|
'core_version' => CoreVersionChecker::getCoreVersion(),
|
||||||
'timestamp' => now()->toIso8601String(),
|
'timestamp' => now()->toIso8601String(),
|
||||||
];
|
];
|
||||||
$cache->put('ext.compatibility_alerts', $alerts, 86400); // 24시간
|
$cache->put('ext.compatibility_alerts', $alerts, 86400); // 24시간
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ use App\Extension\Vendor\VendorInstallContext;
|
|||||||
use App\Extension\Vendor\VendorInstallResult;
|
use App\Extension\Vendor\VendorInstallResult;
|
||||||
use App\Extension\Vendor\VendorMode;
|
use App\Extension\Vendor\VendorMode;
|
||||||
use App\Extension\Vendor\VendorResolver;
|
use App\Extension\Vendor\VendorResolver;
|
||||||
|
use App\Support\PackageManifestCacheHelper;
|
||||||
use Database\Seeders\IdentityMessageDefinitionSeeder;
|
use Database\Seeders\IdentityMessageDefinitionSeeder;
|
||||||
use Database\Seeders\IdentityPolicySeeder;
|
use Database\Seeders\IdentityPolicySeeder;
|
||||||
use Database\Seeders\NotificationDefinitionSeeder;
|
use Database\Seeders\NotificationDefinitionSeeder;
|
||||||
@@ -1835,6 +1836,20 @@ class CoreUpdateService
|
|||||||
}
|
}
|
||||||
|
|
||||||
File::put($envPath, $content);
|
File::put($envPath, $content);
|
||||||
|
|
||||||
|
// 프로세스 환경도 함께 갱신한다.
|
||||||
|
//
|
||||||
|
// 부모가 config 캐시 **없이** 부팅했다면 Dotenv 가 `.env` 를 읽어 `$_ENV['APP_VERSION']` 에
|
||||||
|
// 이전 버전을 채워 두었고, Laravel 의 env 저장소는 불변(immutable)이라 뒤이어 부팅하는
|
||||||
|
// 프로세스가 `.env` 를 다시 읽어도 그 값을 덮지 않는다. 그래서 Step 11 의 `config:cache` 가
|
||||||
|
// 굽는 `bootstrap/cache/config.php` 에 **이전 버전**이 박제되고, 이후 모든 웹 요청이 옛 버전으로
|
||||||
|
// 판정한다 — 새 코어를 요구하는 확장이 `incompatible_core` 로 꺼지는 경로다
|
||||||
|
// (2026-09-07 실측: `config:clear` 후 7.0.10 → 7.0.11 업데이트가 config 캐시에 7.0.10 을 구웠다).
|
||||||
|
//
|
||||||
|
// 캐시 부팅에서는 Dotenv 가 아예 돌지 않아 이 값이 비어 있을 수 있으므로 세 채널 모두 세운다.
|
||||||
|
$_ENV['APP_VERSION'] = $version;
|
||||||
|
$_SERVER['APP_VERSION'] = $version;
|
||||||
|
putenv('APP_VERSION='.$version);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2074,7 +2089,8 @@ class CoreUpdateService
|
|||||||
* 모든 캐시를 초기화하고 패키지 목록을 재생성합니다.
|
* 모든 캐시를 초기화하고 패키지 목록을 재생성합니다.
|
||||||
*
|
*
|
||||||
* vendor 교체 후 bootstrap/cache의 컴파일 캐시가 stale 상태일 수 있으므로
|
* vendor 교체 후 bootstrap/cache의 컴파일 캐시가 stale 상태일 수 있으므로
|
||||||
* services.php/packages.php 삭제 후 package:discover로 재생성합니다.
|
* services.php/packages.php 삭제 후 package:discover로 재생성합니다
|
||||||
|
* (`PackageManifestCacheHelper::rebuild()` — spawn 직전 선정리와 같은 삭제 로직을 공유).
|
||||||
* 이는 composer install의 post-autoload-dump 후속 작업(clearCompiled + package:discover)을 재현합니다.
|
* 이는 composer install의 post-autoload-dump 후속 작업(clearCompiled + package:discover)을 재현합니다.
|
||||||
*/
|
*/
|
||||||
public function clearAllCaches(): void
|
public function clearAllCaches(): void
|
||||||
@@ -2085,14 +2101,10 @@ class CoreUpdateService
|
|||||||
Artisan::call('route:clear');
|
Artisan::call('route:clear');
|
||||||
Artisan::call('view:clear');
|
Artisan::call('view:clear');
|
||||||
|
|
||||||
// 2. 컴파일 캐시 삭제 (composer postAutoloadDump → clearCompiled 재현)
|
// 2~3. 컴파일 캐시 삭제 후 현재 vendor 기반으로 재생성
|
||||||
// services.php, packages.php가 교체 전 vendor를 참조할 수 있음
|
// (composer postAutoloadDump → clearCompiled + package:discover 재현).
|
||||||
$app = app();
|
// services.php, packages.php 가 교체 전 vendor 를 참조할 수 있다.
|
||||||
@unlink($app->getCachedServicesPath());
|
PackageManifestCacheHelper::rebuild();
|
||||||
@unlink($app->getCachedPackagesPath());
|
|
||||||
|
|
||||||
// 3. 현재 vendor 기반으로 packages.php 재생성
|
|
||||||
Artisan::call('package:discover');
|
|
||||||
|
|
||||||
// 4. 확장 오토로드 재생성 (코어 업데이트로 _bundled 변경 가능)
|
// 4. 확장 오토로드 재생성 (코어 업데이트로 _bundled 변경 가능)
|
||||||
Artisan::call('extension:update-autoload');
|
Artisan::call('extension:update-autoload');
|
||||||
@@ -2108,6 +2120,29 @@ class CoreUpdateService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상주 중인 큐 워커에 정상 종료 후 재시작 신호를 보냅니다.
|
||||||
|
*
|
||||||
|
* 큐 워커는 부팅이 한 번뿐이라 코어 코드·config·확장 목록을 기동 시점 상태로 물고 있다.
|
||||||
|
* 코어 업데이트가 파일을 전부 교체해도 워커는 옛 코드로 잡을 계속 처리하며, 그 사실이
|
||||||
|
* 오류로 드러나지 않는다 — 운영자가 워커를 손수 재시작할 때까지 조용히 어긋난 채 돈다.
|
||||||
|
*
|
||||||
|
* 캐시가 새 코드 기준으로 정리된 뒤(`clearAllCaches()` 직후) 호출해야 워커가 새 캐시로
|
||||||
|
* 재기동한다. 신호 전송 실패는 업데이트 결과를 되돌리지 않는다 (경고 로깅 후 계속).
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function signalQueueRestart(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
Artisan::call('queue:restart');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::channel('upgrade')->warning('queue:restart 실행 실패 (업데이트는 계속 진행)', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 코어 업그레이드 컨텍스트의 번들 확장 업데이트 감지.
|
* 코어 업그레이드 컨텍스트의 번들 확장 업데이트 감지.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* vendor 디렉토리가 개발용(require-dev 포함) 설치인지 판정하는 유틸리티.
|
||||||
|
*
|
||||||
|
* `composer install`(옵션 없음)은 require-dev 와 그 전이 의존성까지 설치하고, 그 목록이
|
||||||
|
* `bootstrap/cache/packages.php` 에 provider 로 등재된다. 운영 사이트가 그 상태로 남으면
|
||||||
|
* 이후 코어 업데이트가 vendor 를 `--no-dev` 로 교체할 때 매니페스트에만 남은 provider 를
|
||||||
|
* 찾다 부팅이 깨진다. 그래서 인스톨러와 코어 업데이트가 이 판정으로 운영자에게 알린다.
|
||||||
|
*
|
||||||
|
* 프레임워크에 의존하지 않는다 — 인스톨러는 Laravel 오토로드 없이 도는 순수 PHP 라
|
||||||
|
* 이 클래스를 `require_once` 로 직접 읽는다.
|
||||||
|
*/
|
||||||
|
final class ComposerInstallInfo
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* vendor 디렉토리 기준 Composer 설치 정보 파일의 상대 경로
|
||||||
|
*/
|
||||||
|
public const INSTALLED_JSON = 'composer/installed.json';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* vendor 디렉토리의 Composer 설치 구성을 조사합니다.
|
||||||
|
*
|
||||||
|
* 판정: `installed.json` 의 최상위 `dev` 가 true 이거나 `dev-package-names` 가 비어 있지
|
||||||
|
* 않으면 개발용 설치. 두 신호를 OR 로 보는 이유는 Composer 버전에 따라 한쪽만 채워질 수
|
||||||
|
* 있기 때문이다.
|
||||||
|
*
|
||||||
|
* @param string $vendorPath vendor 디렉토리 절대 경로
|
||||||
|
* @return array{dev: bool|null, packages: array<int, string>} dev 가 null 이면 판정 불가
|
||||||
|
* (파일 부재 · JSON 오류 · Composer 1 형식)
|
||||||
|
*/
|
||||||
|
public static function inspect(string $vendorPath): array
|
||||||
|
{
|
||||||
|
$unknown = ['dev' => null, 'packages' => []];
|
||||||
|
|
||||||
|
$path = rtrim($vendorPath, '/\\').'/'.self::INSTALLED_JSON;
|
||||||
|
if (! is_file($path)) {
|
||||||
|
return $unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = @file_get_contents($path);
|
||||||
|
if ($raw === false || $raw === '') {
|
||||||
|
return $unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($raw, true);
|
||||||
|
|
||||||
|
// Composer 1 은 최상위가 패키지 배열이라 dev 정보 자체가 없다 — 판정 불가로 둔다.
|
||||||
|
if (! is_array($decoded) || ! array_key_exists('packages', $decoded)) {
|
||||||
|
return $unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
$names = [];
|
||||||
|
if (isset($decoded['dev-package-names']) && is_array($decoded['dev-package-names'])) {
|
||||||
|
foreach ($decoded['dev-package-names'] as $name) {
|
||||||
|
if (is_string($name) && trim($name) !== '') {
|
||||||
|
$names[] = $name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$devFlag = $decoded['dev'] ?? null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'dev' => $devFlag === true || $names !== [],
|
||||||
|
'packages' => $names,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* vendor 가 개발용 설치인지 반환합니다.
|
||||||
|
*
|
||||||
|
* @param string $vendorPath vendor 디렉토리 절대 경로
|
||||||
|
* @return bool|null 판정 불가 시 null
|
||||||
|
*/
|
||||||
|
public static function isDevInstall(string $vendorPath): ?bool
|
||||||
|
{
|
||||||
|
return self::inspect($vendorPath)['dev'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* vendor 에 설치된 개발용(require-dev) 패키지 이름 목록을 반환합니다.
|
||||||
|
*
|
||||||
|
* @param string $vendorPath vendor 디렉토리 절대 경로
|
||||||
|
* @return array<int, string> 개발용 패키지가 없거나 판정 불가면 빈 배열
|
||||||
|
*/
|
||||||
|
public static function devPackageNames(string $vendorPath): array
|
||||||
|
{
|
||||||
|
return self::inspect($vendorPath)['packages'];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ namespace App\Support;
|
|||||||
|
|
||||||
use Illuminate\Container\Container;
|
use Illuminate\Container\Container;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
use Illuminate\Support\Facades\Artisan;
|
||||||
|
use Illuminate\Support\Facades\Facade;
|
||||||
use Illuminate\Support\Facades\File;
|
use Illuminate\Support\Facades\File;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
@@ -62,26 +63,47 @@ class ConfigCacheHelper
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 콜백 실행 뒤 전역 컨테이너 인스턴스를 원래 앱으로 되돌립니다.
|
* 콜백 실행 뒤 전역 컨테이너 인스턴스와 파사드 애플리케이션을 원래 앱으로 되돌립니다.
|
||||||
*
|
*
|
||||||
* `config:cache` 는 신선한 설정을 얻기 위해 **새 Application 을 부팅**하는데, `Application` 생성자가
|
* `config:cache` 는 신선한 설정을 얻기 위해 **새 Application 을 부팅**하는데, 그 부팅이 전역 상태를
|
||||||
* `Container::setInstance()` 를 호출하므로 그 순간부터 `app()` 헬퍼가 실행 중인 앱이 아니라 그
|
* 두 군데 바꾼다.
|
||||||
* 일회용 앱을 가리킨다(파사드는 별도 참조라 그대로다). 같은 프로세스에서 그 뒤에 등록되는
|
*
|
||||||
* `app()->terminating()` 콜백은 종료되지 않는 앱에 걸려 **영원히 실행되지 않는다** — 설정 저장
|
* 1. `Application` 생성자의 `Container::setInstance()` — 그 순간부터 `app()` 헬퍼가 실행 중인 앱이
|
||||||
* 뒤의 확장 캐시 버전 bump 가 예약한 정적 재게시가 그렇게 조용히 사라졌다(#651 F5 실측: 버전은
|
* 아니라 일회용 앱을 가리킨다. 같은 프로세스에서 그 뒤에 등록되는 `app()->terminating()` 콜백은
|
||||||
* 올랐는데 게시는 다음 렌더의 자가 치유까지 미뤄짐). 예외도 로그도 없고, 자가 치유가 한 렌더
|
* 종료되지 않는 앱에 걸려 **영원히 실행되지 않는다** — 설정 저장 뒤의 확장 캐시 버전 bump 가
|
||||||
* 뒤에 덮어 주므로 "한 박자 늦게 반영" 으로만 나타난다.
|
* 예약한 정적 재게시가 그렇게 조용히 사라졌다(#651 F5 실측: 버전은 올랐는데 게시는 다음 렌더의
|
||||||
|
* 자가 치유까지 미뤄짐).
|
||||||
|
* 2. `registerBaseBindings()` 의 `Facade::clearResolvedInstances()` + `Facade::setFacadeApplication()`
|
||||||
|
* — 그 순간부터 **모든 파사드**(`Artisan`·`Log`·`DB`·`Cache` …)가 일회용 앱에서 새 인스턴스를
|
||||||
|
* 해석한다. 컨테이너만 되돌리면 이 축이 남는다.
|
||||||
|
*
|
||||||
|
* 2번이 코어 업데이트에서 드러난 형태: Step 11 의 `ConfigCacheHelper::rebuild()` 뒤에 오는
|
||||||
|
* `RouteCacheHelper::rebuild()` 의 `Artisan::call('route:clear')` 가 일회용 앱에서 **새 콘솔 Kernel** 을
|
||||||
|
* 해석하고, 그 Kernel 은 콘솔 Application 을 처음부터 구성하며 등록된 커맨드를 전부 resolve 한다.
|
||||||
|
* 부팅 시점 vendor 가 개발용이었다면 그 목록에 `command.tinker` 같은 require-dev 커맨드가 들어
|
||||||
|
* 있는데, Step 8 이 vendor 를 `--no-dev` 로 이미 교체했으므로 그 클래스가 없어
|
||||||
|
* `Target class [command.tinker] does not exist.` 로 업데이트 전체가 실패·롤백한다
|
||||||
|
* (2026-09-07 7.0.9 → 7.0.11 실측). 프로세스에 이미 등록된 provider 목록은 vendor 교체로 갱신되지
|
||||||
|
* 않으므로, 방어는 "교체 뒤에 콘솔 Application 을 다시 구성하지 않는 것" 이다.
|
||||||
*
|
*
|
||||||
* @param callable $callback 전역 인스턴스를 바꿔 놓을 수 있는 작업
|
* @param callable $callback 전역 인스턴스를 바꿔 놓을 수 있는 작업
|
||||||
*/
|
*/
|
||||||
public static function withPreservedContainer(callable $callback): void
|
public static function withPreservedContainer(callable $callback): void
|
||||||
{
|
{
|
||||||
$app = Container::getInstance();
|
$app = Container::getInstance();
|
||||||
|
$facadeApp = Facade::getFacadeApplication();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$callback();
|
$callback();
|
||||||
} finally {
|
} finally {
|
||||||
Container::setInstance($app);
|
Container::setInstance($app);
|
||||||
|
|
||||||
|
// 일회용 앱이 남긴 파사드 해석 결과를 버리고 원래 앱으로 되돌린다.
|
||||||
|
// 순서 주의: clear 를 먼저 해야 일회용 앱에서 해석된 인스턴스가 캐시에 남지 않는다.
|
||||||
|
Facade::clearResolvedInstances();
|
||||||
|
if ($facadeApp !== null) {
|
||||||
|
Facade::setFacadeApplication($facadeApp);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 PHP 프로세스가 코어 업데이트 프로세스 트리 안에 있는지 판정하는 단일 SSoT.
|
||||||
|
*
|
||||||
|
* 코어 업데이트는 부모 `core:update` 가 `core:execute-upgrade-steps` 를 `proc_open` 으로
|
||||||
|
* spawn 하고, 그 자식이 다시 `config:cache` 등으로 일회용 Application 을 부팅하는 다층 구조다.
|
||||||
|
* 이 트리 안에서만 켜져야 하는 예외 동작이 여럿이라(확장 자동 비활성화 스킵, 코어 버전의
|
||||||
|
* env 우선 판독, `bootstrap/app.php` 의 패키지 매니페스트 자가 치유) 판정이 흩어지면
|
||||||
|
* 한 곳만 어긋나도 그 경로가 조용히 다르게 동작한다 — 판정을 여기 한 곳에 둔다.
|
||||||
|
*
|
||||||
|
* 판정 채널이 둘인 이유: `G7_UPDATE_IN_PROGRESS` env 는 부모가 세우고 spawn 자식에게
|
||||||
|
* `$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 로 복제한다. 조건을 바꾸면 그쪽도 함께 고친다.
|
||||||
|
*/
|
||||||
|
final class CoreUpdateContext
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 코어 업데이트를 수행하는 artisan 커맨드 이름 (argv 보조 판정용)
|
||||||
|
*/
|
||||||
|
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. 명령줄 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(?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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `G7_UPDATE_IN_PROGRESS=1` 환경변수 플래그만 확인합니다 (argv 보조 판정 없음).
|
||||||
|
*
|
||||||
|
* spawn 자식 여부를 가려야 하는 지점(자식은 사전·사후 단계를 부모에 위임)에서 쓴다.
|
||||||
|
* argv 판정을 섞으면 `core:execute-upgrade-steps` 단독 실행이 자식으로 오판된다.
|
||||||
|
*
|
||||||
|
* @return bool env 플래그가 켜져 있으면 true
|
||||||
|
*/
|
||||||
|
public static function hasEnvFlag(): bool
|
||||||
|
{
|
||||||
|
$flag = $_ENV['G7_UPDATE_IN_PROGRESS'] ?? $_SERVER['G7_UPDATE_IN_PROGRESS'] ?? getenv('G7_UPDATE_IN_PROGRESS');
|
||||||
|
|
||||||
|
return $flag === '1' || $flag === 1 || $flag === true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Artisan;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 패키지 매니페스트 캐시(bootstrap/cache/packages.php · services.php) 정리·재생성 헬퍼.
|
||||||
|
*
|
||||||
|
* Laravel 의 `PackageManifest::getManifest()` 는 `packages.php` 가 존재하면 stale 여부를
|
||||||
|
* 검사하지 않고 그대로 읽고, `ProviderRepository::load()` 가 거기 등재된 eager provider 를
|
||||||
|
* `new` 한다. 그래서 vendor 를 교체한 뒤 그 파일을 남겨 두면 다음에 부팅하는 프로세스가
|
||||||
|
* 새 vendor 에 없는 클래스를 찾다 **부팅 단계에서** 죽는다 — 앱 로그가 아직 열리기 전이라
|
||||||
|
* 예외도 남지 않고, 부모 프로세스에는 자식의 비정상 종료로만 보인다.
|
||||||
|
*
|
||||||
|
* `ConfigCacheHelper`/`RouteCacheHelper` 와 같은 자리의 헬퍼로, vendor 를 바꾼 뒤 새 PHP
|
||||||
|
* 프로세스를 띄우는 지점이 이 헬퍼를 경유한다. 삭제 로직을 호출부마다 `@unlink` 로 복제하면
|
||||||
|
* 한 곳만 빠져도 그 경로가 조용히 옛 매니페스트로 부팅한다.
|
||||||
|
*/
|
||||||
|
class PackageManifestCacheHelper
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 패키지 매니페스트 캐시 두 파일을 삭제합니다 (재생성 없음).
|
||||||
|
*
|
||||||
|
* 경로는 `Application` 의 게터로 읽어 `APP_PACKAGES_CACHE`/`APP_SERVICES_CACHE` 환경변수
|
||||||
|
* 재지정을 존중한다(테스트 격리가 이 경로 재지정에 의존한다).
|
||||||
|
*
|
||||||
|
* 삭제 실패(권한 · 소유권 불일치 · Windows 파일 핸들 점유)는 예외로 올리지 않는다 — 부팅 직전에
|
||||||
|
* 불리므로 실패가 흐름을 막으면 안 되고, 코어 업데이트의 마지막 단계(`clearAllCaches()`)가 다시
|
||||||
|
* 시도한다. 대신 지우지 못한 파일의 경로를 돌려준다. spawn 직전 호출부는 그 목록을 업그레이드
|
||||||
|
* 로그에 남긴다 — 그 상태면 자식의 자가 치유도 같은 권한으로 같은 이유로 실패해 증상은 이전
|
||||||
|
* 설치본 provider 의 「Class not found」 그대로인데, 이 기록이 권한이 원인이라는 유일한 흔적이다.
|
||||||
|
*
|
||||||
|
* @return array<int, string> 삭제하지 못하고 남은 파일의 절대 경로. 전부 지웠거나 원래 없었으면 빈 배열
|
||||||
|
*/
|
||||||
|
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)) {
|
||||||
|
$remaining[] = $path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearstatcache();
|
||||||
|
|
||||||
|
return $remaining;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 패키지 매니페스트 캐시를 비우고 현재 vendor 기준으로 다시 만듭니다.
|
||||||
|
*
|
||||||
|
* `package:discover` 는 새 Application 을 부팅하지 않으므로(현재 앱의 `PackageManifest` 를
|
||||||
|
* 그대로 쓴다) `ConfigCacheHelper::withPreservedContainer()` 로 감쌀 필요가 없다.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function rebuild(): void
|
||||||
|
{
|
||||||
|
self::clear();
|
||||||
|
|
||||||
|
Artisan::call('package:discover');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -214,4 +214,39 @@ $app = Application::configure(basePath: dirname(__DIR__))
|
|||||||
});
|
});
|
||||||
})->create();
|
})->create();
|
||||||
|
|
||||||
|
/*
|
||||||
|
| Core Update: Stale Package Manifest Self-Heal — 코어 업데이트 트리 안에서 부팅하는 프로세스
|
||||||
|
| (core:update 부모, G7_UPDATE_IN_PROGRESS=1 을 물려받은 spawn 자식, 그 안의 config:cache/route:cache
|
||||||
|
| 일회용 앱)는 bootstrap/cache/packages.php · services.php 를 비우고 부팅한다. Laravel 은 두 파일이
|
||||||
|
| 없을 때만 vendor/composer/installed.json 에서 다시 만들므로, vendor 교체 뒤 남은 이전 설치본의
|
||||||
|
| 목록(dev composer 의 require-dev 전이 provider 등)으로 부팅하다 "Class ... not found" 로 죽는 일을
|
||||||
|
| 막는다. 7.0.11 미만 부모는 spawn 직전에 비우지 않으므로 그 부모 아래에서 도는 신버전 자식의
|
||||||
|
| 유일한 방어다 (7.0.9/7.0.10 → 7.0.11). 7.0.11+ 부모는 이미 비우므로 여기서는 no-op.
|
||||||
|
| 규칙: 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 = 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)) {
|
||||||
|
try {
|
||||||
|
foreach ([$app->getCachedPackagesPath(), $app->getCachedServicesPath()] as $g7ManifestPath) {
|
||||||
|
if (is_file($g7ManifestPath)) {
|
||||||
|
@unlink($g7ManifestPath); // Windows 핸들 점유 시 실패 가능 — 무시(종전 동작)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clearstatcache();
|
||||||
|
} catch (Throwable) {
|
||||||
|
// 자가 치유 실패는 부팅을 막지 않는다.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unset($g7UpdateFlag, $g7UpdateArgv, $g7ManifestPath);
|
||||||
|
|
||||||
return $app;
|
return $app;
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ _단건 응답: `data` 객체의 필드._
|
|||||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| update_available | boolean | `false` | 새 버전 존재 여부 (최신 릴리스 버전이 현재 버전보다 높으면 `true`) |
|
| update_available | boolean | `false` | 새 버전 존재 여부 (최신 릴리스 버전이 현재 버전보다 높으면 `true`) |
|
||||||
| current_version | string | `7.0.3` | 현재 설치된 코어 버전 (`config('app.version')`) |
|
| current_version | string | `7.0.3` | 현재 설치된 코어 버전 (프로세스 환경값이 아니라 설정의 버전 — `config('app.version')`) |
|
||||||
| latest_version | string | `7.0.3` | GitHub 릴리스에서 조회한 최신 코어 버전 (조회 값이 없으면 현재 버전과 동일) |
|
| latest_version | string | `7.0.3` | GitHub 릴리스에서 조회한 최신 코어 버전 (조회 값이 없으면 현재 버전과 동일) |
|
||||||
| github_url | string | `https://github.com/gnuboard/g7` | 업데이트 조회 대상 GitHub 저장소 URL (`config('app.update.github_url')`) |
|
| github_url | string | `https://github.com/gnuboard/g7` | 업데이트 조회 대상 GitHub 저장소 URL (`config('app.update.github_url')`) |
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
2. 마이그레이션 = 스키마 변경만, 데이터 백필/변환 = 업그레이드 스텝 (역할 분리 필수)
|
2. 마이그레이션 = 스키마 변경만, 데이터 백필/변환 = 업그레이드 스텝 (역할 분리 필수)
|
||||||
3. 업데이트 실행 흐름: 11단계 (감지 → 다운로드 → 백업 → 적용 → 마이그레이션 → 동기화 → 업그레이드 → 마무리)
|
3. 업데이트 실행 흐름: 11단계 (감지 → 다운로드 → 백업 → 적용 → 마이그레이션 → 동기화 → 업그레이드 → 마무리)
|
||||||
4. 롤백: CoreBackupHelper로 백업 생성, 실패 시 자동 복원
|
4. 롤백: CoreBackupHelper로 백업 생성, 실패 시 자동 복원
|
||||||
5. 부트스트랩 호환성 검증: .env의 APP_VERSION < 확장 g7_version 시 자동 비활성화 (1시간 캐시)
|
5. 부트스트랩 호환성 검증: 코어 버전 < 확장 g7_version 시 자동 비활성화 (1시간 캐시)
|
||||||
|
6. vendor 를 교체한 뒤 새 프로세스를 띄우기 전에는 패키지 매니페스트를 비운다 (3계층 — 부모 선정리 / 자식 자가 치유 / 버전 판독 범위)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -72,6 +73,7 @@
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
1. CoreVersionChecker::getCoreVersion() → config('app.version') 읽기
|
1. CoreVersionChecker::getCoreVersion() → config('app.version') 읽기
|
||||||
|
(코어 업데이트 트리 안에서만 env APP_VERSION 우선 — CoreUpdateContext::isInProgress())
|
||||||
2. CoreUpdateService::checkForUpdates() → GitHub API로 최신 릴리스 조회
|
2. CoreUpdateService::checkForUpdates() → GitHub API로 최신 릴리스 조회
|
||||||
3. version_compare(current, latest) → 업데이트 가용 여부 판단
|
3. version_compare(current, latest) → 업데이트 가용 여부 판단
|
||||||
4. 원격 CHANGELOG 캐시 → storage/app/temp/core_remote_changelog.md
|
4. 원격 CHANGELOG 캐시 → storage/app/temp/core_remote_changelog.md
|
||||||
@@ -168,6 +170,9 @@ v접두사 자동 감지 (resolveGithubArchiveUrl):
|
|||||||
- composer.json + composer.lock 의 MD5 비교 (_pending vs base_path)
|
- composer.json + composer.lock 의 MD5 비교 (_pending vs base_path)
|
||||||
- 동일 → "composer 의존성 변경 없음 — 스킵" (Step 6 + Step 8 모두 스킵)
|
- 동일 → "composer 의존성 변경 없음 — 스킵" (Step 6 + Step 8 모두 스킵)
|
||||||
- 변경됨 → composer install --no-dev --optimize-autoloader --no-interaction --no-scripts
|
- 변경됨 → composer install --no-dev --optimize-autoloader --no-interaction --no-scripts
|
||||||
|
- 운영 vendor 의 개발용(require-dev) 패키지 감지 → 로그 기록
|
||||||
|
· 재설치 분기: "--no-dev vendor 로 교체합니다" (정보)
|
||||||
|
· 스킵 분기: "그대로 남습니다 … composer install --no-dev 실행 권장" (경고)
|
||||||
|
|
||||||
3. Bundled 모드 (신규, 공유 호스팅 대응):
|
3. Bundled 모드 (신규, 공유 호스팅 대응):
|
||||||
- _pending/vendor-bundle.zip 무결성 검증 (SHA256)
|
- _pending/vendor-bundle.zip 무결성 검증 (SHA256)
|
||||||
@@ -265,10 +270,26 @@ 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개를 무조건 추가해 중복 회피한다 — 부모가 자식 종료 후 동일 단계를 직접 수행하기 때문이다.
|
> **단독 실행 안전성 (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 이므로 가드는 그대로 발동한다.
|
> **spawn 자식은 이전 버전의 config 캐시로 부팅한다**: 7.0.9 이하 부모는 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` 은 덮어쓰지 않는다).
|
> 같은 이유로 자식이 `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` 은 덮어쓰지 않는다).
|
||||||
|
|
||||||
|
#### spawn 전 캐시 정리 계약 (3계층)
|
||||||
|
|
||||||
|
config 캐시와 같은 문제가 **패키지 매니페스트**(`bootstrap/cache/packages.php` · `services.php`)에도 있고, 이쪽은 결과가 더 무겁다. Laravel 의 `PackageManifest` 는 `packages.php` 가 있으면 stale 여부를 검사하지 않고 그대로 읽고, `ProviderRepository` 가 거기 등재된 eager provider 를 `new` 한다. Step 6/8 이 vendor 를 `--no-dev` 로 교체해도 두 파일은 Step 11 까지 이전 설치본의 것이 남으므로, 이전 설치본이 `composer install`(옵션 없음)로 깔린 개발용 설치였다면 자식은 새 vendor 에 없는 provider 를 찾다 **부팅 단계에서** 죽는다. 예외는 앱 로그가 열리기 전이라 남지 않고, 부모에게는 자식의 비정상 종료로만 보인다 (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`·운영자 셸은 플래그가 없어 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 로 하면 운영자가 보는 근거와 실제 판정이 어긋난다.
|
||||||
|
|
||||||
#### 재실행 안내의 권한 분기 (핸드오프 catch)
|
#### 재실행 안내의 권한 분기 (핸드오프 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 등) 요청이 그 경로에 쓰기 실패한다.
|
spawn 자식이 실패(`proc_open` 미지원 · 비정상 종료 · silent skip)하고 `spawn_failure_mode=abort`(기본값) 이면, 파일·버전은 이미 `toVersion` 으로 반영되지만 업그레이드 스텝이 미실행 상태로 남아 운영자에게 `core:execute-upgrade-steps` 재실행을 안내한다. 이때 **sudo(root) 로 `core:update` 를 실행한 경우**, 안내받은 명령을 root 로 그대로 재실행하면 스텝이 만드는 파일·캐시가 root 소유로 생성되어 이후 웹서버(php-fpm www-data 등) 요청이 그 경로에 쓰기 실패한다.
|
||||||
@@ -288,15 +309,20 @@ spawn 자식이 실패(`proc_open` 미지원 · 비정상 종료 · silent skip)
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
1. .env의 APP_VERSION 갱신
|
1. .env의 APP_VERSION 갱신
|
||||||
2. 캐시 클리어: config, cache, route, view
|
2. 캐시 클리어: config, cache, route, view (spawn 직전에도 config + 패키지 매니페스트 선정리)
|
||||||
3. bootstrap/cache 파일 삭제 (services.php, packages.php)
|
3. bootstrap/cache 파일 삭제 (services.php, packages.php)
|
||||||
4. php artisan package:discover 재실행
|
4. php artisan package:discover 재실행
|
||||||
5. php artisan extension:update-autoload (코어 업데이트로 _bundled 변경 가능)
|
5. php artisan extension:update-autoload (코어 업데이트로 _bundled 변경 가능)
|
||||||
6. _pending 격리 디렉토리(core_{ts}) 루트째 삭제
|
6. _pending 격리 디렉토리(core_{ts}) 루트째 삭제
|
||||||
7. 성공 시 백업 삭제 (이후 `hotfix:rollback-stale-files` 는 대상이 없다)
|
7. 성공 시 백업 삭제 (이후 `hotfix:rollback-stale-files` 는 대상이 없다)
|
||||||
8. 유지보수 모드 해제
|
8. 유지보수 모드 해제
|
||||||
|
9. 큐 워커 재시작 신호 (queue:restart)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 3~4 는 `PackageManifestCacheHelper::rebuild()` 한 호출이다 — spawn 직전 선정리(계층 ①)와 같은 삭제 로직을 공유한다.
|
||||||
|
>
|
||||||
|
> 9 는 상주 큐 워커가 부팅 시점의 코어 코드·config 를 계속 쓰는 것을 막는다. 워커는 옛 코드로도 잡을 정상 처리하므로 오류가 나지 않고, 운영자가 손수 재시작할 때까지 조용히 어긋난 채 돈다. 핸드오프 cleanup 과 `core:execute-upgrade-steps` 단독 실행의 사후 단계도 같은 신호를 보낸다. 롤백 catch 는 제외다 — 백업으로 되돌린 옛 코드가 다시 도는 자리라 재기동시킬 이유가 없다.
|
||||||
|
|
||||||
### Step 12: _bundled 확장 일괄 업데이트 프롬프트 (인터랙티브)
|
### Step 12: _bundled 확장 일괄 업데이트 프롬프트 (인터랙티브)
|
||||||
|
|
||||||
> Trait: `App\Console\Commands\Core\Concerns\BundledExtensionUpdatePrompt`
|
> Trait: `App\Console\Commands\Core\Concerns\BundledExtensionUpdatePrompt`
|
||||||
@@ -586,6 +612,8 @@ public function withCurrentStep(string $stepVersion): self // 불변 복제
|
|||||||
|
|
||||||
```php
|
```php
|
||||||
CoreVersionChecker::getCoreVersion() // → config('app.version')
|
CoreVersionChecker::getCoreVersion() // → config('app.version')
|
||||||
|
// (코어 업데이트 트리 안에서만 env APP_VERSION 우선
|
||||||
|
// — CoreUpdateContext::isInProgress())
|
||||||
```
|
```
|
||||||
|
|
||||||
### 버전 갱신 (Step 11)
|
### 버전 갱신 (Step 11)
|
||||||
@@ -710,7 +738,8 @@ config('app.version') = env('APP_VERSION', 'config/app.php 기본값')
|
|||||||
|--------|---------|------|
|
|--------|---------|------|
|
||||||
| `runUpgradeSteps()` | `(string $from, string $to, ?Closure $onStep): void` | upgrades/ 자동 발견 + 실행 |
|
| `runUpgradeSteps()` | `(string $from, string $to, ?Closure $onStep): void` | upgrades/ 자동 발견 + 실행 |
|
||||||
| `updateVersionInEnv()` | `(string $version): void` | .env의 APP_VERSION 갱신 |
|
| `updateVersionInEnv()` | `(string $version): void` | .env의 APP_VERSION 갱신 |
|
||||||
| `clearAllCaches()` | `(): void` | config/cache/route/view 클리어 + package:discover |
|
| `clearAllCaches()` | `(): void` | config/cache/route/view 클리어 + 패키지 매니페스트 재생성(`PackageManifestCacheHelper::rebuild()`) |
|
||||||
|
| `signalQueueRestart()` | `(): void` | 상주 큐 워커에 재시작 신호 (실패는 경고만 — 업데이트를 되돌리지 않는다) |
|
||||||
|
|
||||||
### 유지보수 모드
|
### 유지보수 모드
|
||||||
|
|
||||||
|
|||||||
@@ -197,6 +197,9 @@ class TemplateService
|
|||||||
```
|
```
|
||||||
인스톨러 단계 1: composer install
|
인스톨러 단계 1: composer install
|
||||||
├─ .env 없음 → ModuleRouteServiceProvider 스킵 ✅
|
├─ .env 없음 → ModuleRouteServiceProvider 스킵 ✅
|
||||||
|
├─ vendor 재사용 분기 (vendor/autoload.php + composer.lock 존재)
|
||||||
|
│ ├─ 개발용 패키지 감지 → 경고 카드·설치 로그 (설치는 계속)
|
||||||
|
│ └─ 이전 환경의 컴파일 캐시 정리 (packages/services/config)
|
||||||
└─ package:discover 정상 완료
|
└─ package:discover 정상 완료
|
||||||
|
|
||||||
인스톨러 단계 2: .env 생성
|
인스톨러 단계 2: .env 생성
|
||||||
|
|||||||
@@ -238,6 +238,20 @@ $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 전파가 유일한 수단입니다.
|
`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()` 위임입니다. 같은 플래그가 서로 다른 계층에서 셋을 게이트하므로 판정이 갈라지면 그중 한 경로만 조용히 다르게 동작합니다.
|
||||||
|
|
||||||
|
| 게이트 대상 | 위치 | 플래그가 없으면 |
|
||||||
|
|-------------|------|-----------------|
|
||||||
|
| 확장·템플릿 자동 비활성화 스킵 | `CoreServiceProvider::validateAndDeactivate*` | 일시적 버전 불일치로 활성 확장이 꺼진다 |
|
||||||
|
| 코어 버전의 env `APP_VERSION` 우선 판독 | `CoreVersionChecker::getCoreVersion()` | 캐시된 config 의 fromVersion 으로 판정한다 (반대로, 트리 밖에서 env 를 우선하면 상주 프로세스가 옛 버전을 물고 확장을 끈다) |
|
||||||
|
| 패키지 매니페스트 자가 치유 | `bootstrap/app.php` | 이전 설치본의 `packages.php` 로 부팅하다 "Class ... not found" 로 죽는다 |
|
||||||
|
|
||||||
|
`bootstrap/app.php` 의 자가 치유 블록은 부팅 전이라 `App\` 클래스를 참조할 수 없어 같은 판정을 순수 PHP 로 복제합니다 — 조건을 바꾸면 양쪽을 함께 고쳐야 합니다.
|
||||||
|
|
||||||
### 동적 엔티티 보존 (Permission / Role / Menu)
|
### 동적 엔티티 보존 (Permission / Role / Menu)
|
||||||
|
|
||||||
모듈·플러그인이 런타임에 동적으로 생성한 Permission/Role/Menu 는 정적 정의(`getPermissions()`, `getRoles()`, `getAdminMenus()`)에 포함되지 않으므로, 업데이트 시 `cleanupStaleModuleEntries()` / `cleanupStalePluginEntries()` 가 stale 로 오판하지 않도록 모듈 측에서 아래 hook 을 override 해 현재 식별자 전체를 반환한다.
|
모듈·플러그인이 런타임에 동적으로 생성한 Permission/Role/Menu 는 정적 정의(`getPermissions()`, `getRoles()`, `getAdminMenus()`)에 포함되지 않으므로, 업데이트 시 `cleanupStaleModuleEntries()` / `cleanupStalePluginEntries()` 가 stale 로 오판하지 않도록 모듈 측에서 아래 hook 을 override 해 현재 식별자 전체를 반환한다.
|
||||||
|
|||||||
@@ -174,6 +174,11 @@ sudo chmod -R 755 storage bootstrap/cache vendor modules plugins templates publi
|
|||||||
두 도구 모두 필요하지 않다.** 소스에서 직접 빌드하거나 개발 환경을 구성할 때, 또는
|
두 도구 모두 필요하지 않다.** 소스에서 직접 빌드하거나 개발 환경을 구성할 때, 또는
|
||||||
Composer 설치 방식을 선택할 때만 필요하다.
|
Composer 설치 방식을 선택할 때만 필요하다.
|
||||||
|
|
||||||
|
Composer 로 직접 설치한다면 `composer install --no-dev --optimize-autoloader` 를 쓴다.
|
||||||
|
옵션 없는 `composer install` 은 개발용 패키지까지 설치하며, 그 상태로 운영하면
|
||||||
|
이후 코어 업데이트가 vendor 를 교체할 때 이전 패키지 목록 캐시와 어긋나 부팅이 깨진다.
|
||||||
|
개발용 패키지가 섞여 있으면 설치 마법사의 「설치 환경 확인」 단계가 경고한다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. 데이터베이스
|
## 2. 데이터베이스
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ class ValidationApi
|
|||||||
'required_files' => $this->checkRequiredFiles(),
|
'required_files' => $this->checkRequiredFiles(),
|
||||||
'https' => $this->checkHttps(),
|
'https' => $this->checkHttps(),
|
||||||
'opcache' => $this->checkOpcache(),
|
'opcache' => $this->checkOpcache(),
|
||||||
|
'vendor_dev_packages' => $this->checkVendorDevPackages(),
|
||||||
'asset_url_mode' => $this->checkAssetUrlMode(),
|
'asset_url_mode' => $this->checkAssetUrlMode(),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -604,6 +605,52 @@ class ValidationApi
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기존 vendor 의 개발용(require-dev) 패키지 포함 여부 (선택 항목 — 경고만, 설치 차단 없음)
|
||||||
|
*
|
||||||
|
* 인스톨러는 vendor 가 이미 있으면 그대로 재사용한다. 그 vendor 가 `composer install`
|
||||||
|
* (옵션 없음)의 결과면 개발용 패키지가 섞여 있고, 그 목록이 패키지 매니페스트에 등재된다.
|
||||||
|
* 이후 코어 업데이트가 vendor 를 `--no-dev` 로 교체하면 매니페스트에만 남은 provider 를
|
||||||
|
* 찾다 부팅이 깨지므로, 설치 시점에 미리 알린다.
|
||||||
|
*
|
||||||
|
* `dev` 가 null 이면 판정 불가(installed.json 부재·형식 불명)로, 경고도 차단도 하지 않는다.
|
||||||
|
*
|
||||||
|
* @return array{required: bool, vendor_exists: bool, dev: bool|null, packages: array<int, string>, message: string}
|
||||||
|
*/
|
||||||
|
private function checkVendorDevPackages(): array
|
||||||
|
{
|
||||||
|
$vendorExists = is_dir(BASE_PATH.'/vendor') && file_exists(BASE_PATH.'/vendor/autoload.php');
|
||||||
|
|
||||||
|
if (! $vendorExists) {
|
||||||
|
return [
|
||||||
|
'required' => false,
|
||||||
|
'vendor_exists' => false,
|
||||||
|
'dev' => null,
|
||||||
|
'packages' => [],
|
||||||
|
'message' => lang('vendor_dev_packages_no_vendor'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once __DIR__.'/../includes/vendor-bundle-installer.php';
|
||||||
|
$info = detectDevVendorInstall(BASE_PATH);
|
||||||
|
|
||||||
|
if ($info['dev'] === true) {
|
||||||
|
$message = lang('vendor_dev_packages_detected_warning', ['count' => count($info['packages'])]);
|
||||||
|
} elseif ($info['dev'] === false) {
|
||||||
|
$message = lang('vendor_dev_packages_none');
|
||||||
|
} else {
|
||||||
|
$message = lang('vendor_dev_packages_unknown');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'required' => false, // 개발용 패키지 감지는 선택 항목 (권장 사항)
|
||||||
|
'vendor_exists' => true,
|
||||||
|
'dev' => $info['dev'],
|
||||||
|
'packages' => $info['packages'],
|
||||||
|
'message' => $message,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 자산 URL 방식 항목 정의 (권장 사항 — 설치를 차단하지 않음)
|
* 자산 URL 방식 항목 정의 (권장 사항 — 설치를 차단하지 않음)
|
||||||
*
|
*
|
||||||
@@ -1133,7 +1180,8 @@ class ValidationApi
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTTPS / OPcache 는 선택 사항이므로 검증하지 않음 (경고만 표시하고 설치는 진행)
|
// HTTPS / OPcache / Composer 의존성 구성은 선택 사항이므로 검증하지 않음
|
||||||
|
// (경고만 표시하고 설치는 진행)
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -482,7 +482,10 @@ function getManualCommands(string $taskId, ?string $target = null): array
|
|||||||
|
|
||||||
switch ($taskId) {
|
switch ($taskId) {
|
||||||
case 'composer_install':
|
case 'composer_install':
|
||||||
$commands[] = "{$composer} install --no-interaction --prefer-dist --optimize-autoloader";
|
// --no-dev 는 마법사가 실제로 실행하는 명령(task-runner.php)과 같아야 한다. 이 안내를
|
||||||
|
// 옵션 없이 따라 하면 개발용(require-dev) 패키지가 섞인 vendor 가 만들어지고, 그 상태는
|
||||||
|
// 이후 코어 업데이트가 vendor 를 교체할 때 이전 패키지 매니페스트와 어긋나 부팅을 깨뜨린다.
|
||||||
|
$commands[] = "{$composer} install --no-interaction --no-dev --prefer-dist --optimize-autoloader";
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'env_create':
|
case 'env_create':
|
||||||
|
|||||||
@@ -487,6 +487,39 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 9-1. Composer 의존성 구성 카드 (권장 사항, 필수 아님)
|
||||||
|
// failedRequirements.push() 를 하지 않는다 — 개발용 패키지가 섞여 있어도 설치는 진행된다.
|
||||||
|
// vendor 자체가 없으면 마법사가 운영용 구성으로 자동 설치하므로 카드를 그리지 않는다.
|
||||||
|
if (data.vendor_dev_packages && data.vendor_dev_packages.vendor_exists) {
|
||||||
|
const vendorInfo = data.vendor_dev_packages;
|
||||||
|
let vendorStatusClass, vendorStatusText, vendorHint = '';
|
||||||
|
if (vendorInfo.dev === true) {
|
||||||
|
// 단순히 '개발용 포함' 만 보이면 이후 코어 업데이트가 깨진다는 실질이 전달되지 않는다.
|
||||||
|
vendorStatusClass = 'status-warning';
|
||||||
|
vendorStatusText = lang('vendor_dev_packages_detected_short')
|
||||||
|
.replace(':count', (vendorInfo.packages || []).length);
|
||||||
|
vendorHint = lang('vendor_dev_packages_detected_warning')
|
||||||
|
.replace(':count', (vendorInfo.packages || []).length);
|
||||||
|
} else if (vendorInfo.dev === false) {
|
||||||
|
vendorStatusClass = 'status-pass';
|
||||||
|
vendorStatusText = lang('vendor_dev_packages_none_short');
|
||||||
|
} else {
|
||||||
|
// installed.json 부재·형식 불명 — 확인 불가 (경고도 차단도 아님)
|
||||||
|
vendorStatusClass = 'status-pass';
|
||||||
|
vendorStatusText = lang('vendor_dev_packages_unknown');
|
||||||
|
}
|
||||||
|
html += renderSingleItemCard(
|
||||||
|
lang('vendor_dev_packages'),
|
||||||
|
vendorStatusClass,
|
||||||
|
vendorStatusText,
|
||||||
|
'',
|
||||||
|
false
|
||||||
|
);
|
||||||
|
if (vendorHint) {
|
||||||
|
html += `<p class="fix-guide-hint" style="margin-top: 0.75rem;">${vendorHint}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 10. 자산 URL 방식 카드 (안내 항목, 필수 아님)
|
// 10. 자산 URL 방식 카드 (안내 항목, 필수 아님)
|
||||||
// 서버는 판정할 수 없으므로(loopback 이 vhost·프록시를 우회) "확인 중" 으로 먼저
|
// 서버는 판정할 수 없으므로(loopback 이 vhost·프록시를 우회) "확인 중" 으로 먼저
|
||||||
// 그리고 refreshAssetUrlModeCard() 가 브라우저 프로브 결과로 갱신한다.
|
// 그리고 refreshAssetUrlModeCard() 가 브라우저 프로브 결과로 갱신한다.
|
||||||
|
|||||||
@@ -490,7 +490,28 @@ if (! function_exists('installComposerDependenciesSSE')) {
|
|||||||
$lockExists = file_exists(BASE_PATH.'/composer.lock');
|
$lockExists = file_exists(BASE_PATH.'/composer.lock');
|
||||||
|
|
||||||
if ($vendorExists && $lockExists) {
|
if ($vendorExists && $lockExists) {
|
||||||
sendSSEEvent('log', ['message' => lang('log_composer_already_installed')]);
|
// 이미 준비된 vendor 를 그대로 쓴다. 그 vendor 가 개발용(require-dev 포함) 설치면
|
||||||
|
// 설치는 계속하되 운영자에게 알린다 — 그대로 두면 이후 코어 업데이트가 vendor 를
|
||||||
|
// --no-dev 로 교체할 때 이전 매니페스트에만 남은 provider 를 찾다 부팅이 깨진다.
|
||||||
|
$devInfo = detectDevVendorInstall(BASE_PATH);
|
||||||
|
if ($devInfo['dev'] === true) {
|
||||||
|
sendSSEEvent('log', ['message' => lang('log_composer_dev_packages_detected', [
|
||||||
|
'count' => count($devInfo['packages']),
|
||||||
|
'packages' => implode(', ', array_slice($devInfo['packages'], 0, 5)).(count($devInfo['packages']) > 5 ? ' …' : ''),
|
||||||
|
])]);
|
||||||
|
// sendSSEEvent('log') 는 progress emitter 안에서 installation.log 에도 기록한다
|
||||||
|
// (SSE·폴링 두 모드 공통) — 여기서 addLog 를 또 부르면 로그에 같은 줄이 두 번 남는다.
|
||||||
|
sendSSEEvent('log', ['message' => lang('warning_composer_dev_packages_kept')]);
|
||||||
|
} else {
|
||||||
|
sendSSEEvent('log', ['message' => lang('log_composer_already_installed')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 재사용 경로에서도 이전 환경의 컴파일 캐시를 정리한다 — 종전에는 정리 없이
|
||||||
|
// return 해, key_generate 가 이미 완료된 재개 설치에서는 어디서도 정리되지 않았다.
|
||||||
|
if (! empty(clearLaravelCompiledCache(BASE_PATH))) {
|
||||||
|
sendSSEEvent('log', ['message' => lang('log_composer_cache_cleared')]);
|
||||||
|
}
|
||||||
|
|
||||||
sendSSEEvent('log', ['message' => lang('log_task_completed', ['task' => $taskName])]);
|
sendSSEEvent('log', ['message' => lang('log_task_completed', ['task' => $taskName])]);
|
||||||
sendSSEEvent('log', ['message' => lang('log_separator')]);
|
sendSSEEvent('log', ['message' => lang('log_separator')]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Support\ComposerInstallInfo;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vendor 번들 설치 헬퍼 (웹 인스톨러 전용 shim).
|
* Vendor 번들 설치 헬퍼 (웹 인스톨러 전용 shim).
|
||||||
*
|
*
|
||||||
@@ -8,7 +10,6 @@
|
|||||||
*
|
*
|
||||||
* Laravel 측 클래스와의 동등성은 VendorBundleInstallerShimTest 가 보장한다.
|
* Laravel 측 클래스와의 동등성은 VendorBundleInstallerShimTest 가 보장한다.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const VENDOR_BUNDLE_SCHEMA_VERSION = '1.0';
|
const VENDOR_BUNDLE_SCHEMA_VERSION = '1.0';
|
||||||
const VENDOR_BUNDLE_ZIP_FILENAME = 'vendor-bundle.zip';
|
const VENDOR_BUNDLE_ZIP_FILENAME = 'vendor-bundle.zip';
|
||||||
const VENDOR_BUNDLE_MANIFEST_FILENAME = 'vendor-bundle.json';
|
const VENDOR_BUNDLE_MANIFEST_FILENAME = 'vendor-bundle.json';
|
||||||
@@ -297,7 +298,7 @@ function deleteVendorBundleDirectory(string $dir): bool
|
|||||||
* 본 함수는 Laravel\Foundation\ComposerScripts::clearCompiled() 와 동일한 3개 파일을 정리한다.
|
* 본 함수는 Laravel\Foundation\ComposerScripts::clearCompiled() 와 동일한 3개 파일을 정리한다.
|
||||||
*
|
*
|
||||||
* @param string $basePath 프로젝트 루트 (bootstrap/cache 의 상위 디렉토리)
|
* @param string $basePath 프로젝트 루트 (bootstrap/cache 의 상위 디렉토리)
|
||||||
* @return array<string> 실제로 삭제된 파일명 목록 (basename)
|
* @return array<string> 실제로 삭제된 파일명 목록 (basename)
|
||||||
*/
|
*/
|
||||||
function clearLaravelCompiledCache(string $basePath): array
|
function clearLaravelCompiledCache(string $basePath): array
|
||||||
{
|
{
|
||||||
@@ -318,6 +319,31 @@ function clearLaravelCompiledCache(string $basePath): array
|
|||||||
return $cleared;
|
return $cleared;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이미 준비된 vendor 가 개발용(require-dev 포함) 설치인지 조사합니다.
|
||||||
|
*
|
||||||
|
* 판정은 코어와 공유하는 `App\Support\ComposerInstallInfo` 가 단독으로 소유한다 — 인스톨러와
|
||||||
|
* 코어 업데이트가 같은 vendor 를 두고 서로 다른 답을 내놓지 않도록.
|
||||||
|
*
|
||||||
|
* 클래스 파일 경로는 인자 `$basePath` 가 아니라 **인스톨러 자신의 트리** 기준으로 읽는다.
|
||||||
|
* 단위 테스트는 임시 디렉토리를 `$basePath` 로 넘기는데 그 경로에는 `app/Support` 가 없다.
|
||||||
|
*
|
||||||
|
* @param string $basePath 프로젝트 루트 (vendor 의 상위 디렉토리)
|
||||||
|
* @return array{dev: bool|null, packages: array<int, string>} dev 가 null 이면 판정 불가
|
||||||
|
*/
|
||||||
|
function detectDevVendorInstall(string $basePath): array
|
||||||
|
{
|
||||||
|
if (! class_exists('App\\Support\\ComposerInstallInfo')) {
|
||||||
|
$classFile = dirname(__DIR__, 3).'/app/Support/ComposerInstallInfo.php';
|
||||||
|
if (! is_file($classFile)) {
|
||||||
|
return ['dev' => null, 'packages' => []];
|
||||||
|
}
|
||||||
|
require_once $classFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ComposerInstallInfo::inspect(rtrim($basePath, '/\\').'/vendor');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Composer 실행이 현재 환경에서 가능한지 검사합니다.
|
* Composer 실행이 현재 환경에서 가능한지 검사합니다.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -243,6 +243,15 @@ return [
|
|||||||
'opcache_disabled_warning' => 'OPcache is disabled. In this state, every single page view re-parses all of your site\'s PHP code from scratch. On identical hardware this can make responses several times slower, and the gap widens as traffic grows. You can continue the installation as-is, but if this server will be used in production we strongly recommend setting opcache.enable=1 in php.ini and restarting your web server.',
|
'opcache_disabled_warning' => 'OPcache is disabled. In this state, every single page view re-parses all of your site\'s PHP code from scratch. On identical hardware this can make responses several times slower, and the gap widens as traffic grows. You can continue the installation as-is, but if this server will be used in production we strongly recommend setting opcache.enable=1 in php.ini and restarting your web server.',
|
||||||
'opcache_unknown' => 'OPcache status could not be determined. Reading PHP settings is restricted on this server.',
|
'opcache_unknown' => 'OPcache status could not be determined. Reading PHP settings is restricted on this server.',
|
||||||
|
|
||||||
|
// Composer Dependency Set Messages
|
||||||
|
'vendor_dev_packages' => 'Composer dependencies',
|
||||||
|
'vendor_dev_packages_none_short' => 'Production set (no dev packages)',
|
||||||
|
'vendor_dev_packages_none' => 'No development packages in vendor. (recommended)',
|
||||||
|
'vendor_dev_packages_detected_short' => ':count development package(s) present — not recommended for production',
|
||||||
|
'vendor_dev_packages_detected_warning' => 'The existing vendor directory contains :count development (require-dev) package(s). Installation can continue, but on a production server run composer install --no-dev --optimize-autoloader from the project root after installation to remove them. (The setup wizard installs the production set automatically only when vendor is absent.)',
|
||||||
|
'vendor_dev_packages_unknown' => 'Could not inspect vendor (installed.json missing or unrecognized).',
|
||||||
|
'vendor_dev_packages_no_vendor' => 'No vendor directory — the wizard installs the production set automatically.',
|
||||||
|
|
||||||
// API Response Messages
|
// API Response Messages
|
||||||
'api_method_not_allowed' => 'Only POST requests are allowed.',
|
'api_method_not_allowed' => 'Only POST requests are allowed.',
|
||||||
'api_invalid_request' => 'Invalid request data.',
|
'api_invalid_request' => 'Invalid request data.',
|
||||||
@@ -318,6 +327,8 @@ return [
|
|||||||
'log_composer_installing_from_lock' => 'Installing dependencies from composer.lock...',
|
'log_composer_installing_from_lock' => 'Installing dependencies from composer.lock...',
|
||||||
'log_composer_fresh_install' => 'Installing new Composer dependencies...',
|
'log_composer_fresh_install' => 'Installing new Composer dependencies...',
|
||||||
'log_composer_cache_cleared' => 'Cleared previous package cache',
|
'log_composer_cache_cleared' => 'Cleared previous package cache',
|
||||||
|
'log_composer_dev_packages_detected' => 'Existing vendor contains :count development package(s) (:packages)',
|
||||||
|
'warning_composer_dev_packages_kept' => 'Keeping the vendor directory that contains development packages. On a production site, run "composer install --no-dev --optimize-autoloader" after installation (installation continues)',
|
||||||
|
|
||||||
// Error Messages - Worker (.env)
|
// Error Messages - Worker (.env)
|
||||||
'error_env_example_not_found' => '.env.example file not found',
|
'error_env_example_not_found' => '.env.example file not found',
|
||||||
|
|||||||
@@ -243,6 +243,15 @@ return [
|
|||||||
'opcache_disabled_warning' => 'OPcache가 비활성화되어 있습니다. 이 상태에서는 방문자가 페이지를 열 때마다 사이트의 모든 PHP 코드를 처음부터 다시 해석합니다. 같은 서버, 같은 사양에서도 응답 속도가 몇 배까지 느려지며 방문자가 많을수록 격차가 커집니다. 설치는 이대로 진행할 수 있지만, 운영에 사용할 서버라면 php.ini에서 opcache.enable=1 을 켠 뒤 웹서버를 재시작하시기를 강력히 권장합니다.',
|
'opcache_disabled_warning' => 'OPcache가 비활성화되어 있습니다. 이 상태에서는 방문자가 페이지를 열 때마다 사이트의 모든 PHP 코드를 처음부터 다시 해석합니다. 같은 서버, 같은 사양에서도 응답 속도가 몇 배까지 느려지며 방문자가 많을수록 격차가 커집니다. 설치는 이대로 진행할 수 있지만, 운영에 사용할 서버라면 php.ini에서 opcache.enable=1 을 켠 뒤 웹서버를 재시작하시기를 강력히 권장합니다.',
|
||||||
'opcache_unknown' => 'OPcache 상태를 확인할 수 없습니다. 서버에서 설정 조회가 제한되어 있습니다.',
|
'opcache_unknown' => 'OPcache 상태를 확인할 수 없습니다. 서버에서 설정 조회가 제한되어 있습니다.',
|
||||||
|
|
||||||
|
// Composer 의존성 구성 메시지
|
||||||
|
'vendor_dev_packages' => 'Composer 의존성 구성',
|
||||||
|
'vendor_dev_packages_none_short' => '운영용 구성 (개발용 패키지 없음)',
|
||||||
|
'vendor_dev_packages_none' => 'vendor 에 개발용 패키지가 없습니다. (권장)',
|
||||||
|
'vendor_dev_packages_detected_short' => '개발용 패키지 :count개 포함 — 운영 사이트에는 권장하지 않음',
|
||||||
|
'vendor_dev_packages_detected_warning' => '이미 준비된 vendor 에 개발용(require-dev) 패키지 :count개가 포함되어 있습니다. 설치는 이대로 진행할 수 있지만, 운영에 사용할 서버라면 설치 후 프로젝트 루트에서 composer install --no-dev --optimize-autoloader 를 실행해 개발용 패키지를 제거하시기를 권장합니다. (설치 마법사는 vendor 가 없을 때만 운영용 구성으로 자동 설치합니다.)',
|
||||||
|
'vendor_dev_packages_unknown' => 'vendor 구성을 확인할 수 없습니다 (installed.json 없음 또는 형식 불명).',
|
||||||
|
'vendor_dev_packages_no_vendor' => 'vendor 없음 — 설치 마법사가 운영용 구성으로 자동 설치합니다.',
|
||||||
|
|
||||||
// API 응답 메시지
|
// API 응답 메시지
|
||||||
'api_method_not_allowed' => 'POST 요청만 허용됩니다.',
|
'api_method_not_allowed' => 'POST 요청만 허용됩니다.',
|
||||||
'api_invalid_request' => '잘못된 요청 데이터입니다.',
|
'api_invalid_request' => '잘못된 요청 데이터입니다.',
|
||||||
@@ -318,6 +327,8 @@ return [
|
|||||||
'log_composer_installing_from_lock' => 'composer.lock 파일을 사용하여 의존성을 설치합니다...',
|
'log_composer_installing_from_lock' => 'composer.lock 파일을 사용하여 의존성을 설치합니다...',
|
||||||
'log_composer_fresh_install' => '새로운 Composer 의존성을 설치합니다...',
|
'log_composer_fresh_install' => '새로운 Composer 의존성을 설치합니다...',
|
||||||
'log_composer_cache_cleared' => '이전 패키지 캐시를 삭제했습니다',
|
'log_composer_cache_cleared' => '이전 패키지 캐시를 삭제했습니다',
|
||||||
|
'log_composer_dev_packages_detected' => '기존 vendor 에 개발용 패키지 :count개가 포함되어 있습니다 (:packages)',
|
||||||
|
'warning_composer_dev_packages_kept' => '개발용 패키지가 포함된 vendor 를 그대로 사용합니다. 운영 사이트라면 설치 후 "composer install --no-dev --optimize-autoloader" 를 실행하세요 (설치는 계속 진행됩니다)',
|
||||||
|
|
||||||
// 에러 메시지 - Worker (.env)
|
// 에러 메시지 - Worker (.env)
|
||||||
'error_env_example_not_found' => '.env.example 파일을 찾을 수 없습니다',
|
'error_env_example_not_found' => '.env.example 파일을 찾을 수 없습니다',
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use Mockery;
|
|||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 확장 프론트엔드 병합 번들 서빙 엔드포인트 Feature 테스트
|
* [case:backend-27] 확장 프론트엔드 병합 번들 서빙 엔드포인트 Feature 테스트
|
||||||
*
|
*
|
||||||
* /api/{modules,plugins}/bundle.{js,css} 의 응답 계약(Content-Type, ETag,
|
* /api/{modules,plugins}/bundle.{js,css} 의 응답 계약(Content-Type, ETag,
|
||||||
* 304 Not Modified, 빈 번들 처리)을 검증한다. ExtensionBundleService 를
|
* 304 Not Modified, 빈 번들 처리)을 검증한다. ExtensionBundleService 를
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ use PHPUnit\Framework\Attributes\Test;
|
|||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 로그인 2단계 인증 테스트
|
* [case:backend-29] 로그인 2단계 인증 테스트
|
||||||
*
|
*
|
||||||
* `security.two_factor_auth` 는 설정 화면에만 있고 구현이 없어, 켜도 아무 일도 일어나지
|
* `security.two_factor_auth` 는 설정 화면에만 있고 구현이 없어, 켜도 아무 일도 일어나지
|
||||||
* 않았습니다. 관리자는 2단계 인증이 걸린 줄 알지만 실제로는 비밀번호 하나로 로그인됩니다.
|
* 않았습니다. 관리자는 2단계 인증이 걸린 줄 알지만 실제로는 비밀번호 하나로 로그인됩니다.
|
||||||
|
|||||||
@@ -165,6 +165,55 @@ class ExecuteUpgradeStepsStandaloneTest extends TestCase
|
|||||||
$this->assertSame(0, $exitCode);
|
$this->assertSame(0, $exitCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐시 정리 블록과 함께 상주 큐 워커 재시작 신호가 나간다 (7.0.11).
|
||||||
|
*
|
||||||
|
* 큐 워커는 부팅이 한 번뿐이라 코어 코드·config 를 기동 시점 상태로 물고 있다. 업데이트가
|
||||||
|
* 파일을 전부 교체해도 워커는 옛 코드로 잡을 계속 처리하며 그 사실이 오류로 드러나지 않는다.
|
||||||
|
*
|
||||||
|
* @effects core_update_step11_signals_queue_restart
|
||||||
|
*/
|
||||||
|
public function test_standalone_post_step_signals_queue_restart(): void
|
||||||
|
{
|
||||||
|
[$service, $module, $plugin, $template, $langPack] = $this->bindMocks();
|
||||||
|
|
||||||
|
$service->shouldReceive('runMigrations')->once();
|
||||||
|
$service->shouldReceive('reloadCoreConfigAndResync')->once();
|
||||||
|
$service->shouldReceive('runUpgradeSteps')->once();
|
||||||
|
$service->shouldReceive('updateVersionInEnv')->once();
|
||||||
|
$service->shouldReceive('clearAllCaches')->once();
|
||||||
|
$service->shouldReceive('signalQueueRestart')->once();
|
||||||
|
$service->shouldReceive('collectBundledExtensionUpdates')->once()->andReturn([
|
||||||
|
'modules' => [], 'plugins' => [], 'templates' => [],
|
||||||
|
]);
|
||||||
|
$langPack->shouldReceive('collectBundledLangPackUpdates')->once()->andReturn([]);
|
||||||
|
|
||||||
|
$this->assertSame(0, $this->runCommand([]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐시 정리를 건너뛰는 경로(spawn 자식)에서는 신호도 보내지 않는다 — 부모가 보낸다.
|
||||||
|
*
|
||||||
|
* @effects core_update_step11_signals_queue_restart
|
||||||
|
*/
|
||||||
|
public function test_skip_cache_clear_also_skips_queue_restart_signal(): void
|
||||||
|
{
|
||||||
|
[$service, $module, $plugin, $template, $langPack] = $this->bindMocks();
|
||||||
|
|
||||||
|
$service->shouldReceive('runMigrations')->once();
|
||||||
|
$service->shouldReceive('reloadCoreConfigAndResync')->once();
|
||||||
|
$service->shouldReceive('runUpgradeSteps')->once();
|
||||||
|
$service->shouldReceive('updateVersionInEnv')->once();
|
||||||
|
$service->shouldNotReceive('clearAllCaches');
|
||||||
|
$service->shouldNotReceive('signalQueueRestart');
|
||||||
|
$service->shouldReceive('collectBundledExtensionUpdates')->once()->andReturn([
|
||||||
|
'modules' => [], 'plugins' => [], 'templates' => [],
|
||||||
|
]);
|
||||||
|
$langPack->shouldReceive('collectBundledLangPackUpdates')->once()->andReturn([]);
|
||||||
|
|
||||||
|
$this->assertSame(0, $this->runCommand(['--skip-cache-clear' => true]));
|
||||||
|
}
|
||||||
|
|
||||||
public function test_skip_bundled_updates_option_bypasses_bundled_prompt(): void
|
public function test_skip_bundled_updates_option_bypasses_bundled_prompt(): void
|
||||||
{
|
{
|
||||||
[$service, $module, $plugin, $template, $langPack] = $this->bindMocks();
|
[$service, $module, $plugin, $template, $langPack] = $this->bindMocks();
|
||||||
@@ -457,6 +506,9 @@ class ExecuteUpgradeStepsStandaloneTest extends TestCase
|
|||||||
// 종료부의 빈 격리 디렉토리 청소(7.0.10 도입)도 모든 경로에서 호출된다 — 전용 케이스가
|
// 종료부의 빈 격리 디렉토리 청소(7.0.10 도입)도 모든 경로에서 호출된다 — 전용 케이스가
|
||||||
// once() 로 단언하고, 나머지 케이스는 기본 허용.
|
// once() 로 단언하고, 나머지 케이스는 기본 허용.
|
||||||
$service->shouldReceive('sweepEmptyStagingDirectories')->andReturn(0)->byDefault();
|
$service->shouldReceive('sweepEmptyStagingDirectories')->andReturn(0)->byDefault();
|
||||||
|
// 캐시 정리 블록이 도는 경로에서는 상주 큐 워커 재시작 신호도 함께 나간다 (7.0.11 도입).
|
||||||
|
// 전용 케이스가 once()/never() 로 단언하고, 나머지 케이스는 기본 허용.
|
||||||
|
$service->shouldReceive('signalQueueRestart')->andReturnNull()->byDefault();
|
||||||
$module = Mockery::mock(ModuleManager::class);
|
$module = Mockery::mock(ModuleManager::class);
|
||||||
$plugin = Mockery::mock(PluginManager::class);
|
$plugin = Mockery::mock(PluginManager::class);
|
||||||
$template = Mockery::mock(TemplateManager::class);
|
$template = Mockery::mock(TemplateManager::class);
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ class CoreUpdateCommandSpawnFailureTest extends TestCase
|
|||||||
|
|
||||||
private ?string $originalEnvVersion = null;
|
private ?string $originalEnvVersion = null;
|
||||||
|
|
||||||
|
private ?string $originalUpdateFlag = null;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
parent::setUp();
|
parent::setUp();
|
||||||
@@ -46,6 +48,12 @@ class CoreUpdateCommandSpawnFailureTest extends TestCase
|
|||||||
$this->failingStepPath = base_path('upgrades/Upgrade_0_0_1_test_spawn_failure_fail.php');
|
$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');
|
$this->silentStepPath = base_path('upgrades/Upgrade_0_0_1_test_spawn_failure_silent.php');
|
||||||
|
|
||||||
|
// 업데이트 트리 플래그도 원값을 보관하고 비운다 — env APP_VERSION 우선 판독이 이 플래그로
|
||||||
|
// 게이트되므로, 다른 테스트가 남긴 플래그가 있으면 부모/자식 시나리오 구분이 무너진다.
|
||||||
|
$flag = $_ENV['G7_UPDATE_IN_PROGRESS'] ?? $_SERVER['G7_UPDATE_IN_PROGRESS'] ?? getenv('G7_UPDATE_IN_PROGRESS');
|
||||||
|
$this->originalUpdateFlag = ($flag === false || $flag === null) ? null : (string) $flag;
|
||||||
|
$this->setUpdateFlag(false);
|
||||||
|
|
||||||
// stale 가드는 spawn 자식이 받는 env APP_VERSION 을 먼저 읽는다 (config 캐시가 있으면
|
// stale 가드는 spawn 자식이 받는 env APP_VERSION 을 먼저 읽는다 (config 캐시가 있으면
|
||||||
// config('app.version') 은 캐시에 박힌 구버전이라 신뢰할 수 없다 — 7.0.9→7.0.10 실사례).
|
// config('app.version') 은 캐시에 박힌 구버전이라 신뢰할 수 없다 — 7.0.9→7.0.10 실사례).
|
||||||
// 각 테스트가 부모/자식 시나리오에 맞춰 env 를 직접 세우도록 원값을 보관하고 비운다.
|
// 각 테스트가 부모/자식 시나리오에 맞춰 env 를 직접 세우도록 원값을 보관하고 비운다.
|
||||||
@@ -63,9 +71,36 @@ class CoreUpdateCommandSpawnFailureTest extends TestCase
|
|||||||
|
|
||||||
$this->setEnvVersion($this->originalEnvVersion);
|
$this->setEnvVersion($this->originalEnvVersion);
|
||||||
|
|
||||||
|
if ($this->originalUpdateFlag === null) {
|
||||||
|
$this->setUpdateFlag(false);
|
||||||
|
} else {
|
||||||
|
$_ENV['G7_UPDATE_IN_PROGRESS'] = $this->originalUpdateFlag;
|
||||||
|
$_SERVER['G7_UPDATE_IN_PROGRESS'] = $this->originalUpdateFlag;
|
||||||
|
putenv('G7_UPDATE_IN_PROGRESS='.$this->originalUpdateFlag);
|
||||||
|
}
|
||||||
|
|
||||||
parent::tearDown();
|
parent::tearDown();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 업데이트 트리 플래그를 세 채널($_ENV/$_SERVER/putenv)에 세우거나 지웁니다.
|
||||||
|
*
|
||||||
|
* @param bool $enabled true 면 플래그 설정, false 면 세 채널 모두 제거
|
||||||
|
*/
|
||||||
|
private function setUpdateFlag(bool $enabled): void
|
||||||
|
{
|
||||||
|
if (! $enabled) {
|
||||||
|
unset($_ENV['G7_UPDATE_IN_PROGRESS'], $_SERVER['G7_UPDATE_IN_PROGRESS']);
|
||||||
|
putenv('G7_UPDATE_IN_PROGRESS');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$_ENV['G7_UPDATE_IN_PROGRESS'] = '1';
|
||||||
|
$_SERVER['G7_UPDATE_IN_PROGRESS'] = '1';
|
||||||
|
putenv('G7_UPDATE_IN_PROGRESS=1');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 프로세스 env 의 APP_VERSION 을 세우거나(문자열) 비운다(null).
|
* 프로세스 env 의 APP_VERSION 을 세우거나(문자열) 비운다(null).
|
||||||
*
|
*
|
||||||
@@ -436,6 +471,10 @@ PHP);
|
|||||||
public function run_upgrade_steps_spawn_자식은_config_캐시가_stale_해도_env_버전으로_가드를_통과한다(): void
|
public function run_upgrade_steps_spawn_자식은_config_캐시가_stale_해도_env_버전으로_가드를_통과한다(): void
|
||||||
{
|
{
|
||||||
// 자식이 받는 env 는 toVersion, 캐시로 부팅한 config 는 fromVersion.
|
// 자식이 받는 env 는 toVersion, 캐시로 부팅한 config 는 fromVersion.
|
||||||
|
// 실제 spawn 은 `APP_VERSION` 과 `G7_UPDATE_IN_PROGRESS=1` 을 **함께** 넘긴다
|
||||||
|
// (CoreUpdateCommand::spawnUpgradeStepsProcess 의 $env). env 우선 판독은 그 플래그가
|
||||||
|
// 그리는 업데이트 트리 안에서만 켜지므로, 자식 시뮬레이션은 둘 다 세워야 실제와 같다.
|
||||||
|
$this->setUpdateFlag(true);
|
||||||
$this->setEnvVersion('7.0.10');
|
$this->setEnvVersion('7.0.10');
|
||||||
config(['app.version' => '7.0.9']);
|
config(['app.version' => '7.0.9']);
|
||||||
config(['app.update.spawn_failure_mode' => 'abort']);
|
config(['app.update.spawn_failure_mode' => 'abort']);
|
||||||
|
|||||||
@@ -0,0 +1,393 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Console;
|
||||||
|
|
||||||
|
use App\Console\Commands\Core\CoreUpdateCommand;
|
||||||
|
use App\Exceptions\UpgradeHandoffException;
|
||||||
|
use Illuminate\Console\OutputStyle;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use Symfony\Component\Console\Input\ArrayInput;
|
||||||
|
use Symfony\Component\Console\Output\BufferedOutput;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [case:backend-31] stale 패키지 매니페스트로 인한 spawn 자식 부팅 실패 회귀 테스트 (dev-g7 #658).
|
||||||
|
*
|
||||||
|
* 회귀 시나리오 (sir.kr 제보, 7.0.9 → 7.0.10):
|
||||||
|
* 운영 사이트가 `composer install`(옵션 없음)로 깔린 dev 설치본이면
|
||||||
|
* `bootstrap/cache/packages.php` 에 require-dev 전이 의존성의 provider(`laravel/mcp` 의
|
||||||
|
* `McpServiceProvider` 등)가 등재된다. 코어 업데이트는 Step 6/8 에서 vendor 를 `--no-dev`
|
||||||
|
* 로 교체하지만 그 두 파일은 Step 11 까지 남고, Laravel 은 `packages.php` 가 **없을 때만**
|
||||||
|
* 다시 만든다. Step 10 spawn 자식은 그 목록을 읽어 새 vendor 에 없는 provider 를 `new`
|
||||||
|
* 하다 부팅 단계에서 죽고, 부모는 핸드오프(수동 재개 안내)로 중단한다.
|
||||||
|
*
|
||||||
|
* 세 계층을 각각 잠근다:
|
||||||
|
* - 계층 ① 부모가 spawn 직전에 두 파일을 비운다 (R1·R2)
|
||||||
|
* - 계층 ② 신버전 자식이 `G7_UPDATE_IN_PROGRESS=1` 이면 스스로 비우고 부팅한다 (R3)
|
||||||
|
* — 7.0.11 미만 부모는 비우지 않으므로 그 부모 아래에서는 이것이 유일한 방어다
|
||||||
|
* - 대조군: 업데이트 트리 밖 프로세스는 매니페스트를 건드리지 않는다 (R4)
|
||||||
|
*
|
||||||
|
* 격리: 실제 `bootstrap/cache/*` 를 백업하지 않고 `APP_PACKAGES_CACHE`/`APP_SERVICES_CACHE`
|
||||||
|
* 를 base_path 상대 POSIX 경로로 재지정한다(`Application::normalizeCachePath` 는 `/`·`\` 로
|
||||||
|
* 시작하지 않는 값을 basePath 상대로 해석하므로 Windows 절대 경로는 어긋난다). 자식도 같은
|
||||||
|
* env 를 물려받아 같은 파일을 가리킨다 — `CoreUpdateCommandSpawnFailureTest` 선례와 동일.
|
||||||
|
*/
|
||||||
|
class CoreUpdateCommandStalePackageManifestTest extends TestCase
|
||||||
|
{
|
||||||
|
/** @var array<string, string|null> 테스트가 덮어쓴 env 의 원값 (3채널 복원용) */
|
||||||
|
private array $originalEnv = [];
|
||||||
|
|
||||||
|
/** @var string base_path 상대 — packages.php 캐시 경로 */
|
||||||
|
private string $relativePackagesPath;
|
||||||
|
|
||||||
|
/** @var string base_path 상대 — services.php 캐시 경로 */
|
||||||
|
private string $relativeServicesPath;
|
||||||
|
|
||||||
|
private string $packagesPath;
|
||||||
|
|
||||||
|
private string $servicesPath;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$uniq = uniqid();
|
||||||
|
$this->relativePackagesPath = "storage/framework/testing/stale-manifest-{$uniq}/packages.php";
|
||||||
|
$this->relativeServicesPath = "storage/framework/testing/stale-manifest-{$uniq}/services.php";
|
||||||
|
$this->packagesPath = base_path($this->relativePackagesPath);
|
||||||
|
$this->servicesPath = base_path($this->relativeServicesPath);
|
||||||
|
|
||||||
|
File::ensureDirectoryExists(dirname($this->packagesPath));
|
||||||
|
|
||||||
|
$this->setEnv('APP_PACKAGES_CACHE', $this->relativePackagesPath);
|
||||||
|
$this->setEnv('APP_SERVICES_CACHE', $this->relativeServicesPath);
|
||||||
|
|
||||||
|
// 부모 PHPUnit 프로세스에 다른 테스트가 남긴 플래그가 있으면 대조군(R4)이 무의미해진다.
|
||||||
|
$this->setEnv('G7_UPDATE_IN_PROGRESS', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
foreach ($this->originalEnv as $key => $value) {
|
||||||
|
if ($value === null) {
|
||||||
|
unset($_ENV[$key], $_SERVER[$key]);
|
||||||
|
putenv($key);
|
||||||
|
} else {
|
||||||
|
$_ENV[$key] = $value;
|
||||||
|
$_SERVER[$key] = $value;
|
||||||
|
putenv($key.'='.$value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->originalEnv = [];
|
||||||
|
|
||||||
|
$dir = dirname($this->packagesPath);
|
||||||
|
if (File::isDirectory($dir)) {
|
||||||
|
File::deleteDirectory($dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계층 ①: 부모는 자식이 부팅에 실패하더라도 그 전에 두 매니페스트를 비운다.
|
||||||
|
*
|
||||||
|
* 자식을 확실히 실패시키려고 `php_binary` 를 존재하지 않는 경로로 둔다 — 정리는
|
||||||
|
* `proc_open` 앞에서 일어나므로 자식의 성패와 무관하게 관측된다.
|
||||||
|
*
|
||||||
|
* @effects spawnUpgradeStepsProcess_clears_package_manifests_before_proc_open
|
||||||
|
*/
|
||||||
|
#[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();
|
||||||
|
|
||||||
|
$this->assertSame($this->packagesPath, $this->app->getCachedPackagesPath(), '전제: APP_PACKAGES_CACHE 가 경로를 정한다');
|
||||||
|
$this->assertFileExists($this->packagesPath, '전제: 부모 시점에 packages.php 가 존재한다');
|
||||||
|
$this->assertFileExists($this->servicesPath, '전제: 부모 시점에 services.php 가 존재한다');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->invokeSpawn();
|
||||||
|
$this->fail('자식이 부팅할 수 없으므로 abort 모드에서 UpgradeHandoffException 이 발생해야 한다');
|
||||||
|
} catch (UpgradeHandoffException) {
|
||||||
|
// 기대된 경로 — 아래 단언이 본 검증이다.
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertFileDoesNotExist($this->packagesPath, 'spawn 전에 packages.php 를 비워야 자식이 새 vendor 기준으로 다시 만든다');
|
||||||
|
$this->assertFileDoesNotExist($this->servicesPath, 'spawn 전에 services.php 도 함께 비워야 한다');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계층 ①: stale dev 매니페스트가 있어도 자식은 정상 부팅해 스텝 0건을 통과한다.
|
||||||
|
*
|
||||||
|
* 계층 ①②가 모두 없으면 자식이 `Class ... not found` 로 죽어 abort 예외가 난다.
|
||||||
|
*
|
||||||
|
* @effects spawn_child_boots_with_regenerated_package_manifest_when_parent_left_stale_dev_manifest
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function stale_dev_매니페스트가_있어도_spawn_자식은_정상_부팅해_스텝_0건을_통과한다(): void
|
||||||
|
{
|
||||||
|
if (! function_exists('proc_open')) {
|
||||||
|
$this->markTestSkipped('proc_open 미지원 환경');
|
||||||
|
}
|
||||||
|
|
||||||
|
config(['app.update.spawn_failure_mode' => 'abort']);
|
||||||
|
|
||||||
|
$this->writeStaleManifests();
|
||||||
|
|
||||||
|
$result = $this->invokeSpawn();
|
||||||
|
|
||||||
|
$this->assertTrue($result, 'stale 매니페스트가 정리되면 자식은 정상 부팅해 스텝 0건으로 성공해야 한다');
|
||||||
|
$this->assertStringNotContainsString(
|
||||||
|
'NonExistentServiceProvider',
|
||||||
|
(string) @file_get_contents($this->packagesPath),
|
||||||
|
'재생성된 매니페스트에 존재하지 않는 provider 가 남아 있으면 안 된다'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계층 ②: 업데이트 플래그를 물려받은 자식은 부모가 비우지 않았어도 스스로 비우고 부팅한다.
|
||||||
|
*
|
||||||
|
* 7.0.11 미만 부모(이미 배포된 7.0.9·7.0.10)는 spawn 직전에 비우지 않으므로, 그 부모
|
||||||
|
* 아래에서 도는 신버전 자식에게는 이것이 유일한 방어다. 부모 역할은 `proc_open` 으로
|
||||||
|
* 직접 재현한다 — 정리 없이 stale 매니페스트를 남긴 채 자식을 띄운다.
|
||||||
|
*
|
||||||
|
* @effects bootstrap_app_unlinks_stale_package_manifest_when_update_flag_present
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function 업데이트_플래그가_있는_자식은_stale_매니페스트를_스스로_지우고_부팅한다(): void
|
||||||
|
{
|
||||||
|
if (! function_exists('proc_open')) {
|
||||||
|
$this->markTestSkipped('proc_open 미지원 환경');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->writeStaleManifests();
|
||||||
|
|
||||||
|
[$exitCode, $output] = $this->runArtisanChild(['G7_UPDATE_IN_PROGRESS' => '1']);
|
||||||
|
|
||||||
|
$this->assertSame(0, $exitCode, "업데이트 플래그가 있는 자식은 stale 매니페스트를 스스로 지우고 부팅해야 한다. 출력:\n{$output}");
|
||||||
|
$this->assertStringNotContainsString('NonExistentServiceProvider', $output, '자가 치유 후에는 없는 provider 를 참조하지 않는다');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대조군: 업데이트 트리 밖 프로세스(웹 요청·큐 워커·운영자 셸)는 매니페스트를 건드리지 않는다.
|
||||||
|
*
|
||||||
|
* 자가 치유가 트리 밖으로 새면 평상시 모든 부팅이 매니페스트를 지웠다 다시 만들게 된다.
|
||||||
|
* 이 테스트는 계층 ② 도입 전후로 항상 green 이어야 한다.
|
||||||
|
*
|
||||||
|
* @effects bootstrap_app_leaves_package_manifest_untouched_without_update_flag
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function 업데이트_플래그가_없는_프로세스는_매니페스트를_건드리지_않는다(): void
|
||||||
|
{
|
||||||
|
if (! function_exists('proc_open')) {
|
||||||
|
$this->markTestSkipped('proc_open 미지원 환경');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->writeStaleManifests();
|
||||||
|
$before = md5_file($this->packagesPath);
|
||||||
|
|
||||||
|
[$exitCode, $output] = $this->runArtisanChild(['G7_UPDATE_IN_PROGRESS' => null]);
|
||||||
|
|
||||||
|
$this->assertNotSame(0, $exitCode, '플래그 없는 자식은 stale 매니페스트 그대로 부팅해 실패해야 한다 (자가 치유가 트리 밖으로 새지 않았다는 증거)');
|
||||||
|
$this->assertStringContainsString('NonExistentServiceProvider', $output, '실패 원인이 stale provider 여야 한다');
|
||||||
|
$this->assertFileExists($this->packagesPath, '트리 밖 프로세스는 매니페스트를 지우지 않는다');
|
||||||
|
$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 매니페스트 두 개를 만든다.
|
||||||
|
*/
|
||||||
|
private function writeStaleManifests(): void
|
||||||
|
{
|
||||||
|
File::ensureDirectoryExists(dirname($this->packagesPath));
|
||||||
|
|
||||||
|
File::put($this->packagesPath, "<?php return ['tests/stale-fixture' => ['providers' => ['Tests\\\\Fixtures\\\\Stale\\\\NonExistentServiceProvider']]];\n");
|
||||||
|
File::put($this->servicesPath, "<?php return ['providers' => ['Tests\\\\Fixtures\\\\Stale\\\\NonExistentServiceProvider'], 'eager' => ['Tests\\\\Fixtures\\\\Stale\\\\NonExistentServiceProvider'], 'deferred' => [], 'when' => []];\n");
|
||||||
|
|
||||||
|
clearstatcache();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `spawnUpgradeStepsProcess` 를 리플렉션으로 호출합니다.
|
||||||
|
*
|
||||||
|
* @param \Closure|null $log 업그레이드 로그 클로저. 생략하면 버린다
|
||||||
|
* @return bool spawn 성공 여부
|
||||||
|
*/
|
||||||
|
private function invokeSpawn(?\Closure $log = null): bool
|
||||||
|
{
|
||||||
|
$command = app(CoreUpdateCommand::class);
|
||||||
|
|
||||||
|
$input = new ArrayInput([]);
|
||||||
|
$output = new BufferedOutput;
|
||||||
|
$style = new OutputStyle($input, $output);
|
||||||
|
|
||||||
|
$reflection = new \ReflectionClass($command);
|
||||||
|
$property = $reflection->getProperty('output');
|
||||||
|
$property->setAccessible(true);
|
||||||
|
$property->setValue($command, $style);
|
||||||
|
|
||||||
|
if ($reflection->hasProperty('input')) {
|
||||||
|
$inputProp = $reflection->getProperty('input');
|
||||||
|
$inputProp->setAccessible(true);
|
||||||
|
$inputProp->setValue($command, $input);
|
||||||
|
}
|
||||||
|
|
||||||
|
$method = new \ReflectionMethod(CoreUpdateCommand::class, 'spawnUpgradeStepsProcess');
|
||||||
|
$method->setAccessible(true);
|
||||||
|
|
||||||
|
return (bool) $method->invoke($command, '9.9.8', '9.9.9', true, $log ?? fn () => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `php artisan --version` 을 별도 프로세스로 실행합니다.
|
||||||
|
*
|
||||||
|
* 운영 코드(`spawnUpgradeStepsProcess`)와 동형으로 `getenv()` 기반 env 합집합을 넘긴다 —
|
||||||
|
* `variables_order` 에 `E` 가 없으면 `$_ENV` 만으로는 자식이 캐시 경로 재지정을 못 받는다.
|
||||||
|
*
|
||||||
|
* @param array<string, string|null> $overrides 덮어쓸 env (null 이면 자식 env 에서 제거)
|
||||||
|
* @return array{0: int, 1: string} 종료 코드와 stdout+stderr 합본
|
||||||
|
*/
|
||||||
|
private function runArtisanChild(array $overrides): array
|
||||||
|
{
|
||||||
|
$env = array_merge(getenv(), $_ENV);
|
||||||
|
foreach ($overrides as $key => $value) {
|
||||||
|
if ($value === null) {
|
||||||
|
unset($env[$key]);
|
||||||
|
} else {
|
||||||
|
$env[$key] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$commandLine = sprintf('%s %s --version', escapeshellarg(PHP_BINARY), escapeshellarg(base_path('artisan')));
|
||||||
|
|
||||||
|
$descriptors = [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
|
||||||
|
$process = proc_open($commandLine, $descriptors, $pipes, base_path(), $env);
|
||||||
|
|
||||||
|
if (! is_resource($process)) {
|
||||||
|
$this->fail('proc_open 자원 생성 실패');
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($pipes[0]);
|
||||||
|
$out = (string) stream_get_contents($pipes[1]);
|
||||||
|
$err = (string) stream_get_contents($pipes[2]);
|
||||||
|
fclose($pipes[1]);
|
||||||
|
fclose($pipes[2]);
|
||||||
|
|
||||||
|
return [proc_close($process), $out."\n".$err];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* env 를 세 채널($_ENV/$_SERVER/putenv)에 세우고 원값을 복원용으로 보관합니다.
|
||||||
|
*
|
||||||
|
* @param string $key 환경변수 이름
|
||||||
|
* @param string|null $value 세울 값. null 이면 제거
|
||||||
|
*/
|
||||||
|
private function setEnv(string $key, ?string $value): void
|
||||||
|
{
|
||||||
|
if (! array_key_exists($key, $this->originalEnv)) {
|
||||||
|
$current = $_ENV[$key] ?? $_SERVER[$key] ?? getenv($key);
|
||||||
|
$this->originalEnv[$key] = ($current === false || $current === null) ? null : (string) $current;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($value === null) {
|
||||||
|
unset($_ENV[$key], $_SERVER[$key]);
|
||||||
|
putenv($key);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$_ENV[$key] = $value;
|
||||||
|
$_SERVER[$key] = $value;
|
||||||
|
putenv($key.'='.$value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Installer;
|
||||||
|
|
||||||
|
use App\Support\ComposerInstallInfo;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인스톨러 vendor 재사용 경로의 dev 패키지 감지·경고·캐시 정리 회귀 테스트 (dev-g7 #658).
|
||||||
|
*
|
||||||
|
* 배경: `INSTALL.md`/`README.md` 가 인스톨러 **전에** `composer install`(옵션 없음)을 지시했고,
|
||||||
|
* 인스톨러는 `vendor/autoload.php` + `composer.lock` 이 있으면 composer 단계를 통째로 건너뛰며
|
||||||
|
* 그 vendor 를 아무 표시 없이 그대로 썼다. 그 결과 운영 사이트에 개발용 패키지가 남고,
|
||||||
|
* 이후 코어 업데이트가 vendor 를 `--no-dev` 로 교체할 때 이전 매니페스트에만 남은 provider 를
|
||||||
|
* 찾다 부팅이 깨졌다(sir.kr 제보, 7.0.9 → 7.0.10).
|
||||||
|
*
|
||||||
|
* `task-runner.php` 는 진입과 함께 SSE 를 실행하는 파일이라 테스트에서 include 할 수 없다.
|
||||||
|
* 그래서 순수 함수(`detectDevVendorInstall`)는 직접 호출해 검증하고, 분기의 배치·순서는
|
||||||
|
* 소스 문자열 계약으로 잠근다 — `BootstrapCacheCleanupTest` 와 같은 방식이다.
|
||||||
|
*/
|
||||||
|
class DevVendorDetectionTest extends TestCase
|
||||||
|
{
|
||||||
|
private string $testBase;
|
||||||
|
|
||||||
|
private string $vendorPath;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->testBase = storage_path('app/test-dev-vendor-'.uniqid());
|
||||||
|
$this->vendorPath = $this->testBase.'/vendor';
|
||||||
|
File::ensureDirectoryExists($this->vendorPath.'/composer');
|
||||||
|
|
||||||
|
require_once base_path('public/install/includes/vendor-bundle-installer.php');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
if (File::isDirectory($this->testBase)) {
|
||||||
|
File::deleteDirectory($this->testBase);
|
||||||
|
}
|
||||||
|
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* dev 설치본을 감지한다.
|
||||||
|
*
|
||||||
|
* @effects installer_detects_dev_vendor_from_installed_json
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function detect_dev_vendor_install_은_dev_설치본을_감지한다(): void
|
||||||
|
{
|
||||||
|
$this->writeInstalledJson(['packages' => [], 'dev' => true, 'dev-package-names' => ['laravel/boost', 'laravel/mcp']]);
|
||||||
|
|
||||||
|
$info = detectDevVendorInstall($this->testBase);
|
||||||
|
|
||||||
|
$this->assertTrue($info['dev']);
|
||||||
|
$this->assertSame(['laravel/boost', 'laravel/mcp'], $info['packages']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `--no-dev` 설치본은 경고 대상이 아니다.
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function detect_dev_vendor_install_은_운영용_설치본을_경고하지_않는다(): void
|
||||||
|
{
|
||||||
|
$this->writeInstalledJson(['packages' => [], 'dev' => false, 'dev-package-names' => []]);
|
||||||
|
|
||||||
|
$info = detectDevVendorInstall($this->testBase);
|
||||||
|
|
||||||
|
$this->assertFalse($info['dev']);
|
||||||
|
$this->assertSame([], $info['packages']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* installed.json 이 없으면 판정 불가(null) — 경고도 차단도 하지 않는다.
|
||||||
|
*
|
||||||
|
* @effects installer_treats_missing_installed_json_as_unknown
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function detect_dev_vendor_install_은_installed_json_부재를_판정_불가로_다룬다(): void
|
||||||
|
{
|
||||||
|
$info = detectDevVendorInstall($this->testBase);
|
||||||
|
|
||||||
|
$this->assertNull($info['dev']);
|
||||||
|
$this->assertSame([], $info['packages']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인스톨러 shim 과 코어 판정기가 같은 답을 낸다 — 갈라지면 화면과 로그가 어긋난다.
|
||||||
|
*
|
||||||
|
* @effects installer_shim_matches_core_composer_install_info
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function 인스톨러_shim_과_코어_판정기의_결과가_같다(): void
|
||||||
|
{
|
||||||
|
foreach ([
|
||||||
|
['packages' => [], 'dev' => true, 'dev-package-names' => ['laravel/mcp']],
|
||||||
|
['packages' => [], 'dev' => false, 'dev-package-names' => []],
|
||||||
|
] as $payload) {
|
||||||
|
$this->writeInstalledJson($payload);
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
ComposerInstallInfo::inspect($this->vendorPath),
|
||||||
|
detectDevVendorInstall($this->testBase),
|
||||||
|
'shim 은 코어 판정기에 위임하기만 해야 한다'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 재사용 분기가 dev 를 감지해 경고하되 설치를 계속한다 (소스 계약).
|
||||||
|
*
|
||||||
|
* 감지·경고·"설치 계속" 셋이 한 분기 안에 있어야 한다 — 차단으로 바뀌면 PO 결정(D1) 위반이다.
|
||||||
|
*
|
||||||
|
* @effects installer_warns_and_keeps_dev_vendor
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function 재사용_분기가_dev_를_감지해_경고하고_설치를_계속한다(): void
|
||||||
|
{
|
||||||
|
$source = File::get(base_path('public/install/includes/task-runner.php'));
|
||||||
|
|
||||||
|
$this->assertStringContainsString('detectDevVendorInstall(BASE_PATH)', $source, '재사용 분기가 dev 판정을 호출해야 한다');
|
||||||
|
$this->assertStringContainsString('log_composer_dev_packages_detected', $source, '감지 사실을 설치 로그에 남겨야 한다');
|
||||||
|
$this->assertStringContainsString('warning_composer_dev_packages_kept', $source, '정리 명령 안내 경고를 남겨야 한다');
|
||||||
|
|
||||||
|
$branch = $this->reuseBranchSource($source);
|
||||||
|
$this->assertStringContainsString("return ['success' => true]", $branch, '경고는 설치를 차단하지 않는다 (D1)');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 재사용 경로도 컴파일 캐시를 정리하고, 그 정리가 완료 표시보다 앞선다 (소스 계약).
|
||||||
|
*
|
||||||
|
* 종전에는 정리 없이 return 해, `key_generate` 가 이미 완료된 재개 설치에서는 어디서도
|
||||||
|
* 정리되지 않았다.
|
||||||
|
*
|
||||||
|
* @effects installer_clears_compiled_cache_on_reuse_path
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function 재사용_분기가_컴파일_캐시를_정리하고_완료_표시보다_앞선다(): void
|
||||||
|
{
|
||||||
|
$branch = $this->reuseBranchSource(File::get(base_path('public/install/includes/task-runner.php')));
|
||||||
|
|
||||||
|
$clearPos = strpos($branch, 'clearLaravelCompiledCache(BASE_PATH)');
|
||||||
|
$completePos = strpos($branch, "markTaskCompleted('composer_install')");
|
||||||
|
|
||||||
|
$this->assertNotFalse($clearPos, '재사용 분기가 컴파일 캐시를 정리해야 한다');
|
||||||
|
$this->assertNotFalse($completePos, '전제: 재사용 분기가 작업 완료를 표시한다');
|
||||||
|
$this->assertLessThan($completePos, $clearPos, '캐시 정리는 완료 표시보다 앞서야 한다');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 신규 언어 키가 ko/en 양쪽에 있고 치환 자리가 보존되어 있다.
|
||||||
|
*
|
||||||
|
* 한쪽만 있으면 그 로케일에서 키 문자열이 그대로 화면에 노출되고, 치환 자리가 빠지면
|
||||||
|
* 건수·패키지 목록이 사라진 문장이 나간다 — 둘 다 오류 없이 통과한다.
|
||||||
|
*
|
||||||
|
* @effects installer_lang_keys_exist_in_both_locales
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function 신규_언어_키가_ko_와_en_양쪽에_있고_치환_자리를_보존한다(): void
|
||||||
|
{
|
||||||
|
$ko = require base_path('public/install/lang/ko.php');
|
||||||
|
$en = require base_path('public/install/lang/en.php');
|
||||||
|
|
||||||
|
$keys = [
|
||||||
|
'vendor_dev_packages',
|
||||||
|
'vendor_dev_packages_none_short',
|
||||||
|
'vendor_dev_packages_none',
|
||||||
|
'vendor_dev_packages_detected_short',
|
||||||
|
'vendor_dev_packages_detected_warning',
|
||||||
|
'vendor_dev_packages_unknown',
|
||||||
|
'vendor_dev_packages_no_vendor',
|
||||||
|
'log_composer_dev_packages_detected',
|
||||||
|
'warning_composer_dev_packages_kept',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($keys as $key) {
|
||||||
|
$this->assertArrayHasKey($key, $ko, "ko 에 {$key} 가 있어야 한다");
|
||||||
|
$this->assertArrayHasKey($key, $en, "en 에 {$key} 가 있어야 한다");
|
||||||
|
$this->assertNotSame('', trim((string) $ko[$key]), "ko 의 {$key} 는 비어 있으면 안 된다");
|
||||||
|
$this->assertNotSame('', trim((string) $en[$key]), "en 의 {$key} 는 비어 있으면 안 된다");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['vendor_dev_packages_detected_short', 'vendor_dev_packages_detected_warning'] as $key) {
|
||||||
|
$this->assertStringContainsString(':count', $ko[$key], "ko 의 {$key} 는 :count 치환 자리를 가져야 한다");
|
||||||
|
$this->assertStringContainsString(':count', $en[$key], "en 의 {$key} 는 :count 치환 자리를 가져야 한다");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ([':count', ':packages'] as $placeholder) {
|
||||||
|
$this->assertStringContainsString($placeholder, $ko['log_composer_dev_packages_detected']);
|
||||||
|
$this->assertStringContainsString($placeholder, $en['log_composer_dev_packages_detected']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `installComposerDependenciesSSE()` 안의 vendor 재사용 분기 본문을 잘라냅니다.
|
||||||
|
*
|
||||||
|
* @param string $source task-runner.php 전문
|
||||||
|
* @return string 재사용 분기 블록
|
||||||
|
*/
|
||||||
|
private function reuseBranchSource(string $source): string
|
||||||
|
{
|
||||||
|
$start = strpos($source, 'if ($vendorExists && $lockExists) {');
|
||||||
|
$this->assertNotFalse($start, '전제: vendor 재사용 분기가 존재한다');
|
||||||
|
|
||||||
|
$end = strpos($source, 'if ($vendorExists && ! $lockExists) {', $start);
|
||||||
|
$this->assertNotFalse($end, '전제: 재사용 분기 다음에 lock 부재 분기가 온다');
|
||||||
|
|
||||||
|
return substr($source, $start, $end - $start);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인스톨러·코어가 다루는 composer install 명령은 전부 `--no-dev` 를 갖는다.
|
||||||
|
*
|
||||||
|
* 실행되는 명령만이 아니라 **실패 시 운영자에게 안내하는 명령**도 대상이다. 안내를 옵션 없이
|
||||||
|
* 따라 하면 개발용(require-dev) 패키지가 섞인 vendor 가 만들어지고, 그 상태는 이후 코어
|
||||||
|
* 업데이트가 vendor 를 교체할 때 이전 패키지 매니페스트와 어긋나 부팅을 깨뜨린다 — 이 저장소가
|
||||||
|
* 막으려는 바로 그 상태다. 실제로 `rollback-functions.php` 의 안내가 그렇게 어긋나 있었다.
|
||||||
|
*
|
||||||
|
* 모집단은 소스에서 정규식으로 파생한다 — **파일 목록도 파생 대상이다.** 검사할 파일을 손으로
|
||||||
|
* 열거하면 네 번째 파일이 안내 명령을 갖게 돼도 케이스 없이 통과한다(그 형태가 이 결함을
|
||||||
|
* 처음 놓친 원인이었다: 리터럴 `"composer install"` 만 보는 파생식이 `{$composer} install` 을
|
||||||
|
* 못 봤다). 그래서 디렉토리 트리를 훑어 매칭되는 파일 자체를 찾는다. 파생이 비면 그 자체로
|
||||||
|
* 실패시켜 "검사했으나 0건" 과 "보지 않음" 을 구분한다.
|
||||||
|
*
|
||||||
|
* @effects installer_composer_commands_all_carry_no_dev
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function 안내하는_것까지_포함해_모든_composer_install_명령이_no_dev_를_갖는다(): void
|
||||||
|
{
|
||||||
|
$found = [];
|
||||||
|
|
||||||
|
foreach ($this->phpSourcesUnder(['public/install', 'app', 'upgrades', 'bootstrap']) as $relative) {
|
||||||
|
$source = File::get(base_path($relative));
|
||||||
|
|
||||||
|
preg_match_all(
|
||||||
|
'/\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?[^\n]{0,15}install [^\n\'"]*/',
|
||||||
|
$source,
|
||||||
|
$matches,
|
||||||
|
PREG_SET_ORDER
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($matches as $match) {
|
||||||
|
// 변수명에 composer 가 들어간 것만 — 정규식 안에서 걸러내면 첫 문자 소비 때문에
|
||||||
|
// `$composer` 자신이 매칭에서 빠진다.
|
||||||
|
if (stripos($match[1], 'composer') === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$found[] = [$relative, $match[0]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertNotEmpty(
|
||||||
|
$found,
|
||||||
|
'composer install 명령을 하나도 찾지 못했다 — 파생식이 소스 형태를 따라가지 못하면 이 검사는 공허하게 통과한다'
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($found as [$relative, $command]) {
|
||||||
|
$this->assertStringContainsString(
|
||||||
|
'--no-dev',
|
||||||
|
$command,
|
||||||
|
"{$relative} 의 composer install 명령에 --no-dev 가 있어야 한다: {$command}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 주어진 디렉토리 트리 아래의 PHP 소스 파일 경로를 저장소에서 파생합니다.
|
||||||
|
*
|
||||||
|
* 검사 대상 파일을 손으로 열거하지 않기 위한 것이다 — 새 파일이 composer 명령을 갖게 되면
|
||||||
|
* 목록을 고치지 않아도 모집단에 들어온다. `vendor/` 는 제3자 코드라 제외한다.
|
||||||
|
*
|
||||||
|
* @param array<int, string> $roots base_path 기준 상대 디렉토리
|
||||||
|
* @return array<int, string> base_path 기준 상대 파일 경로 (POSIX 구분자)
|
||||||
|
*/
|
||||||
|
private function phpSourcesUnder(array $roots): array
|
||||||
|
{
|
||||||
|
$files = [];
|
||||||
|
|
||||||
|
foreach ($roots as $root) {
|
||||||
|
$absoluteRoot = base_path($root);
|
||||||
|
if (! is_dir($absoluteRoot)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$iterator = new \RecursiveIteratorIterator(
|
||||||
|
new \RecursiveDirectoryIterator($absoluteRoot, \FilesystemIterator::SKIP_DOTS)
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($iterator as $file) {
|
||||||
|
if (! $file->isFile() || strtolower($file->getExtension()) !== 'php') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$relative = str_replace('\\', '/', substr($file->getPathname(), strlen(base_path()) + 1));
|
||||||
|
|
||||||
|
if (str_contains($relative, '/vendor/')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$files[] = $relative;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort($files);
|
||||||
|
|
||||||
|
return $files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* installed.json 픽스처를 씁니다.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $payload JSON 으로 직렬화할 내용
|
||||||
|
*/
|
||||||
|
private function writeInstalledJson(array $payload): void
|
||||||
|
{
|
||||||
|
File::put($this->vendorPath.'/'.ComposerInstallInfo::INSTALLED_JSON, json_encode($payload));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ use App\Support\EnvPriority;
|
|||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `.env` 키 단위 우선 규약의 계약 테스트.
|
* [case:backend-26] `.env` 키 단위 우선 규약의 계약 테스트.
|
||||||
*
|
*
|
||||||
* 이 결함군은 오류를 남기지 않는다 — 매핑이 뒤처지거나 배선이 빠지면 잠금이 조용히
|
* 이 결함군은 오류를 남기지 않는다 — 매핑이 뒤처지거나 배선이 빠지면 잠금이 조용히
|
||||||
* 미발동하고, 그 상태는 "그 키가 `.env` 에 없다"와 화면상 구분되지 않는다. 그래서
|
* 미발동하고, 그 상태는 "그 키가 `.env` 에 없다"와 화면상 구분되지 않는다. 그래서
|
||||||
|
|||||||
@@ -3,24 +3,45 @@
|
|||||||
namespace Tests\Unit\Extension;
|
namespace Tests\Unit\Extension;
|
||||||
|
|
||||||
use App\Extension\CoreVersionChecker;
|
use App\Extension\CoreVersionChecker;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CoreVersionChecker::getCoreVersion() env 우선 읽기 회귀 테스트.
|
* CoreVersionChecker::getCoreVersion() 의 env 우선 판독 **범위** 회귀 테스트.
|
||||||
*
|
*
|
||||||
* 코어 업그레이드 중 spawn 프로세스(proc_open)가 `APP_VERSION={toVersion}` env 를 전달하지만
|
* 두 방향의 회귀를 함께 잠근다.
|
||||||
* `bootstrap/cache/config.php` 가 존재하면 `LoadConfiguration` 이 캐시된 리터럴을 사용해
|
*
|
||||||
* env 오버라이드가 반영되지 않는 회귀가 있었다. 본 테스트는 `getCoreVersion()` 이
|
* 1. 업데이트 트리 **안**: spawn 프로세스(proc_open)가 `APP_VERSION={toVersion}` env 를 전달하지만
|
||||||
* env 를 우선 읽어 config cache 유무와 무관하게 spawn 이 전달한 버전을 신뢰함을 검증한다.
|
* `bootstrap/cache/config.php` 가 있으면 `LoadConfiguration` 이 캐시된 리터럴을 써서 env
|
||||||
|
* 오버라이드가 반영되지 않는다. 여기서는 env 가 우선해야 확장이 자동 비활성화되지 않는다.
|
||||||
|
* 2. 업데이트 트리 **밖**: `php artisan serve` · 큐 워커처럼 오래 사는 프로세스는 기동 시점의
|
||||||
|
* `APP_VERSION` 을 물고 있다. 업데이트가 끝나도 그 프로세스만 옛 버전으로 판정해, 새 코어를
|
||||||
|
* 요구하는 확장을 `incompatible_core` 로 꺼 버린다(관리자 템플릿이 꺼지면 복구 UI 도 사라진다 —
|
||||||
|
* 2026-09-07 실측). 여기서는 config 가 유일한 근거여야 한다.
|
||||||
*/
|
*/
|
||||||
class CoreVersionCheckerEnvPriorityTest extends TestCase
|
class CoreVersionCheckerEnvPriorityTest extends TestCase
|
||||||
{
|
{
|
||||||
private ?string $originalEnvVersion = null;
|
private ?string $originalEnvVersion = null;
|
||||||
|
|
||||||
|
private ?string $originalUpdateFlag = null;
|
||||||
|
|
||||||
|
/** @var array<int, string> */
|
||||||
|
private array $originalArgv = [];
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
parent::setUp();
|
parent::setUp();
|
||||||
|
|
||||||
$this->originalEnvVersion = $_ENV['APP_VERSION'] ?? null;
|
$this->originalEnvVersion = $_ENV['APP_VERSION'] ?? null;
|
||||||
|
|
||||||
|
$flag = $_ENV['G7_UPDATE_IN_PROGRESS'] ?? $_SERVER['G7_UPDATE_IN_PROGRESS'] ?? getenv('G7_UPDATE_IN_PROGRESS');
|
||||||
|
$this->originalUpdateFlag = ($flag === false || $flag === null) ? null : (string) $flag;
|
||||||
|
|
||||||
|
$this->originalArgv = $_SERVER['argv'] ?? [];
|
||||||
|
|
||||||
|
// 각 테스트가 트리 안/밖을 명시적으로 세우도록 기본은 "트리 밖" 으로 둔다.
|
||||||
|
$this->setUpdateFlag(false);
|
||||||
|
$_SERVER['argv'] = ['artisan', 'inspire'];
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function tearDown(): void
|
protected function tearDown(): void
|
||||||
@@ -33,23 +54,48 @@ class CoreVersionCheckerEnvPriorityTest extends TestCase
|
|||||||
} else {
|
} else {
|
||||||
putenv('APP_VERSION');
|
putenv('APP_VERSION');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($this->originalUpdateFlag === null) {
|
||||||
|
$this->setUpdateFlag(false);
|
||||||
|
} else {
|
||||||
|
$_ENV['G7_UPDATE_IN_PROGRESS'] = $this->originalUpdateFlag;
|
||||||
|
$_SERVER['G7_UPDATE_IN_PROGRESS'] = $this->originalUpdateFlag;
|
||||||
|
putenv('G7_UPDATE_IN_PROGRESS='.$this->originalUpdateFlag);
|
||||||
|
}
|
||||||
|
|
||||||
|
$_SERVER['argv'] = $this->originalArgv;
|
||||||
|
|
||||||
parent::tearDown();
|
parent::tearDown();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 업데이트 트리 안에서는 env APP_VERSION 이 config 보다 우선한다.
|
||||||
|
*
|
||||||
|
* @effects getCoreVersion_prefers_env_APP_VERSION_only_inside_update_tree
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
public function test_env_app_version_overrides_config(): void
|
public function test_env_app_version_overrides_config(): void
|
||||||
{
|
{
|
||||||
|
$this->setUpdateFlag(true);
|
||||||
config()->set('app.version', '7.0.0-beta.1'); // 캐시된 stale 값 시뮬레이션
|
config()->set('app.version', '7.0.0-beta.1'); // 캐시된 stale 값 시뮬레이션
|
||||||
$_ENV['APP_VERSION'] = '7.0.0-beta.2'; // spawn 이 전달한 toVersion
|
$_ENV['APP_VERSION'] = '7.0.0-beta.2'; // spawn 이 전달한 toVersion
|
||||||
|
|
||||||
$this->assertSame(
|
$this->assertSame(
|
||||||
'7.0.0-beta.2',
|
'7.0.0-beta.2',
|
||||||
CoreVersionChecker::getCoreVersion(),
|
CoreVersionChecker::getCoreVersion(),
|
||||||
'env APP_VERSION 이 config 보다 우선해야 합니다.'
|
'업데이트 트리 안에서는 env APP_VERSION 이 config 보다 우선해야 합니다.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 업데이트 트리 안이어도 env 가 없으면 config 로 내려간다.
|
||||||
|
*
|
||||||
|
* @effects getCoreVersion_falls_back_to_config_when_env_absent_inside_update_tree
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
public function test_falls_back_to_config_when_env_missing(): void
|
public function test_falls_back_to_config_when_env_missing(): void
|
||||||
{
|
{
|
||||||
|
$this->setUpdateFlag(true);
|
||||||
unset($_ENV['APP_VERSION'], $_SERVER['APP_VERSION']);
|
unset($_ENV['APP_VERSION'], $_SERVER['APP_VERSION']);
|
||||||
putenv('APP_VERSION');
|
putenv('APP_VERSION');
|
||||||
config()->set('app.version', '7.0.0-beta.1');
|
config()->set('app.version', '7.0.0-beta.1');
|
||||||
@@ -61,9 +107,14 @@ class CoreVersionCheckerEnvPriorityTest extends TestCase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* spawn 자식 시나리오: 캐시된 config 는 fromVersion, env 는 toVersion → 확장이 살아남는다.
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
public function test_spawn_scenario_cached_config_with_env_override(): void
|
public function test_spawn_scenario_cached_config_with_env_override(): void
|
||||||
{
|
{
|
||||||
// spawn 환경 시뮬레이션: config 는 fromVersion 으로 캐시, env 는 toVersion
|
// spawn 환경 시뮬레이션: config 는 fromVersion 으로 캐시, env 는 toVersion
|
||||||
|
$this->setUpdateFlag(true);
|
||||||
config()->set('app.version', '7.0.0-beta.1');
|
config()->set('app.version', '7.0.0-beta.1');
|
||||||
$_ENV['APP_VERSION'] = '7.0.0-beta.2';
|
$_ENV['APP_VERSION'] = '7.0.0-beta.2';
|
||||||
|
|
||||||
@@ -73,4 +124,72 @@ class CoreVersionCheckerEnvPriorityTest extends TestCase
|
|||||||
'spawn 에서 env 로 전달된 toVersion 기준으로 호환성이 평가되어 확장이 자동 비활성화되지 않아야 합니다.'
|
'spawn 에서 env 로 전달된 toVersion 기준으로 호환성이 평가되어 확장이 자동 비활성화되지 않아야 합니다.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 업데이트 트리 **밖**에서는 프로세스 env 의 옛 APP_VERSION 을 무시하고 config 를 쓴다.
|
||||||
|
*
|
||||||
|
* 업데이트 전에 띄운 `php artisan serve` 가 `7.0.9` 를 물고 있는 상황을 시뮬레이션한다.
|
||||||
|
* env 를 그대로 믿으면 `>=7.0.10` 을 요구하는 관리자 템플릿이 비활성화되어 화면이 통째로
|
||||||
|
* 사라진다 — 그 상태에서는 복구 UI 에도 도달할 수 없다.
|
||||||
|
*
|
||||||
|
* @effects getCoreVersion_ignores_env_APP_VERSION_outside_update_tree
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function test_env_app_version_is_ignored_outside_update_tree(): void
|
||||||
|
{
|
||||||
|
$this->setUpdateFlag(false);
|
||||||
|
config()->set('app.version', '7.0.10'); // 업데이트가 끝나 .env·config 는 새 버전
|
||||||
|
$_ENV['APP_VERSION'] = '7.0.9'; // 기동 시점 env 를 물고 있는 상주 프로세스
|
||||||
|
$_SERVER['APP_VERSION'] = '7.0.9';
|
||||||
|
putenv('APP_VERSION=7.0.9');
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
'7.0.10',
|
||||||
|
CoreVersionChecker::getCoreVersion(),
|
||||||
|
'업데이트 트리 밖에서는 config 버전이 근거여야 합니다.'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertTrue(
|
||||||
|
CoreVersionChecker::isCompatible('>=7.0.10'),
|
||||||
|
'상주 프로세스의 옛 env 때문에 새 코어를 요구하는 확장이 비활성화되면 안 됩니다.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* env 플래그가 전파되지 않아도 argv 가 업데이트 커맨드면 env 우선이 켜진다.
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function test_argv_update_command_enables_env_priority(): void
|
||||||
|
{
|
||||||
|
$this->setUpdateFlag(false);
|
||||||
|
$_SERVER['argv'] = ['artisan', 'core:execute-upgrade-steps', '--from=7.0.9', '--to=7.0.10'];
|
||||||
|
|
||||||
|
config()->set('app.version', '7.0.9');
|
||||||
|
$_ENV['APP_VERSION'] = '7.0.10';
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
'7.0.10',
|
||||||
|
CoreVersionChecker::getCoreVersion(),
|
||||||
|
'argv 보조 판정으로도 업데이트 트리로 인식해 env 를 우선해야 합니다.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 업데이트 트리 플래그를 세 채널($_ENV/$_SERVER/putenv)에 세우거나 지웁니다.
|
||||||
|
*
|
||||||
|
* @param bool $enabled true 면 플래그 설정, false 면 세 채널 모두 제거
|
||||||
|
*/
|
||||||
|
private function setUpdateFlag(bool $enabled): void
|
||||||
|
{
|
||||||
|
if (! $enabled) {
|
||||||
|
unset($_ENV['G7_UPDATE_IN_PROGRESS'], $_SERVER['G7_UPDATE_IN_PROGRESS']);
|
||||||
|
putenv('G7_UPDATE_IN_PROGRESS');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$_ENV['G7_UPDATE_IN_PROGRESS'] = '1';
|
||||||
|
$_SERVER['G7_UPDATE_IN_PROGRESS'] = '1';
|
||||||
|
putenv('G7_UPDATE_IN_PROGRESS=1');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use PHPUnit\Framework\Attributes\Test;
|
|||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 템플릿 업데이트 성공 cleanup 의 config 캐시 무효화 구조 테스트 (#588, 공개 #119)
|
* [case:cache-17] 템플릿 업데이트 성공 cleanup 의 config 캐시 무효화 구조 테스트 (#588, 공개 #119)
|
||||||
*
|
*
|
||||||
* `template.config.{identifier}` 는 버전 접미사 없는 고정 키라 캐시 버전 bump 로
|
* `template.config.{identifier}` 는 버전 접미사 없는 고정 키라 캐시 버전 bump 로
|
||||||
* 무효화되지 않는다. updateTemplate() 성공 cleanup 이 clearTemplateCache() 를
|
* 무효화되지 않는다. updateTemplate() 성공 cleanup 이 clearTemplateCache() 를
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use Illuminate\Support\Facades\File;
|
|||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SettingsMigrator owner 상속 회귀 테스트.
|
* [case:backend-28] SettingsMigrator owner 상속 회귀 테스트.
|
||||||
*
|
*
|
||||||
* sudo update 흐름에서 모듈/플러그인 upgrade step 이 root 로 실행될 때
|
* sudo update 흐름에서 모듈/플러그인 upgrade step 이 root 로 실행될 때
|
||||||
* `SettingsMigrator::writeJsonFile` 가 만드는 *.json 파일이 root 소유로 영구 잔존하는
|
* `SettingsMigrator::writeJsonFile` 가 만드는 *.json 파일이 root 소유로 영구 잔존하는
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Installer;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\Attributes\PreserveGlobalState;
|
||||||
|
use PHPUnit\Framework\Attributes\RunClassInSeparateProcess;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설치 2단계(설치 환경 확인)의 Composer 의존성 구성 카드 회귀 테스트 (dev-g7 #658).
|
||||||
|
*
|
||||||
|
* 이 항목은 **선택 항목**이다 — 개발용 패키지가 섞여 있어도 설치는 진행되어야 한다(PO 결정 D1).
|
||||||
|
* 게이트로 승격되면 이미 dev vendor 로 준비해 둔 운영자가 설치를 시작조차 못 한다.
|
||||||
|
*
|
||||||
|
* `check-configuration.php` 는 `CHECK_CONFIGURATION_LIBRARY` 모드로 로드해 요청 처리를 막고,
|
||||||
|
* 판정 메서드만 리플렉션으로 호출한다 — `RequirementsResponseUtf8Test` 와 같은 방식.
|
||||||
|
*/
|
||||||
|
#[RunClassInSeparateProcess]
|
||||||
|
#[PreserveGlobalState(false)]
|
||||||
|
class RequirementsVendorDevPackagesTest extends TestCase
|
||||||
|
{
|
||||||
|
private static string $sharedBase = '';
|
||||||
|
|
||||||
|
private static string $skipReason = '';
|
||||||
|
|
||||||
|
public static function setUpBeforeClass(): void
|
||||||
|
{
|
||||||
|
parent::setUpBeforeClass();
|
||||||
|
|
||||||
|
$tempPrefix = realpath(sys_get_temp_dir()) ?: sys_get_temp_dir();
|
||||||
|
if (defined('BASE_PATH')) {
|
||||||
|
$resolved = realpath((string) BASE_PATH) ?: (string) BASE_PATH;
|
||||||
|
if (strpos($resolved, $tempPrefix) !== 0) {
|
||||||
|
self::$skipReason = 'BASE_PATH ('.$resolved.') 가 시스템 temp 하위가 아님 — '.
|
||||||
|
'격리 실행 필요: php vendor/bin/phpunit --filter=RequirementsVendorDevPackagesTest';
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self::$sharedBase = (string) BASE_PATH;
|
||||||
|
} else {
|
||||||
|
self::$sharedBase = sys_get_temp_dir().'/g7-installer-vendor-dev-'.bin2hex(random_bytes(4));
|
||||||
|
define('BASE_PATH', self::$sharedBase);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['/storage/logs', '/storage/app', '/bootstrap/cache'] as $dir) {
|
||||||
|
if (! is_dir(self::$sharedBase.$dir)) {
|
||||||
|
mkdir(self::$sharedBase.$dir, 0755, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! isset($_SERVER['SCRIPT_NAME'])) {
|
||||||
|
$_SERVER['SCRIPT_NAME'] = '/install/index.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 라이브러리 모드 — 파일 로드만으로 요청을 처리하지 않도록 한다.
|
||||||
|
if (! defined('CHECK_CONFIGURATION_LIBRARY')) {
|
||||||
|
define('CHECK_CONFIGURATION_LIBRARY', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once dirname(__DIR__, 3).'/public/install/includes/config.php';
|
||||||
|
require_once dirname(__DIR__, 3).'/public/install/includes/functions.php';
|
||||||
|
require_once dirname(__DIR__, 3).'/public/install/api/check-configuration.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
if (self::$skipReason !== '') {
|
||||||
|
$this->markTestSkipped(self::$skipReason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* vendor 가 없는 신규 설치: 판정 불가로 두고, 마법사가 자동 설치한다는 안내만 낸다.
|
||||||
|
*
|
||||||
|
* @effects installer_env_check_shows_dev_vendor_card_without_gating
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function vendor_가_없으면_자동_설치_안내로_보고한다(): void
|
||||||
|
{
|
||||||
|
$result = $this->invokeCheck();
|
||||||
|
|
||||||
|
$this->assertFalse($result['required'], 'Composer 의존성 구성은 선택 항목이다');
|
||||||
|
$this->assertFalse($result['vendor_exists']);
|
||||||
|
$this->assertNull($result['dev'], 'vendor 가 없으면 판정 불가다');
|
||||||
|
$this->assertSame([], $result['packages']);
|
||||||
|
$this->assertNotSame('', trim($result['message']));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* dev vendor 를 감지하면 목록과 함께 보고하되 `required` 는 그대로 false 다.
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function dev_vendor_를_감지해도_필수_항목으로_승격되지_않는다(): void
|
||||||
|
{
|
||||||
|
$this->writeVendor(['packages' => [], 'dev' => true, 'dev-package-names' => ['laravel/boost', 'laravel/mcp']]);
|
||||||
|
|
||||||
|
$result = $this->invokeCheck();
|
||||||
|
|
||||||
|
$this->assertFalse($result['required'], 'dev 감지는 설치를 차단하지 않는다 (D1)');
|
||||||
|
$this->assertTrue($result['vendor_exists']);
|
||||||
|
$this->assertTrue($result['dev']);
|
||||||
|
$this->assertSame(['laravel/boost', 'laravel/mcp'], $result['packages']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `--no-dev` vendor 는 통과로 보고한다.
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function 운영용_vendor_는_통과로_보고한다(): void
|
||||||
|
{
|
||||||
|
$this->writeVendor(['packages' => [], 'dev' => false, 'dev-package-names' => []]);
|
||||||
|
|
||||||
|
$result = $this->invokeCheck();
|
||||||
|
|
||||||
|
$this->assertFalse($result['required']);
|
||||||
|
$this->assertTrue($result['vendor_exists']);
|
||||||
|
$this->assertFalse($result['dev']);
|
||||||
|
$this->assertSame([], $result['packages']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게이트(`isAllRequiredPassed`)가 이 항목을 보지 않는다 — 소스 계약.
|
||||||
|
*
|
||||||
|
* 반환값 검사만으로는 부족하다: 게이트가 나중에 이 키를 참조하도록 바뀌어도
|
||||||
|
* `required=false` 자체는 그대로라 행위 테스트가 통과한다.
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function 필수_통과_판정이_이_항목을_참조하지_않는다(): void
|
||||||
|
{
|
||||||
|
$source = (string) file_get_contents(dirname(__DIR__, 3).'/public/install/api/check-configuration.php');
|
||||||
|
|
||||||
|
$start = strpos($source, 'private function isAllRequiredPassed(');
|
||||||
|
$this->assertNotFalse($start, '전제: 게이트 메서드가 존재한다');
|
||||||
|
|
||||||
|
$end = strpos($source, "\n }", $start);
|
||||||
|
$this->assertNotFalse($end, '전제: 게이트 메서드 본문을 잘라낼 수 있다');
|
||||||
|
|
||||||
|
$body = substr($source, $start, $end - $start);
|
||||||
|
|
||||||
|
$this->assertStringNotContainsString(
|
||||||
|
'vendor_dev_packages',
|
||||||
|
$body,
|
||||||
|
'선택 항목이 필수 통과 판정에 들어가면 dev vendor 로 준비한 설치가 시작조차 되지 않는다'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 화면 카드가 존재하고 실패 목록에 넣지 않는다 — 소스 계약.
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function 설치_화면_카드가_실패_목록에_넣지_않는다(): void
|
||||||
|
{
|
||||||
|
$js = (string) file_get_contents(dirname(__DIR__, 3).'/public/install/assets/js/installer.js');
|
||||||
|
|
||||||
|
$start = strpos($js, 'data.vendor_dev_packages && data.vendor_dev_packages.vendor_exists');
|
||||||
|
$this->assertNotFalse($start, 'vendor_dev_packages 카드 렌더 블록이 있어야 한다');
|
||||||
|
|
||||||
|
$end = strpos($js, '// 10. 자산 URL 방식 카드', $start);
|
||||||
|
$this->assertNotFalse($end, '전제: 다음 카드 블록이 경계를 이룬다');
|
||||||
|
|
||||||
|
$block = substr($js, $start, $end - $start);
|
||||||
|
|
||||||
|
$this->assertStringContainsString("lang('vendor_dev_packages')", $block, '카드 제목을 언어 키로 그려야 한다');
|
||||||
|
$this->assertStringNotContainsString('failedRequirements.push', $block, '선택 항목은 실패 목록에 넣지 않는다');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `checkVendorDevPackages()` 를 리플렉션으로 호출합니다.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed> 판정 결과
|
||||||
|
*/
|
||||||
|
private function invokeCheck(): array
|
||||||
|
{
|
||||||
|
$api = new \ValidationApi;
|
||||||
|
$method = new \ReflectionMethod(\ValidationApi::class, 'checkVendorDevPackages');
|
||||||
|
$method->setAccessible(true);
|
||||||
|
|
||||||
|
/** @var array<string, mixed> $result */
|
||||||
|
$result = $method->invoke($api);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 임시 BASE_PATH 아래에 vendor 픽스처를 만듭니다.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $installedJson installed.json 내용
|
||||||
|
*/
|
||||||
|
private function writeVendor(array $installedJson): void
|
||||||
|
{
|
||||||
|
$vendor = self::$sharedBase.'/vendor';
|
||||||
|
if (! is_dir($vendor.'/composer')) {
|
||||||
|
mkdir($vendor.'/composer', 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
file_put_contents($vendor.'/autoload.php', "<?php // test fixture\n");
|
||||||
|
file_put_contents($vendor.'/composer/installed.json', json_encode($installedJson));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Services;
|
||||||
|
|
||||||
|
use App\Services\CoreUpdateService;
|
||||||
|
use Illuminate\Support\Facades\Artisan;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 코어 업데이트 후 큐 워커 재시작 신호 회귀 테스트 (dev-g7 #658).
|
||||||
|
*
|
||||||
|
* 코어 업데이트는 어느 지점에서도 `queue:restart` 를 보내지 않았다. 큐 워커는 부팅이 한 번뿐이라
|
||||||
|
* 코어 코드·config·확장 목록을 기동 시점 상태로 물고 있고, 업데이트가 파일을 전부 교체해도
|
||||||
|
* 옛 코드로 잡을 계속 처리한다 — 오류로 드러나지 않으므로 운영자가 손수 재시작할 때까지
|
||||||
|
* 조용히 어긋난 채 돈다(번들 확장 일괄 업데이트는 Manager 직접 호출이라 기존
|
||||||
|
* `ExtensionUpdateQueueListener` 도 걸리지 않는다).
|
||||||
|
*/
|
||||||
|
class CoreUpdateServiceQueueRestartTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* `queue:restart` 를 정확히 한 번 호출한다.
|
||||||
|
*
|
||||||
|
* @effects core_update_step11_signals_queue_restart
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function signal_queue_restart_는_queue_restart_를_한_번_호출한다(): void
|
||||||
|
{
|
||||||
|
Artisan::shouldReceive('call')->once()->with('queue:restart')->andReturn(0);
|
||||||
|
|
||||||
|
app(CoreUpdateService::class)->signalQueueRestart();
|
||||||
|
|
||||||
|
$this->assertTrue(true, 'Artisan 기대치는 tearDown 에서 검증된다');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 신호 전송 실패는 업데이트를 되돌리지 않는다 — 예외를 삼키고 계속한다.
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function signal_queue_restart_는_실패해도_예외를_전파하지_않는다(): void
|
||||||
|
{
|
||||||
|
Artisan::shouldReceive('call')
|
||||||
|
->once()
|
||||||
|
->with('queue:restart')
|
||||||
|
->andThrow(new \RuntimeException('큐 재시작 신호 전송 실패 시뮬레이션'));
|
||||||
|
|
||||||
|
app(CoreUpdateService::class)->signalQueueRestart();
|
||||||
|
|
||||||
|
$this->assertTrue(true, '예외가 전파되면 이 지점에 도달하지 못한다');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 업데이트를 마치는 세 지점이 캐시 정리 **직후** 신호를 보낸다 (호출 지점 계약).
|
||||||
|
*
|
||||||
|
* 행위 테스트로는 이 축을 덮을 수 없다 — `CoreUpdateCommandHandoffTest` 의 docblock 이
|
||||||
|
* 밝히듯 `handle()` 의 catch cleanup 시퀀스는 GitHub 연동·migration·vendor 설치까지
|
||||||
|
* 끌고 들어와야 하는 통합 면적이라 그 테스트 범위 밖이다. 그래서 세 지점의 배치를
|
||||||
|
* 소스로 잠근다: 새 마무리 지점이 신호 없이 추가되면 여기서 걸린다.
|
||||||
|
*
|
||||||
|
* 롤백 catch 는 의도적 제외다 — 백업으로 되돌린 **옛** 코드가 다시 도는 자리라 워커를
|
||||||
|
* 재기동시킬 이유가 없다.
|
||||||
|
*
|
||||||
|
* @effects core_update_step11_signals_queue_restart
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function 캐시_정리_뒤_큐_신호를_보내는_지점이_셋이고_롤백은_제외된다(): void
|
||||||
|
{
|
||||||
|
$sites = [];
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
'app/Console/Commands/Core/CoreUpdateCommand.php',
|
||||||
|
'app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php',
|
||||||
|
] as $relative) {
|
||||||
|
$source = (string) file_get_contents(base_path($relative));
|
||||||
|
|
||||||
|
$offset = 0;
|
||||||
|
while (($pos = strpos($source, '$service->clearAllCaches();', $offset)) !== false) {
|
||||||
|
// 정리 직후 구간(다음 400자)에 신호가 있는지 본다 — 사이에 낀 주석은 허용하되
|
||||||
|
// 다른 마무리 단계를 한참 지나 붙는 것은 "직후" 가 아니다.
|
||||||
|
$window = substr($source, $pos, 400);
|
||||||
|
$sites[] = [
|
||||||
|
'file' => $relative,
|
||||||
|
'line' => substr_count(substr($source, 0, $pos), "\n") + 1,
|
||||||
|
'signals' => str_contains($window, '$service->signalQueueRestart();'),
|
||||||
|
// 롤백 지점은 백업 복원 직후라 바로 뒤에 "롤백 후 캐시 정리 완료" 로그가 온다.
|
||||||
|
'is_rollback' => str_contains($window, '롤백 후 캐시 정리'),
|
||||||
|
];
|
||||||
|
$offset = $pos + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertNotEmpty($sites, '전제: clearAllCaches 호출 지점을 찾을 수 있어야 한다');
|
||||||
|
|
||||||
|
$signalling = array_values(array_filter($sites, fn (array $s): bool => $s['signals']));
|
||||||
|
$silent = array_values(array_filter($sites, fn (array $s): bool => ! $s['signals']));
|
||||||
|
|
||||||
|
$this->assertCount(
|
||||||
|
3,
|
||||||
|
$signalling,
|
||||||
|
'Step 11 · 핸드오프 cleanup · 단독 실행 사후 단계 셋이 신호를 보내야 한다. 실제: '
|
||||||
|
.json_encode($sites, JSON_UNESCAPED_SLASHES)
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertCount(
|
||||||
|
1,
|
||||||
|
$silent,
|
||||||
|
'신호를 보내지 않는 지점은 롤백 catch 하나뿐이어야 한다. 실제: '
|
||||||
|
.json_encode($silent, JSON_UNESCAPED_SLASHES)
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertTrue(
|
||||||
|
$silent[0]['is_rollback'],
|
||||||
|
'제외된 지점은 롤백 catch 여야 한다 (옛 코드로 되돌린 자리라 워커 재기동 대상이 아니다). 실제: '
|
||||||
|
.json_encode($silent[0], JSON_UNESCAPED_SLASHES)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,14 +39,72 @@ class CoreUpdateServiceTest extends TestCase
|
|||||||
*/
|
*/
|
||||||
private array $tempFiles = [];
|
private array $tempFiles = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 진입 시점의 업데이트 트리 플래그 (tearDown 에서 복원)
|
||||||
|
*/
|
||||||
|
private string|false $originalUpdateFlag = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 진입 시점의 APP_VERSION 3채널 값 (tearDown 에서 복원)
|
||||||
|
*
|
||||||
|
* @var array{env: string|null, server: string|null, putenv: string|false}
|
||||||
|
*/
|
||||||
|
private array $originalAppVersion = ['env' => null, 'server' => null, 'putenv' => false];
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
parent::setUp();
|
parent::setUp();
|
||||||
|
|
||||||
|
// `updateVersionInEnv()` 는 `.env` 파일만이 아니라 프로세스 환경(3채널)도 갱신한다.
|
||||||
|
// 그 부수효과가 클래스 안에 남으면 **뒤이은 테스트가 부팅하는 앱의 `config('app.version')`**
|
||||||
|
// 이 오염된다(`config/app.php` 가 `env('APP_VERSION', …)` 이므로). 실제로 그 순서 의존으로
|
||||||
|
// `test_check_updates_command_shows_success` 가 "현재 최신 버전입니다" 대신 업데이트 있음으로
|
||||||
|
// 갈렸다. 진입 시 스냅샷을 떠 두고 종료 시 되돌린다.
|
||||||
|
$this->originalAppVersion = [
|
||||||
|
'env' => $_ENV['APP_VERSION'] ?? null,
|
||||||
|
'server' => $_SERVER['APP_VERSION'] ?? null,
|
||||||
|
'putenv' => getenv('APP_VERSION'),
|
||||||
|
];
|
||||||
|
|
||||||
|
// 이 클래스의 한 케이스가 `core:update` 를 실제로 호출하는데, 부모 커맨드는 "프로세스
|
||||||
|
// 종료와 함께 소멸" 을 전제로 `G7_UPDATE_IN_PROGRESS` 를 정리하지 않는다. PHPUnit 은
|
||||||
|
// 프로세스가 이어지므로 그 플래그가 이후 모든 테스트로 새어 나가, 부팅마다
|
||||||
|
// `bootstrap/app.php` 의 매니페스트 자가 치유가 발동한다(내용은 같아도 불필요한 churn).
|
||||||
|
// 진입 시 비우고 종료 시 되돌린다 — ExecuteUpgradeStepsStandaloneTest 와 같은 규율.
|
||||||
|
$this->originalUpdateFlag = getenv('G7_UPDATE_IN_PROGRESS');
|
||||||
|
putenv('G7_UPDATE_IN_PROGRESS');
|
||||||
|
unset($_ENV['G7_UPDATE_IN_PROGRESS'], $_SERVER['G7_UPDATE_IN_PROGRESS']);
|
||||||
|
|
||||||
$this->service = new CoreUpdateService;
|
$this->service = new CoreUpdateService;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function tearDown(): void
|
protected function tearDown(): void
|
||||||
{
|
{
|
||||||
|
// APP_VERSION 3채널 복원 (updateVersionInEnv 부수효과 격리)
|
||||||
|
foreach (['env' => &$_ENV, 'server' => &$_SERVER] as $slot => &$bag) {
|
||||||
|
if ($this->originalAppVersion[$slot] === null) {
|
||||||
|
unset($bag['APP_VERSION']);
|
||||||
|
} else {
|
||||||
|
$bag['APP_VERSION'] = $this->originalAppVersion[$slot];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($bag);
|
||||||
|
|
||||||
|
if ($this->originalAppVersion['putenv'] === false) {
|
||||||
|
putenv('APP_VERSION');
|
||||||
|
} else {
|
||||||
|
putenv('APP_VERSION='.$this->originalAppVersion['putenv']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->originalUpdateFlag === false) {
|
||||||
|
putenv('G7_UPDATE_IN_PROGRESS');
|
||||||
|
unset($_ENV['G7_UPDATE_IN_PROGRESS'], $_SERVER['G7_UPDATE_IN_PROGRESS']);
|
||||||
|
} else {
|
||||||
|
putenv('G7_UPDATE_IN_PROGRESS='.$this->originalUpdateFlag);
|
||||||
|
$_ENV['G7_UPDATE_IN_PROGRESS'] = $this->originalUpdateFlag;
|
||||||
|
$_SERVER['G7_UPDATE_IN_PROGRESS'] = $this->originalUpdateFlag;
|
||||||
|
}
|
||||||
|
|
||||||
// 임시 파일 정리
|
// 임시 파일 정리
|
||||||
foreach ($this->tempFiles as $file) {
|
foreach ($this->tempFiles as $file) {
|
||||||
if (File::exists($file)) {
|
if (File::exists($file)) {
|
||||||
@@ -425,6 +483,55 @@ MD;
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `.env` 파일만이 아니라 프로세스 환경(3채널)도 함께 갱신하는지 검증합니다.
|
||||||
|
*
|
||||||
|
* 부모가 config 캐시 없이 부팅했다면 Dotenv 가 `$_ENV['APP_VERSION']` 에 이전 버전을 채워 두고,
|
||||||
|
* env 저장소가 불변이라 뒤이어 부팅하는 `config:cache` 프로세스가 `.env` 를 다시 읽어도 그 값이
|
||||||
|
* 바뀌지 않는다. 그러면 `bootstrap/cache/config.php` 에 **이전 버전**이 박제되어 이후 모든 웹
|
||||||
|
* 요청이 옛 버전으로 판정한다 — 새 코어를 요구하는 확장이 `incompatible_core` 로 꺼지는 경로다.
|
||||||
|
* (2026-09-07 실측: `config:clear` 후 7.0.10 → 7.0.11 업데이트가 config 캐시에 7.0.10 을 구웠다.)
|
||||||
|
*/
|
||||||
|
public function test_update_version_in_env_also_updates_process_env(): void
|
||||||
|
{
|
||||||
|
$originalEnv = $_ENV['APP_VERSION'] ?? null;
|
||||||
|
$originalServer = $_SERVER['APP_VERSION'] ?? null;
|
||||||
|
$originalPutenv = getenv('APP_VERSION');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 업데이트 전 프로세스 환경 = 이전 버전 (캐시 없이 부팅한 부모의 상태)
|
||||||
|
$_ENV['APP_VERSION'] = '1.0.0';
|
||||||
|
$_SERVER['APP_VERSION'] = '1.0.0';
|
||||||
|
putenv('APP_VERSION=1.0.0');
|
||||||
|
|
||||||
|
$this->withIsolatedBasePath(function (string $tempBase): void {
|
||||||
|
File::put($tempBase.DIRECTORY_SEPARATOR.'.env', "APP_NAME=G7\nAPP_VERSION=1.0.0\n");
|
||||||
|
|
||||||
|
$this->service->updateVersionInEnv('2.5.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->assertSame('2.5.0', $_ENV['APP_VERSION'], '$_ENV 채널이 갱신되어야 한다');
|
||||||
|
$this->assertSame('2.5.0', $_SERVER['APP_VERSION'], '$_SERVER 채널이 갱신되어야 한다');
|
||||||
|
$this->assertSame('2.5.0', getenv('APP_VERSION'), 'putenv 채널이 갱신되어야 한다');
|
||||||
|
} finally {
|
||||||
|
if ($originalEnv === null) {
|
||||||
|
unset($_ENV['APP_VERSION']);
|
||||||
|
} else {
|
||||||
|
$_ENV['APP_VERSION'] = $originalEnv;
|
||||||
|
}
|
||||||
|
if ($originalServer === null) {
|
||||||
|
unset($_SERVER['APP_VERSION']);
|
||||||
|
} else {
|
||||||
|
$_SERVER['APP_VERSION'] = $originalServer;
|
||||||
|
}
|
||||||
|
if ($originalPutenv === false) {
|
||||||
|
putenv('APP_VERSION');
|
||||||
|
} else {
|
||||||
|
putenv('APP_VERSION='.$originalPutenv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* .env 파일에 APP_VERSION 이 없을 때 새로 추가하는지 검증합니다.
|
* .env 파일에 APP_VERSION 이 없을 때 새로 추가하는지 검증합니다.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use Mockery;
|
|||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ExtensionBundleService 단위 테스트
|
* [case:backend-30] ExtensionBundleService 단위 테스트
|
||||||
*
|
*
|
||||||
* 활성 확장 IIFE/CSS 의 priority 정렬, `\n;\n` 구분자 병합, sourceMappingURL
|
* 활성 확장 IIFE/CSS 의 priority 정렬, `\n;\n` 구분자 병합, sourceMappingURL
|
||||||
* 처리, 확장별 fault tolerance, 캐시 파일 생성/정리를 검증한다.
|
* 처리, 확장별 fault tolerance, 캐시 파일 생성/정리를 검증한다.
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use Illuminate\Support\Facades\Cache;
|
|||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 코어 단건 설정 저장(setSetting)의 부수효과 정합 테스트 (공개 #114 동종, B-3)
|
* [case:cache-18] 코어 단건 설정 저장(setSetting)의 부수효과 정합 테스트 (공개 #114 동종, B-3)
|
||||||
*
|
*
|
||||||
* 단건 저장은 `PUT /api/admin/settings/{key}` 로 실제 도달 가능한 경로인데, 벌크 저장이
|
* 단건 저장은 `PUT /api/admin/settings/{key}` 로 실제 도달 가능한 경로인데, 벌크 저장이
|
||||||
* 수행하는 부수효과 중 일부를 건너뛰었다:
|
* 수행하는 부수효과 중 일부를 건너뛰었다:
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Support;
|
||||||
|
|
||||||
|
use App\Support\ComposerInstallInfo;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* vendor 개발용 설치 판정 SSoT 테스트 (dev-g7 #658).
|
||||||
|
*
|
||||||
|
* 인스톨러(순수 PHP)와 코어 업데이트가 같은 vendor 를 두고 서로 다른 답을 내놓지 않도록
|
||||||
|
* 판정을 이 클래스 하나가 소유한다. "판정 불가"(null)와 "개발용 아님"(false)을 구분하는 것이
|
||||||
|
* 핵심이다 — 뭉뚱그리면 installed.json 이 없는 환경이 "운영용 구성 확인됨" 으로 보인다.
|
||||||
|
*/
|
||||||
|
class ComposerInstallInfoTest extends TestCase
|
||||||
|
{
|
||||||
|
private string $vendorPath;
|
||||||
|
|
||||||
|
private string $basePath;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->basePath = storage_path('framework/testing/composer-install-info-'.uniqid());
|
||||||
|
$this->vendorPath = $this->basePath.'/vendor';
|
||||||
|
|
||||||
|
File::ensureDirectoryExists($this->vendorPath.'/composer');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
if (File::isDirectory($this->basePath)) {
|
||||||
|
File::deleteDirectory($this->basePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `dev: true` + 이름 목록 → 개발용 설치.
|
||||||
|
*
|
||||||
|
* @effects core_detects_dev_vendor_from_installed_json
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function dev_true_와_이름_목록이_있으면_개발용_설치로_판정한다(): void
|
||||||
|
{
|
||||||
|
$this->writeInstalledJson([
|
||||||
|
'packages' => [],
|
||||||
|
'dev' => true,
|
||||||
|
'dev-package-names' => ['laravel/boost', 'laravel/mcp', 'laravel/pail'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$info = ComposerInstallInfo::inspect($this->vendorPath);
|
||||||
|
|
||||||
|
$this->assertTrue($info['dev']);
|
||||||
|
$this->assertSame(['laravel/boost', 'laravel/mcp', 'laravel/pail'], $info['packages']);
|
||||||
|
$this->assertTrue(ComposerInstallInfo::isDevInstall($this->vendorPath));
|
||||||
|
$this->assertCount(3, ComposerInstallInfo::devPackageNames($this->vendorPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `--no-dev` 설치 → 개발용 아님.
|
||||||
|
*
|
||||||
|
* @effects core_reports_no_dev_packages_for_no_dev_vendor
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function dev_false_이고_이름_목록이_비면_운영용_설치로_판정한다(): void
|
||||||
|
{
|
||||||
|
$this->writeInstalledJson([
|
||||||
|
'packages' => [],
|
||||||
|
'dev' => false,
|
||||||
|
'dev-package-names' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$info = ComposerInstallInfo::inspect($this->vendorPath);
|
||||||
|
|
||||||
|
$this->assertFalse($info['dev']);
|
||||||
|
$this->assertSame([], $info['packages']);
|
||||||
|
$this->assertFalse(ComposerInstallInfo::isDevInstall($this->vendorPath));
|
||||||
|
$this->assertSame([], ComposerInstallInfo::devPackageNames($this->vendorPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `dev` 는 false 인데 이름 목록이 남아 있으면 개발용으로 본다 — 두 신호는 OR 다.
|
||||||
|
* Composer 버전에 따라 한쪽만 채워질 수 있고, 등재된 provider 가 있다는 사실이 중요하다.
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function dev_false_라도_이름_목록이_비어있지_않으면_개발용으로_판정한다(): void
|
||||||
|
{
|
||||||
|
$this->writeInstalledJson([
|
||||||
|
'packages' => [],
|
||||||
|
'dev' => false,
|
||||||
|
'dev-package-names' => ['laravel/mcp'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertTrue(ComposerInstallInfo::isDevInstall($this->vendorPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* installed.json 이 없으면 판정 불가(null) — "개발용 아님"(false)과 구분한다.
|
||||||
|
*
|
||||||
|
* @effects core_returns_unknown_when_installed_json_missing
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function installed_json_이_없으면_판정_불가다(): void
|
||||||
|
{
|
||||||
|
$info = ComposerInstallInfo::inspect($this->vendorPath);
|
||||||
|
|
||||||
|
$this->assertNull($info['dev']);
|
||||||
|
$this->assertSame([], $info['packages']);
|
||||||
|
$this->assertNull(ComposerInstallInfo::isDevInstall($this->vendorPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON 파싱 실패도 판정 불가다.
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function json_이_깨졌으면_판정_불가다(): void
|
||||||
|
{
|
||||||
|
File::put($this->vendorPath.'/'.ComposerInstallInfo::INSTALLED_JSON, '{ not json');
|
||||||
|
|
||||||
|
$this->assertNull(ComposerInstallInfo::isDevInstall($this->vendorPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composer 1 형식(최상위가 패키지 배열)은 dev 정보가 없어 판정 불가다.
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function composer1_형식은_판정_불가다(): void
|
||||||
|
{
|
||||||
|
File::put($this->vendorPath.'/'.ComposerInstallInfo::INSTALLED_JSON, json_encode([
|
||||||
|
['name' => 'vendor/pkg', 'version' => '1.0.0'],
|
||||||
|
]));
|
||||||
|
|
||||||
|
$this->assertNull(ComposerInstallInfo::isDevInstall($this->vendorPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이름 목록의 비문자열·공백 항목은 걸러낸다 (건수를 그대로 화면에 표시하므로).
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function 이름_목록의_비문자열과_공백_항목을_걸러낸다(): void
|
||||||
|
{
|
||||||
|
$this->writeInstalledJson([
|
||||||
|
'packages' => [],
|
||||||
|
'dev' => true,
|
||||||
|
'dev-package-names' => ['laravel/mcp', '', ' ', 123, null, ['nested'], 'laravel/pail'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame(['laravel/mcp', 'laravel/pail'], ComposerInstallInfo::devPackageNames($this->vendorPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 경로 끝의 구분자 유무와 무관하게 같은 결과를 낸다.
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function 경로_끝_구분자_유무와_무관하게_동작한다(): void
|
||||||
|
{
|
||||||
|
$this->writeInstalledJson([
|
||||||
|
'packages' => [],
|
||||||
|
'dev' => true,
|
||||||
|
'dev-package-names' => ['laravel/mcp'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertTrue(ComposerInstallInfo::isDevInstall($this->vendorPath.'/'));
|
||||||
|
$this->assertTrue(ComposerInstallInfo::isDevInstall($this->vendorPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* installed.json 픽스처를 씁니다.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $payload JSON 으로 직렬화할 내용
|
||||||
|
*/
|
||||||
|
private function writeInstalledJson(array $payload): void
|
||||||
|
{
|
||||||
|
File::put($this->vendorPath.'/'.ComposerInstallInfo::INSTALLED_JSON, json_encode($payload));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ namespace Tests\Unit\Support;
|
|||||||
|
|
||||||
use App\Support\ConfigCacheHelper;
|
use App\Support\ConfigCacheHelper;
|
||||||
use Illuminate\Container\Container;
|
use Illuminate\Container\Container;
|
||||||
|
use Illuminate\Contracts\Console\Kernel;
|
||||||
|
use Illuminate\Support\Facades\Facade;
|
||||||
use PHPUnit\Framework\Attributes\Test;
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
@@ -59,6 +61,84 @@ class ConfigCacheHelperContainerTest extends TestCase
|
|||||||
$this->assertSame($original, Container::getInstance());
|
$this->assertSame($original, Container::getInstance());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 콜백이 파사드 애플리케이션을 바꿔 놓아도 실행 후 원래 앱으로 돌아온다.
|
||||||
|
*
|
||||||
|
* `config:cache` 가 부팅하는 일회용 앱의 `registerBaseBindings()` 는 컨테이너 인스턴스만이 아니라
|
||||||
|
* `Facade::clearResolvedInstances()` + `Facade::setFacadeApplication()` 도 호출한다. 컨테이너만
|
||||||
|
* 되돌리면 그 뒤의 **모든 파사드 호출**(`Artisan`·`Log`·`DB`·`Cache` …)이 종료된 일회용 앱에서
|
||||||
|
* 인스턴스를 해석한다.
|
||||||
|
*
|
||||||
|
* 코어 업데이트에서 드러난 형태: Step 11 의 `ConfigCacheHelper::rebuild()` 뒤에 오는
|
||||||
|
* `RouteCacheHelper::rebuild()` 의 `Artisan::call('route:clear')` 가 일회용 앱에서 **새 콘솔 Kernel**
|
||||||
|
* 을 해석하고, 그 Kernel 이 콘솔 Application 을 처음부터 구성하며 등록된 커맨드를 전부 resolve 해
|
||||||
|
* `Target class [command.tinker] does not exist.` 로 업데이트가 실패·롤백했다 (2026-09-07 실측).
|
||||||
|
*
|
||||||
|
* @effects config_cache_rebuild_preserves_facade_application
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function 콜백이_파사드_애플리케이션을_바꿔도_원래_앱으로_되돌린다(): void
|
||||||
|
{
|
||||||
|
$original = Facade::getFacadeApplication();
|
||||||
|
$this->assertNotNull($original, '전제: 파사드 애플리케이션이 설정되어 있다');
|
||||||
|
|
||||||
|
ConfigCacheHelper::withPreservedContainer(static function (): void {
|
||||||
|
// 일회용 앱 부팅이 하는 일과 같은 형태 — 해석 캐시를 비우고 다른 앱을 가리킨다.
|
||||||
|
Facade::clearResolvedInstances();
|
||||||
|
Facade::setFacadeApplication(new Container);
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
$original,
|
||||||
|
Facade::getFacadeApplication(),
|
||||||
|
'config:cache 뒤 파사드가 일회용 앱을 가리킨 채 남으면 후속 Artisan::call 이 새 콘솔 Kernel 을 만든다'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 콜백이 예외를 던져도 파사드 애플리케이션은 되돌린다.
|
||||||
|
*
|
||||||
|
* @effects config_cache_rebuild_preserves_facade_application
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function 콜백이_실패해도_파사드_애플리케이션을_되돌린다(): void
|
||||||
|
{
|
||||||
|
$original = Facade::getFacadeApplication();
|
||||||
|
|
||||||
|
try {
|
||||||
|
ConfigCacheHelper::withPreservedContainer(static function (): void {
|
||||||
|
Facade::setFacadeApplication(new Container);
|
||||||
|
|
||||||
|
throw new \RuntimeException('boom');
|
||||||
|
});
|
||||||
|
$this->fail('예외가 전파되어야 한다');
|
||||||
|
} catch (\RuntimeException) {
|
||||||
|
// 기대한 예외
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertSame($original, Facade::getFacadeApplication());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 되돌린 뒤 파사드가 원래 앱의 인스턴스를 해석한다 (해석 캐시 오염 없음).
|
||||||
|
*
|
||||||
|
* @effects config_cache_rebuild_preserves_facade_application
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function 되돌린_뒤_파사드가_원래_앱의_인스턴스를_해석한다(): void
|
||||||
|
{
|
||||||
|
$original = Facade::getFacadeApplication();
|
||||||
|
$kernelBefore = app(Kernel::class);
|
||||||
|
|
||||||
|
ConfigCacheHelper::withPreservedContainer(static function (): void {
|
||||||
|
Facade::clearResolvedInstances();
|
||||||
|
Facade::setFacadeApplication(new Container);
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->assertSame($original, Facade::getFacadeApplication());
|
||||||
|
$this->assertSame($kernelBefore, app(Kernel::class), '콘솔 Kernel 이 새로 만들어지면 등록된 커맨드를 전부 다시 resolve 한다');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `rebuild()` 가 `config:cache` 호출을 보존 래퍼로 감싼다 (소스 구조).
|
* `rebuild()` 가 `config:cache` 호출을 보존 래퍼로 감싼다 (소스 구조).
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Support;
|
||||||
|
|
||||||
|
use App\Support\CoreUpdateContext;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 코어 업데이트 트리 판정 단일 SSoT 테스트 (dev-g7 #658).
|
||||||
|
*
|
||||||
|
* 이 판정 하나가 세 가지를 게이트한다 — 확장 자동 비활성화 스킵, 코어 버전의 env 우선 판독,
|
||||||
|
* `bootstrap/app.php` 의 패키지 매니페스트 자가 치유. 판정이 어긋나면 그중 한 경로만 조용히
|
||||||
|
* 다르게 동작하므로 세 채널($_ENV/$_SERVER/putenv)과 argv 를 각각 잠근다.
|
||||||
|
*/
|
||||||
|
class CoreUpdateContextTest extends TestCase
|
||||||
|
{
|
||||||
|
/** @var string|null 원래 env 값 (3채널 복원용) */
|
||||||
|
private ?string $originalFlag = null;
|
||||||
|
|
||||||
|
/** @var array<int, string> 원래 argv */
|
||||||
|
private array $originalArgv = [];
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$current = $_ENV['G7_UPDATE_IN_PROGRESS'] ?? $_SERVER['G7_UPDATE_IN_PROGRESS'] ?? getenv('G7_UPDATE_IN_PROGRESS');
|
||||||
|
$this->originalFlag = ($current === false || $current === null) ? null : (string) $current;
|
||||||
|
$this->originalArgv = $_SERVER['argv'] ?? [];
|
||||||
|
|
||||||
|
$this->clearFlag();
|
||||||
|
$_SERVER['argv'] = ['artisan', 'inspire'];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
if ($this->originalFlag === null) {
|
||||||
|
$this->clearFlag();
|
||||||
|
} else {
|
||||||
|
$_ENV['G7_UPDATE_IN_PROGRESS'] = $this->originalFlag;
|
||||||
|
$_SERVER['G7_UPDATE_IN_PROGRESS'] = $this->originalFlag;
|
||||||
|
putenv('G7_UPDATE_IN_PROGRESS='.$this->originalFlag);
|
||||||
|
}
|
||||||
|
|
||||||
|
$_SERVER['argv'] = $this->originalArgv;
|
||||||
|
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 플래그도 업데이트 argv 도 없으면 트리 밖이다.
|
||||||
|
*
|
||||||
|
* @effects CoreUpdateContext_isInProgress_detects_env_flag_or_update_argv
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function 플래그와_argv_가_모두_없으면_트리_밖이다(): void
|
||||||
|
{
|
||||||
|
$this->assertFalse(CoreUpdateContext::isInProgress());
|
||||||
|
$this->assertFalse(CoreUpdateContext::hasEnvFlag());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `$_ENV` 채널만으로도 감지한다.
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function env_배열_채널의_플래그를_감지한다(): void
|
||||||
|
{
|
||||||
|
$_ENV['G7_UPDATE_IN_PROGRESS'] = '1';
|
||||||
|
|
||||||
|
$this->assertTrue(CoreUpdateContext::isInProgress());
|
||||||
|
$this->assertTrue(CoreUpdateContext::hasEnvFlag());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `$_SERVER` 채널만으로도 감지한다 (`variables_order` 에 `E` 가 없으면 `$_ENV` 가 빈다).
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function server_배열_채널의_플래그를_감지한다(): void
|
||||||
|
{
|
||||||
|
$_SERVER['G7_UPDATE_IN_PROGRESS'] = '1';
|
||||||
|
|
||||||
|
$this->assertTrue(CoreUpdateContext::isInProgress());
|
||||||
|
$this->assertTrue(CoreUpdateContext::hasEnvFlag());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* putenv 채널만으로도 감지한다 (proc_open 이 자식에 넘긴 프로세스 환경 테이블).
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function putenv_채널의_플래그를_감지한다(): void
|
||||||
|
{
|
||||||
|
putenv('G7_UPDATE_IN_PROGRESS=1');
|
||||||
|
|
||||||
|
$this->assertTrue(CoreUpdateContext::isInProgress());
|
||||||
|
$this->assertTrue(CoreUpdateContext::hasEnvFlag());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* '1' 이 아닌 값은 플래그가 아니다.
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function 플래그_값이_1_이_아니면_트리_밖이다(): void
|
||||||
|
{
|
||||||
|
$_ENV['G7_UPDATE_IN_PROGRESS'] = '0';
|
||||||
|
|
||||||
|
$this->assertFalse(CoreUpdateContext::isInProgress());
|
||||||
|
$this->assertFalse(CoreUpdateContext::hasEnvFlag());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 플래그가 전파되지 않은 극단 상황 대비 — argv 로도 판정한다.
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function 업데이트_커맨드_argv_로도_트리_안으로_판정한다(): void
|
||||||
|
{
|
||||||
|
foreach (['core:update', 'core:execute-upgrade-steps'] as $command) {
|
||||||
|
$_SERVER['argv'] = ['artisan', $command, '--force'];
|
||||||
|
|
||||||
|
$this->assertTrue(CoreUpdateContext::isInProgress(), "argv {$command} 는 업데이트 트리로 판정한다");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 웹 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 자식으로
|
||||||
|
* 오판되어 사전·사후 단계를 통째로 건너뛴다.
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function has_env_flag_는_argv_만으로는_참이_되지_않는다(): void
|
||||||
|
{
|
||||||
|
$_SERVER['argv'] = ['artisan', 'core:execute-upgrade-steps', '--force'];
|
||||||
|
|
||||||
|
$this->assertTrue(CoreUpdateContext::isInProgress(), 'isInProgress 는 argv 로도 참');
|
||||||
|
$this->assertFalse(CoreUpdateContext::hasEnvFlag(), 'hasEnvFlag 는 env 플래그만 본다');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `bootstrap/app.php` 자가 치유 블록의 판정 조건이 이 클래스와 동형이다.
|
||||||
|
*
|
||||||
|
* 그 블록은 부팅 전이라 `App\` 클래스를 참조할 수 없어 같은 판정을 순수 PHP 로 **복제**한다.
|
||||||
|
* 복제본은 어긋나도 예외를 내지 않는다 — 옛 부모(7.0.9·7.0.10) 아래에서 도는 신버전 자식의
|
||||||
|
* 유일한 방어가 조용히 사라지고, 증상은 그 조합에서만 나타나므로 개발 저장소에서는 영영
|
||||||
|
* 드러나지 않는다. 그래서 두 조건이 같은지를 여기서 잠근다 — 종전에는 양쪽 주석의 상호
|
||||||
|
* 참조가 유일한 방어였다.
|
||||||
|
*
|
||||||
|
* 대조 항목은 판정을 이루는 다섯 축 전부다: 환경변수 이름 · 읽는 채널 3종 · 참으로 받는 값
|
||||||
|
* 3종 · argv 로 인정하는 커맨드 목록 · argv 를 신뢰하는 SAPI 목록(뒤의 둘은 리플렉션으로 이
|
||||||
|
* 클래스에서 파생 — 손으로 적으면 항목이 하나 늘어도 통과한다).
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function bootstrap_자가치유_조건이_이_판정기와_동형이다(): void
|
||||||
|
{
|
||||||
|
$bootstrap = (string) file_get_contents(base_path('bootstrap/app.php'));
|
||||||
|
|
||||||
|
$start = strpos($bootstrap, 'Core Update: Stale Package Manifest Self-Heal');
|
||||||
|
$this->assertNotFalse($start, '자가 치유 블록의 주석 마커를 찾지 못했다 — 마커가 바뀌었다면 이 검사부터 고친다');
|
||||||
|
|
||||||
|
$end = strpos($bootstrap, 'return $app;', $start);
|
||||||
|
$this->assertNotFalse($end, '자가 치유 블록의 끝(return $app;)을 찾지 못했다');
|
||||||
|
|
||||||
|
$block = substr($bootstrap, $start, $end - $start);
|
||||||
|
|
||||||
|
// ① 환경변수 이름 · ② 읽는 채널 3종 — CoreUpdateContext::hasEnvFlag() 와 같아야 한다.
|
||||||
|
foreach ([
|
||||||
|
"\$_ENV['G7_UPDATE_IN_PROGRESS']",
|
||||||
|
"\$_SERVER['G7_UPDATE_IN_PROGRESS']",
|
||||||
|
"getenv('G7_UPDATE_IN_PROGRESS')",
|
||||||
|
] as $channel) {
|
||||||
|
$this->assertStringContainsString(
|
||||||
|
$channel,
|
||||||
|
$block,
|
||||||
|
"자가 치유 블록이 판정 채널 {$channel} 을 읽지 않는다 — 그 채널만 살아 있는 호스팅에서 방어가 빠진다"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ③ 참으로 받는 값 3종.
|
||||||
|
foreach (["=== '1'", '=== 1', '=== true'] as $accepted) {
|
||||||
|
$this->assertStringContainsString(
|
||||||
|
$accepted,
|
||||||
|
$block,
|
||||||
|
"자가 치유 블록이 플래그 값 비교 `{$accepted}` 를 갖지 않는다"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ④ argv 로 인정하는 커맨드 — 목록은 이 클래스에서 파생한다.
|
||||||
|
$reflection = new \ReflectionClass(CoreUpdateContext::class);
|
||||||
|
$commands = $reflection->getConstant('UPDATE_COMMANDS');
|
||||||
|
|
||||||
|
$this->assertIsArray($commands);
|
||||||
|
$this->assertNotEmpty($commands, '커맨드 목록이 비면 이 대조는 공허하게 통과한다');
|
||||||
|
|
||||||
|
foreach ($commands as $command) {
|
||||||
|
$this->assertStringContainsString(
|
||||||
|
"'{$command}'",
|
||||||
|
$block,
|
||||||
|
"자가 치유 블록의 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 를 신뢰한다"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세 채널($_ENV/$_SERVER/putenv)에서 플래그를 제거합니다.
|
||||||
|
*/
|
||||||
|
private function clearFlag(): void
|
||||||
|
{
|
||||||
|
unset($_ENV['G7_UPDATE_IN_PROGRESS'], $_SERVER['G7_UPDATE_IN_PROGRESS']);
|
||||||
|
putenv('G7_UPDATE_IN_PROGRESS');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ use PHPUnit\Framework\Attributes\Test;
|
|||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `CustomAssets::publishableFiles()` — 게시·변경 감지가 공유하는 열거자 테스트 (#651 F7).
|
* [case:cache-19] `CustomAssets::publishableFiles()` — 게시·변경 감지가 공유하는 열거자 테스트 (#651 F7).
|
||||||
*
|
*
|
||||||
* 정적 게시는 `custom/**` 를 재귀로 복사하고, 변경 감지는 종전에 최상위 css/js 의 mtime 만
|
* 정적 게시는 `custom/**` 를 재귀로 복사하고, 변경 감지는 종전에 최상위 css/js 의 mtime 만
|
||||||
* 서명했다. 두 범위를 서로 다른 코드가 정의하면 어긋나고, 그 어긋남은 "글꼴을 바꿨는데
|
* 서명했다. 두 범위를 서로 다른 코드가 정의하면 어긋나고, 그 어긋남은 "글꼴을 바꿨는데
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Support;
|
||||||
|
|
||||||
|
use App\Support\PackageManifestCacheHelper;
|
||||||
|
use Illuminate\Support\Facades\Artisan;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 패키지 매니페스트 캐시 헬퍼 단위 테스트 (dev-g7 #658).
|
||||||
|
*
|
||||||
|
* 이 헬퍼가 삭제 로직의 단일 지점이다 — spawn 직전 선정리(`CoreUpdateCommand`)와 업데이트
|
||||||
|
* 마무리(`CoreUpdateService::clearAllCaches()`)가 같은 코드를 쓴다.
|
||||||
|
*
|
||||||
|
* 격리: 실제 `bootstrap/cache/*` 를 건드리지 않도록 `APP_PACKAGES_CACHE`/`APP_SERVICES_CACHE`
|
||||||
|
* 를 base_path 상대 경로로 재지정한다(`Application::normalizeCachePath` 규칙).
|
||||||
|
*/
|
||||||
|
class PackageManifestCacheHelperTest extends TestCase
|
||||||
|
{
|
||||||
|
/** @var array<string, string|null> 테스트가 덮어쓴 env 의 원값 (3채널 복원용) */
|
||||||
|
private array $originalEnv = [];
|
||||||
|
|
||||||
|
private string $packagesPath;
|
||||||
|
|
||||||
|
private string $servicesPath;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$uniq = uniqid();
|
||||||
|
$relPackages = "storage/framework/testing/manifest-helper-{$uniq}/packages.php";
|
||||||
|
$relServices = "storage/framework/testing/manifest-helper-{$uniq}/services.php";
|
||||||
|
|
||||||
|
$this->packagesPath = base_path($relPackages);
|
||||||
|
$this->servicesPath = base_path($relServices);
|
||||||
|
|
||||||
|
File::ensureDirectoryExists(dirname($this->packagesPath));
|
||||||
|
|
||||||
|
$this->setEnv('APP_PACKAGES_CACHE', $relPackages);
|
||||||
|
$this->setEnv('APP_SERVICES_CACHE', $relServices);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
foreach ($this->originalEnv as $key => $value) {
|
||||||
|
if ($value === null) {
|
||||||
|
unset($_ENV[$key], $_SERVER[$key]);
|
||||||
|
putenv($key);
|
||||||
|
} else {
|
||||||
|
$_ENV[$key] = $value;
|
||||||
|
$_SERVER[$key] = $value;
|
||||||
|
putenv($key.'='.$value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->originalEnv = [];
|
||||||
|
|
||||||
|
$dir = dirname($this->packagesPath);
|
||||||
|
if (File::isDirectory($dir)) {
|
||||||
|
File::deleteDirectory($dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* env 로 재지정한 경로의 두 파일을 지운다 (하드코딩된 bootstrap/cache 가 아니라).
|
||||||
|
*
|
||||||
|
* @effects PackageManifestCacheHelper_clear_unlinks_packages_and_services_at_configured_paths
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function clear_는_설정된_경로의_packages_와_services_를_지운다(): void
|
||||||
|
{
|
||||||
|
File::put($this->packagesPath, "<?php return [];\n");
|
||||||
|
File::put($this->servicesPath, "<?php return [];\n");
|
||||||
|
|
||||||
|
$this->assertSame($this->packagesPath, $this->app->getCachedPackagesPath(), '전제: env 재지정이 경로를 정한다');
|
||||||
|
$this->assertSame($this->servicesPath, $this->app->getCachedServicesPath(), '전제: env 재지정이 경로를 정한다');
|
||||||
|
|
||||||
|
$remaining = PackageManifestCacheHelper::clear();
|
||||||
|
|
||||||
|
$this->assertFileDoesNotExist($this->packagesPath);
|
||||||
|
$this->assertFileDoesNotExist($this->servicesPath);
|
||||||
|
$this->assertSame([], $remaining, '전부 지웠으면 남은 파일이 없다');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파일이 없어도 예외를 던지지 않는다 (부팅 직전에 불리므로 실패가 부팅을 막으면 안 된다).
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function clear_는_파일이_없으면_아무_일도_하지_않는다(): void
|
||||||
|
{
|
||||||
|
$this->assertFileDoesNotExist($this->packagesPath, '전제: 파일이 없다');
|
||||||
|
|
||||||
|
$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);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* rebuild() 는 삭제 후 package:discover 로 다시 만든다.
|
||||||
|
*
|
||||||
|
* @effects clearAllCaches_rebuilds_package_manifest_via_helper
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function rebuild_는_지운_뒤_package_discover_를_한_번_호출한다(): void
|
||||||
|
{
|
||||||
|
File::put($this->packagesPath, "<?php return [];\n");
|
||||||
|
File::put($this->servicesPath, "<?php return [];\n");
|
||||||
|
|
||||||
|
Artisan::shouldReceive('call')->once()->with('package:discover')->andReturn(0);
|
||||||
|
|
||||||
|
PackageManifestCacheHelper::rebuild();
|
||||||
|
|
||||||
|
$this->assertFileDoesNotExist($this->packagesPath, 'rebuild 는 먼저 지운다');
|
||||||
|
$this->assertFileDoesNotExist($this->servicesPath, 'rebuild 는 먼저 지운다');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* env 를 세 채널($_ENV/$_SERVER/putenv)에 세우고 원값을 복원용으로 보관합니다.
|
||||||
|
*
|
||||||
|
* @param string $key 환경변수 이름
|
||||||
|
* @param string|null $value 세울 값. null 이면 제거
|
||||||
|
*/
|
||||||
|
private function setEnv(string $key, ?string $value): void
|
||||||
|
{
|
||||||
|
if (! array_key_exists($key, $this->originalEnv)) {
|
||||||
|
$current = $_ENV[$key] ?? $_SERVER[$key] ?? getenv($key);
|
||||||
|
$this->originalEnv[$key] = ($current === false || $current === null) ? null : (string) $current;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($value === null) {
|
||||||
|
unset($_ENV[$key], $_SERVER[$key]);
|
||||||
|
putenv($key);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$_ENV[$key] = $value;
|
||||||
|
$_SERVER[$key] = $value;
|
||||||
|
putenv($key.'='.$value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -174,6 +174,39 @@ putenv('APP_ROUTES_CACHE='.$testingRoutesCache);
|
|||||||
$_ENV['APP_ROUTES_CACHE'] = $testingRoutesCache;
|
$_ENV['APP_ROUTES_CACHE'] = $testingRoutesCache;
|
||||||
$_SERVER['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 오토로더 로드
|
// Composer 오토로더 로드
|
||||||
$loader = require __DIR__.'/../vendor/autoload.php';
|
$loader = require __DIR__.'/../vendor/autoload.php';
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,14 @@ axes:
|
|||||||
staging_source_shape: [inner_extracted_root, staging_root_itself, external_source_dir] # cleanupPending 이 받는 경로 형태 — ZIP·GitHub / --local·--source 복제 / --source 외부
|
staging_source_shape: [inner_extracted_root, staging_root_itself, external_source_dir] # cleanupPending 이 받는 경로 형태 — ZIP·GitHub / --local·--source 복제 / --source 외부
|
||||||
staging_leftover_content: [empty_skeleton, has_files, non_core_dir] # 자식 청소 술어 — 빈 껍데기만 대상
|
staging_leftover_content: [empty_skeleton, has_files, non_core_dir] # 자식 청소 술어 — 빈 껍데기만 대상
|
||||||
resume_exec_context: [non_root, root_web_known, root_web_symmetric, root_web_unknown] # 핸드오프 재실행 권한 안내 분기 (실행 사용자 × 웹서버 계정 식별성)
|
resume_exec_context: [non_root, root_web_known, root_web_symmetric, root_web_unknown] # 핸드오프 재실행 권한 안내 분기 (실행 사용자 × 웹서버 계정 식별성)
|
||||||
|
package_manifest_state: [fresh, stale_dev_provider] # bootstrap/cache/packages.php 가 이전 설치본(dev composer)의 provider 를 담고 있는가
|
||||||
|
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 를 교체했는가 스킵했는가
|
||||||
|
|
||||||
exclusions:
|
exclusions:
|
||||||
- { spawn_branch: proc_open_disabled, parent_memory_version: equal_to_to, reason: "proc_open 비활성 시점에 부모는 항상 fromVersion 메모리 — equal 케이스 시뮬 불가" }
|
- { spawn_branch: proc_open_disabled, parent_memory_version: equal_to_to, reason: "proc_open 비활성 시점에 부모는 항상 fromVersion 메모리 — equal 케이스 시뮬 불가" }
|
||||||
@@ -61,6 +69,10 @@ exclusions:
|
|||||||
- { child_process_origin: beta_4_or_earlier, steps_discovered_signal: discovered_zero, reason: "동일 — 구버전 자식은 discovered 필드 미발행(absent)" }
|
- { child_process_origin: beta_4_or_earlier, steps_discovered_signal: discovered_zero, reason: "동일 — 구버전 자식은 discovered 필드 미발행(absent)" }
|
||||||
- { spawn_branch: child_zero_steps_no_step_files, steps_discovered_signal: discovered_positive, reason: "스텝 파일 부재 브랜치는 정의상 discovered=0 — positive 모순" }
|
- { spawn_branch: child_zero_steps_no_step_files, steps_discovered_signal: discovered_positive, reason: "스텝 파일 부재 브랜치는 정의상 discovered=0 — positive 모순" }
|
||||||
- { spawn_branch: child_zero_steps_with_discovered, steps_discovered_signal: discovered_zero, reason: "스텝 파일 존재 브랜치는 정의상 discovered>0 — zero 모순" }
|
- { spawn_branch: child_zero_steps_with_discovered, steps_discovered_signal: discovered_zero, reason: "스텝 파일 존재 브랜치는 정의상 discovered>0 — zero 모순" }
|
||||||
|
- { 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:
|
effects:
|
||||||
# §2 spawn_failure_mode + failSpawnWithMode
|
# §2 spawn_failure_mode + failSpawnWithMode
|
||||||
@@ -113,6 +125,23 @@ effects:
|
|||||||
- snapshotOwnershipDetailed_excludes_current_run_staging_root
|
- snapshotOwnershipDetailed_excludes_current_run_staging_root
|
||||||
- execute_bundled_updates_child_sweeps_empty_staging_directories_left_by_parent
|
- execute_bundled_updates_child_sweeps_empty_staging_directories_left_by_parent
|
||||||
- apply_mode_incremental_prune_hint_does_not_reference_rollback_command
|
- apply_mode_incremental_prune_hint_does_not_reference_rollback_command
|
||||||
|
# #658 stale 패키지 매니페스트 3계층 (부모 선정리 / 자식 자가 치유 / 버전 판독 범위)
|
||||||
|
- spawnUpgradeStepsProcess_clears_package_manifests_before_proc_open
|
||||||
|
- spawn_child_boots_with_regenerated_package_manifest_when_parent_left_stale_dev_manifest
|
||||||
|
- bootstrap_app_unlinks_stale_package_manifest_when_update_flag_present
|
||||||
|
- bootstrap_app_leaves_package_manifest_untouched_without_update_flag
|
||||||
|
- 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
|
||||||
|
# #658 상주 큐 워커 재시작 신호
|
||||||
|
- core_update_step11_signals_queue_restart
|
||||||
|
|
||||||
test_files:
|
test_files:
|
||||||
- tests/Feature/Console/CoreUpdateCommandSpawnFailureTest.php
|
- tests/Feature/Console/CoreUpdateCommandSpawnFailureTest.php
|
||||||
@@ -125,6 +154,11 @@ test_files:
|
|||||||
- tests/Unit/Extension/Helpers/FilePermissionHelperSymlinkTest.php
|
- tests/Unit/Extension/Helpers/FilePermissionHelperSymlinkTest.php
|
||||||
- tests/Unit/Services/CoreUpdateServiceStagingCleanupTest.php
|
- tests/Unit/Services/CoreUpdateServiceStagingCleanupTest.php
|
||||||
- tests/Feature/Console/ExecuteBundledUpdatesCommandTest.php
|
- tests/Feature/Console/ExecuteBundledUpdatesCommandTest.php
|
||||||
|
- tests/Feature/Console/CoreUpdateCommandStalePackageManifestTest.php
|
||||||
|
- tests/Unit/Support/PackageManifestCacheHelperTest.php
|
||||||
|
- tests/Unit/Support/CoreUpdateContextTest.php
|
||||||
|
- tests/Unit/Extension/CoreVersionCheckerEnvPriorityTest.php
|
||||||
|
- tests/Unit/Services/CoreUpdateServiceQueueRestartTest.php
|
||||||
|
|
||||||
# 본 매니페스트의 axes cross product 는 800+ 케이스이나, 실제 회귀 가드는 test_files 의
|
# 본 매니페스트의 axes cross product 는 800+ 케이스이나, 실제 회귀 가드는 test_files 의
|
||||||
# 통과 테스트들이 SSoT — 매니페스트는 매트릭스 SSoT 역할.
|
# 통과 테스트들이 SSoT — 매니페스트는 매트릭스 SSoT 역할.
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# audit:allow test-scenario-coverage [cross_product] reason: task-runner.php 는 진입과 함께 SSE 를 실행하는 파일이라 include 불가 — 조합은 순수 함수 단위 effect 로 검증하고, 분기 순서는 소스 문자열로 잠근다.
|
||||||
|
|
||||||
|
feature: 인스톨러 vendor 재사용 경로의 개발용(require-dev) 패키지 감지·안내와 컴파일 캐시 정리
|
||||||
|
|
||||||
|
description: |
|
||||||
|
인스톨러는 `vendor/autoload.php` 와 `composer.lock` 이 둘 다 있으면 composer 단계를 통째로
|
||||||
|
건너뛰고 그 vendor 를 그대로 쓴다. 그런데 그 vendor 가 `composer install`(옵션 없음)의
|
||||||
|
결과면 require-dev 와 그 전이 의존성까지 들어 있고, 그 목록이 `bootstrap/cache/packages.php`
|
||||||
|
에 provider 로 등재된다. 종전에는 이 사실을 아무 데도 표시하지 않았다.
|
||||||
|
|
||||||
|
그 상태로 운영하면 이후 코어 업데이트가 vendor 를 `--no-dev` 로 교체할 때 매니페스트에만
|
||||||
|
남은 provider 를 찾다 spawn 자식이 부팅 단계에서 죽고, 업데이트가 「Class ... not found」
|
||||||
|
와 수동 재개 안내로 멈춘다 (sir.kr 제보, 7.0.9 → 7.0.10).
|
||||||
|
|
||||||
|
같은 재사용 분기가 컴파일 캐시도 정리하지 않고 return 했다. `key_generate` 가 이미 완료된
|
||||||
|
재개 설치에서는 인스톨러 어느 지점에서도 정리가 일어나지 않아, 이전 환경의 캐시가 그대로
|
||||||
|
남았다.
|
||||||
|
|
||||||
|
처치 (PO 결정 D1 — 설치를 차단하지 않는다):
|
||||||
|
1. 설치 환경 확인(Step 2) 에 「Composer 의존성 구성」 카드를 **선택 항목**으로 추가.
|
||||||
|
경고 상태여도 `isAllRequiredPassed()` 는 이를 보지 않고 `다음` 버튼도 활성이다.
|
||||||
|
2. 설치 로그(Step 5) 에 감지 사실과 정리 명령 안내를 남긴다.
|
||||||
|
3. 재사용 경로에서도 `clearLaravelCompiledCache()` 를 호출한다.
|
||||||
|
|
||||||
|
판정은 코어와 공유하는 `App\Support\ComposerInstallInfo` 가 단독으로 소유한다 — 인스톨러와
|
||||||
|
코어 업데이트가 같은 vendor 를 두고 서로 다른 답을 내놓으면 화면과 로그가 어긋난다.
|
||||||
|
"판정 불가"(installed.json 부재·JSON 오류·Composer 1 형식)는 "개발용 아님" 과 구분한다.
|
||||||
|
|
||||||
|
axes:
|
||||||
|
vendor_state: [absent, present_no_dev, present_dev, present_without_lock]
|
||||||
|
composer_available: [yes, no]
|
||||||
|
install_resume_state: [fresh, resumed_key_generate_done]
|
||||||
|
surface: [env_check_card, install_log]
|
||||||
|
|
||||||
|
exclusions:
|
||||||
|
- { vendor_state: absent, surface: env_check_card, reason: "vendor 가 없으면 카드를 그리지 않는다 — 마법사가 운영용 구성으로 자동 설치하므로 안내할 것이 없다" }
|
||||||
|
- { vendor_state: absent, install_resume_state: resumed_key_generate_done, reason: "vendor 가 없는 상태로 key_generate 까지 도달하는 흐름이 없다 — composer 단계가 선행한다" }
|
||||||
|
- { vendor_state: present_without_lock, surface: env_check_card, reason: "lock 부재는 재사용 분기에 들어가지 않고 vendor 삭제 후 재설치로 간다 (기존 경로, 이번 변경 대상 아님)" }
|
||||||
|
- { vendor_state: present_without_lock, install_resume_state: resumed_key_generate_done, reason: "동일 — 재사용 분기 밖" }
|
||||||
|
- { vendor_state: present_no_dev, composer_available: no, reason: "이미 준비된 vendor 를 재사용하므로 composer 실행 가능 여부가 분기를 바꾸지 않는다" }
|
||||||
|
- { vendor_state: present_dev, composer_available: no, reason: "동일 — 재사용 분기는 composer 를 실행하지 않는다" }
|
||||||
|
|
||||||
|
effects:
|
||||||
|
# 판정 SSoT (App\Support\ComposerInstallInfo)
|
||||||
|
- core_detects_dev_vendor_from_installed_json
|
||||||
|
- core_reports_no_dev_packages_for_no_dev_vendor
|
||||||
|
- core_returns_unknown_when_installed_json_missing
|
||||||
|
# 인스톨러 shim (순수 PHP — Laravel 오토로드 없이 로드)
|
||||||
|
- installer_detects_dev_vendor_from_installed_json
|
||||||
|
- installer_treats_missing_installed_json_as_unknown
|
||||||
|
- installer_shim_matches_core_composer_install_info
|
||||||
|
# 재사용 분기 — 경고하되 설치는 계속하고, 캐시를 정리한다
|
||||||
|
- installer_warns_and_keeps_dev_vendor
|
||||||
|
- installer_clears_compiled_cache_on_reuse_path
|
||||||
|
# 설치 환경 확인 카드 — 선택 항목이며 게이트로 승격되지 않는다
|
||||||
|
- installer_env_check_shows_dev_vendor_card_without_gating
|
||||||
|
# 다국어 — ko/en 쌍과 치환 자리
|
||||||
|
- installer_lang_keys_exist_in_both_locales
|
||||||
|
|
||||||
|
# 실행되는 명령만이 아니라 실패 시 안내하는 명령까지 --no-dev 를 갖는다
|
||||||
|
- installer_composer_commands_all_carry_no_dev
|
||||||
|
|
||||||
|
test_files:
|
||||||
|
- tests/Feature/Installer/DevVendorDetectionTest.php
|
||||||
|
- tests/Unit/Support/ComposerInstallInfoTest.php
|
||||||
|
- tests/Unit/Installer/RequirementsVendorDevPackagesTest.php
|
||||||
|
|
||||||
|
manual_verification: |
|
||||||
|
브라우저 실측 매트릭스는 구현 계획서 §7.3 (T1 환경 확인 카드 / T3 설치 로그 / T10(a) 재개 설치
|
||||||
|
캐시 정리 / T10(b) vendor 없는 신규 설치) 를 따른다. 인스톨러는 SSE 스트림으로 진행하므로
|
||||||
|
로그 문구·카드 배지는 실제 브라우저에서만 최종 확인된다.
|
||||||
Reference in New Issue
Block a user