Merge pull request from gnuboard:HeuJung/issue619
feat(core): 정적 게시 수명주기 보완 — 빌드 파괴·권한·무결성
This commit is contained in:
@@ -1223,7 +1223,23 @@ php artisan plugin:build sirsoft-payment --active # 활성 디렉토리에
|
|||||||
|
|
||||||
> **빌드 원칙**: 기본값은 `_bundled` 디렉토리. 빌드 결과물은 빌드 경로 내에만 남음.
|
> **빌드 원칙**: 기본값은 `_bundled` 디렉토리. 빌드 결과물은 빌드 경로 내에만 남음.
|
||||||
|
|
||||||
`_bundled` 의 `dist/`(코어는 `public/build/core/`)는 Git 추적되는 배포 산출물이다 (`*.map` 만 ignore). src 변경 시 커밋 dist 를 `--production` 으로 동반 재빌드한다 — 신규 소스 리터럴이 dist 에 없으면 stale 빌드이며, 정적 검사가 이를 검출한다. 커밋 dist 에 `//# sourceMappingURL=` 참조를 남기지 않는다 — `.map` 은 배포본에 존재하지 않아 브라우저 개발자 도구에서 404 를 유발한다. 코어 3번들 재빌드는 `core:build --production` (`--full` 은 앱 번들 전용 — `public/build` 를 비워 코어 3번들을 지운다).
|
`_bundled` 의 `dist/`(코어는 `public/build/core/`)는 Git 추적되는 배포 산출물이다 (`*.map` 만 ignore). src 변경 시 커밋 dist 를 `--production` 으로 동반 재빌드한다 — 신규 소스 리터럴이 dist 에 없으면 stale 빌드이며, 정적 검사가 이를 검출한다. 커밋 dist 에 `//# sourceMappingURL=` 참조를 남기지 않는다 — `.map` 은 배포본에 존재하지 않아 브라우저 개발자 도구에서 404 를 유발한다. 코어 3번들 재빌드는 `core:build --production`.
|
||||||
|
|
||||||
|
### 빌드는 자기 산출물만 교체한다 (`emptyOutDir`)
|
||||||
|
|
||||||
|
모든 vite config 는 `build.emptyOutDir: false` 를 **명시**한다. 기본값 `true` 는 산출물 디렉토리를 통째로 비우는데, 그 디렉토리에는 vite 가 만들지 않는 서빙 자산이 함께 산다.
|
||||||
|
|
||||||
|
| 함께 지워지던 것 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| `public/build/core/` 3번들 | 폴백이 없다 — `template-engine.min.js` 는 동기 classic 스크립트라 소실 = **사이트 부팅 불가**, 안내 화면조차 렌더되지 않는다 |
|
||||||
|
| `public/build/ext/{v}/` 게시본 | 이미 배달된 HTML 의 immutable URL 이 404. 재게시로 새 버전이 생겨도 **그 URL 은 복구되지 않는다** |
|
||||||
|
| 확장 `dist/vendor/` | 확장이 동봉한 구동 제3자 자산 소실 (자체 제공 원칙 위반) |
|
||||||
|
|
||||||
|
소실은 예외도 서버 로그도 남기지 않는다 — 브라우저 404 로만 나타나므로 운영자에게는 흔적이 없다. 잔존하는 구 해시 산출물은 `manifest.json`(또는 고정 파일명)이 선택하므로 참조되지 않는 사표이고, 정리 책임은 빌드 커맨드가 진다.
|
||||||
|
|
||||||
|
빌드 커맨드의 산출물 정리는 **활성 디렉토리를 건너뛴다.** 정리는 빌드 *전에* 돌므로 웹이 서빙 중인 `dist/` 를 비우면 빌드 완료까지가 통째로 서빙 공백이 되고, 빌드가 실패하면 빈 채로 남는다. 정리 대상은 `_bundled` / `_pending` 소스 디렉토리뿐이다.
|
||||||
|
|
||||||
|
정적 검사가 모든 vite config 의 명시 선언을 강제한다 (기본값 의존 금지 — 규약이 코드에 남지 않으면 다음 편집자가 같은 결함을 재도입한다).
|
||||||
> 활성 디렉토리 반영은 `update` 커맨드로만 수행. `--watch` 모드는 실시간 개발용으로 활성 디렉토리를 자동 사용.
|
> 활성 디렉토리 반영은 `update` 커맨드로만 수행. `--watch` 모드는 실시간 개발용으로 활성 디렉토리를 자동 사용.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
- 주소 끝에 `?custom=off` 를 붙여 열면 운영자가 추가한 CSS·JS 없이 화면이 표시됩니다. 추가한 스타일이 화면을 망가뜨려 고치러 들어갈 수조차 없게 된 상황에서 쓰는 탈출구이며, 레이아웃 편집기 툴바에도 같은 동작의 버튼이 있습니다. 이 설정은 저장되지 않고 그 화면에만 적용됩니다.
|
- 주소 끝에 `?custom=off` 를 붙여 열면 운영자가 추가한 CSS·JS 없이 화면이 표시됩니다. 추가한 스타일이 화면을 망가뜨려 고치러 들어갈 수조차 없게 된 상황에서 쓰는 탈출구이며, 레이아웃 편집기 툴바에도 같은 동작의 버튼이 있습니다. 이 설정은 저장되지 않고 그 화면에만 적용됩니다.
|
||||||
- 템플릿·모듈·플러그인의 `custom/` 파일이 다른 확장 자산과 같은 방식으로 정적 파일로 게시됩니다. CSS 안에서 글꼴·이미지를 상대 경로(`url('./font.woff2')`)로 참조할 수 있게 되었고, 파일을 고치면 자동으로 다시 게시됩니다.
|
- 템플릿·모듈·플러그인의 `custom/` 파일이 다른 확장 자산과 같은 방식으로 정적 파일로 게시됩니다. CSS 안에서 글꼴·이미지를 상대 경로(`url('./font.woff2')`)로 참조할 수 있게 되었고, 파일을 고치면 자동으로 다시 게시됩니다.
|
||||||
- 초기 화면에 필요한 다국어·컴포넌트 정의·라우트 정보·확장 번들·템플릿 에셋을 정적 파일로 미리 만들어 웹서버가 직접 전달합니다. 확장 설치/활성화나 레이아웃 편집 시 자동으로 다시 생성되며, 파일이 없으면 기존 방식으로 동작합니다. 초기 화면 표시가 빨라집니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
|
- 초기 화면에 필요한 다국어·컴포넌트 정의·라우트 정보·확장 번들·템플릿 에셋을 정적 파일로 미리 만들어 웹서버가 직접 전달합니다. 확장 설치/활성화나 레이아웃 편집 시 자동으로 다시 생성되며, 파일이 없으면 기존 방식으로 동작합니다. 초기 화면 표시가 빨라집니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
|
||||||
|
- 관리자 대시보드에 초기 화면 파일 생성 실패 알림이 추가되었습니다. 원인(폴더 권한·디스크 공간·캐시)에 따라 다른 안내가 표시되며, 서버에서 `php artisan ext-static:status` 로 더 자세한 상태를 확인할 수 있습니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
@@ -29,6 +30,15 @@
|
|||||||
- 사이트 첫 접속 시 확장 캐시 버전이 어긋나 있으면 라우트·다국어 데이터를 두 번 내려받던 문제를 수정했습니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
|
- 사이트 첫 접속 시 확장 캐시 버전이 어긋나 있으면 라우트·다국어 데이터를 두 번 내려받던 문제를 수정했습니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
|
||||||
- 레이아웃 props 의 `$switch` 조건 분기 값이 검색엔진(봇) 화면에서는 해석되지 않아 해당 속성이 표시되지 않던 문제를 수정했습니다. 이제 일반 화면과 봇 화면이 동일하게 분기 값을 렌더링합니다.
|
- 레이아웃 props 의 `$switch` 조건 분기 값이 검색엔진(봇) 화면에서는 해석되지 않아 해당 속성이 표시되지 않던 문제를 수정했습니다. 이제 일반 화면과 봇 화면이 동일하게 분기 값을 렌더링합니다.
|
||||||
- sudo(root) 로 코어를 업데이트하면 업데이트 과정이 만든 캐시 파일이 root 소유로 남아, 이후 웹 화면 전체가 서버 오류(500)가 되거나 캐시가 동작하지 않을 수 있던 문제를 수정했습니다. 업데이트 종료 시 캐시·번들 디렉토리 소유권을 자동 정상화하고, 캐시 쓰기 실패는 화면을 중단시키지 않고 경고 로그와 함께 무캐시로 계속 동작합니다.
|
- sudo(root) 로 코어를 업데이트하면 업데이트 과정이 만든 캐시 파일이 root 소유로 남아, 이후 웹 화면 전체가 서버 오류(500)가 되거나 캐시가 동작하지 않을 수 있던 문제를 수정했습니다. 업데이트 종료 시 캐시·번들 디렉토리 소유권을 자동 정상화하고, 캐시 쓰기 실패는 화면을 중단시키지 않고 경고 로그와 함께 무캐시로 계속 동작합니다.
|
||||||
|
- 프론트엔드를 다시 빌드하면 초기 화면 파일과 화면 구동에 필요한 코어 파일이 함께 지워져, 이미 열려 있던 페이지에서 파일을 찾지 못하던 문제를 수정했습니다. 빌드는 이제 자기 산출물만 교체합니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
|
||||||
|
- 명령줄에서 초기 화면 파일을 처음 만들면 이후 웹에서 다시 만들지 못해, 확장을 설치·변경해도 초기 화면 파일이 갱신되지 않던 문제를 수정했습니다. 만들어진 파일이 웹 프로세스도 쓸 수 있는 권한을 갖도록 정리하고, 권한이 부족하면 화면이 느려질 뿐 멈추지 않도록 처리한 뒤 관리자 대시보드에 원인을 알립니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
|
||||||
|
- 디스크가 가득 찬 상태에서 만들어진 손상된 초기 화면 파일이 그대로 사용되어 화면이 뜨지 않던 문제를 수정했습니다. 파일을 만들 때 기록된 내용이 온전한지 확인하고, 손상이 확인되면 종전 방식으로 자동 전환합니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
|
||||||
|
- 초기 화면 파일을 새로 만드는 도중 짧은 시간 동안 기존 파일을 찾지 못할 수 있던 문제를 수정했습니다. 새 파일이 완성된 뒤에야 이전 파일을 정리합니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
|
||||||
|
- 캐시를 비운 뒤 정리 작업이 먼저 실행되면 아직 사용 중인 직전 초기 화면 파일이 삭제될 수 있던 문제를 수정했습니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
|
||||||
|
- 확장 프로그램의 스크립트·스타일 묶음을 저장하지 못하는 환경에서 해당 요청이 서버 오류(500)가 되던 문제를 수정했습니다. 저장에 실패해도 화면에는 정상적으로 전달됩니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
|
||||||
|
- 배포 도중 확장 파일이 잠시 비면 내용이 빠진 빈 묶음이 정상 응답으로 전달되어, 한참 뒤 기능이 동작하지 않는 형태로만 드러나던 문제를 수정했습니다. 이제 그 상태를 오류로 알립니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
|
||||||
|
- 일부 확장자(`.mjs`, `.webp`, `.otf`) 의 없는 파일을 요청하면 파일 대신 페이지 내용이 전달되어 화면이 깨지던 문제를 수정했습니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
|
||||||
|
- 초기 화면 파일을 새로 만들 때 파일 시스템이 일시적으로 이동을 거부하면 그 한 번으로 생성이 실패하던 문제를 수정했습니다. 이제 잠시 후 다시 시도하며, 사이트 동작에는 영향이 없지만 불필요한 실패 알림이 뜨던 상황이 사라집니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
|
||||||
|
|
||||||
## [7.0.9] - 2026-08-24
|
## [7.0.9] - 2026-08-24
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -516,8 +516,8 @@ cp .env.example .env
|
|||||||
<!-- community-contributors:start -->
|
<!-- community-contributors:start -->
|
||||||
<p>
|
<p>
|
||||||
<a href="https://github.com/jiwonpapa" title="jiwonpapa"><img src="https://github.com/jiwonpapa.png" width="48" alt="jiwonpapa"></a>
|
<a href="https://github.com/jiwonpapa" title="jiwonpapa"><img src="https://github.com/jiwonpapa.png" width="48" alt="jiwonpapa"></a>
|
||||||
<a href="https://github.com/Tuwasduliebst" title="Tuwasduliebst"><img src="https://github.com/Tuwasduliebst.png" width="48" alt="Tuwasduliebst"></a>
|
|
||||||
<a href="https://github.com/glitter-gim" title="glitter-gim"><img src="https://github.com/glitter-gim.png" width="48" alt="glitter-gim"></a>
|
<a href="https://github.com/glitter-gim" title="glitter-gim"><img src="https://github.com/glitter-gim.png" width="48" alt="glitter-gim"></a>
|
||||||
|
<a href="https://github.com/Tuwasduliebst" title="Tuwasduliebst"><img src="https://github.com/Tuwasduliebst.png" width="48" alt="Tuwasduliebst"></a>
|
||||||
<a href="https://github.com/jordy-bitree" title="jordy-bitree"><img src="https://github.com/jordy-bitree.png" width="48" alt="jordy-bitree"></a>
|
<a href="https://github.com/jordy-bitree" title="jordy-bitree"><img src="https://github.com/jordy-bitree.png" width="48" alt="jordy-bitree"></a>
|
||||||
<a href="https://github.com/laelbe" title="laelbe"><img src="https://github.com/laelbe.png" width="48" alt="laelbe"></a>
|
<a href="https://github.com/laelbe" title="laelbe"><img src="https://github.com/laelbe.png" width="48" alt="laelbe"></a>
|
||||||
<a href="https://github.com/lyg-kaban" title="lyg-kaban"><img src="https://github.com/lyg-kaban.png" width="48" alt="lyg-kaban"></a>
|
<a href="https://github.com/lyg-kaban" title="lyg-kaban"><img src="https://github.com/lyg-kaban.png" width="48" alt="lyg-kaban"></a>
|
||||||
|
|||||||
@@ -530,8 +530,8 @@ Thanks to everyone who reported an issue or suggested a feature that shipped —
|
|||||||
<!-- community-contributors:start -->
|
<!-- community-contributors:start -->
|
||||||
<p>
|
<p>
|
||||||
<a href="https://github.com/jiwonpapa" title="jiwonpapa"><img src="https://github.com/jiwonpapa.png" width="48" alt="jiwonpapa"></a>
|
<a href="https://github.com/jiwonpapa" title="jiwonpapa"><img src="https://github.com/jiwonpapa.png" width="48" alt="jiwonpapa"></a>
|
||||||
<a href="https://github.com/Tuwasduliebst" title="Tuwasduliebst"><img src="https://github.com/Tuwasduliebst.png" width="48" alt="Tuwasduliebst"></a>
|
|
||||||
<a href="https://github.com/glitter-gim" title="glitter-gim"><img src="https://github.com/glitter-gim.png" width="48" alt="glitter-gim"></a>
|
<a href="https://github.com/glitter-gim" title="glitter-gim"><img src="https://github.com/glitter-gim.png" width="48" alt="glitter-gim"></a>
|
||||||
|
<a href="https://github.com/Tuwasduliebst" title="Tuwasduliebst"><img src="https://github.com/Tuwasduliebst.png" width="48" alt="Tuwasduliebst"></a>
|
||||||
<a href="https://github.com/jordy-bitree" title="jordy-bitree"><img src="https://github.com/jordy-bitree.png" width="48" alt="jordy-bitree"></a>
|
<a href="https://github.com/jordy-bitree" title="jordy-bitree"><img src="https://github.com/jordy-bitree.png" width="48" alt="jordy-bitree"></a>
|
||||||
<a href="https://github.com/laelbe" title="laelbe"><img src="https://github.com/laelbe.png" width="48" alt="laelbe"></a>
|
<a href="https://github.com/laelbe" title="laelbe"><img src="https://github.com/laelbe.png" width="48" alt="laelbe"></a>
|
||||||
<a href="https://github.com/lyg-kaban" title="lyg-kaban"><img src="https://github.com/lyg-kaban.png" width="48" alt="lyg-kaban"></a>
|
<a href="https://github.com/lyg-kaban" title="lyg-kaban"><img src="https://github.com/lyg-kaban.png" width="48" alt="lyg-kaban"></a>
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ trait PrunesBuildOutput
|
|||||||
* 감시(watch) 모드에서는 호출하지 않는다 — 개발 중 재빌드마다 지우면 브라우저가
|
* 감시(watch) 모드에서는 호출하지 않는다 — 개발 중 재빌드마다 지우면 브라우저가
|
||||||
* 참조 중인 파일이 사라진다.
|
* 참조 중인 파일이 사라진다.
|
||||||
*
|
*
|
||||||
|
* **활성 디렉토리는 정리하지 않는다.** prune 은 빌드 *전에* 실행되므로, 웹이 서빙
|
||||||
|
* 중인 `dist/` 를 비우면 prune~빌드 완료 구간 전체가 서빙 공백이 된다(빌드가 실패하면
|
||||||
|
* 빈 채로 남는다). 확장 개발은 `_bundled` 에서 수행하고 활성 반영은 `{type}:update`
|
||||||
|
* 가 담당하므로, 활성 경로 빌드는 예외적 경로다 — 그 경우 stale 산출물이 누적되는
|
||||||
|
* 것을 감수하고 서빙 연속성을 택한다. 외부 확장처럼 `_bundled` 가 없어 활성 빌드가
|
||||||
|
* 유일한 경로인 경우에도 사이트가 끊기지 않는다.
|
||||||
|
*
|
||||||
* @param string $buildPath 확장 루트 경로 (`dist/` 의 부모)
|
* @param string $buildPath 확장 루트 경로 (`dist/` 의 부모)
|
||||||
* @param array<int, string> $preserve 삭제하지 않을 최상위 항목명
|
* @param array<int, string> $preserve 삭제하지 않을 최상위 항목명
|
||||||
* @return array<int, string> 삭제한 최상위 항목명 목록
|
* @return array<int, string> 삭제한 최상위 항목명 목록
|
||||||
@@ -38,6 +45,16 @@ trait PrunesBuildOutput
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! $this->isBundledSourcePath($buildPath)) {
|
||||||
|
$this->warn(
|
||||||
|
' ⚠️ 활성 디렉토리 빌드 — 이전 산출물을 정리하지 않습니다 '
|
||||||
|
.'(정리하면 빌드 완료까지 서빙이 끊깁니다). stale 산출물이 누적될 수 있으니 '
|
||||||
|
.'개발은 _bundled 에서 하고 활성 반영은 update 커맨드로 하세요.'
|
||||||
|
);
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
$removed = [];
|
$removed = [];
|
||||||
|
|
||||||
foreach (new \FilesystemIterator($distPath, \FilesystemIterator::SKIP_DOTS) as $item) {
|
foreach (new \FilesystemIterator($distPath, \FilesystemIterator::SKIP_DOTS) as $item) {
|
||||||
@@ -66,4 +83,21 @@ trait PrunesBuildOutput
|
|||||||
|
|
||||||
return $removed;
|
return $removed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 빌드 경로가 소스 디렉토리(`_bundled` / `_pending`)인지 판정합니다.
|
||||||
|
*
|
||||||
|
* 이 둘은 웹이 서빙하지 않는 소스 보관소라 비워도 서빙 공백이 없다. 그 밖의 경로는
|
||||||
|
* 활성 디렉토리(= 서빙 중)로 본다 — 판정을 뒤집어 두면(활성 목록을 열거하면) 새로운
|
||||||
|
* 배치가 생길 때마다 조용히 활성 경로가 정리 대상이 된다.
|
||||||
|
*
|
||||||
|
* @param string $buildPath 확장 루트 경로
|
||||||
|
* @return bool 소스 디렉토리 여부
|
||||||
|
*/
|
||||||
|
private function isBundledSourcePath(string $buildPath): bool
|
||||||
|
{
|
||||||
|
$normalized = str_replace('\\', '/', $buildPath);
|
||||||
|
|
||||||
|
return str_contains($normalized, '/_bundled/') || str_contains($normalized, '/_pending/');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,6 +135,8 @@ class BuildCoreCommand extends Command
|
|||||||
return $dashboardResult;
|
return $dashboardResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->pruneStaleSourceMaps($productionMode);
|
||||||
|
|
||||||
$this->info('✅ 코어 빌드 완료 (템플릿 엔진 + 레이아웃 편집기 + DevTools + 개발 대시보드 CSS)');
|
$this->info('✅ 코어 빌드 완료 (템플릿 엔진 + 레이아웃 편집기 + DevTools + 개발 대시보드 CSS)');
|
||||||
$this->showEngineBuildResults($projectPath);
|
$this->showEngineBuildResults($projectPath);
|
||||||
$this->incrementExtensionCacheVersion();
|
$this->incrementExtensionCacheVersion();
|
||||||
@@ -142,6 +144,44 @@ class BuildCoreCommand extends Command
|
|||||||
return Command::SUCCESS;
|
return Command::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로덕션 빌드 후 `public/build/core/` 에 남은 소스맵을 제거합니다.
|
||||||
|
*
|
||||||
|
* 프로덕션 빌드는 `G7_BUILD_SOURCEMAP=0` 으로 맵을 **만들지 않을 뿐**, 이전 개발 빌드가
|
||||||
|
* 남긴 맵을 지우지는 않는다. 그 디렉토리는 웹루트라 남아 있는 맵은 웹서버가 그대로
|
||||||
|
* 서빙하고, 맵에는 원본 코드 전문(`sourcesContent`)이 담긴다 — 확장자 화이트리스트가
|
||||||
|
* 막아 주는 확장 에셋과 달리 이 경로는 정적 서빙이라 통과한다.
|
||||||
|
*
|
||||||
|
* 종전에는 루트 `npm run build` 의 `emptyOutDir` 이 디렉토리를 통째로 비우면서 이 맵들을
|
||||||
|
* 함께 지웠다. 그 동작은 서빙 중인 코어 번들·게시본까지 지우는 결함이라 껐으므로(#122),
|
||||||
|
* 소스맵 정리 책임을 빌드 커맨드가 명시적으로 넘겨받는다.
|
||||||
|
*
|
||||||
|
* @param bool $productionMode 프로덕션 빌드 여부
|
||||||
|
*/
|
||||||
|
private function pruneStaleSourceMaps(bool $productionMode): void
|
||||||
|
{
|
||||||
|
if (! $productionMode) {
|
||||||
|
// 로컬 빌드는 디버깅을 위해 맵을 의도적으로 생성한다 — 지우면 그 목적이 사라진다.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$removed = [];
|
||||||
|
|
||||||
|
foreach (glob(public_path('build/core').DIRECTORY_SEPARATOR.'*.map') ?: [] as $map) {
|
||||||
|
if (@unlink($map)) {
|
||||||
|
$removed[] = basename($map);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->warn(' ⚠️ 소스맵 삭제 실패 (수동 제거 필요): '.$map);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($removed !== []) {
|
||||||
|
$this->line(' 🧹 잔존 소스맵 제거: '.implode(', ', $removed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 감시 모드에서 엔진 번들 + 편집기 번들을 병렬로 vite --watch 실행합니다.
|
* 감시 모드에서 엔진 번들 + 편집기 번들을 병렬로 vite --watch 실행합니다.
|
||||||
*
|
*
|
||||||
@@ -222,6 +262,7 @@ class BuildCoreCommand extends Command
|
|||||||
);
|
);
|
||||||
|
|
||||||
if ($result === Command::SUCCESS && ! $watchMode) {
|
if ($result === Command::SUCCESS && ! $watchMode) {
|
||||||
|
$this->pruneStaleSourceMaps($productionMode);
|
||||||
$this->info('✅ 코어 빌드 완료 (전체)');
|
$this->info('✅ 코어 빌드 완료 (전체)');
|
||||||
$this->showFullBuildResults($projectPath);
|
$this->showFullBuildResults($projectPath);
|
||||||
$this->incrementExtensionCacheVersion();
|
$this->incrementExtensionCacheVersion();
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\ExtensionStaticCacheService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 부트스트랩 리소스 정적 게시 상태 점검 커맨드 (#122)
|
||||||
|
*
|
||||||
|
* 게시 실패는 사이트를 멈추지 않는다 — API 폴백으로 넘어가 화면은 정상이다. 그래서 정상
|
||||||
|
* 운영 환경에서 실패를 확인할 방법이 사실상 없었다(`/dev` 대시보드는 `app.debug` 가 필요한데
|
||||||
|
* 게시는 프로덕션 전용이다). 이 커맨드가 그 통로다.
|
||||||
|
*
|
||||||
|
* 출력: 현재 버전 / 게시 여부 / manifest 파일 수 / 게시 트리 쓰기 가능성 /
|
||||||
|
* 최근 실패 마커 / 잔존 버전 목록.
|
||||||
|
*
|
||||||
|
* 종료 코드: 이상이 하나라도 있으면 비-0. 비-0 은 "커맨드 실행 실패" 가 아니라
|
||||||
|
* **이상 발견 신호**다 — 운영자가 조치할 대상이 있다는 뜻이다.
|
||||||
|
*/
|
||||||
|
class StatusExtensionStaticCacheCommand extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*/
|
||||||
|
protected $signature = 'ext-static:status';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*/
|
||||||
|
protected $description = '부트스트랩 리소스 정적 게시 상태를 점검합니다 (이상 발견 시 비-0 종료)';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*
|
||||||
|
* @param ExtensionStaticCacheService $service 정적 게시 서비스
|
||||||
|
* @return int 이상 없으면 0, 이상 발견 시 1
|
||||||
|
*/
|
||||||
|
public function handle(ExtensionStaticCacheService $service): int
|
||||||
|
{
|
||||||
|
$problems = [];
|
||||||
|
|
||||||
|
$enabled = $service->isEnabled();
|
||||||
|
$version = ExtensionStaticCacheService::getExtensionCacheVersion();
|
||||||
|
$base = $service->baseDir();
|
||||||
|
$versionDir = $service->versionDir($version);
|
||||||
|
$published = $service->isPublished($version);
|
||||||
|
|
||||||
|
$this->line('');
|
||||||
|
$this->line('<options=bold>부트스트랩 리소스 정적 게시 상태</>');
|
||||||
|
$this->line('');
|
||||||
|
|
||||||
|
$this->line(' 실행 계정 : '.ExtensionStaticCacheService::currentProcessUser());
|
||||||
|
$this->line(' kill-switch : '.($enabled ? '활성 (게시함)' : '<comment>비활성 (게시 안 함)</comment>'));
|
||||||
|
$this->line(' 현재 캐시 버전 : '.$version);
|
||||||
|
$this->line(' 게시 루트 : '.$base);
|
||||||
|
|
||||||
|
// kill-switch 가 꺼져 있으면 미게시는 정상이다 — 이상으로 세지 않는다.
|
||||||
|
if (! $enabled) {
|
||||||
|
$this->line('');
|
||||||
|
$this->comment('kill-switch 가 꺼져 있어 게시 상태는 점검하지 않습니다 (core.static_cache.enabled).');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) 게시 트리 쓰기 가능성 — 제보 본건(P1/P2)의 직접 지표
|
||||||
|
$writable = File::isDirectory($base) ? is_writable($base) : $this->parentIsWritable($base);
|
||||||
|
|
||||||
|
if ($writable) {
|
||||||
|
$this->line(' 트리 쓰기 : 가능');
|
||||||
|
} else {
|
||||||
|
$this->line(' 트리 쓰기 : <error>불가</error>');
|
||||||
|
$problems[] = sprintf(
|
||||||
|
'게시 트리에 쓸 수 없습니다 (%s, owner=%s, perms=%s). 웹 계정이 재게시할 수 없어 '
|
||||||
|
.'모든 요청이 API 폴백으로 동작합니다.',
|
||||||
|
$base,
|
||||||
|
(string) (@fileowner($base) ?: 'unknown'),
|
||||||
|
File::exists($base) ? substr(sprintf('%o', @fileperms($base)), -4) : 'absent'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 현재 버전 게시 여부
|
||||||
|
if ($published) {
|
||||||
|
$manifest = json_decode((string) @file_get_contents($versionDir.'/manifest.json'), true);
|
||||||
|
$fileCount = is_array($manifest) ? count($manifest['files'] ?? []) : 0;
|
||||||
|
|
||||||
|
$this->line(' 게시 상태 : 완료');
|
||||||
|
$this->line(' manifest 파일 : '.$fileCount.'건');
|
||||||
|
$this->line(' 게시 시각 : '.(is_array($manifest) ? ($manifest['published_at'] ?? '?') : '?'));
|
||||||
|
|
||||||
|
if ($fileCount === 0) {
|
||||||
|
$problems[] = 'manifest 에 기록된 파일이 0건입니다 — 게시가 비어 있습니다.';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$this->line(' 게시 상태 : <comment>미게시</comment>');
|
||||||
|
$problems[] = sprintf(
|
||||||
|
'현재 버전(%d)이 게시되지 않았습니다. 다음 웹 렌더의 자가 치유가 시도하며, '
|
||||||
|
.'즉시 게시하려면 `php artisan ext-static:publish` 를 실행하세요.',
|
||||||
|
$version
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 최근 실패 마커 — 원인별로 조치가 다르다
|
||||||
|
$marker = ExtensionStaticCacheService::failureMarker();
|
||||||
|
|
||||||
|
if ($marker !== null) {
|
||||||
|
$this->line('');
|
||||||
|
$this->line(' <error>최근 게시 실패</error>');
|
||||||
|
$this->line(' 사유 : '.($marker['reason'] ?? '?').' — '.$this->reasonHint($marker['reason'] ?? ''));
|
||||||
|
$this->line(' 버전 : '.($marker['version'] ?? '?'));
|
||||||
|
$this->line(' 시각 : '.($marker['at'] ?? '?'));
|
||||||
|
$this->line(' 연속 실패 : '.($marker['count'] ?? '?').'회');
|
||||||
|
$this->line(' 상세 : '.($marker['message'] ?? ''));
|
||||||
|
|
||||||
|
$problems[] = '게시 실패 마커가 남아 있습니다 (사유: '.($marker['reason'] ?? '?').').';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) 잔존 버전 목록 — 누적은 삭제 실패(소유권 불일치)의 지표다
|
||||||
|
$versions = $this->publishedVersions($base);
|
||||||
|
|
||||||
|
$this->line('');
|
||||||
|
$this->line(' 잔존 버전 : '.($versions === [] ? '(없음)' : implode(', ', $versions)));
|
||||||
|
|
||||||
|
if (count($versions) > 3) {
|
||||||
|
$problems[] = sprintf(
|
||||||
|
'게시 버전이 %d개 누적됐습니다 (정상은 현재+직전 2개). 삭제가 실패하고 있을 수 '
|
||||||
|
.'있습니다 — 소유권/권한을 확인하세요.',
|
||||||
|
count($versions)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->line('');
|
||||||
|
|
||||||
|
if ($problems === []) {
|
||||||
|
$this->info('이상 없음.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($problems as $problem) {
|
||||||
|
$this->warn('• '.$problem);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->line('');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게시 루트가 아직 없을 때, 만들 수 있는지(부모 쓰기 가능) 판정합니다.
|
||||||
|
*
|
||||||
|
* @param string $base 게시 루트 절대 경로
|
||||||
|
* @return bool 생성 가능 여부
|
||||||
|
*/
|
||||||
|
private function parentIsWritable(string $base): bool
|
||||||
|
{
|
||||||
|
$parent = dirname($base);
|
||||||
|
|
||||||
|
return File::isDirectory($parent) && is_writable($parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 실패 사유 코드에 대한 조치 힌트를 반환합니다.
|
||||||
|
*
|
||||||
|
* 사유별로 볼 곳이 다르다 — 뭉뚱그리면 운영자가 어디를 봐야 할지 알 수 없다.
|
||||||
|
*
|
||||||
|
* @param string $reason 사유 코드
|
||||||
|
* @return string 조치 힌트
|
||||||
|
*/
|
||||||
|
private function reasonHint(string $reason): string
|
||||||
|
{
|
||||||
|
return match ($reason) {
|
||||||
|
'parent_not_writable' => '게시 트리 권한 문제입니다. CLI 계정과 웹 계정이 그룹을 공유하고 '
|
||||||
|
.'`public/build` 가 그룹 쓰기(g+w)인지 확인하세요.',
|
||||||
|
'write_failed' => '쓰기 도중 실패했습니다. 디스크 여유 공간과 quota 를 확인하세요.',
|
||||||
|
'lock_unavailable' => '캐시 락을 얻지 못했습니다. 캐시 저장소(파일 캐시 디렉토리 권한 등)를 확인하세요.',
|
||||||
|
default => '상세 메시지를 확인하세요.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게시 루트에 남아 있는 버전 디렉토리 목록을 반환합니다 (오름차순).
|
||||||
|
*
|
||||||
|
* @param string $base 게시 루트 절대 경로
|
||||||
|
* @return array<int, int> 버전 목록
|
||||||
|
*/
|
||||||
|
private function publishedVersions(string $base): array
|
||||||
|
{
|
||||||
|
if (! File::isDirectory($base)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$versions = [];
|
||||||
|
|
||||||
|
foreach (File::directories($base) as $dir) {
|
||||||
|
$name = basename($dir);
|
||||||
|
|
||||||
|
if (ctype_digit($name)) {
|
||||||
|
$versions[] = (int) $name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort($versions);
|
||||||
|
|
||||||
|
return $versions;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -344,6 +344,11 @@ class ExtensionPendingHelper
|
|||||||
?\Closure $onProgress = null
|
?\Closure $onProgress = null
|
||||||
): void {
|
): void {
|
||||||
File::ensureDirectoryExists($dest, 0775);
|
File::ensureDirectoryExists($dest, 0775);
|
||||||
|
// 디렉토리도 부모 소유권을 상속시킨다 — 파일만 `copyFile` 로 상속시키면 sudo 로
|
||||||
|
// 실행된 설치/업데이트가 만든 **디렉토리**가 root 소유로 남아, 이후 웹 프로세스의
|
||||||
|
// 쓰기가 그 디렉토리에서 막힌다. 형제 구현 `ExtensionBackupHelper::
|
||||||
|
// copyDirectoryWithProgress` 는 이미 같은 방어를 갖고 있다 (계층 불균형 해소).
|
||||||
|
FilePermissionHelper::inheritOwnershipFromParent($dest);
|
||||||
$items = new \FilesystemIterator($source, \FilesystemIterator::SKIP_DOTS);
|
$items = new \FilesystemIterator($source, \FilesystemIterator::SKIP_DOTS);
|
||||||
|
|
||||||
foreach ($items as $item) {
|
foreach ($items as $item) {
|
||||||
@@ -384,6 +389,7 @@ class ExtensionPendingHelper
|
|||||||
private static function syncDirectoryContents(string $source, string $dest, ?\Closure $onProgress = null): void
|
private static function syncDirectoryContents(string $source, string $dest, ?\Closure $onProgress = null): void
|
||||||
{
|
{
|
||||||
File::ensureDirectoryExists($dest, 0775);
|
File::ensureDirectoryExists($dest, 0775);
|
||||||
|
FilePermissionHelper::inheritOwnershipFromParent($dest);
|
||||||
|
|
||||||
$failed = [];
|
$failed = [];
|
||||||
self::overlayDirectory($source, $dest, $source, $onProgress, $failed);
|
self::overlayDirectory($source, $dest, $source, $onProgress, $failed);
|
||||||
@@ -458,6 +464,9 @@ class ExtensionPendingHelper
|
|||||||
array &$failed
|
array &$failed
|
||||||
): void {
|
): void {
|
||||||
File::ensureDirectoryExists($dest, 0775);
|
File::ensureDirectoryExists($dest, 0775);
|
||||||
|
// 제자리 동기화 폴백 경로도 동일 방어 — rename 경로만 고치면 파일 잠금으로
|
||||||
|
// 이 경로로 떨어진 교체에서만 소유권이 조용히 어긋난다.
|
||||||
|
FilePermissionHelper::inheritOwnershipFromParent($dest);
|
||||||
$items = new \FilesystemIterator($source, \FilesystemIterator::SKIP_DOTS);
|
$items = new \FilesystemIterator($source, \FilesystemIterator::SKIP_DOTS);
|
||||||
|
|
||||||
foreach ($items as $item) {
|
foreach ($items as $item) {
|
||||||
|
|||||||
@@ -109,6 +109,17 @@ trait ClearsTemplateCaches
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 재생성도 bump 다 — 게시를 예약한다. `incrementExtensionCacheVersion()` 과
|
||||||
|
// 동형화해 "캐시 버전 갱신 → 게시" 가 **모든 경로**에서 성립하게 한다 (#122).
|
||||||
|
//
|
||||||
|
// 이 경로가 빠져 있던 동안 `cache:clear` 후 첫 호출자가 CLI 면 포인터만 새 버전으로
|
||||||
|
// 점프하고 산출물은 옛 버전에 남았다. 스케줄 GC(`ext-static:cleanup`)가 바로 그
|
||||||
|
// 첫 호출자라, cleanup 첫 줄의 `getExtensionCacheVersion()` 이 실존하지 않는 새
|
||||||
|
// 버전을 만들어 내고 보존 대상이 `[없는 새 버전, 실존 최신 1개]` 가 되어 **진짜
|
||||||
|
// 직전 버전이 삭제**됐다 (매일 04:28 재현). CLI 는 terminating 예약이 그대로
|
||||||
|
// 유효하므로 커맨드 종료 시점에 게시가 수행된다.
|
||||||
|
ExtensionStaticCacheService::schedulePublishOnTerminate();
|
||||||
|
|
||||||
return $newVersion;
|
return $newVersion;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Controllers\Api\Public;
|
namespace App\Http\Controllers\Api\Public;
|
||||||
|
|
||||||
use App\Http\Controllers\Api\Base\PublicBaseController;
|
use App\Http\Controllers\Api\Base\PublicBaseController;
|
||||||
|
use App\Http\Controllers\Concerns\ServesExtensionBundles;
|
||||||
use App\Http\Requests\Public\Module\ServeModuleAssetRequest;
|
use App\Http\Requests\Public\Module\ServeModuleAssetRequest;
|
||||||
use App\Services\ExtensionBundleService;
|
use App\Services\ExtensionBundleService;
|
||||||
use App\Services\ModuleService;
|
use App\Services\ModuleService;
|
||||||
@@ -17,6 +18,8 @@ use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
|||||||
*/
|
*/
|
||||||
class PublicModuleController extends PublicBaseController
|
class PublicModuleController extends PublicBaseController
|
||||||
{
|
{
|
||||||
|
use ServesExtensionBundles;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ModuleService $moduleService,
|
private readonly ModuleService $moduleService,
|
||||||
private readonly ExtensionBundleService $bundleService
|
private readonly ExtensionBundleService $bundleService
|
||||||
@@ -27,9 +30,10 @@ class PublicModuleController extends PublicBaseController
|
|||||||
/**
|
/**
|
||||||
* 활성 모듈 프론트엔드 IIFE 병합 번들(JS)을 서빙합니다.
|
* 활성 모듈 프론트엔드 IIFE 병합 번들(JS)을 서빙합니다.
|
||||||
*
|
*
|
||||||
* 활성 global 모듈 에셋이 없으면 빈 200 응답(text/javascript)을 반환한다
|
* 병합 파일을 fileResponse 로 서빙한다(ETag/304/환경별 Cache-Control 재사용).
|
||||||
* (프론트는 빈 스크립트 로드로 무해). 그 외에는 병합 파일을 fileResponse 로
|
* 디스크 캐시가 실패하면 메모리 병합 결과로 200 을 낸다(캐시는 최적화일 뿐이다).
|
||||||
* 서빙(ETag/304/환경별 Cache-Control 재사용).
|
* 에셋을 선언한 활성 확장이 0개면 빈 200, 선언은 있는데 결과가 비면 503 —
|
||||||
|
* 판정은 ServesExtensionBundles::bundleResponse() 단일 지점.
|
||||||
*
|
*
|
||||||
* @return BinaryFileResponse|Response 병합 JS 파일 응답 또는 빈 응답
|
* @return BinaryFileResponse|Response 병합 JS 파일 응답 또는 빈 응답
|
||||||
*/
|
*/
|
||||||
@@ -37,14 +41,7 @@ class PublicModuleController extends PublicBaseController
|
|||||||
{
|
{
|
||||||
$this->logApiUsage('modules.bundle', ['kind' => 'js']);
|
$this->logApiUsage('modules.bundle', ['kind' => 'js']);
|
||||||
|
|
||||||
$version = $this->bundleService->getCurrentVersion();
|
return $this->bundleResponse($this->bundleService, 'module', 'js', 'text/javascript');
|
||||||
$path = $this->bundleService->getBundleFilePath('module', 'js', $version);
|
|
||||||
|
|
||||||
if ($path === '') {
|
|
||||||
return response('', 200)->header('Content-Type', 'text/javascript');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->fileResponse($path, 'text/javascript', 31536000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,14 +53,7 @@ class PublicModuleController extends PublicBaseController
|
|||||||
{
|
{
|
||||||
$this->logApiUsage('modules.bundle', ['kind' => 'css']);
|
$this->logApiUsage('modules.bundle', ['kind' => 'css']);
|
||||||
|
|
||||||
$version = $this->bundleService->getCurrentVersion();
|
return $this->bundleResponse($this->bundleService, 'module', 'css', 'text/css');
|
||||||
$path = $this->bundleService->getBundleFilePath('module', 'css', $version);
|
|
||||||
|
|
||||||
if ($path === '') {
|
|
||||||
return response('', 200)->header('Content-Type', 'text/css');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->fileResponse($path, 'text/css', 31536000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Controllers\Api\Public;
|
namespace App\Http\Controllers\Api\Public;
|
||||||
|
|
||||||
use App\Http\Controllers\Api\Base\PublicBaseController;
|
use App\Http\Controllers\Api\Base\PublicBaseController;
|
||||||
|
use App\Http\Controllers\Concerns\ServesExtensionBundles;
|
||||||
use App\Http\Requests\Public\Plugin\ServePluginAssetRequest;
|
use App\Http\Requests\Public\Plugin\ServePluginAssetRequest;
|
||||||
use App\Services\ExtensionBundleService;
|
use App\Services\ExtensionBundleService;
|
||||||
use App\Services\PluginService;
|
use App\Services\PluginService;
|
||||||
@@ -17,6 +18,8 @@ use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
|||||||
*/
|
*/
|
||||||
class PublicPluginController extends PublicBaseController
|
class PublicPluginController extends PublicBaseController
|
||||||
{
|
{
|
||||||
|
use ServesExtensionBundles;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly PluginService $pluginService,
|
private readonly PluginService $pluginService,
|
||||||
private readonly ExtensionBundleService $bundleService
|
private readonly ExtensionBundleService $bundleService
|
||||||
@@ -27,8 +30,10 @@ class PublicPluginController extends PublicBaseController
|
|||||||
/**
|
/**
|
||||||
* 활성 플러그인 프론트엔드 IIFE 병합 번들(JS)을 서빙합니다.
|
* 활성 플러그인 프론트엔드 IIFE 병합 번들(JS)을 서빙합니다.
|
||||||
*
|
*
|
||||||
* 활성 global 플러그인 에셋이 없으면 빈 200 응답(text/javascript)을 반환한다.
|
* 병합 파일을 fileResponse 로 서빙한다(ETag/304/환경별 Cache-Control 재사용).
|
||||||
* 그 외에는 병합 파일을 fileResponse 로 서빙(ETag/304/환경별 Cache-Control 재사용).
|
* 디스크 캐시가 실패하면 메모리 병합 결과로 200 을 낸다(캐시는 최적화일 뿐이다).
|
||||||
|
* 에셋을 선언한 활성 확장이 0개면 빈 200, 선언은 있는데 결과가 비면 503 —
|
||||||
|
* 판정은 ServesExtensionBundles::bundleResponse() 단일 지점.
|
||||||
*
|
*
|
||||||
* @return BinaryFileResponse|Response 병합 JS 파일 응답 또는 빈 응답
|
* @return BinaryFileResponse|Response 병합 JS 파일 응답 또는 빈 응답
|
||||||
*/
|
*/
|
||||||
@@ -36,14 +41,7 @@ class PublicPluginController extends PublicBaseController
|
|||||||
{
|
{
|
||||||
$this->logApiUsage('plugins.bundle', ['kind' => 'js']);
|
$this->logApiUsage('plugins.bundle', ['kind' => 'js']);
|
||||||
|
|
||||||
$version = $this->bundleService->getCurrentVersion();
|
return $this->bundleResponse($this->bundleService, 'plugin', 'js', 'text/javascript');
|
||||||
$path = $this->bundleService->getBundleFilePath('plugin', 'js', $version);
|
|
||||||
|
|
||||||
if ($path === '') {
|
|
||||||
return response('', 200)->header('Content-Type', 'text/javascript');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->fileResponse($path, 'text/javascript', 31536000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,14 +53,7 @@ class PublicPluginController extends PublicBaseController
|
|||||||
{
|
{
|
||||||
$this->logApiUsage('plugins.bundle', ['kind' => 'css']);
|
$this->logApiUsage('plugins.bundle', ['kind' => 'css']);
|
||||||
|
|
||||||
$version = $this->bundleService->getCurrentVersion();
|
return $this->bundleResponse($this->bundleService, 'plugin', 'css', 'text/css');
|
||||||
$path = $this->bundleService->getBundleFilePath('plugin', 'css', $version);
|
|
||||||
|
|
||||||
if ($path === '') {
|
|
||||||
return response('', 200)->header('Content-Type', 'text/css');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->fileResponse($path, 'text/css', 31536000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Concerns;
|
||||||
|
|
||||||
|
use App\Services\ExtensionBundleService;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 확장(모듈/플러그인) 병합 번들 서빙의 공통 판정 (#122 E1/E2).
|
||||||
|
*
|
||||||
|
* 모듈 컨트롤러와 플러그인 컨트롤러가 **같은 판정**을 해야 한다. 각자 구현하면 한쪽만
|
||||||
|
* 고쳐진 채 다른 쪽이 조용히 옛 동작으로 남는다 — 실제로 두 컨트롤러는 지금까지 동일
|
||||||
|
* 코드를 복제하고 있었다.
|
||||||
|
*
|
||||||
|
* 판정은 셋으로 갈린다:
|
||||||
|
*
|
||||||
|
* 1. 병합 결과가 있고 디스크 캐시도 성공 → 파일 응답 (ETag/304/immutable 재사용)
|
||||||
|
* 2. 병합 결과는 있는데 디스크 캐시가 실패 → **메모리 결과를 그대로 200** 으로 서빙.
|
||||||
|
* 디스크 캐시는 최적화이므로 쓰기 실패가 공개 엔드포인트의 500 이 되면 안 된다.
|
||||||
|
* 3. 병합 결과가 비었음 →
|
||||||
|
* - 에셋을 선언한 활성 확장이 **0개**면 정상 → 빈 200
|
||||||
|
* - 선언은 있는데 결과가 0 이면 장애(배포 중 `dist` 가 잠깐 빔, 경로 어긋남) →
|
||||||
|
* **503**. 빈 200 으로 내보내면 프론트는 404 도 오류도 받지 못한 채 한참 뒤
|
||||||
|
* "Unknown action handler" 로 죽어, 원인이 번들이라는 사실이 드러나지 않는다.
|
||||||
|
*/
|
||||||
|
trait ServesExtensionBundles
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 확장 병합 번들을 서빙합니다.
|
||||||
|
*
|
||||||
|
* @param ExtensionBundleService $bundleService 번들 서비스
|
||||||
|
* @param string $type 'module' | 'plugin'
|
||||||
|
* @param string $kind 'js' | 'css'
|
||||||
|
* @param string $mimeType 응답 Content-Type
|
||||||
|
* @return BinaryFileResponse|Response 번들 응답
|
||||||
|
*/
|
||||||
|
protected function bundleResponse(
|
||||||
|
ExtensionBundleService $bundleService,
|
||||||
|
string $type,
|
||||||
|
string $kind,
|
||||||
|
string $mimeType
|
||||||
|
): BinaryFileResponse|Response {
|
||||||
|
$version = $bundleService->getCurrentVersion();
|
||||||
|
$path = $bundleService->getBundleFilePath($type, $kind, $version);
|
||||||
|
|
||||||
|
if ($path !== '') {
|
||||||
|
return $this->fileResponse($path, $mimeType, 31536000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 파일 경로가 비었다 — 병합 결과 자체가 비었거나, 디스크 캐시가 실패했다.
|
||||||
|
$content = $bundleService->buildBundleContent($type, $kind);
|
||||||
|
|
||||||
|
if ($content !== '') {
|
||||||
|
// 디스크 캐시 실패 (2) — 메모리 결과로 서빙한다. 캐시 헤더는 붙이지 않는다:
|
||||||
|
// 이 응답은 캐시에 실패한 상태의 산출물이라 다음 요청에서 정상 경로로
|
||||||
|
// 돌아갈 수 있어야 한다.
|
||||||
|
return response($content, 200)
|
||||||
|
->header('Content-Type', $mimeType)
|
||||||
|
->header('Cache-Control', 'no-cache, private');
|
||||||
|
}
|
||||||
|
|
||||||
|
$declared = $bundleService->countAssetDeclaringExtensions($type, $kind);
|
||||||
|
|
||||||
|
if ($declared > 0) {
|
||||||
|
// (3) 장애 — 선언은 있는데 결과가 0
|
||||||
|
Log::error('확장 번들 병합 결과가 비었습니다 — 에셋을 선언한 활성 확장이 있습니다', [
|
||||||
|
'type' => $type,
|
||||||
|
'kind' => $kind,
|
||||||
|
'version' => $version,
|
||||||
|
'declared_extensions' => $declared,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response('', 503)
|
||||||
|
->header('Content-Type', $mimeType)
|
||||||
|
->header('Cache-Control', 'no-cache, private');
|
||||||
|
}
|
||||||
|
|
||||||
|
// (3) 정상 — 선언이 0개면 빈 번들이 맞다
|
||||||
|
return response('', 200)->header('Content-Type', $mimeType);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Listeners;
|
||||||
|
|
||||||
|
use App\Contracts\Extension\HookListenerInterface;
|
||||||
|
use App\Helpers\TimezoneHelper;
|
||||||
|
use App\Services\ExtensionStaticCacheService;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 부트스트랩 리소스 정적 게시 실패 알림 리스너 (#122)
|
||||||
|
*
|
||||||
|
* 게시 실패는 사이트를 멈추지 않는다 — API 폴백으로 넘어가 화면은 정상이고, 서버 로그에만
|
||||||
|
* warning 이 쌓인다. 그래서 쓰기 불가 환경에서 정적 fast path 가 **영구히 꺼진 채로**
|
||||||
|
* 운영되는 상태를 아무도 눈치채지 못했다(제보 본건).
|
||||||
|
*
|
||||||
|
* 관리자 대시보드가 그 사실이 운영자에게 도달하는 통로다. 대시보드 레이아웃·컨트롤러·
|
||||||
|
* API 스키마는 건드리지 않는다 — 기존 `core.dashboard.alerts` 필터 훅에 항목을 얹기만 한다.
|
||||||
|
*
|
||||||
|
* 표시 조건: 실패 마커가 존재하고 **연속 실패가 2회 이상**. 1회는 배포 중 일시적 경합일 수
|
||||||
|
* 있고, 그 경우 다음 렌더의 자가 치유가 해소한다 — 매번 알리면 알림이 소음이 된다.
|
||||||
|
*
|
||||||
|
* @since 7.0.10
|
||||||
|
*/
|
||||||
|
class StaticPublishFailureAlertListener implements HookListenerInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 알림을 띄우기 시작하는 연속 실패 횟수.
|
||||||
|
*/
|
||||||
|
private const ALERT_THRESHOLD = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 구독할 훅과 메서드 매핑 반환.
|
||||||
|
*
|
||||||
|
* @return array 훅 매핑 배열
|
||||||
|
*/
|
||||||
|
public static function getSubscribedHooks(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'core.dashboard.alerts' => [
|
||||||
|
'method' => 'addStaticPublishAlert',
|
||||||
|
'priority' => 15,
|
||||||
|
'type' => 'filter',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 훅 이벤트 처리 (기본 핸들러).
|
||||||
|
*
|
||||||
|
* @param mixed ...$args 훅에서 전달된 인수들
|
||||||
|
*/
|
||||||
|
public function handle(...$args): void
|
||||||
|
{
|
||||||
|
// 기본 핸들러는 사용하지 않음
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 정적 게시 실패 알림을 대시보드에 추가합니다.
|
||||||
|
*
|
||||||
|
* @param array $alerts 기존 알림 배열
|
||||||
|
* @return array 알림이 추가된 배열
|
||||||
|
*/
|
||||||
|
public function addStaticPublishAlert(array $alerts): array
|
||||||
|
{
|
||||||
|
$marker = ExtensionStaticCacheService::failureMarker();
|
||||||
|
|
||||||
|
if ($marker === null) {
|
||||||
|
return $alerts;
|
||||||
|
}
|
||||||
|
|
||||||
|
$count = (int) ($marker['count'] ?? 0);
|
||||||
|
|
||||||
|
if ($count < self::ALERT_THRESHOLD) {
|
||||||
|
return $alerts;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reason = (string) ($marker['reason'] ?? 'write_failed');
|
||||||
|
|
||||||
|
$alerts[] = [
|
||||||
|
'id' => 'static_publish_failure',
|
||||||
|
'type' => 'warning',
|
||||||
|
// 원인별로 조치가 다르다 — 뭉뚱그리면 운영자가 어디를 봐야 할지 알 수 없다.
|
||||||
|
'subtype' => 'static_publish_'.$reason,
|
||||||
|
'icon' => 'exclamation-triangle',
|
||||||
|
'title' => __('extensions.alerts.static_publish_failed_title'),
|
||||||
|
'message' => __('extensions.alerts.static_publish_failed_'.$this->messageKeySuffix($reason), [
|
||||||
|
'count' => $count,
|
||||||
|
]),
|
||||||
|
'time' => isset($marker['at'])
|
||||||
|
? TimezoneHelper::toUserCarbon(Carbon::parse($marker['at']))?->diffForHumans()
|
||||||
|
: null,
|
||||||
|
'read' => false,
|
||||||
|
];
|
||||||
|
|
||||||
|
return $alerts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사유 코드를 다국어 키 접미사로 변환합니다.
|
||||||
|
*
|
||||||
|
* 알 수 없는 사유는 일반 문구로 떨어뜨린다 — 키가 없으면 번역기가 키 문자열을 그대로
|
||||||
|
* 화면에 내보낸다.
|
||||||
|
*
|
||||||
|
* @param string $reason 사유 코드
|
||||||
|
* @return string 다국어 키 접미사
|
||||||
|
*/
|
||||||
|
private function messageKeySuffix(string $reason): string
|
||||||
|
{
|
||||||
|
return match ($reason) {
|
||||||
|
'parent_not_writable', 'write_failed', 'lock_unavailable' => $reason,
|
||||||
|
default => 'write_failed',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ use App\Extension\PluginManager;
|
|||||||
use App\Http\View\Composers\TemplateComposer;
|
use App\Http\View\Composers\TemplateComposer;
|
||||||
use App\Http\View\Composers\UserTemplateComposer;
|
use App\Http\View\Composers\UserTemplateComposer;
|
||||||
use App\Listeners\ExtensionCompatibilityAlertListener;
|
use App\Listeners\ExtensionCompatibilityAlertListener;
|
||||||
|
use App\Listeners\StaticPublishFailureAlertListener;
|
||||||
use App\Notifications\NotificationChannelManager;
|
use App\Notifications\NotificationChannelManager;
|
||||||
use App\Services\ChannelReadinessService;
|
use App\Services\ChannelReadinessService;
|
||||||
use App\Services\GeoIpService;
|
use App\Services\GeoIpService;
|
||||||
@@ -99,8 +100,8 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
View::composer('admin', TemplateComposer::class);
|
View::composer('admin', TemplateComposer::class);
|
||||||
View::composer('app', UserTemplateComposer::class);
|
View::composer('app', UserTemplateComposer::class);
|
||||||
|
|
||||||
// 확장 호환성 알림 리스너 등록
|
// 코어 훅 리스너 등록 (대시보드 알림 등)
|
||||||
$this->registerExtensionCompatibilityAlertListener();
|
$this->registerCoreHookListeners();
|
||||||
|
|
||||||
// SQL 쿼리 로그 설정
|
// SQL 쿼리 로그 설정
|
||||||
$this->configureSqlQueryLogging();
|
$this->configureSqlQueryLogging();
|
||||||
@@ -135,25 +136,36 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 확장 호환성 알림 리스너를 등록합니다.
|
* 코어가 소유한 훅 리스너를 등록합니다.
|
||||||
*
|
*
|
||||||
* 코어 버전 호환성 문제로 자동 비활성화된 확장에 대한
|
* 확장 리스너와 달리 코어 리스너는 자동 발견 대상이 아니므로 여기서 등록한다. 목록만
|
||||||
* 알림을 관리자 대시보드에 표시하기 위한 훅 리스너입니다.
|
* 늘리면 되도록 루프로 둔다 — 리스너마다 등록 메서드를 복제하면 한 곳만 빠져도 그
|
||||||
|
* 리스너가 조용히 동작하지 않는다(등록 실패는 예외도 로그도 남기지 않는다).
|
||||||
|
*
|
||||||
|
* 등록 대상:
|
||||||
|
* - `ExtensionCompatibilityAlertListener` — 코어 호환성으로 자동 비활성화/재호환된 확장
|
||||||
|
* - `StaticPublishFailureAlertListener` — 부트스트랩 리소스 정적 게시 실패 (#122)
|
||||||
*/
|
*/
|
||||||
private function registerExtensionCompatibilityAlertListener(): void
|
private function registerCoreHookListeners(): void
|
||||||
{
|
{
|
||||||
$listener = new ExtensionCompatibilityAlertListener;
|
$listenerClasses = [
|
||||||
$subscribedHooks = ExtensionCompatibilityAlertListener::getSubscribedHooks();
|
ExtensionCompatibilityAlertListener::class,
|
||||||
|
StaticPublishFailureAlertListener::class,
|
||||||
|
];
|
||||||
|
|
||||||
foreach ($subscribedHooks as $hookName => $config) {
|
foreach ($listenerClasses as $listenerClass) {
|
||||||
$method = $config['method'] ?? 'handle';
|
$listener = new $listenerClass;
|
||||||
$priority = $config['priority'] ?? 10;
|
|
||||||
$type = $config['type'] ?? 'action';
|
|
||||||
|
|
||||||
if ($type === 'filter') {
|
foreach ($listenerClass::getSubscribedHooks() as $hookName => $config) {
|
||||||
HookManager::addFilter($hookName, [$listener, $method], $priority);
|
$method = $config['method'] ?? 'handle';
|
||||||
} else {
|
$priority = $config['priority'] ?? 10;
|
||||||
HookManager::addAction($hookName, [$listener, $method], $priority);
|
$type = $config['type'] ?? 'action';
|
||||||
|
|
||||||
|
if ($type === 'filter') {
|
||||||
|
HookManager::addFilter($hookName, [$listener, $method], $priority);
|
||||||
|
} else {
|
||||||
|
HookManager::addAction($hookName, [$listener, $method], $priority);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,20 @@ class AllowedModuleFileType implements ValidationRule
|
|||||||
'woff', 'woff2', 'ttf', 'otf', 'eot',
|
'woff', 'woff2', 'ttf', 'otf', 'eot',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 환경과 무관한 기본 허용 확장자 목록을 반환합니다.
|
||||||
|
*
|
||||||
|
* `getAllowedExtensions()` 는 로컬에서 소스맵(`map`)을 덧붙이는 **환경 의존** 게터라
|
||||||
|
* 라우트 패턴처럼 정의 시점에 한 번 굳어 캐시에 박히는 소비자가 쓰면 안 된다
|
||||||
|
* (캐시를 구운 환경에 따라 패턴이 달라진다). 그런 소비자는 이 게터를 쓴다.
|
||||||
|
*
|
||||||
|
* @return array<string> 기본 허용 확장자 목록
|
||||||
|
*/
|
||||||
|
public static function allowedExtensions(): array
|
||||||
|
{
|
||||||
|
return self::ALLOWED_EXTENSIONS;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 허용된 파일 타입인지 검증
|
* 허용된 파일 타입인지 검증
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -27,6 +27,20 @@ class AllowedPluginFileType implements ValidationRule
|
|||||||
'woff', 'woff2', 'ttf', 'otf', 'eot',
|
'woff', 'woff2', 'ttf', 'otf', 'eot',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 환경과 무관한 기본 허용 확장자 목록을 반환합니다.
|
||||||
|
*
|
||||||
|
* `getAllowedExtensions()` 는 로컬에서 소스맵(`map`)을 덧붙이는 **환경 의존** 게터라
|
||||||
|
* 라우트 패턴처럼 정의 시점에 한 번 굳어 캐시에 박히는 소비자가 쓰면 안 된다
|
||||||
|
* (캐시를 구운 환경에 따라 패턴이 달라진다). 그런 소비자는 이 게터를 쓴다.
|
||||||
|
*
|
||||||
|
* @return array<string> 기본 허용 확장자 목록
|
||||||
|
*/
|
||||||
|
public static function allowedExtensions(): array
|
||||||
|
{
|
||||||
|
return self::ALLOWED_EXTENSIONS;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 허용된 파일 타입인지 검증
|
* 허용된 파일 타입인지 검증
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -27,6 +27,20 @@ class AllowedTemplateFileType implements ValidationRule
|
|||||||
'woff', 'woff2', 'ttf', 'otf', 'eot',
|
'woff', 'woff2', 'ttf', 'otf', 'eot',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 환경과 무관한 기본 허용 확장자 목록을 반환합니다.
|
||||||
|
*
|
||||||
|
* `getAllowedExtensions()` 는 로컬에서 소스맵(`map`)을 덧붙이는 **환경 의존** 게터라
|
||||||
|
* 라우트 패턴처럼 정의 시점에 한 번 굳어 캐시에 박히는 소비자가 쓰면 안 된다
|
||||||
|
* (캐시를 구운 환경에 따라 패턴이 달라진다). 그런 소비자는 이 게터를 쓴다.
|
||||||
|
*
|
||||||
|
* @return array<string> 기본 허용 확장자 목록
|
||||||
|
*/
|
||||||
|
public static function allowedExtensions(): array
|
||||||
|
{
|
||||||
|
return self::ALLOWED_EXTENSIONS;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 허용된 파일 타입인지 검증
|
* 허용된 파일 타입인지 검증
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -39,6 +39,13 @@ class ExtensionBundleService
|
|||||||
*/
|
*/
|
||||||
private const BUNDLE_DISK = 'ext-bundles';
|
private const BUNDLE_DISK = 'ext-bundles';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원자적 쓰기 임시 파일(`*.tmp.{pid}`)을 잔존물로 보는 나이 (초).
|
||||||
|
*
|
||||||
|
* pid 는 재사용되므로 "그 pid 가 살아 있는가" 로는 진행 중 여부를 판정할 수 없다.
|
||||||
|
*/
|
||||||
|
private const TEMP_BUNDLE_STALE_SECONDS = 600;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 서비스 주입
|
* 서비스 주입
|
||||||
*
|
*
|
||||||
@@ -236,20 +243,104 @@ class ExtensionBundleService
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
$storage = $this->bundleStorage();
|
|
||||||
$relativeName = $this->bundleFileName($type, $kind, $version);
|
$relativeName = $this->bundleFileName($type, $kind, $version);
|
||||||
|
|
||||||
// 비프로덕션은 캐시하지 않고 임시 파일로 매번 build → rebuild 즉시 반영
|
// 디스크 캐시는 **최적화**다 — 쓰기 실패가 공개 엔드포인트의 500 이 되면 안 된다.
|
||||||
if (! app()->environment('production')) {
|
// `ext-bundles` 디스크는 `throw => true` 라 권한 문제(uid 독점 0700 등)에서
|
||||||
return $this->writeAtomically($storage, $relativeName, $content, cache: false);
|
// `UnableToWriteFile` 이 그대로 올라오고, 그러면 모든 확장의 프론트엔드 JS/CSS 가
|
||||||
}
|
// 통째로 나가지 못한다. 병합 결과는 이미 메모리에 있으므로 그것을 그대로 응답하면
|
||||||
|
// 화면은 정상이다 (커밋 63a30ab29 의 AbstractCacheDriver fail-soft 와 같은 원칙).
|
||||||
|
try {
|
||||||
|
$storage = $this->bundleStorage();
|
||||||
|
|
||||||
// 프로덕션: 동일 version 캐시가 있으면 그대로 사용
|
// 비프로덕션은 캐시하지 않고 임시 파일로 매번 build → rebuild 즉시 반영
|
||||||
if ($storage->exists('', $relativeName)) {
|
if (! app()->environment('production')) {
|
||||||
return $storage->getBasePath('').'/'.$relativeName;
|
return $this->writeAtomically($storage, $relativeName, $content, cache: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->writeAtomically($storage, $relativeName, $content, cache: true);
|
// 프로덕션: 동일 version 캐시가 있으면 그대로 사용
|
||||||
|
if ($storage->exists('', $relativeName)) {
|
||||||
|
return $storage->getBasePath('').'/'.$relativeName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->writeAtomically($storage, $relativeName, $content, cache: true);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('확장 번들 디스크 캐시 실패 — 메모리 병합 결과로 서빙합니다', [
|
||||||
|
'type' => $type,
|
||||||
|
'kind' => $kind,
|
||||||
|
'version' => $version,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 번들을 서빙할 때 쓸 병합 결과를 반환합니다 (디스크 캐시 실패 시 메모리 폴백용).
|
||||||
|
*
|
||||||
|
* @param string $type 'module' | 'plugin'
|
||||||
|
* @param string $kind 'js' | 'css'
|
||||||
|
* @return string 병합 결과 (없으면 빈 문자열)
|
||||||
|
*/
|
||||||
|
public function buildBundleContent(string $type, string $kind): string
|
||||||
|
{
|
||||||
|
return $kind === 'css'
|
||||||
|
? $this->buildCssBundle($type)
|
||||||
|
: $this->buildJsBundle($type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 해당 타입에서 프론트엔드 에셋을 **선언한** 활성 확장 수를 반환합니다.
|
||||||
|
*
|
||||||
|
* "선언이 0 이라 빈 번들" (정상)과 "선언은 있는데 병합 결과가 0" (장애 — 배포 중
|
||||||
|
* `dist` 가 잠깐 비었거나 경로가 어긋났다)을 구분하는 유일한 근거다. 구분하지 않으면
|
||||||
|
* 후자가 빈 200 으로 나가고, 프론트는 404 도 오류도 받지 못한 채 한참 뒤
|
||||||
|
* "Unknown action handler" 로 죽는다.
|
||||||
|
*
|
||||||
|
* 판정은 **kind 별**이다 — js 만 선언한 확장이 있는 상태에서 css 번들이 비는 것은
|
||||||
|
* 정상이므로, 그 경우까지 장애로 보면 정상 구성이 503 이 된다.
|
||||||
|
*
|
||||||
|
* 근거는 manifest 의 `assets.{kind}.output` **선언**이며 산출물 파일의 존재를 보지
|
||||||
|
* 않는다. `getOrderedGlobalAssetPaths()` / `hasAssets()` / `getBuiltAssetPaths()` 는
|
||||||
|
* 전부 `file_exists()` 게이트를 타므로, 그 경로로 세면 "dist 가 잠깐 빔" 이 곧
|
||||||
|
* "선언 0" 이 되어 **막으려던 바로 그 상태가 정상(빈 200)으로 판정된다.** 선언과
|
||||||
|
* 산출은 다른 축이고, 이 메서드가 재는 것은 선언 축이다.
|
||||||
|
*
|
||||||
|
* @param string $type 'module' | 'plugin'
|
||||||
|
* @param string $kind 'js' | 'css'
|
||||||
|
* @return int 해당 kind 의 에셋을 선언한 활성 확장 수
|
||||||
|
*/
|
||||||
|
public function countAssetDeclaringExtensions(string $type, string $kind): int
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$extensions = $type === 'plugin'
|
||||||
|
? $this->pluginManager->getActivePlugins()
|
||||||
|
: $this->moduleManager->getActiveModules();
|
||||||
|
|
||||||
|
$declared = 0;
|
||||||
|
|
||||||
|
foreach ($extensions as $extension) {
|
||||||
|
// global 전략만 번들 대상 — 병합 대상 모집단과 동일한 필터를 쓴다
|
||||||
|
if (($extension->getAssetLoadingConfig()['strategy'] ?? 'global') !== 'global') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($extension->getAssets()[$kind]['output'] ?? null)) {
|
||||||
|
$declared++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $declared;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('확장 에셋 선언 수 집계 실패', [
|
||||||
|
'type' => $type,
|
||||||
|
'kind' => $kind,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -271,6 +362,17 @@ class ExtensionBundleService
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 원자적 쓰기의 임시 파일(`{type}.{v}.{kind}.tmp.{pid}`)은 번들 파일 패턴에
|
||||||
|
// 맞지 않아 GC 대상에서 통째로 빠져 있었다 — rename 이 실패한 만큼 영구
|
||||||
|
// 잔존한다(실측 560개). 나이 가드를 붙여 진행 중인 쓰기는 건드리지 않는다.
|
||||||
|
if ($this->isStaleTempBundleFile($name, $storage)) {
|
||||||
|
if ($storage->delete('', $name)) {
|
||||||
|
$deleted++;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->isBundleFile($name) && $storage->delete('', $name)) {
|
if ($this->isBundleFile($name) && $storage->delete('', $name)) {
|
||||||
$deleted++;
|
$deleted++;
|
||||||
}
|
}
|
||||||
@@ -279,15 +381,43 @@ class ExtensionBundleService
|
|||||||
return $deleted;
|
return $deleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파일명이 **오래된** 원자적 쓰기 임시 파일인지 판정합니다.
|
||||||
|
*
|
||||||
|
* 진행 중인 쓰기를 파괴하지 않도록 나이 가드를 둔다 — pid 는 재사용되므로 "그 pid 가
|
||||||
|
* 살아 있는가" 로는 판정할 수 없다.
|
||||||
|
*
|
||||||
|
* @param string $name 파일명
|
||||||
|
* @param CoreStorageDriver $storage 번들 디스크 스토리지
|
||||||
|
* @return bool 삭제 대상 여부
|
||||||
|
*/
|
||||||
|
private function isStaleTempBundleFile(string $name, CoreStorageDriver $storage): bool
|
||||||
|
{
|
||||||
|
if (! preg_match('/^(module|plugin)\.\d+\.(js|css)\.tmp\.\d+$/', $name)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mtime = @filemtime($storage->getBasePath('').'/'.$name);
|
||||||
|
|
||||||
|
// 나이를 읽지 못하면 남긴다 — 진행 중인 쓰기를 지우는 쪽이 더 나쁘다.
|
||||||
|
return $mtime !== false && (time() - $mtime) > self::TEMP_BUNDLE_STALE_SECONDS;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 번들 캐시 파일을 삭제합니다(cache-clear 커맨드용).
|
* 번들 캐시 파일을 삭제합니다(cache-clear 커맨드용).
|
||||||
*
|
*
|
||||||
|
* **현재 버전은 보존한다** — `cleanupStaleBundles()` 와 같은 정책이다. 현재 버전까지
|
||||||
|
* 지우면 같은 순간 서빙 중인 웹 요청이 "존재함" 판정 직후 `filemtime()` 에서 500 을
|
||||||
|
* 낸다(bump 직후 TOCTOU). 캐시 파일은 없으면 다음 요청이 다시 만들므로, 지우는 것의
|
||||||
|
* 이득은 없고 그 창의 500 만 남는다.
|
||||||
|
*
|
||||||
* @param string|null $type 'module' | 'plugin' 지정 시 해당 타입만, null 이면 전체
|
* @param string|null $type 'module' | 'plugin' 지정 시 해당 타입만, null 이면 전체
|
||||||
* @return int 삭제된 파일 수
|
* @return int 삭제된 파일 수
|
||||||
*/
|
*/
|
||||||
public function clearBundles(?string $type = null): int
|
public function clearBundles(?string $type = null): int
|
||||||
{
|
{
|
||||||
$storage = $this->bundleStorage();
|
$storage = $this->bundleStorage();
|
||||||
|
$currentVersion = $this->getCurrentVersion();
|
||||||
$deleted = 0;
|
$deleted = 0;
|
||||||
|
|
||||||
foreach ($storage->files('', '') as $file) {
|
foreach ($storage->files('', '') as $file) {
|
||||||
@@ -297,6 +427,11 @@ class ExtensionBundleService
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 현재 버전 보존 (cleanupStaleBundles 와 동형 — 정책이 갈라지면 한쪽이 창을 연다)
|
||||||
|
if ($this->matchesVersion($name, $currentVersion)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// 타입 필터 (파일명 접두사 `{type}.`)
|
// 타입 필터 (파일명 접두사 `{type}.`)
|
||||||
if ($type !== null && ! str_starts_with($name, $type.'.')) {
|
if ($type !== null && ! str_starts_with($name, $type.'.')) {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Contracts\Extension\CacheInterface;
|
||||||
use App\Contracts\Repositories\ModuleRepositoryInterface;
|
use App\Contracts\Repositories\ModuleRepositoryInterface;
|
||||||
use App\Contracts\Repositories\PluginRepositoryInterface;
|
use App\Contracts\Repositories\PluginRepositoryInterface;
|
||||||
use App\Contracts\Repositories\TemplateRepositoryInterface;
|
use App\Contracts\Repositories\TemplateRepositoryInterface;
|
||||||
use App\Exceptions\StaticCachePublishException;
|
use App\Exceptions\StaticCachePublishException;
|
||||||
|
use App\Extension\Cache\CoreCacheDriver;
|
||||||
use App\Extension\Helpers\FilePermissionHelper;
|
use App\Extension\Helpers\FilePermissionHelper;
|
||||||
use App\Extension\Traits\ClearsTemplateCaches;
|
use App\Extension\Traits\ClearsTemplateCaches;
|
||||||
use App\Helpers\ResponseHelper;
|
use App\Helpers\ResponseHelper;
|
||||||
@@ -46,6 +48,24 @@ class ExtensionStaticCacheService
|
|||||||
/** 로케일 패턴 — 경로 세그먼트 화이트리스트 */
|
/** 로케일 패턴 — 경로 세그먼트 화이트리스트 */
|
||||||
private const LOCALE_PATTERN = '/^[a-z]{2}(?:[-_][A-Za-z0-9]{2,8})?$/';
|
private const LOCALE_PATTERN = '/^[a-z]{2}(?:[-_][A-Za-z0-9]{2,8})?$/';
|
||||||
|
|
||||||
|
/** 게시 작업 디렉토리(.tmp/.old)를 미완료 잔존물로 보는 나이 (초) — 게시 락 TTL 300 의 2배 */
|
||||||
|
private const WORK_DIR_STALE_SECONDS = 600;
|
||||||
|
|
||||||
|
/** 게시 실패 마커 캐시 키 */
|
||||||
|
private const FAILURE_MARKER_KEY = 'ext.static.publish_failure';
|
||||||
|
|
||||||
|
/** 실패 마커 TTL (초) — 이 창 안에서는 같은 버전의 재예약을 억제한다 */
|
||||||
|
private const FAILURE_MARKER_TTL = 300;
|
||||||
|
|
||||||
|
/** 게시 트리 디렉토리 권한 — umask 무력화 대상 */
|
||||||
|
private const PUBLISH_DIR_MODE = 0775;
|
||||||
|
|
||||||
|
/** 게시 트리 디렉토리 rename 시도 횟수 (일시 거부 흡수) */
|
||||||
|
private const RENAME_ATTEMPTS = 3;
|
||||||
|
|
||||||
|
/** rename 재시도 간 대기 (마이크로초) — 실측 표본은 전부 1회 재시도로 해소됐다 */
|
||||||
|
private const RENAME_RETRY_DELAY_US = 200_000;
|
||||||
|
|
||||||
/** terminating 게시 예약 플래그 (프로세스당 1회) */
|
/** terminating 게시 예약 플래그 (프로세스당 1회) */
|
||||||
private static bool $publishScheduled = false;
|
private static bool $publishScheduled = false;
|
||||||
|
|
||||||
@@ -85,9 +105,35 @@ class ExtensionStaticCacheService
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$lock = Cache::lock(self::LOCK_PREFIX.$version, 300);
|
// 락 미획득은 두 상황이고 조치가 다르다.
|
||||||
|
//
|
||||||
|
// (a) 다른 프로세스가 게시 중 (정상) — `get()` 이 예외 없이 false.
|
||||||
|
// 건너뛰는 것이 옳은 동작이므로 마커를 남기지 않는다. 남기면 정상적인 동시
|
||||||
|
// 요청 경합이 실패로 집계돼 대시보드에 거짓 장애 알림이 뜬다.
|
||||||
|
// (b) 캐시 저장소가 락을 제공하지 못함 (장애) — `Cache::lock()`/`get()` 이 던진다
|
||||||
|
// (락 미지원 드라이버, 파일 캐시 디렉토리 권한 불일치 등). 이 경우 게시는
|
||||||
|
// 매 요청 조용히 스킵되므로 사유를 마커에 남겨 진단 표면까지 도달시킨다.
|
||||||
|
try {
|
||||||
|
$lock = Cache::lock(self::LOCK_PREFIX.$version, 300);
|
||||||
|
$acquired = $lock->get();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('정적 게시 락 획득 불가 — 캐시 저장소를 확인하세요', [
|
||||||
|
'version' => $version,
|
||||||
|
'store' => config('cache.default'),
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::recordFailure($version, 'lock_unavailable', $e->getMessage());
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $acquired) {
|
||||||
|
Log::debug('정적 게시 락 미획득 — 이번 호출은 건너뜁니다', [
|
||||||
|
'version' => $version,
|
||||||
|
'store' => config('cache.default'),
|
||||||
|
]);
|
||||||
|
|
||||||
if (! $lock->get()) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,10 +188,16 @@ class ExtensionStaticCacheService
|
|||||||
foreach (File::directories($base) as $dir) {
|
foreach (File::directories($base) as $dir) {
|
||||||
$name = basename($dir);
|
$name = basename($dir);
|
||||||
|
|
||||||
// 미완료 tmp 잔존물은 무조건 제거 대상 (rename 전 실패 흔적)
|
// 진행 중 게시의 작업 디렉토리(`{v}.tmp`)와 스왑 대기분(`{v}.old`)은 나이로
|
||||||
if (str_ends_with($name, '.tmp')) {
|
// 가른다 — 락은 **버전별**이라 서로 다른 버전의 게시가 동시에 진행될 수 있고,
|
||||||
File::deleteDirectory($dir);
|
// 나이 무관 삭제는 그 순간 살아 있는 남의 tmp 를 파괴한다. 게시 락 TTL 이
|
||||||
$deleted++;
|
// 300초이므로 그 두 배를 미완료 판정 기준으로 삼는다.
|
||||||
|
if (str_ends_with($name, '.tmp') || str_ends_with($name, '.old')) {
|
||||||
|
if (! $this->isStaleWorkDirectory($dir)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$deleted += $this->deleteVersionDirectory($dir) ? 1 : 0;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -155,27 +207,79 @@ class ExtensionStaticCacheService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 현재 버전과, 현재를 제외한 최신 1개(직전) 보존
|
// 현재 버전과, 현재를 제외한 최신 1개(직전) 보존.
|
||||||
$keep = [$current];
|
//
|
||||||
|
// 현재 버전 디렉토리가 **실존하지 않으면** 그 자리를 보존 슬롯으로 쓰지 않는다 —
|
||||||
|
// 없는 것을 보존해 봐야 슬롯 하나를 버리는 셈이고, 그만큼 실존 버전이 한 개 더
|
||||||
|
// 지워진다. 그 경우 "실존하는 최신 2개" 를 보존한다. 게시는 GC 시점 이후
|
||||||
|
// (terminating)에 수행되므로 "포인터는 새 버전인데 산출물은 아직 없음" 은
|
||||||
|
// 정상 상태이며, 브라우저에 배달된 직전 HTML 은 여전히 옛 버전 URL 을 참조한다.
|
||||||
|
$keep = File::isDirectory($this->versionDir($current)) ? [$current] : [];
|
||||||
$others = array_keys($versions);
|
$others = array_keys($versions);
|
||||||
rsort($others);
|
rsort($others);
|
||||||
foreach ($others as $v) {
|
foreach ($others as $v) {
|
||||||
if ($v !== $current) {
|
if (in_array($v, $keep, true)) {
|
||||||
$keep[] = $v;
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$keep[] = $v;
|
||||||
|
|
||||||
|
if (count($keep) >= 2) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($versions as $v => $dir) {
|
foreach ($versions as $v => $dir) {
|
||||||
if (! in_array($v, $keep, true)) {
|
if (! in_array($v, $keep, true)) {
|
||||||
File::deleteDirectory($dir);
|
$deleted += $this->deleteVersionDirectory($dir) ? 1 : 0;
|
||||||
$deleted++;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $deleted;
|
return $deleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게시 작업 디렉토리(`.tmp` / `.old`)가 미완료 잔존물로 판정될 만큼 오래됐는지 봅니다.
|
||||||
|
*
|
||||||
|
* 나이를 읽지 못하는 경우(권한·경합으로 `filemtime` 실패)는 **삭제하지 않는다** —
|
||||||
|
* 진행 중인 게시를 파괴하는 쪽이 잔존물을 한 주기 더 남기는 쪽보다 나쁘다.
|
||||||
|
*
|
||||||
|
* @param string $dir 검사할 작업 디렉토리 절대 경로
|
||||||
|
* @return bool 삭제 대상 여부
|
||||||
|
*/
|
||||||
|
private function isStaleWorkDirectory(string $dir): bool
|
||||||
|
{
|
||||||
|
$mtime = @filemtime($dir);
|
||||||
|
|
||||||
|
if ($mtime === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (time() - $mtime) > self::WORK_DIR_STALE_SECONDS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게시 디렉토리를 삭제하고 성공 여부를 반환합니다.
|
||||||
|
*
|
||||||
|
* 반환값을 검사하지 않으면 소유권 불일치로 삭제가 실패해도 "N개 삭제" 로 보고되어,
|
||||||
|
* 구버전·고아 tmp 가 무한 누적되는 동안 운영자에게는 정상으로 보인다.
|
||||||
|
*
|
||||||
|
* @param string $dir 삭제할 디렉토리 절대 경로
|
||||||
|
* @return bool 삭제 성공 여부
|
||||||
|
*/
|
||||||
|
private function deleteVersionDirectory(string $dir): bool
|
||||||
|
{
|
||||||
|
if (File::deleteDirectory($dir)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::warning('정적 게시 디렉토리 삭제 실패 — 잔존물이 누적됩니다 (소유권/권한 확인 필요)', [
|
||||||
|
'path' => $dir,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 수명주기 이벤트에서 호출되는 terminating 게시 예약.
|
* 수명주기 이벤트에서 호출되는 terminating 게시 예약.
|
||||||
*
|
*
|
||||||
@@ -204,6 +308,20 @@ class ExtensionStaticCacheService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 신선한 실패 마커가 있으면 예약하지 않는다 (백오프).
|
||||||
|
//
|
||||||
|
// 쓰기 불가 환경(게시 트리 소유권 불일치 등)에서는 게시가 **매 요청** 실패하는데,
|
||||||
|
// 실패 전까지 전 로케일 lang 병합 + 전 템플릿 dist 복사를 이미 다 헛돈 뒤다.
|
||||||
|
// 사이트는 API 폴백으로 살아 있어 아무도 눈치채지 못한 채 모든 프로덕션 요청이
|
||||||
|
// 그 비용을 낸다. 마커 TTL 창당 1회로 억제한다.
|
||||||
|
//
|
||||||
|
// 억제 대상은 **같은 버전**뿐이다. 버전이 오르면 그 버전은 아직 한 번도 시도된
|
||||||
|
// 적이 없으므로, 이전 버전의 마커로 막으면 "버전 갱신 → 게시" 규율(D1)이 TTL
|
||||||
|
// 창(최대 300초) 동안 도로 끊긴다.
|
||||||
|
if (self::hasFreshFailureMarker(self::getExtensionCacheVersion())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
self::$publishScheduled = true;
|
self::$publishScheduled = true;
|
||||||
|
|
||||||
app()->terminating(static function (): void {
|
app()->terminating(static function (): void {
|
||||||
@@ -223,6 +341,19 @@ class ExtensionStaticCacheService
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테스트 전용 — terminating 예약 플래그의 현재 값을 반환합니다.
|
||||||
|
*
|
||||||
|
* 게시 예약은 부수효과가 `app()->terminating()` 콜백 등록뿐이라 밖에서 관측할 방법이
|
||||||
|
* 없다. "예약했는가" 를 단언하려면 이 플래그가 유일한 통로다.
|
||||||
|
*
|
||||||
|
* @return bool 예약 여부
|
||||||
|
*/
|
||||||
|
public static function isPublishScheduledForTesting(): bool
|
||||||
|
{
|
||||||
|
return self::$publishScheduled;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 테스트 격리용 — terminating 예약 플래그를 초기화합니다.
|
* 테스트 격리용 — terminating 예약 플래그를 초기화합니다.
|
||||||
*/
|
*/
|
||||||
@@ -232,6 +363,101 @@ class ExtensionStaticCacheService
|
|||||||
self::$rootProcessForTesting = null;
|
self::$rootProcessForTesting = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최근 게시 실패 마커를 반환합니다 (없으면 null).
|
||||||
|
*
|
||||||
|
* 진단 표면(`ext-static:status`, 대시보드 알림)이 읽는 유일한 통로다 — 게시 실패는
|
||||||
|
* 사이트를 멈추지 않고 API 폴백으로 넘어가므로, 이 마커가 없으면 정상 운영 환경에서
|
||||||
|
* 실패를 확인할 방법이 로그뿐이다.
|
||||||
|
*
|
||||||
|
* @return array{version:int, at:string, reason:string, count:int, message:string}|null 실패 마커
|
||||||
|
*/
|
||||||
|
public static function failureMarker(): ?array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$marker = self::markerCache()->get(self::FAILURE_MARKER_KEY);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// 마커 저장소 자체가 불능인 환경 — best-effort 다 (아래 recordFailure 주석 참조)
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_array($marker) ? $marker : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게시 실패를 마커에 기록합니다 (연속 실패 횟수 누적).
|
||||||
|
*
|
||||||
|
* best-effort 다 — 마커를 쓰지 못하는 환경(캐시 저장소 자체가 불능)이 실재하며,
|
||||||
|
* 그 경우 백오프는 걸리지 않는다. 다만 in-process `$publishScheduled` 가드는 그대로
|
||||||
|
* 유효하므로 한 요청 안에서 반복되지는 않는다.
|
||||||
|
*
|
||||||
|
* @param int $version 실패한 확장 캐시 버전
|
||||||
|
* @param string $reason 실패 사유 코드 (`parent_not_writable` / `write_failed` / `lock_unavailable`)
|
||||||
|
* @param string $message 진단용 원문 메시지
|
||||||
|
*/
|
||||||
|
private static function recordFailure(int $version, string $reason, string $message): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$previous = self::failureMarker();
|
||||||
|
$count = ($previous !== null && ($previous['version'] ?? null) === $version)
|
||||||
|
? (int) ($previous['count'] ?? 0) + 1
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
self::markerCache()->put(self::FAILURE_MARKER_KEY, [
|
||||||
|
'version' => $version,
|
||||||
|
'at' => now()->toIso8601String(),
|
||||||
|
'reason' => $reason,
|
||||||
|
'count' => $count,
|
||||||
|
'message' => $message,
|
||||||
|
], self::FAILURE_MARKER_TTL);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::debug('정적 게시 실패 마커 기록 실패 (백오프 미적용)', ['error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게시 성공 시 실패 마커를 제거합니다.
|
||||||
|
*/
|
||||||
|
private static function clearFailureMarker(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
self::markerCache()->forget(self::FAILURE_MARKER_KEY);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// 마커 제거 실패는 무해하다 — TTL 로 자연 만료된다
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 지정 버전에 대한 TTL 창 안의 실패 마커가 존재하는지 판정합니다 (백오프 게이트).
|
||||||
|
*
|
||||||
|
* 버전을 한정하지 않으면 bump 직후의 새 버전이 이전 버전의 마커로 억제된다 —
|
||||||
|
* 아직 한 번도 시도되지 않은 버전을 실패로 취급하는 셈이다.
|
||||||
|
*
|
||||||
|
* @param int $version 판정할 확장 캐시 버전
|
||||||
|
* @return bool 해당 버전의 신선한 실패 마커 존재 여부
|
||||||
|
*/
|
||||||
|
private static function hasFreshFailureMarker(int $version): bool
|
||||||
|
{
|
||||||
|
$marker = self::failureMarker();
|
||||||
|
|
||||||
|
return $marker !== null && ($marker['version'] ?? null) === $version;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 실패 마커용 캐시 드라이버를 반환합니다.
|
||||||
|
*
|
||||||
|
* 캐시 버전 키와 **같은 스토어**를 쓴다 — 마커를 쓰는 쪽(게시)과 읽는 쪽(진단 커맨드·
|
||||||
|
* 대시보드 알림)이 다른 스토어를 가리키면 실패가 기록돼도 화면에는 영영 뜨지 않는다.
|
||||||
|
* `ClearsTemplateCaches::extensionCacheStore()` 는 프로세스 1회 메모이즈된 고정 스토어라
|
||||||
|
* settings 로드 타이밍에 좌우되지 않는다.
|
||||||
|
*
|
||||||
|
* @return CacheInterface 코어 네임스페이스 캐시 드라이버
|
||||||
|
*/
|
||||||
|
private static function markerCache(): CacheInterface
|
||||||
|
{
|
||||||
|
return new CoreCacheDriver(self::extensionCacheStore());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 테스트 전용 — root 프로세스 판정을 강제합니다 (null 로 실판정 복귀).
|
* 테스트 전용 — root 프로세스 판정을 강제합니다 (null 로 실판정 복귀).
|
||||||
*
|
*
|
||||||
@@ -300,10 +526,19 @@ class ExtensionStaticCacheService
|
|||||||
$base = $this->baseDir();
|
$base = $this->baseDir();
|
||||||
$tmp = $base.DIRECTORY_SEPARATOR.$version.'.tmp';
|
$tmp = $base.DIRECTORY_SEPARATOR.$version.'.tmp';
|
||||||
$final = $this->versionDir($version);
|
$final = $this->versionDir($version);
|
||||||
|
$old = $final.'.old';
|
||||||
|
$swapped = false;
|
||||||
|
|
||||||
|
// 프리플라이트 — 게시 트리를 만들 수 없는 환경이면 병합을 시작하기 전에 끊는다.
|
||||||
|
// 전 로케일 lang 병합 + 전 템플릿 dist 복사를 다 헛돈 뒤 mkdir 에서 실패하는
|
||||||
|
// 것과, 시작 전에 사유를 남기고 끊는 것은 비용이 전혀 다르다.
|
||||||
|
if (! $this->ensurePublishRootWritable($version)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
File::deleteDirectory($tmp);
|
File::deleteDirectory($tmp);
|
||||||
File::ensureDirectoryExists($tmp, 0775);
|
$this->makeDirectory($tmp);
|
||||||
|
|
||||||
$files = [];
|
$files = [];
|
||||||
|
|
||||||
@@ -319,12 +554,24 @@ class ExtensionStaticCacheService
|
|||||||
|
|
||||||
$this->publishExtensionCustomAssets($tmp, $files);
|
$this->publishExtensionCustomAssets($tmp, $files);
|
||||||
|
|
||||||
// 원자적 스왑 — force 재게시 시 기존 디렉토리를 비켜낸 뒤 rename
|
// 원자적 스왑 — 기존 디렉토리를 **먼저 지우지 않는다.** 삭제 후 rename 사이의
|
||||||
if (File::isDirectory($final)) {
|
// 창에서는 이미 배달된 HTML 이 참조하는 CSS/JS/폰트가 전부 404 가 되고, 폰트는
|
||||||
File::deleteDirectory($final);
|
// 복구기가 없다. 대신 `.old` 로 비켜낸 뒤 rename 하고, 성공을 확인한 다음에
|
||||||
|
// `.old` 를 지운다. 중간 실패 시 `.old` 를 제자리로 되돌린다.
|
||||||
|
File::deleteDirectory($old);
|
||||||
|
|
||||||
|
if (File::isDirectory($final) && ! $this->renameDirectory($final, $old)) {
|
||||||
|
throw new StaticCachePublishException("Failed to move aside publish directory: {$final} -> {$old}");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! @rename($tmp, $final)) {
|
$swapped = File::isDirectory($old);
|
||||||
|
|
||||||
|
if (! $this->renameDirectory($tmp, $final)) {
|
||||||
|
// 새 디렉토리를 앉히지 못했다 — 비켜낸 기존 버전을 즉시 되돌린다.
|
||||||
|
if ($swapped && $this->renameDirectory($old, $final)) {
|
||||||
|
$swapped = false;
|
||||||
|
}
|
||||||
|
|
||||||
throw new StaticCachePublishException("Failed to rename publish directory: {$tmp} -> {$final}");
|
throw new StaticCachePublishException("Failed to rename publish directory: {$tmp} -> {$final}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,6 +579,12 @@ class ExtensionStaticCacheService
|
|||||||
$this->writeManifest($final, $version, $files);
|
$this->writeManifest($final, $version, $files);
|
||||||
unset($this->publishedMemo[$version]);
|
unset($this->publishedMemo[$version]);
|
||||||
|
|
||||||
|
// 새 버전이 완성된 뒤에만 구버전을 치운다 (여기까지 오면 404 창이 없다)
|
||||||
|
if ($swapped) {
|
||||||
|
File::deleteDirectory($old);
|
||||||
|
$swapped = false;
|
||||||
|
}
|
||||||
|
|
||||||
// sudo/root CLI 게시 대응 — terminating 게시는 코어 업데이트의
|
// sudo/root CLI 게시 대응 — terminating 게시는 코어 업데이트의
|
||||||
// restoreOwnership **이후**(프로세스 종료 시)에 실행되므로, root 소유로
|
// restoreOwnership **이후**(프로세스 종료 시)에 실행되므로, root 소유로
|
||||||
// 남으면 이후 php-fpm 의 재게시·GC 가 영구 실패한다. 부모(public/build)
|
// 남으면 이후 php-fpm 의 재게시·GC 가 영구 실패한다. 부모(public/build)
|
||||||
@@ -341,6 +594,8 @@ class ExtensionStaticCacheService
|
|||||||
// 인라인 GC (현재 + 직전 1개 보존)
|
// 인라인 GC (현재 + 직전 1개 보존)
|
||||||
$this->cleanup();
|
$this->cleanup();
|
||||||
|
|
||||||
|
self::clearFailureMarker();
|
||||||
|
|
||||||
Log::info('부트스트랩 리소스 정적 게시 완료', [
|
Log::info('부트스트랩 리소스 정적 게시 완료', [
|
||||||
'version' => $version,
|
'version' => $version,
|
||||||
'files' => count($files),
|
'files' => count($files),
|
||||||
@@ -357,12 +612,220 @@ class ExtensionStaticCacheService
|
|||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
'at' => $e->getFile().':'.$e->getLine(),
|
'at' => $e->getFile().':'.$e->getLine(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// 정리 대상은 **실제 존재하는 쪽**이다. rename 이 이미 성공했다면 `$tmp` 는
|
||||||
|
// 존재하지 않으므로 그것만 지우는 종전 코드는 no-op 이었고, manifest 기록이
|
||||||
|
// 실패한 경우 manifest 없는 완성 디렉토리가 영구 잔존했다 —
|
||||||
|
// `isPublished()` 가 영원히 false 라 요청마다 지웠다 만들기를 반복한다.
|
||||||
File::deleteDirectory($tmp);
|
File::deleteDirectory($tmp);
|
||||||
|
|
||||||
|
if (! File::isDirectory($final.DIRECTORY_SEPARATOR)
|
||||||
|
|| ! is_file($final.DIRECTORY_SEPARATOR.self::MANIFEST_FILE)) {
|
||||||
|
File::deleteDirectory($final);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 비켜낸 기존 버전이 남아 있으면 제자리로 되돌린다 — 되돌리지 못하면
|
||||||
|
// `.old` 로 둔 채 나이 가드가 붙은 GC 에 맡긴다(즉시 삭제하지 않는다).
|
||||||
|
if ($swapped && ! File::isDirectory($final)) {
|
||||||
|
$this->renameDirectory($old, $final);
|
||||||
|
}
|
||||||
|
|
||||||
|
unset($this->publishedMemo[$version]);
|
||||||
|
|
||||||
|
self::recordFailure($version, 'write_failed', $e->getMessage());
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게시 루트(`public/build/ext`)를 만들 수 있고 쓸 수 있는지 확인합니다.
|
||||||
|
*
|
||||||
|
* 부모(`public/build`)가 다른 계정 소유 + `g-w` 면 웹 프로세스는 `ext` 를 **mkdir 조차
|
||||||
|
* 하지 못한다.** 그 경우 최초 게시가 CLI 로 강제되고, 그 순간 트리 소유권이 CLI 계정으로
|
||||||
|
* 고정되어 이후 웹 재게시가 영구 실패한다 (제보 본건).
|
||||||
|
*
|
||||||
|
* @param int $version 게시 대상 버전 (실패 마커 기록용)
|
||||||
|
* @return bool 게시를 진행해도 되는지 여부
|
||||||
|
*/
|
||||||
|
private function ensurePublishRootWritable(int $version): bool
|
||||||
|
{
|
||||||
|
$base = $this->baseDir();
|
||||||
|
|
||||||
|
if (! File::isDirectory($base)) {
|
||||||
|
// 게시 루트가 아직 없으면 만든다. 검사 대상은 `public/build` 고정이 아니라
|
||||||
|
// **실재하는 최근접 조상**이다 — `public/build` 자체가 없는 환경(신규 설치,
|
||||||
|
// 테스트 격리 public 경로)에서 부모 존재를 요구하면 정상 상황을 실패로 만든다.
|
||||||
|
$ancestor = $this->nearestExistingAncestor($base);
|
||||||
|
|
||||||
|
if ($ancestor === null || ! is_writable($ancestor)) {
|
||||||
|
$this->failPreflight(
|
||||||
|
$version,
|
||||||
|
$ancestor ?? dirname($base),
|
||||||
|
'게시 루트를 만들 상위 디렉토리에 쓸 수 없습니다'
|
||||||
|
);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 조상이 쓰기 가능해도 mkdir 은 실패할 수 있다 — 경로 중간이 **파일**이거나
|
||||||
|
// 경합으로 사라지는 경우다. `ensureDirectoryExists` 는 그 실패를 예외로
|
||||||
|
// 던지는데, 이 프리플라이트는 `publishVersion` 의 try 블록 **밖**에서 돌므로
|
||||||
|
// 잡지 않으면 예외가 호출자에게 그대로 새어 나간다 — 게시 실패는 사이트를
|
||||||
|
// 멈추지 않는다는 계약이 그 지점에서 깨진다.
|
||||||
|
try {
|
||||||
|
$this->makeDirectory($base);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->failPreflight($version, $base, '게시 루트를 만들지 못했습니다: '.$e->getMessage());
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearstatcache(true, $base);
|
||||||
|
|
||||||
|
if (! File::isDirectory($base) || ! is_writable($base)) {
|
||||||
|
$this->failPreflight($version, $base, '게시 루트에 쓸 수 없습니다');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 경로에서 위로 올라가며 실재하는 첫 디렉토리를 찾습니다.
|
||||||
|
*
|
||||||
|
* @param string $path 기준 경로
|
||||||
|
* @return string|null 실재하는 최근접 조상 (루트까지 없으면 null)
|
||||||
|
*/
|
||||||
|
private function nearestExistingAncestor(string $path): ?string
|
||||||
|
{
|
||||||
|
$current = dirname($path);
|
||||||
|
|
||||||
|
while (! File::isDirectory($current)) {
|
||||||
|
$parent = dirname($current);
|
||||||
|
|
||||||
|
if ($parent === $current) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$current = $parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프리플라이트 실패를 로그 + 실패 마커에 남깁니다.
|
||||||
|
*
|
||||||
|
* @param int $version 게시 대상 버전
|
||||||
|
* @param string $path 쓰기 불가로 판정된 경로
|
||||||
|
* @param string $summary 사람이 읽는 요약
|
||||||
|
*/
|
||||||
|
private function failPreflight(int $version, string $path, string $summary): void
|
||||||
|
{
|
||||||
|
$detail = sprintf(
|
||||||
|
'%s (%s, owner=%s, perms=%s, process_user=%s)',
|
||||||
|
$summary,
|
||||||
|
$path,
|
||||||
|
(string) (@fileowner($path) ?: 'unknown'),
|
||||||
|
File::exists($path) ? substr(sprintf('%o', @fileperms($path)), -4) : 'absent',
|
||||||
|
self::currentProcessUser(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Log::warning('부트스트랩 리소스 정적 게시 프리플라이트 실패 — API 폴백으로 동작합니다', [
|
||||||
|
'version' => $version,
|
||||||
|
'path' => $path,
|
||||||
|
'reason' => 'parent_not_writable',
|
||||||
|
'detail' => $detail,
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::recordFailure($version, 'parent_not_writable', $detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 프로세스의 실행 계정명을 반환합니다 (진단용, 미지원 환경은 'unknown').
|
||||||
|
*
|
||||||
|
* @return string 계정명 또는 uid 문자열
|
||||||
|
*/
|
||||||
|
public static function currentProcessUser(): string
|
||||||
|
{
|
||||||
|
if (! function_exists('posix_geteuid')) {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
$uid = posix_geteuid();
|
||||||
|
|
||||||
|
if (function_exists('posix_getpwuid')) {
|
||||||
|
$info = @posix_getpwuid($uid);
|
||||||
|
|
||||||
|
if (is_array($info) && isset($info['name'])) {
|
||||||
|
return (string) $info['name'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) $uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게시 트리 디렉토리를 만들고 umask 와 무관하게 권한·소유권을 정합화합니다.
|
||||||
|
*
|
||||||
|
* `File::ensureDirectoryExists($dir, 0775)` 의 mode 인자는 **umask 로 깎인다** —
|
||||||
|
* umask 022 환경에서는 0755 가 되어 웹 계정(그룹 공유)이 쓸 수 없다. 명시 `chmod` 로
|
||||||
|
* umask 를 무력화하고, 부모 소유권을 상속시켜 CLI 계정 고정을 막는다.
|
||||||
|
* (선례: `CoreUpdateService::ensureWritableDirectories`)
|
||||||
|
*
|
||||||
|
* @param string $dir 생성할 디렉토리 절대 경로
|
||||||
|
*/
|
||||||
|
private function makeDirectory(string $dir): void
|
||||||
|
{
|
||||||
|
File::ensureDirectoryExists($dir, self::PUBLISH_DIR_MODE);
|
||||||
|
|
||||||
|
// ensureDirectoryExists 의 mode 는 umask 로 깎이므로 명시 chmod 로 확정한다.
|
||||||
|
@chmod($dir, self::PUBLISH_DIR_MODE);
|
||||||
|
|
||||||
|
FilePermissionHelper::inheritOwnershipFromParent($dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게시 트리의 디렉토리 rename 을 유한 재시도와 함께 수행합니다.
|
||||||
|
*
|
||||||
|
* 갓 쓰여진 파일이 든 디렉토리의 rename 은 **목적지가 비어 있는데도** 첫 시도가
|
||||||
|
* 거부될 수 있다. 실측(4표본): 실패 순간 `$final` 은 부재였고 `.old` 스왑도
|
||||||
|
* 관여하지 않았는데 `rename` 이 false 를 돌려줬으며, 상태를 바꾸지 않고 그대로
|
||||||
|
* 다시 호출하면 전부 성공했다(즉시 1건 / 200ms 후 3건, 1,200ms 까지 간 표본 0).
|
||||||
|
* 운영 환경에서도 같은 사유의 게시 실패가 관측됐다 — 재시도 한 번이면 성공했을
|
||||||
|
* 게시가 실패 마커와 대시보드 알림까지 올라간다.
|
||||||
|
*
|
||||||
|
* 특정 OS 로 분기하지 않는다. 이 거부는 파일시스템·스토리지 계층(네트워크 마운트,
|
||||||
|
* 스냅샷, 백신·인덱서 등)이면 어디서든 성립하는 조건이고, 분기를 두면 그 플랫폼
|
||||||
|
* 밖에서 같은 실패가 조용히 남는다. 재시도가 불필요한 환경에서는 첫 시도가
|
||||||
|
* 성공하므로 비용이 0 이다.
|
||||||
|
*
|
||||||
|
* 성공하지 못하면 false 를 돌려주며 **실패 계약은 종전 그대로다** — 호출부가
|
||||||
|
* `.old` 롤백과 예외를 그대로 수행한다.
|
||||||
|
*
|
||||||
|
* @param string $from 원본 경로
|
||||||
|
* @param string $to 목적지 경로
|
||||||
|
* @return bool rename 성공 여부
|
||||||
|
*/
|
||||||
|
private function renameDirectory(string $from, string $to): bool
|
||||||
|
{
|
||||||
|
for ($attempt = 1; $attempt <= self::RENAME_ATTEMPTS; $attempt++) {
|
||||||
|
if (@rename($from, $to)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($attempt < self::RENAME_ATTEMPTS) {
|
||||||
|
usleep(self::RENAME_RETRY_DELAY_US);
|
||||||
|
clearstatcache();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* root 로 실행된 CLI 게시의 산출물 소유권을 부모 디렉토리 기준으로 정상화합니다.
|
* root 로 실행된 CLI 게시의 산출물 소유권을 부모 디렉토리 기준으로 정상화합니다.
|
||||||
*
|
*
|
||||||
@@ -373,19 +836,38 @@ class ExtensionStaticCacheService
|
|||||||
*/
|
*/
|
||||||
private function normalizeOwnership(): void
|
private function normalizeOwnership(): void
|
||||||
{
|
{
|
||||||
if (! function_exists('chown') || ! function_exists('posix_geteuid') || posix_geteuid() !== 0) {
|
$base = $this->baseDir();
|
||||||
|
|
||||||
|
if (! File::isDirectory($base)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$parent = dirname($this->baseDir());
|
// ① root 갈래 — 소유권을 부모 기준으로 되돌린다 (sudo CLI 게시 대응).
|
||||||
$owner = @fileowner($parent);
|
if (function_exists('chown') && function_exists('posix_geteuid') && posix_geteuid() === 0) {
|
||||||
$group = @filegroup($parent);
|
$parent = dirname($base);
|
||||||
|
$owner = @fileowner($parent);
|
||||||
|
$group = @filegroup($parent);
|
||||||
|
|
||||||
if ($owner === false || $owner === 0) {
|
if ($owner !== false && $owner !== 0) {
|
||||||
return;
|
FilePermissionHelper::chownRecursive($base, $owner, $group);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
FilePermissionHelper::chownRecursive($this->baseDir(), $owner, $group);
|
// ② 항상 실행 갈래 — 비-root CLI 계정(`deploy` 등)으로 게시한 경우를 덮는다.
|
||||||
|
//
|
||||||
|
// 종전에는 root 가 아니면 즉시 no-op 이었다. 그러나 실제 제보는 **비-root CLI 계정
|
||||||
|
// ≠ 웹 계정** 이었다: CLI 가 최초 게시하면서 트리가 `0755 deploy:deploy` 로 굳고,
|
||||||
|
// 이후 웹(php-fpm)의 재게시가 영구 실패한 채 로그 warning 만 남았다.
|
||||||
|
//
|
||||||
|
// chgrp 로 부모 그룹을 상속시키고 트리에 `g+w` 를 승격하면 그룹을 공유하는 웹
|
||||||
|
// 계정이 재게시할 수 있다. 비-root 에서 `@chown` 은 실패해도 무해하고(false 반환),
|
||||||
|
// `chgrp`(자기가 속한 그룹으로) 와 `chmod g+w`(자기 소유 파일)는 성립한다.
|
||||||
|
//
|
||||||
|
// 한계: 이 방식은 CLI 계정과 웹 계정이 **그룹을 공유**할 때만 성립한다. 공유하지
|
||||||
|
// 않는 환경은 프리플라이트가 실패 마커를 남기고 진단 표면(`ext-static:status`,
|
||||||
|
// 대시보드 알림)이 그 사실을 운영자에게 전달한다 — 규정 문서 §6 참조.
|
||||||
|
FilePermissionHelper::inheritOwnershipFromParent($base);
|
||||||
|
FilePermissionHelper::syncGroupWritabilityDetailed($base, force: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -658,7 +1140,7 @@ class ExtensionStaticCacheService
|
|||||||
*/
|
*/
|
||||||
private function writeJson(string $absolutePath, mixed $data, array &$files, string $tmp): void
|
private function writeJson(string $absolutePath, mixed $data, array &$files, string $tmp): void
|
||||||
{
|
{
|
||||||
File::ensureDirectoryExists(dirname($absolutePath), 0775);
|
$this->makeDirectory(dirname($absolutePath));
|
||||||
|
|
||||||
$json = json_encode($data, ResponseHelper::JSON_ENCODE_OPTIONS);
|
$json = json_encode($data, ResponseHelper::JSON_ENCODE_OPTIONS);
|
||||||
|
|
||||||
@@ -666,9 +1148,7 @@ class ExtensionStaticCacheService
|
|||||||
throw new StaticCachePublishException("Failed to encode JSON payload: {$absolutePath}");
|
throw new StaticCachePublishException("Failed to encode JSON payload: {$absolutePath}");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (File::put($absolutePath, $json) === false) {
|
$this->putVerified($absolutePath, $json);
|
||||||
throw new StaticCachePublishException("Failed to write file: {$absolutePath}");
|
|
||||||
}
|
|
||||||
|
|
||||||
$files[] = $this->relativePath($absolutePath, $tmp);
|
$files[] = $this->relativePath($absolutePath, $tmp);
|
||||||
}
|
}
|
||||||
@@ -683,12 +1163,24 @@ class ExtensionStaticCacheService
|
|||||||
*/
|
*/
|
||||||
private function copyFile(string $source, string $target, array &$files, string $tmp): void
|
private function copyFile(string $source, string $target, array &$files, string $tmp): void
|
||||||
{
|
{
|
||||||
File::ensureDirectoryExists(dirname($target), 0775);
|
$this->makeDirectory(dirname($target));
|
||||||
|
|
||||||
if (! File::copy($source, $target)) {
|
if (! File::copy($source, $target)) {
|
||||||
throw new StaticCachePublishException("Failed to copy file: {$source} -> {$target}");
|
throw new StaticCachePublishException("Failed to copy file: {$source} -> {$target}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 복사도 절단될 수 있다 — 디스크 풀/quota 에서 `copy()` 는 true 를 반환하면서
|
||||||
|
// 짧은 파일을 남긴다. 크기 대조로 그 자리에서 잡는다.
|
||||||
|
clearstatcache(true, $target);
|
||||||
|
$expected = @filesize($source);
|
||||||
|
$actual = @filesize($target);
|
||||||
|
|
||||||
|
if ($expected !== false && $actual !== $expected) {
|
||||||
|
throw new StaticCachePublishException(
|
||||||
|
"Truncated copy: {$target} ({$actual} of {$expected} bytes)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$files[] = $this->relativePath($target, $tmp);
|
$files[] = $this->relativePath($target, $tmp);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -715,9 +1207,7 @@ class ExtensionStaticCacheService
|
|||||||
|
|
||||||
HTACCESS;
|
HTACCESS;
|
||||||
|
|
||||||
if (File::put($tmp.DIRECTORY_SEPARATOR.'.htaccess', $content) === false) {
|
$this->putVerified($tmp.DIRECTORY_SEPARATOR.'.htaccess', $content);
|
||||||
throw new StaticCachePublishException('Failed to write .htaccess');
|
|
||||||
}
|
|
||||||
|
|
||||||
$files[] = '.htaccess';
|
$files[] = '.htaccess';
|
||||||
}
|
}
|
||||||
@@ -739,8 +1229,40 @@ class ExtensionStaticCacheService
|
|||||||
|
|
||||||
$json = json_encode($manifest, ResponseHelper::JSON_ENCODE_OPTIONS);
|
$json = json_encode($manifest, ResponseHelper::JSON_ENCODE_OPTIONS);
|
||||||
|
|
||||||
if ($json === false || File::put($finalDir.DIRECTORY_SEPARATOR.self::MANIFEST_FILE, $json) === false) {
|
if ($json === false) {
|
||||||
throw new StaticCachePublishException('Failed to write manifest');
|
throw new StaticCachePublishException('Failed to encode manifest');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->putVerified($finalDir.DIRECTORY_SEPARATOR.self::MANIFEST_FILE, $json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파일을 기록하고 **기록된 바이트 수를 검증**합니다.
|
||||||
|
*
|
||||||
|
* `File::put()` 은 실패 시에만 `false` 를 돌려주는 게 아니다 — 디스크 풀·quota 초과에서는
|
||||||
|
* 쓴 만큼의 짧은 `int` 를 반환하며 성공한 것처럼 보인다. `=== false` 검사만 하면 그
|
||||||
|
* **절단된 JSON 이 200 으로 서빙**되고, 프론트의 `fetchStaticFirst` 는 `response.ok` 만
|
||||||
|
* 보므로 폴백하지 않은 채 `response.json()` 이 던져 부팅 전체가 실패한다. 3층 폴백이
|
||||||
|
* 유일하게 개입하지 못하는 경로라, 여기서 잡지 못하면 다른 어디서도 잡히지 않는다.
|
||||||
|
*
|
||||||
|
* @param string $absolutePath 기록 대상 절대 경로
|
||||||
|
* @param string $contents 기록할 내용
|
||||||
|
*
|
||||||
|
* @throws StaticCachePublishException 쓰기 실패 또는 바이트 수 불일치
|
||||||
|
*/
|
||||||
|
private function putVerified(string $absolutePath, string $contents): void
|
||||||
|
{
|
||||||
|
$expected = strlen($contents);
|
||||||
|
$written = File::put($absolutePath, $contents);
|
||||||
|
|
||||||
|
if ($written === false) {
|
||||||
|
throw new StaticCachePublishException("Failed to write file: {$absolutePath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($written !== $expected) {
|
||||||
|
throw new StaticCachePublishException(
|
||||||
|
"Truncated write: {$absolutePath} ({$written} of {$expected} bytes)"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use App\Rules\AllowedModuleFileType;
|
||||||
|
use App\Rules\AllowedPluginFileType;
|
||||||
|
use App\Rules\AllowedTemplateFileType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPA catch-all 라우트의 정적 확장자 제외 패턴 (#122).
|
||||||
|
*
|
||||||
|
* catch-all 은 등록되지 않은 경로에 SPA 셸 HTML 을 돌려준다. 그런데 **정적 자산 요청**이
|
||||||
|
* 거기 걸리면 존재하지 않는 `.mjs` 가 `Content-Type: text/html` 인 200 을 받고, 브라우저는
|
||||||
|
* 그것을 스크립트로 파싱하다 죽는다. `onerror` 는 발화하지 않으므로(응답은 성공이다)
|
||||||
|
* 태그 복구기도 뜨지 않는다 — 예외도 404 도 없이 화면만 조용히 비는 결함이다.
|
||||||
|
*
|
||||||
|
* 제외 목록을 라우트 파일에 손으로 적어 두면 에셋 서빙이 허용하는 확장자와 갈라진다.
|
||||||
|
* 실제로 갈라져 있었다 — `mjs` · `webp` · `otf` 세 가지가 서빙은 되는데 제외 목록에는
|
||||||
|
* 없었다. `.json` 이 무사했던 것은 lookahead 에 끝 앵커가 없어 `.js` 에 **부분일치**한
|
||||||
|
* 우연이었을 뿐이다. 그래서 목록을 손으로 두지 않고 에셋 서빙 화이트리스트에서 **파생**시켜
|
||||||
|
* 우연을 제거한다.
|
||||||
|
*
|
||||||
|
* 모집단은 세 화이트리스트의 **합집합**이다. 게시 트리(`public/build/ext/{v}/`)에는
|
||||||
|
* 템플릿 dist 뿐 아니라 모듈·플러그인의 사용자 추가 에셋도 함께 실리므로, 한 종류만
|
||||||
|
* 기준으로 삼으면 나머지가 서빙하는 확장자가 다시 빠진다(`ico` 가 그 경우다 — 모듈·
|
||||||
|
* 플러그인은 허용하고 템플릿은 허용하지 않는다).
|
||||||
|
*
|
||||||
|
* 라우트 캐시 안전: `where()` 인자는 **정의 시점**에 평가되어 컴파일된 정규식으로 캐시에
|
||||||
|
* 박히므로, 캐시 로드 경로는 이 클래스를 참조하지 않는다. 라우트 파일에 전역 함수를
|
||||||
|
* 선언하지도 않는다 (CLAUDE.md "라우트 캐시 안전성").
|
||||||
|
*
|
||||||
|
* 환경 의존 게터(`getAllowedExtensions()`)를 쓰지 않는 이유도 같다 — 그것은 로컬에서
|
||||||
|
* 소스맵(`map`)을 덧붙이므로, 패턴이 **캐시를 구운 환경**에 따라 달라진다.
|
||||||
|
*/
|
||||||
|
final class StaticExtensionPattern
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* SPA catch-all `where()` 에 넣을 제외 lookahead 를 반환합니다.
|
||||||
|
*
|
||||||
|
* 끝 앵커(`$`)를 붙이지 않는다 — 쿼리스트링이 아닌 경로 뒤 세그먼트가 붙은 형태
|
||||||
|
* (`/a.js/b`)까지 함께 제외하는 종전 동작을 유지하기 위함이며, 앵커를 붙이면
|
||||||
|
* 정적 미스가 SPA 풀 렌더를 경유해 조용히 비싸진다.
|
||||||
|
*
|
||||||
|
* @return string `(?!.*\.(js|css|…)).*` 형태의 라우트 패턴 조각
|
||||||
|
*/
|
||||||
|
public static function catchAllExclusion(): string
|
||||||
|
{
|
||||||
|
return '(?!.*\.('.implode('|', self::servedExtensions()).')).*';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 정적으로 서빙될 수 있는 확장자 전체(세 화이트리스트 합집합)를 반환합니다.
|
||||||
|
*
|
||||||
|
* @return array<int, string> 중복 없는 확장자 목록
|
||||||
|
*/
|
||||||
|
public static function servedExtensions(): array
|
||||||
|
{
|
||||||
|
return array_values(array_unique([
|
||||||
|
...AllowedTemplateFileType::allowedExtensions(),
|
||||||
|
...AllowedModuleFileType::allowedExtensions(),
|
||||||
|
...AllowedPluginFileType::allowedExtensions(),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -76,12 +76,22 @@ return [
|
|||||||
|
|
||||||
// 확장(모듈/플러그인) 프론트엔드 IIFE/CSS 번들 병합 결과 캐시.
|
// 확장(모듈/플러그인) 프론트엔드 IIFE/CSS 번들 병합 결과 캐시.
|
||||||
// ExtensionBundleService 가 version-in-path 파일명으로 저장/서빙한다.
|
// ExtensionBundleService 가 version-in-path 파일명으로 저장/서빙한다.
|
||||||
|
//
|
||||||
|
// `permissions` 선언이 없으면 Flysystem 이 디스크 인스턴스화 시점에 root 를
|
||||||
|
// `0700` 으로 만든다. 그러면 **먼저 touch 한 프로세스가 독점**한다 — CLI 가 먼저면
|
||||||
|
// 이후 php-fpm 의 쓰기가 `UnableToWriteFile` 로 죽고, `throw => true` 라 그 예외가
|
||||||
|
// 공개 번들 엔드포인트의 500 으로 그대로 나간다. 디스크 생성 시점이 유일한 예방
|
||||||
|
// 지점이므로 여기서 그룹 쓰기를 열어 둔다 (실패 시 fail-soft 는 서비스가 담당).
|
||||||
'ext-bundles' => [
|
'ext-bundles' => [
|
||||||
'driver' => 'local',
|
'driver' => 'local',
|
||||||
'root' => storage_path('app/ext-bundles'),
|
'root' => storage_path('app/ext-bundles'),
|
||||||
'serve' => false,
|
'serve' => false,
|
||||||
'throw' => true,
|
'throw' => true,
|
||||||
'report' => false,
|
'report' => false,
|
||||||
|
'permissions' => [
|
||||||
|
'file' => ['public' => 0664, 'private' => 0664],
|
||||||
|
'dir' => ['public' => 0775, 'private' => 0775],
|
||||||
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
'public' => [
|
'public' => [
|
||||||
|
|||||||
@@ -141,13 +141,13 @@ Authorization: Bearer {YOUR_TOKEN}
|
|||||||
|
|
||||||
**응답 필드** (`data` 내부)
|
**응답 필드** (`data` 내부)
|
||||||
|
|
||||||
_목록 응답: `data` 배열 항목의 필드. `data` 는 `core.dashboard.alerts` 필터 훅의 결과이며, 알릴 항목이 없으면 빈 배열(`[]`)입니다. 코어 기본 리스너(`ExtensionCompatibilityAlertListener`)가 주입하는 항목의 필드는 다음과 같습니다._
|
_목록 응답: `data` 배열 항목의 필드. `data` 는 `core.dashboard.alerts` 필터 훅의 결과이며, 알릴 항목이 없으면 빈 배열(`[]`)입니다. 코어 기본 리스너(`ExtensionCompatibilityAlertListener`, `StaticPublishFailureAlertListener`)가 주입하는 항목의 필드는 다음과 같습니다._
|
||||||
|
|
||||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| id | string | `compat_plugins_sirsoft-gdpr` | 알림 식별자 (`compat_{type}_{identifier}` = 자동 비활성화, `recover_{type}_{identifier}` = 재호환. 알림 닫기(dismiss) 상태 판정 키) |
|
| id | string | `compat_plugins_sirsoft-gdpr` | 알림 식별자 (`compat_{type}_{identifier}` = 자동 비활성화, `recover_{type}_{identifier}` = 재호환. 알림 닫기(dismiss) 상태 판정 키) |
|
||||||
| type | string | `warning` | 알림 등급 (`warning`: 코어 비호환 자동 비활성화, `info`: 재호환 복구 가능) |
|
| type | string | `warning` | 알림 등급 (`warning`: 코어 비호환 자동 비활성화, `info`: 재호환 복구 가능) |
|
||||||
| subtype | string | `incompatible_core` | 알림 세부 분류 (`incompatible_core`: 코어 버전 비호환으로 자동 비활성화됨, `recovery_available`: 코어 업그레이드 후 다시 활성화 가능) |
|
| subtype | string | `incompatible_core` | 알림 세부 분류 (`incompatible_core`: 코어 버전 비호환으로 자동 비활성화됨, `recovery_available`: 코어 업그레이드 후 다시 활성화 가능, `static_publish_parent_not_writable` · `static_publish_write_failed` · `static_publish_lock_unavailable`: 초기 화면 파일 생성이 2회 이상 연속 실패 — 각각 폴더 권한 / 디스크 공간 / 캐시 저장소가 원인) |
|
||||||
| icon | string | `exclamation-triangle` | 아이콘 식별자 (warning: `exclamation-triangle`, info: `check-circle`) |
|
| icon | string | `exclamation-triangle` | 아이콘 식별자 (warning: `exclamation-triangle`, info: `check-circle`) |
|
||||||
| title | string | `플러그인 "sirsoft-gdpr" 자동 비활성화됨` | 알림 제목 (다국어 문구 — `extensions.alerts.incompatible_deactivated` / `recovered_title`) |
|
| title | string | `플러그인 "sirsoft-gdpr" 자동 비활성화됨` | 알림 제목 (다국어 문구 — `extensions.alerts.incompatible_deactivated` / `recovered_title`) |
|
||||||
| message | string | `필요 버전: 7.0.0-beta.9, 현재 설치됨: 7.0.0-beta.8` | 알림 본문 (다국어 문구 — `extensions.alerts.incompatible_message` / `recovered_body`) |
|
| message | string | `필요 버전: 7.0.0-beta.9, 현재 설치됨: 7.0.0-beta.8` | 알림 본문 (다국어 문구 — `extensions.alerts.incompatible_message` / `recovered_body`) |
|
||||||
|
|||||||
@@ -144,6 +144,29 @@ URL 을 만드는 쪽도 문자열로 직접 조립하지 않는다. 서버는 `
|
|||||||
자동 차단: 정적 검사 대상 (위반 시 차단). 면제는 인라인 주석
|
자동 차단: 정적 검사 대상 (위반 시 차단). 면제는 인라인 주석
|
||||||
`// audit:allow dynamic-route-static-extension reason: ...`.
|
`// audit:allow dynamic-route-static-extension reason: ...`.
|
||||||
|
|
||||||
|
### SPA catch-all 의 정적 확장자 제외
|
||||||
|
|
||||||
|
SPA catch-all(`routes/web.php` 의 admin·user 그룹)은 등록되지 않은 경로에 SPA 셸 HTML 을 돌려준다.
|
||||||
|
**정적 자산 요청이 거기 걸리면 안 된다** — 없는 `.mjs` 가 `Content-Type: text/html` 인 200 을 받고,
|
||||||
|
브라우저는 그것을 스크립트로 파싱하다 죽는다. 응답이 성공이라 `onerror` 는 발화하지 않으므로 태그
|
||||||
|
복구기도 뜨지 않는다: 예외도 404 도 없이 화면 기능만 사라진다.
|
||||||
|
|
||||||
|
제외 목록을 라우트 파일에 손으로 적으면 에셋 서빙이 허용하는 확장자와 갈라진다. 실제로 갈라져 있었고
|
||||||
|
(`mjs` · `webp` · `otf` 가 서빙은 되는데 제외 목록엔 없었다), `.json` 이 무사했던 것은 lookahead 에 끝
|
||||||
|
앵커가 없어 `.js` 에 **부분일치**한 우연이었다.
|
||||||
|
|
||||||
|
그래서 목록을 파생시킨다 — `App\Support\StaticExtensionPattern::catchAllExclusion()` 이
|
||||||
|
`Allowed{Template,Module,Plugin}FileType` 세 화이트리스트의 **합집합**에서 패턴을 만들고, 두 catch-all 이
|
||||||
|
그것을 호출한다. 합집합인 이유: 게시 트리에는 템플릿 dist 뿐 아니라 모듈·플러그인의 사용자 추가 에셋도
|
||||||
|
함께 실리므로 한 종류만 기준으로 삼으면 나머지가 서빙하는 확장자(`ico`)가 다시 빠진다.
|
||||||
|
|
||||||
|
환경 의존 게터(`getAllowedExtensions()`, 로컬에서 `map` 을 덧붙인다)는 쓰지 않는다 — `where()` 인자는
|
||||||
|
정의 시점에 평가되어 컴파일된 정규식으로 라우트 캐시에 박히므로, 패턴이 **캐시를 구운 환경**에 따라
|
||||||
|
달라지면 안 된다. 캐시 로드 경로는 이 클래스를 참조하지 않으므로 캐시 안전성도 유지된다.
|
||||||
|
|
||||||
|
라우트 정의를 바꿨으므로 배포 시 라우트 캐시를 재생성해야 한다 — 재생성 전에는 이전에 컴파일된 패턴이
|
||||||
|
그대로 쓰인다.
|
||||||
|
|
||||||
두 형태는 모두 영구 유지한다 — 확장자 형태를 제거하면 URL 을 하드코딩한 서드파티 확장이 깨진다.
|
두 형태는 모두 영구 유지한다 — 확장자 형태를 제거하면 URL 을 하드코딩한 서드파티 확장이 깨진다.
|
||||||
엔드포인트별 변환 규칙 표와 프로브 판정 절차: [API 레퍼런스 진입점](./api/README.md) "자산 URL 이중 모드".
|
엔드포인트별 변환 규칙 표와 프로브 판정 절차: [API 레퍼런스 진입점](./api/README.md) "자산 URL 이중 모드".
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,35 @@ URL 은 **public 아래 실제 파일일 때만** 200 이 되므로, "폰트·
|
|||||||
|
|
||||||
- 동시성: 게시는 캐시 락으로 단일 실행. manifest 존재 시 skip(멱등).
|
- 동시성: 게시는 캐시 락으로 단일 실행. manifest 존재 시 skip(멱등).
|
||||||
- 실패 정책: 쓰기 실패는 로그만 남기고 tmp 정리 — 사이트는 API 폴백으로 정상 ("정적 fast path 미적용" 상태이지 장애가 아니다). 다음 렌더의 자가 치유가 재시도한다.
|
- 실패 정책: 쓰기 실패는 로그만 남기고 tmp 정리 — 사이트는 API 폴백으로 정상 ("정적 fast path 미적용" 상태이지 장애가 아니다). 다음 렌더의 자가 치유가 재시도한다.
|
||||||
|
|
||||||
|
### 게시는 갱신 → 생성 → **완전성 확인** 이 한 묶음이다
|
||||||
|
|
||||||
|
"캐시 버전이 올랐다" 와 "그 버전의 산출물이 온전히 존재한다" 는 다른 사실이다. 둘 사이가 벌어지면 포인터는 미래를 가리키는데 파일은 없거나 깨진 상태가 되고, 그 상태는 예외도 로그도 남기지 않는다. 그래서 게시는 다음 4단계를 한 묶음으로 수행한다.
|
||||||
|
|
||||||
|
| 단계 | 수행 | 실패 시 |
|
||||||
|
|---|---|---|
|
||||||
|
| ① 프리플라이트 | 게시 루트를 만들고 쓸 수 있는지 먼저 확인 | 전 로케일 병합·전 템플릿 복사를 **시작하기 전에** 중단 + 실패 마커 기록 |
|
||||||
|
| ② staging 쓰기 | `{v}.tmp/` 에 전부 기록 | tmp 정리 후 폴백 |
|
||||||
|
| ③ 완전성 확인 | 파일마다 **기록된 바이트 수**를 대조 (복사는 원본과 크기 대조) | 예외 → manifest 미기록 → 그 버전은 영원히 "미게시" |
|
||||||
|
| ④ 원자 스왑 | 기존 버전을 `.old` 로 비켜낸 뒤 rename → manifest 기록 → `.old` 삭제 | 비켜낸 기존 버전을 제자리로 되돌린다 |
|
||||||
|
|
||||||
|
③ 이 없으면 `File::put()` 의 반환값이 짧은 `int` 인 경우(디스크 풀·quota)를 통과시킨다 — `=== false` 검사만으로는 잡히지 않고, 절단된 JSON 을 웹서버가 정상 200 으로 서빙한다. 프론트의 `fetchStaticFirst` 도 같은 이유로 본문이 JSON 으로 파싱되는지까지 확인한다 (양쪽 다 있어야 3층 폴백에 구멍이 없다).
|
||||||
|
|
||||||
|
④ 에서 기존 디렉토리를 **먼저 지우지 않는** 이유: 삭제와 rename 사이의 창에서 이미 배달된 HTML 이 참조하는 CSS/JS/폰트가 전부 404 가 되고, 폰트에는 복구기가 없다.
|
||||||
|
|
||||||
|
`.tmp` 와 `.old` 는 GC 대상이되 **10분 나이 가드**를 받는다. 게시 락은 버전별이라 서로 다른 버전의 게시가 동시에 진행될 수 있고, 나이를 보지 않으면 그 순간 살아 있는 다른 게시의 작업 디렉토리를 파괴한다.
|
||||||
|
|
||||||
|
④ 의 디렉토리 rename 은 **유한 재시도**를 거친 뒤에야 실패로 본다. 갓 쓰여진 파일이 든 디렉토리의 rename 은 목적지가 비어 있는데도 첫 시도가 거부될 수 있고, 상태를 바꾸지 않고 그대로 다시 호출하면 성공한다(실측 4표본: 즉시 1건 · 200ms 후 3건). 재시도가 없으면 그 한 번이 그대로 게시 실패가 되어, 사이트는 API 폴백으로 멀쩡한데 실패 마커와 대시보드 알림만 올라간다.
|
||||||
|
|
||||||
|
이 재시도는 OS 로 분기하지 않는다. 거부는 파일시스템·스토리지 계층(네트워크 마운트, 스냅샷, 백신·인덱서 등)이면 어디서든 성립하는 조건이고, 특정 플랫폼에서만 재시도하면 그 밖에서 같은 실패가 조용히 남는다. 재시도가 불필요한 환경에서는 첫 시도가 성공하므로 비용이 0 이다. **실패 계약은 종전 그대로다** — 끝내 실패하면 `.old` 롤백과 예외가 그대로 수행된다.
|
||||||
|
|
||||||
|
### 실패는 억제되고 기록된다
|
||||||
|
|
||||||
|
쓰기 불가 환경에서는 게시가 **매 요청** 실패한다. 그 전까지 전 로케일 lang 병합과 전 템플릿 dist 복사를 이미 다 헛돈 뒤이고, 사이트는 폴백으로 살아 있어 아무도 눈치채지 못한다. 그래서 실패는 캐시 키 `ext.static.publish_failure` 에 `{version, at, reason, count}` 로 기록되고, 그 마커가 신선한 동안(TTL 300초)에는 게시를 재예약하지 않는다.
|
||||||
|
|
||||||
|
마커는 진단 표면의 입력이기도 하다 — `ext-static:status` 커맨드와 관리자 대시보드 알림이 이 마커를 읽는다. 사유는 셋으로 갈린다: `parent_not_writable`(권한) / `write_failed`(디스크) / `lock_unavailable`(캐시). 조치할 곳이 다르므로 뭉뚱그리지 않는다.
|
||||||
|
|
||||||
|
마커 자체를 쓰지 못하는 환경(캐시 저장소 불능)에서는 백오프가 걸리지 않는다 — best-effort 다. 그 경우에도 프로세스 안의 예약 가드는 그대로 유효해 한 요청 안에서 반복되지는 않는다.
|
||||||
- routes 병합이 열화 상태(확장 업데이트 진행 중 등)면 그 산출물은 게시하지 않는다 — 정적 파일은 스스로 회복되지 않으므로 열화가 다음 bump 까지 박제된다. 같은 규율이 폴백 API 의 HTTP 캐시 헤더에도 적용된다 — 열화 응답에는 `public, max-age` 를 부여하지 않는다 (브라우저/CDN 박제 방지).
|
- routes 병합이 열화 상태(확장 업데이트 진행 중 등)면 그 산출물은 게시하지 않는다 — 정적 파일은 스스로 회복되지 않으므로 열화가 다음 bump 까지 박제된다. 같은 규율이 폴백 API 의 HTTP 캐시 헤더에도 적용된다 — 열화 응답에는 `public, max-age` 를 부여하지 않는다 (브라우저/CDN 박제 방지).
|
||||||
|
|
||||||
## 5. 운영자 kill-switch
|
## 5. 운영자 kill-switch
|
||||||
@@ -125,6 +154,27 @@ URL 은 **public 아래 실제 파일일 때만** 200 이 되므로, "폰트·
|
|||||||
- **sudo 코어 업데이트 시**: 업데이트가 root 로 실행되면 종료 시점(소유권 복원 `app.update.restore_ownership` **이후**)에 도는 terminating 게시가 root 소유 산출물을 만들 수 있다. 산출물은 게시 트리만이 아니다 — 게시는 캐시 락(`storage/framework/cache/data/xx` 샤드 디렉토리 신설)과 병합 번들 빌드(`storage/app/ext-bundles`)까지 **간접적으로 쓴다**. root 소유 캐시 샤드가 남으면 이후 웹 프로세스의 캐시 쓰기가 그 샤드에 해시되는 순간 Permission denied 로 죽어 전면 500 이 된다(7.0.10 업그레이드 실사례). 따라서 **root 프로세스에서는 terminating 자동 게시를 예약하지 않고** 다음 웹 렌더의 자가 치유(웹 계정)에 위임한다. 명시적 `ext-static:publish` 는 root 로도 실행 가능하며 게시 트리(`public/build/ext`)는 부모 소유권 상속으로 정상화되지만, storage 측 간접 산출물의 소유권은 운영자 책임이다 — sudo 로 수동 게시했다면 `storage`/`bootstrap/cache` 의 그룹 쓰기(`chgrp -R {웹그룹}` + `chmod -R g+w`)를 확인한다.
|
- **sudo 코어 업데이트 시**: 업데이트가 root 로 실행되면 종료 시점(소유권 복원 `app.update.restore_ownership` **이후**)에 도는 terminating 게시가 root 소유 산출물을 만들 수 있다. 산출물은 게시 트리만이 아니다 — 게시는 캐시 락(`storage/framework/cache/data/xx` 샤드 디렉토리 신설)과 병합 번들 빌드(`storage/app/ext-bundles`)까지 **간접적으로 쓴다**. root 소유 캐시 샤드가 남으면 이후 웹 프로세스의 캐시 쓰기가 그 샤드에 해시되는 순간 Permission denied 로 죽어 전면 500 이 된다(7.0.10 업그레이드 실사례). 따라서 **root 프로세스에서는 terminating 자동 게시를 예약하지 않고** 다음 웹 렌더의 자가 치유(웹 계정)에 위임한다. 명시적 `ext-static:publish` 는 root 로도 실행 가능하며 게시 트리(`public/build/ext`)는 부모 소유권 상속으로 정상화되지만, storage 측 간접 산출물의 소유권은 운영자 책임이다 — sudo 로 수동 게시했다면 `storage`/`bootstrap/cache` 의 그룹 쓰기(`chgrp -R {웹그룹}` + `chmod -R g+w`)를 확인한다.
|
||||||
- **코어 업데이트의 orphan 정리**: 게시본은 릴리즈 소스에 없는 로컬 파생물이므로 `app.update.excludes` 에 `build/ext` 로 등록되어 있다 — `--prune` 업데이트가 orphan 으로 삭제하지 않고, 백업 대상에서도 제외된다.
|
- **코어 업데이트의 orphan 정리**: 게시본은 릴리즈 소스에 없는 로컬 파생물이므로 `app.update.excludes` 에 `build/ext` 로 등록되어 있다 — `--prune` 업데이트가 orphan 으로 삭제하지 않고, 백업 대상에서도 제외된다.
|
||||||
|
|
||||||
|
### CLI 계정과 웹 계정이 다를 때
|
||||||
|
|
||||||
|
제보된 실제 상황은 root 가 아니었다 — **비-root CLI 계정(`deploy` 등)이 웹 계정과 다른** 구성이다. CLI 가 최초 게시를 하면 트리가 `0755 deploy:deploy` 로 굳고, 이후 웹(php-fpm)의 재게시가 영구히 실패한다. 사이트는 API 폴백으로 살아 있어 아무도 모른 채 정적 fast path 만 꺼진다.
|
||||||
|
|
||||||
|
그래서 게시는 소유권 정상화를 **두 갈래**로 수행한다.
|
||||||
|
|
||||||
|
- root 갈래: 부모 기준으로 소유권을 되돌린다 (sudo CLI 게시 대응).
|
||||||
|
- 항상 실행 갈래: 부모 그룹을 상속(`chgrp`)시키고 트리에 `g+w` 를 승격한다. 비-root 에서 `chown` 은 실패해도 무해하고, `chgrp`(자기가 속한 그룹으로)와 `chmod g+w`(자기 소유 파일)는 성립한다.
|
||||||
|
|
||||||
|
디렉토리는 `ensureDirectoryExists(..., 0775)` **직후 명시 `chmod`** 로 굳힌다 — mode 인자는 umask 로 깎이므로(umask 022 → 0755) 그것만으로는 그룹 쓰기가 남지 않는다.
|
||||||
|
|
||||||
|
**이 방식의 한계**: CLI 계정과 웹 계정이 **그룹을 공유**할 때만 성립한다. 공유하지 않는 환경에서는 프리플라이트가 `parent_not_writable` 로 실패 마커를 남기고, `ext-static:status` 와 관리자 대시보드 알림이 그 사실을 운영자에게 전달한다. 그 경우의 조치는 운영자가 `public/build` 의 그룹·권한을 맞추는 것이다.
|
||||||
|
|
||||||
|
### 상태 점검
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan ext-static:status
|
||||||
|
```
|
||||||
|
|
||||||
|
현재 버전 / 게시 여부 / manifest 파일 수 / 트리 쓰기 가능성 / 최근 실패 마커(사유별 조치 힌트 포함) / 잔존 버전 목록을 출력한다. **이상이 있으면 비-0 으로 종료**한다 — 이는 "커맨드 실행 실패" 가 아니라 조치할 대상이 있다는 신호다.
|
||||||
|
|
||||||
권한 문제로 게시가 계속 실패하는 환경(공유 호스팅 등)에서는 `G7_STATIC_CACHE=false` 로 기능을 끄면 경고 로그도 남지 않는다.
|
권한 문제로 게시가 계속 실패하는 환경(공유 호스팅 등)에서는 `G7_STATIC_CACHE=false` 로 기능을 끄면 경고 로그도 남지 않는다.
|
||||||
|
|
||||||
## 7. 다중 웹서버 제약
|
## 7. 다중 웹서버 제약
|
||||||
@@ -152,5 +202,5 @@ Apache 는 게시 트리에 포함된 `.htaccess` 가 같은 캐시 헤더와 `m
|
|||||||
|
|
||||||
- 정적 우선 URL 규칙은 서버측 `AssetUrl` 과 프론트측 `assetUrl.ts`(+ 자가 복구 파샬 역변환)가 **항상 쌍으로** 수정되어야 한다 — 한쪽만 바꾸면 그 자산만 404 가 된다.
|
- 정적 우선 URL 규칙은 서버측 `AssetUrl` 과 프론트측 `assetUrl.ts`(+ 자가 복구 파샬 역변환)가 **항상 쌍으로** 수정되어야 한다 — 한쪽만 바꾸면 그 자산만 404 가 된다.
|
||||||
- 게시 산출물(`public/build/ext/`)은 git 미추적·release 페이로드 제외다. `public/build/core/` 는 계속 추적한다 (배포 산출물) — 혼동 금지.
|
- 게시 산출물(`public/build/ext/`)은 git 미추적·release 페이로드 제외다. `public/build/core/` 는 계속 추적한다 (배포 산출물) — 혼동 금지.
|
||||||
- 루트 `npm run build`(기본 vite 앱 빌드)는 `public/build` 를 비운다(`emptyOutDir`) — `core` 와 `ext` 가 함께 지워진다. `core` 는 `core:build --production` 으로 재생성해야 하고(공개 #70 의 실제 원인), `ext` 는 다음 프로덕션 렌더의 자가 치유가 재게시한다.
|
- 루트 `npm run build`(기본 vite 앱 빌드)는 더 이상 `public/build` 를 비우지 않는다 — 루트 vite config 가 `build.emptyOutDir: false` 를 명시한다. 종전에는 기본값 `true` 로 `core`(폴백 없는 코어 3번들)와 `ext`(게시본)가 함께 지워졌고, 이미 배달된 HTML 의 immutable URL 은 재게시로도 복구되지 않았다(공개 #70 · #122). 잔존 구 해시 산출물은 `manifest.json` 이 선택하므로 참조되지 않는 사표이며, 산출물 정리 책임은 빌드 커맨드(`PrunesBuildOutput`)가 진다. 저장소의 모든 vite config 는 이 규약을 명시해야 한다 — 기본값 의존은 정적 검사가 차단한다.
|
||||||
- 확장 병합 번들의 생성 규율은 [module-assets.md "서버측 번들 병합"](../extension/module-assets.md) 이 소유한다. 본 게시는 그 산출물의 **사본**만 만든다.
|
- 확장 병합 번들의 생성 규율은 [module-assets.md "서버측 번들 병합"](../extension/module-assets.md) 이 소유한다. 본 게시는 그 산출물의 **사본**만 만든다.
|
||||||
|
|||||||
@@ -501,6 +501,27 @@ GET /api/plugins/bundle.css?v={version}
|
|||||||
| 절대경로 게터 | `getBuiltAssetAbsolutePaths()` 사용. `base_path("modules"\|"plugins")` 직접 조립 금지 | `extension-bundle-asset-path-getter` |
|
| 절대경로 게터 | `getBuiltAssetAbsolutePaths()` 사용. `base_path("modules"\|"plugins")` 직접 조립 금지 | `extension-bundle-asset-path-getter` |
|
||||||
| 확장별 try/catch | 파일 읽기 실패 시 해당 확장만 skip, 나머지 병합 지속 | (메모리+회귀테스트) |
|
| 확장별 try/catch | 파일 읽기 실패 시 해당 확장만 skip, 나머지 병합 지속 | (메모리+회귀테스트) |
|
||||||
| CSS url() | 상대경로 url() 을 가진 CSS 는 번들 제외(개별 폴백) | - |
|
| CSS url() | 상대경로 url() 을 가진 CSS 는 번들 제외(개별 폴백) | - |
|
||||||
|
| 디스크 캐시 fail-soft | 캐시 쓰기 실패는 **500 이 아니다** — 메모리 병합 결과를 그대로 200 으로 서빙 | (계약 테스트) |
|
||||||
|
| 빈 번들 판정 | 선언 0개면 빈 200, 선언은 있는데 결과가 0이면 **503** | (계약 테스트) |
|
||||||
|
|
||||||
|
### 디스크 캐시 실패와 빈 번들 (응답 계약)
|
||||||
|
|
||||||
|
번들 디스크 캐시는 **최적화**다. `ext-bundles` 디스크는 `throw => true` 라 권한 문제(먼저 touch 한 프로세스가 `0700` 으로 독점하는 경우 등)에서 `UnableToWriteFile` 이 그대로 올라오는데, 그것이 공개 엔드포인트의 500 이 되면 **모든 확장의 프론트엔드 JS/CSS 가 통째로 나가지 못한다.** 병합 결과는 이미 메모리에 있으므로 그것을 그대로 응답한다(코어 캐시 드라이버의 fail-soft 와 같은 원칙). 예방 지점은 디스크 선언이다 — `config/filesystems.php` 의 `ext-bundles` 에 `permissions`(dir `0775` / file `0664`)를 선언해 Flysystem 이 root 를 `0700` 으로 만들지 않게 한다.
|
||||||
|
|
||||||
|
빈 번들은 두 가지를 구분해야 한다.
|
||||||
|
|
||||||
|
| 상태 | 판정 | 응답 |
|
||||||
|
|---|---|---|
|
||||||
|
| 에셋을 선언한 활성 확장이 0개 | 정상 | 빈 200 |
|
||||||
|
| 선언은 있는데 병합 결과가 0 | 장애 (배포 중 `dist` 가 잠깐 빔, 경로 어긋남) | **503** + `Log::error` |
|
||||||
|
|
||||||
|
구분하지 않으면 후자가 빈 200 으로 나가고, 프론트는 404 도 오류도 받지 못한 채 한참 뒤 "Unknown action handler" 로 죽는다 — 그 시점에는 원인이 번들이라는 사실이 화면에도 로그에도 남아 있지 않다. 판정은 **kind 별**이다(js 만 선언한 확장이 있는 상태에서 css 번들이 비는 것은 정상).
|
||||||
|
|
||||||
|
"선언" 의 근거는 manifest 의 `assets.{kind}.output` 이며 **산출물 파일의 존재를 보지 않는다.** 병합 경로가 쓰는 게터들(`getOrderedGlobalAssetPaths()` · `hasAssets()` · `getBuiltAssetPaths()`)은 모두 `file_exists()` 로 거르므로, 그 경로로 선언 수를 세면 "`dist` 가 잠깐 빔" 이 곧 "선언 0" 이 되어 **막으려던 상태가 정상(빈 200)으로 판정된다.** 선언과 산출은 다른 축이고, 이 판정이 재는 것은 선언 축이다.
|
||||||
|
|
||||||
|
두 판정은 모듈·플러그인 컨트롤러가 **공유하는 단일 지점**(`ServesExtensionBundles::bundleResponse()`)에 둔다. 각자 구현하면 한쪽만 고쳐진 채 다른 쪽이 옛 동작으로 남는다.
|
||||||
|
|
||||||
|
`clearBundles()`(cache-clear 커맨드)는 **현재 버전을 보존**한다 — `cleanupStaleBundles()` 와 같은 정책이다. 현재 버전까지 지우면 같은 순간 서빙 중인 요청이 "존재함" 판정 직후 `filemtime()` 에서 500 을 낸다. 원자적 쓰기의 임시 파일(`*.tmp.{pid}`)도 GC 대상이되 10분 나이 가드를 받는다(pid 는 재사용되므로 생사로는 진행 여부를 판정할 수 없다).
|
||||||
|
|
||||||
### 캐시 stale 관리
|
### 캐시 stale 관리
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
- 레이아웃 편집기 툴바에서 운영자 추가 에셋을 잠시 끄고 켜는 항목의 일본어 번역을 추가했습니다 — 추가한 파일이 화면을 깨뜨렸을 때 그 파일 없이 화면을 다시 여는 안내가 함께 표시됩니다.
|
- 레이아웃 편집기 툴바에서 운영자 추가 에셋을 잠시 끄고 켜는 항목의 일본어 번역을 추가했습니다 — 추가한 파일이 화면을 깨뜨렸을 때 그 파일 없이 화면을 다시 여는 안내가 함께 표시됩니다.
|
||||||
- 확장을 제거할 때 운영자가 넣어 둔 파일의 사본을 보관했다는 안내의 일본어 번역을 추가했습니다 — 모듈·플러그인·템플릿 제거 결과 화면에서 보관 경로가 일본어로 표시됩니다.
|
- 확장을 제거할 때 운영자가 넣어 둔 파일의 사본을 보관했다는 안내의 일본어 번역을 추가했습니다 — 모듈·플러그인·템플릿 제거 결과 화면에서 보관 경로가 일본어로 표시됩니다.
|
||||||
- 운영자 추가 에셋의 저장·올리기·삭제가 활동 로그에 남을 때 표시되는 문구의 일본어 번역을 추가했습니다.
|
- 운영자 추가 에셋의 저장·올리기·삭제가 활동 로그에 남을 때 표시되는 문구의 일본어 번역을 추가했습니다.
|
||||||
|
- 초기 화면 파일 생성이 거듭 실패할 때 관리자 대시보드에 표시되는 안내의 일본어 번역을 추가했습니다 — 폴더 권한·디스크 공간·캐시 저장소 중 어느 쪽이 원인인지에 따라 다른 안내가 일본어 로케일에서 표시됩니다.
|
||||||
|
|
||||||
## [1.0.8] - 2026-08-24
|
## [1.0.8] - 2026-08-24
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ return [
|
|||||||
'auto_deactivated_listed' => '自動無効化された拡張の一覧です。',
|
'auto_deactivated_listed' => '自動無効化された拡張の一覧です。',
|
||||||
'recover_action' => '再度有効化',
|
'recover_action' => '再度有効化',
|
||||||
'dismiss_action' => '通知を閉じる',
|
'dismiss_action' => '通知を閉じる',
|
||||||
|
'static_publish_failed_title' => '初期画面ファイルの生成に失敗',
|
||||||
|
'static_publish_failed_parent_not_writable' => '初期画面ファイルを保存するフォルダに書き込めず、:count 回連続で失敗しました。サイトは正常に動作しますが、最初の画面の表示が遅くなります。サーバーで `php artisan ext-static:status` を実行して原因を確認してください。',
|
||||||
|
'static_publish_failed_write_failed' => '初期画面ファイルの生成が :count 回連続で失敗しました。ディスクの空き容量を確認してください。サイトは正常に動作しますが、最初の画面の表示が遅くなります。',
|
||||||
|
'static_publish_failed_lock_unavailable' => '初期画面ファイルの生成が :count 回連続でスキップされました。キャッシュストアの状態を確認してください。サイトは正常に動作しますが、最初の画面の表示が遅くなります。',
|
||||||
],
|
],
|
||||||
'badges' => [
|
'badges' => [
|
||||||
'incompatible' => 'コアアップグレード必要',
|
'incompatible' => 'コアアップグレード必要',
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ return [
|
|||||||
'auto_deactivated_listed' => 'Auto-deactivated extensions listed.',
|
'auto_deactivated_listed' => 'Auto-deactivated extensions listed.',
|
||||||
'recover_action' => 'Re-activate',
|
'recover_action' => 'Re-activate',
|
||||||
'dismiss_action' => 'Dismiss alert',
|
'dismiss_action' => 'Dismiss alert',
|
||||||
|
'static_publish_failed_title' => 'Failed to generate startup files',
|
||||||
|
'static_publish_failed_parent_not_writable' => 'Could not write to the folder for startup files — :count consecutive failures. The site works normally but the first screen loads more slowly. Run `php artisan ext-static:status` on the server to see the cause.',
|
||||||
|
'static_publish_failed_write_failed' => 'Generating startup files failed :count times in a row. Check available disk space. The site works normally but the first screen loads more slowly.',
|
||||||
|
'static_publish_failed_lock_unavailable' => 'Startup file generation was skipped :count times in a row. Check the cache store. The site works normally but the first screen loads more slowly.',
|
||||||
],
|
],
|
||||||
|
|
||||||
'badges' => [
|
'badges' => [
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ return [
|
|||||||
'auto_deactivated_listed' => '자동 비활성화된 확장 목록입니다.',
|
'auto_deactivated_listed' => '자동 비활성화된 확장 목록입니다.',
|
||||||
'recover_action' => '다시 활성화',
|
'recover_action' => '다시 활성화',
|
||||||
'dismiss_action' => '알림 닫기',
|
'dismiss_action' => '알림 닫기',
|
||||||
|
'static_publish_failed_title' => '초기 화면 파일 생성 실패',
|
||||||
|
'static_publish_failed_parent_not_writable' => '초기 화면 파일을 저장할 폴더에 쓸 수 없어 :count회 연속 실패했습니다. 사이트는 정상 동작하지만 첫 화면이 느려집니다. 서버에서 `php artisan ext-static:status` 로 원인을 확인하세요.',
|
||||||
|
'static_publish_failed_write_failed' => '초기 화면 파일을 만드는 중 :count회 연속 실패했습니다. 디스크 여유 공간을 확인하세요. 사이트는 정상 동작하지만 첫 화면이 느려집니다.',
|
||||||
|
'static_publish_failed_lock_unavailable' => '초기 화면 파일 생성이 :count회 연속 건너뛰어졌습니다. 캐시 저장소 상태를 확인하세요. 사이트는 정상 동작하지만 첫 화면이 느려집니다.',
|
||||||
],
|
],
|
||||||
|
|
||||||
'badges' => [
|
'badges' => [
|
||||||
|
|||||||
+13
-13
File diff suppressed because one or more lines are too long
@@ -105,6 +105,66 @@ describe('ModuleAssetLoader', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 추가 에셋(`custom/`)의 정적 게시 폴백 (#122 R2/W9).
|
||||||
|
*
|
||||||
|
* 스타일(`type: 'style'`) 축은 형제 파일 `ModuleAssetLoader.customAssets.test.ts`
|
||||||
|
* 가 세 확장 타입 전부에 대해 이미 고정하고 있다. 여기 남기는 것은 그 파일이 덮지
|
||||||
|
* 않는 **스크립트 축**뿐이다 — 스타일은 `loadStylesheetWithRetry`, 스크립트는
|
||||||
|
* `loadScriptWithRetry` 로 서로 다른 로더를 타므로 한쪽의 통과가 다른 쪽의 근거가
|
||||||
|
* 되지 않는다.
|
||||||
|
*/
|
||||||
|
describe('loadCustomAssets 정적 게시 폴백 — script 축 (#122 R2/W9)', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
delete (window as any).G7Config;
|
||||||
|
document.querySelectorAll('[id^="g7-custom-"]').forEach(el => el.remove());
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @effects custom_asset_static_miss_falls_back_to_api
|
||||||
|
*/
|
||||||
|
it('정적 게시 JS 미스 시 레거시 API URL 로 전환한다', async () => {
|
||||||
|
(window as any).G7Config = {
|
||||||
|
customAssets: [
|
||||||
|
{
|
||||||
|
id: 'custom:modules:sirsoft-board:custom.js',
|
||||||
|
type: 'script',
|
||||||
|
url: '/build/ext/42/modules/sirsoft-board/assets/custom/custom.js',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const urls: string[] = [];
|
||||||
|
const appendSpy = vi.spyOn(document.head, 'appendChild').mockImplementation(((node: any) => {
|
||||||
|
if (node.tagName === 'SCRIPT') {
|
||||||
|
urls.push(node.getAttribute('src'));
|
||||||
|
document.body.appendChild(node);
|
||||||
|
|
||||||
|
const failed = urls.length === 1;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (failed) {
|
||||||
|
node.onerror?.(new Event('error'));
|
||||||
|
} else {
|
||||||
|
node.onload?.(new Event('load'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}) as any);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loader.loadCustomAssets();
|
||||||
|
|
||||||
|
expect(urls).toEqual([
|
||||||
|
'/build/ext/42/modules/sirsoft-board/assets/custom/custom.js',
|
||||||
|
'/api/modules/assets/sirsoft-board/custom/custom.js?v=42',
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
appendSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('loadActiveExtensionAssets', () => {
|
describe('loadActiveExtensionAssets', () => {
|
||||||
it('JS 에셋을 priority 오름차순으로 DOM에 append한다 (실행 순서 보장)', async () => {
|
it('JS 에셋을 priority 오름차순으로 DOM에 append한다 (실행 순서 보장)', async () => {
|
||||||
const appendOrder: string[] = [];
|
const appendOrder: string[] = [];
|
||||||
|
|||||||
@@ -13,13 +13,32 @@ import { fetchStaticFirst } from '../fetchStaticFirst';
|
|||||||
import { extStaticUrl, extStaticVersion, staticToLegacy } from '../assetUrl';
|
import { extStaticUrl, extStaticVersion, staticToLegacy } from '../assetUrl';
|
||||||
|
|
||||||
function ok(body: unknown = {}): Response {
|
function ok(body: unknown = {}): Response {
|
||||||
|
const text = JSON.stringify(body);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 200,
|
status: 200,
|
||||||
|
statusText: 'OK',
|
||||||
|
headers: new Headers({ 'Content-Type': 'application/json' }),
|
||||||
|
text: async () => text,
|
||||||
json: async () => body,
|
json: async () => body,
|
||||||
} as unknown as Response;
|
} as unknown as Response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 200 인데 본문이 잘린 응답 — 디스크 풀/quota 로 절단된 게시본이 서빙되는 상황.
|
||||||
|
*/
|
||||||
|
function truncated(raw: string): Response {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
statusText: 'OK',
|
||||||
|
headers: new Headers({ 'Content-Type': 'application/json' }),
|
||||||
|
text: async () => raw,
|
||||||
|
json: async () => JSON.parse(raw),
|
||||||
|
} as unknown as Response;
|
||||||
|
}
|
||||||
|
|
||||||
function notFound(): Response {
|
function notFound(): Response {
|
||||||
return { ok: false, status: 404 } as unknown as Response;
|
return { ok: false, status: 404 } as unknown as Response;
|
||||||
}
|
}
|
||||||
@@ -50,6 +69,36 @@ describe('fetchStaticFirst (#122)', () => {
|
|||||||
expect(String(fetchMock.mock.calls[0][0])).toContain('/build/ext/7/');
|
expect(String(fetchMock.mock.calls[0][0])).toContain('/build/ext/7/');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('정적 200 이어도 본문이 손상 JSON 이면 legacy 로 폴백한다 (#122 A1)', async () => {
|
||||||
|
// 디스크 풀/quota 로 절단된 게시본은 웹서버가 **정상 200** 으로 서빙한다.
|
||||||
|
// `response.ok` 만 보고 돌려주면 호출부의 `.json()` 이 던지는데 그 지점에는
|
||||||
|
// 폴백이 없어 부팅 전체가 실패한다 — 3층 폴백이 개입 못 하던 유일한 경로.
|
||||||
|
fetchMock.mockImplementation((url: string) =>
|
||||||
|
Promise.resolve(
|
||||||
|
String(url).includes('/build/ext/')
|
||||||
|
? truncated('{"messages":{"a":"b"')
|
||||||
|
: ok({ from: 'legacy' })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await fetchStaticFirst('/build/ext/7/templates/t/lang/ko.json', '/api/templates/t/lang/ko.json');
|
||||||
|
|
||||||
|
await expect(response.json()).resolves.toEqual({ from: 'legacy' });
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('malformed JSON'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('정적 200 + 온전한 JSON 은 본문이 그대로 소비된다 (검증이 본문을 삼키지 않는다)', async () => {
|
||||||
|
// 검증을 위해 body 를 읽으므로, 읽고 나서 호출부가 다시 소비할 수 있어야 한다.
|
||||||
|
// (Response body 는 1회용 스트림이라 그대로 돌려주면 호출부에서 빈다.)
|
||||||
|
fetchMock.mockResolvedValue(ok({ from: 'static', nested: { a: 1 } }));
|
||||||
|
|
||||||
|
const response = await fetchStaticFirst('/build/ext/7/templates/t/routes.json', '/api/templates/t/routes.json');
|
||||||
|
|
||||||
|
await expect(response.json()).resolves.toEqual({ from: 'static', nested: { a: 1 } });
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('정적 404 이면 legacy 로 1회 폴백하고 warn 을 남긴다', async () => {
|
it('정적 404 이면 legacy 로 1회 폴백하고 warn 을 남긴다', async () => {
|
||||||
fetchMock.mockImplementation((url: string) =>
|
fetchMock.mockImplementation((url: string) =>
|
||||||
Promise.resolve(String(url).includes('/build/ext/') ? notFound() : ok({ from: 'legacy' }))
|
Promise.resolve(String(url).includes('/build/ext/') ? notFound() : ok({ from: 'legacy' }))
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
*
|
*
|
||||||
* legacy 측은 `fetchWithRetry` 를 재사용해 종전의 네트워크 복원력(#463)을 유지한다.
|
* legacy 측은 `fetchWithRetry` 를 재사용해 종전의 네트워크 복원력(#463)을 유지한다.
|
||||||
*
|
*
|
||||||
|
* 정적 응답은 `ok` 뿐 아니라 **본문이 JSON 으로 파싱되는지**까지 확인한다 — 디스크
|
||||||
|
* 풀/quota 로 절단된 게시본을 웹서버가 정상 200 으로 서빙하기 때문이다
|
||||||
|
* (engine-v1.63.2, #122 A1).
|
||||||
|
*
|
||||||
* @since engine-v1.61.0
|
* @since engine-v1.61.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -34,7 +38,31 @@ export async function fetchStaticFirst(
|
|||||||
const response = await fetch(staticUrl, options.init);
|
const response = await fetch(staticUrl, options.init);
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
return response;
|
// 200 이라고 본문이 온전하다는 뜻은 아니다. 디스크 풀/quota 에서 게시가 잘린
|
||||||
|
// 파일을 남기면 웹서버는 그것을 **정상 200 으로** 서빙한다. `response.ok` 만
|
||||||
|
// 보고 돌려주면 호출부의 `response.json()` 이 던지고, 그 지점에는 폴백이 없어
|
||||||
|
// 부팅 전체가 실패한다 — 3층 폴백이 유일하게 개입하지 못하는 경로였다.
|
||||||
|
//
|
||||||
|
// 이 헬퍼의 소비자는 전부 JSON 부트스트랩 리소스(routes/lang/components)이므로
|
||||||
|
// 파싱 가능 여부를 여기서 판정할 수 있다. 파싱은 두 번 일어나지만(여기서 검증,
|
||||||
|
// 호출부에서 소비) 그 비용은 부팅 실패와 비교할 값이 아니다.
|
||||||
|
const text = await response.text();
|
||||||
|
|
||||||
|
try {
|
||||||
|
JSON.parse(text);
|
||||||
|
|
||||||
|
return new Response(text, {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers: response.headers,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
console.warn(
|
||||||
|
`[fetchStaticFirst] Static fast path returned malformed JSON — falling back to API: ${staticUrl}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return fetchWithRetry(legacyUrl, options);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 디버그 게이트 없는 console.warn — 폴백은 자가 치유 실패를 발견할 유일한
|
// 디버그 게이트 없는 console.warn — 폴백은 자가 치유 실패를 발견할 유일한
|
||||||
|
|||||||
@@ -5,6 +5,15 @@
|
|||||||
>
|
>
|
||||||
> 형식: [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)
|
> 형식: [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)
|
||||||
|
|
||||||
|
## [engine-v1.63.2] - 2026-08-27
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
#### 정적 게시본이 200 인데 본문이 손상된 경우 폴백하지 못하던 문제 (#122)
|
||||||
|
|
||||||
|
- `fetchStaticFirst()` 가 정적 응답의 본문이 **JSON 으로 파싱되는지까지** 확인한 뒤 돌려준다. 종전에는 `response.ok` 만 보았는데, 디스크가 가득 찼거나 quota 를 넘긴 상태에서 만들어진 게시본은 내용이 중간에 잘린 채로도 웹서버가 정상 200 으로 서빙한다. 그러면 폴백이 걸리지 않은 채 호출부의 `response.json()` 이 예외를 던지고, 그 지점에는 폴백 계층이 없어 화면 전체가 뜨지 않았다 — 정적·API·태그 3층 폴백이 유일하게 개입하지 못하던 경로다. 이제 손상이 확인되면 종전 API 로 폴백하고, 폴백 사실을 콘솔 경고 한 줄로 남긴다(조용한 폴백 금지).
|
||||||
|
- 검증을 위해 본문을 읽더라도 호출부가 그대로 다시 소비할 수 있다 — 응답 본문은 1회용 스트림이라, 읽고 그대로 돌려주면 호출부에서 빈 본문이 된다.
|
||||||
|
|
||||||
## [engine-v1.63.1] - 2026-08-27
|
## [engine-v1.63.1] - 2026-08-27
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -1079,6 +1079,10 @@ if (isset($_GET['ajax_action'])) {
|
|||||||
<span>정적 게시 정리</span>
|
<span>정적 게시 정리</span>
|
||||||
<span class="text-[10px] opacity-60">(ext-static:cleanup)</span>
|
<span class="text-[10px] opacity-60">(ext-static:cleanup)</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button onclick="runCommand('ext-static:status')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded transition-colors">
|
||||||
|
<span>정적 게시 점검</span>
|
||||||
|
<span class="text-[10px] opacity-60">(ext-static:status)</span>
|
||||||
|
</button>
|
||||||
<button onclick="runCommand('seo:prune-stats')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded transition-colors">
|
<button onclick="runCommand('seo:prune-stats')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded transition-colors">
|
||||||
<span>SEO 캐시 통계 정리</span>
|
<span>SEO 캐시 통계 정리</span>
|
||||||
<span class="text-[10px] opacity-60">(seo:prune-stats)</span>
|
<span class="text-[10px] opacity-60">(seo:prune-stats)</span>
|
||||||
|
|||||||
+3
-2
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use App\Http\Controllers\Api\Public\SitemapController;
|
use App\Http\Controllers\Api\Public\SitemapController;
|
||||||
use App\Seo\TemplateRouteResolver;
|
use App\Seo\TemplateRouteResolver;
|
||||||
|
use App\Support\StaticExtensionPattern;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
@@ -35,7 +36,7 @@ Route::prefix('admin')
|
|||||||
}
|
}
|
||||||
|
|
||||||
return view('admin');
|
return view('admin');
|
||||||
})->where('any', '(?!.*\.(js|css|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|eot)).*');
|
})->where('any', StaticExtensionPattern::catchAllExclusion());
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sitemap XML 라우트
|
// Sitemap XML 라우트
|
||||||
@@ -56,5 +57,5 @@ Route::middleware(['template.dependencies:user', 'seo'])
|
|||||||
}
|
}
|
||||||
|
|
||||||
return view('app');
|
return view('app');
|
||||||
})->where('any', '(?!admin)(?!api)(?!plugins)(?!.*\.(js|css|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|eot)).*');
|
})->where('any', '(?!admin)(?!api)(?!plugins)'.StaticExtensionPattern::catchAllExclusion());
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,13 +38,23 @@ class ExtensionBundleServingTest extends TestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ExtensionBundleService 를 지정 경로를 반환하도록 mock 바인딩한다.
|
* 번들 서비스를 모킹합니다.
|
||||||
|
*
|
||||||
|
* `$memoryContent` / `$declaredCount` 는 빈 경로(`$returnPath === ''`)일 때의 분기를
|
||||||
|
* 정한다 — 디스크 캐시 실패(메모리 폴백)인지, 선언 0개(정상 빈 번들)인지,
|
||||||
|
* 선언은 있는데 결과가 0인지(장애)를 컨트롤러가 이 둘로 가른다.
|
||||||
|
*
|
||||||
|
* @param string $returnPath getBundleFilePath 반환값
|
||||||
|
* @param string $memoryContent buildBundleContent 반환값
|
||||||
|
* @param int $declaredCount countAssetDeclaringExtensions 반환값
|
||||||
*/
|
*/
|
||||||
private function bindBundleService(string $returnPath): void
|
private function bindBundleService(string $returnPath, string $memoryContent = '', int $declaredCount = 0): void
|
||||||
{
|
{
|
||||||
$mock = Mockery::mock(ExtensionBundleService::class);
|
$mock = Mockery::mock(ExtensionBundleService::class);
|
||||||
$mock->shouldReceive('getCurrentVersion')->andReturn(777);
|
$mock->shouldReceive('getCurrentVersion')->andReturn(777);
|
||||||
$mock->shouldReceive('getBundleFilePath')->andReturn($returnPath);
|
$mock->shouldReceive('getBundleFilePath')->andReturn($returnPath);
|
||||||
|
$mock->shouldReceive('buildBundleContent')->andReturn($memoryContent);
|
||||||
|
$mock->shouldReceive('countAssetDeclaringExtensions')->andReturn($declaredCount);
|
||||||
$this->app->instance(ExtensionBundleService::class, $mock);
|
$this->app->instance(ExtensionBundleService::class, $mock);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,8 +90,8 @@ class ExtensionBundleServingTest extends TestCase
|
|||||||
|
|
||||||
public function test_empty_bundle_returns_empty_ok_response(): void
|
public function test_empty_bundle_returns_empty_ok_response(): void
|
||||||
{
|
{
|
||||||
// 활성 global 에셋이 없으면 서비스가 빈 경로 반환 → 컨트롤러 빈 200
|
// 에셋을 선언한 활성 확장이 0개 → 빈 번들이 정상이다 → 빈 200
|
||||||
$this->bindBundleService('');
|
$this->bindBundleService('', memoryContent: '', declaredCount: 0);
|
||||||
|
|
||||||
$response = $this->get('/api/modules/bundle.js?v=777');
|
$response = $this->get('/api/modules/bundle.js?v=777');
|
||||||
|
|
||||||
@@ -91,6 +101,71 @@ class ExtensionBundleServingTest extends TestCase
|
|||||||
$this->assertSame('', $response->getContent());
|
$this->assertSame('', $response->getContent());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 디스크 캐시 실패는 500 이 아니라 메모리 병합 결과 200 이다 (E1 fail-soft).
|
||||||
|
*
|
||||||
|
* `ext-bundles` 디스크는 `throw => true` 라 권한 문제(uid 독점 0700 등)에서
|
||||||
|
* `UnableToWriteFile` 이 그대로 올라온다. 캐시는 최적화일 뿐이므로 그 실패가
|
||||||
|
* 모든 확장의 프론트엔드 JS/CSS 를 통째로 막으면 안 된다.
|
||||||
|
*
|
||||||
|
* @effects bundle_disk_write_failure_falls_back_to_memory
|
||||||
|
*/
|
||||||
|
public function test_disk_cache_failure_serves_memory_merged_result(): void
|
||||||
|
{
|
||||||
|
// 디스크 캐시 실패 → 경로는 빈 문자열, 그러나 병합 결과는 메모리에 있다
|
||||||
|
$this->bindBundleService('', memoryContent: '(function(){window.MEM=1})()', declaredCount: 2);
|
||||||
|
|
||||||
|
$response = $this->get('/api/modules/bundle.js?v=777');
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$this->assertStringStartsWith('text/javascript', $response->headers->get('Content-Type'));
|
||||||
|
$this->assertStringContainsString('window.MEM=1', $response->getContent());
|
||||||
|
// 캐시에 실패한 산출물이므로 immutable 로 박제하지 않는다
|
||||||
|
$this->assertStringContainsString('no-cache', (string) $response->headers->get('Cache-Control'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 선언은 있는데 병합 결과가 비면 503 이다 (E2).
|
||||||
|
*
|
||||||
|
* 배포 중 `dist` 가 잠깐 비면 **모든 확장이 빠진 빈 번들**이 200 으로 나가고, 프론트는
|
||||||
|
* 404 도 오류도 받지 못한 채 한참 뒤 "Unknown action handler" 로 죽는다 — 그 시점에는
|
||||||
|
* 원인이 번들이라는 사실이 화면에도 로그에도 남아 있지 않다.
|
||||||
|
*
|
||||||
|
* @effects bundle_empty_with_declared_assets_returns_503
|
||||||
|
*/
|
||||||
|
public function test_empty_bundle_with_declared_assets_returns_503(): void
|
||||||
|
{
|
||||||
|
$this->bindBundleService('', memoryContent: '', declaredCount: 3);
|
||||||
|
|
||||||
|
$response = $this->get('/api/modules/bundle.js?v=777');
|
||||||
|
|
||||||
|
$response->assertStatus(503);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 플러그인 경로도 같은 판정을 쓴다 — 두 컨트롤러가 갈라지지 않는다 (장애 쪽).
|
||||||
|
*
|
||||||
|
* @effects bundle_decision_is_shared_across_extension_types
|
||||||
|
*/
|
||||||
|
public function test_plugin_bundle_returns_503_when_declared_but_empty(): void
|
||||||
|
{
|
||||||
|
$this->bindBundleService('', memoryContent: '', declaredCount: 1);
|
||||||
|
|
||||||
|
$this->get('/api/plugins/bundle.css?v=777')->assertStatus(503);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 플러그인 경로도 같은 판정을 쓴다 — 두 컨트롤러가 갈라지지 않는다 (정상 쪽).
|
||||||
|
*
|
||||||
|
* @effects bundle_decision_is_shared_across_extension_types
|
||||||
|
*/
|
||||||
|
public function test_plugin_bundle_returns_empty_ok_when_nothing_declared(): void
|
||||||
|
{
|
||||||
|
$this->bindBundleService('', memoryContent: '', declaredCount: 0);
|
||||||
|
|
||||||
|
$this->get('/api/plugins/bundle.css?v=777')->assertOk();
|
||||||
|
}
|
||||||
|
|
||||||
public function test_module_bundle_css_serves_with_css_content_type(): void
|
public function test_module_bundle_css_serves_with_css_content_type(): void
|
||||||
{
|
{
|
||||||
$path = $this->fixtureDir.'/module.777.css';
|
$path = $this->fixtureDir.'/module.777.css';
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Console;
|
||||||
|
|
||||||
|
use App\Console\Commands\Concerns\PrunesBuildOutput;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 빌드 산출물 정리의 경로 범위 테스트 (#122 C1).
|
||||||
|
*
|
||||||
|
* `pruneBuildOutput()` 은 빌드 **전에** 실행된다. 웹이 서빙 중인 활성 디렉토리의 `dist/` 를
|
||||||
|
* 비우면 prune~빌드 완료 구간 전체가 서빙 공백이 되고, 빌드가 실패하면 빈 채로 남는다.
|
||||||
|
* 그래서 소스 디렉토리(`_bundled` / `_pending`)만 정리 대상이다.
|
||||||
|
*/
|
||||||
|
class BuildCommandPruneScopeTest extends TestCase
|
||||||
|
{
|
||||||
|
private string $root;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->root = storage_path('framework/testing/prune-scope');
|
||||||
|
File::deleteDirectory($this->root);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
File::deleteDirectory($this->root);
|
||||||
|
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 트레이트를 노출하는 테스트 더블 — 커맨드 전체를 띄우지 않고 판정만 검사한다.
|
||||||
|
*/
|
||||||
|
private function harness(): object
|
||||||
|
{
|
||||||
|
return new class
|
||||||
|
{
|
||||||
|
use PrunesBuildOutput;
|
||||||
|
|
||||||
|
/** @var array<int, string> */
|
||||||
|
public array $warnings = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 커맨드의 warn() 을 대체합니다.
|
||||||
|
*
|
||||||
|
* @param string $message 경고 문구
|
||||||
|
*/
|
||||||
|
public function warn($message, $verbosity = null): void
|
||||||
|
{
|
||||||
|
$this->warnings[] = (string) $message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $path 빌드 경로
|
||||||
|
* @return array<int, string> 삭제된 항목
|
||||||
|
*/
|
||||||
|
public function prune(string $path): array
|
||||||
|
{
|
||||||
|
return $this->pruneBuildOutput($path);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 산출물이 있는 dist 를 만듭니다.
|
||||||
|
*
|
||||||
|
* @param string $buildPath 확장 루트 경로
|
||||||
|
*/
|
||||||
|
private function seedDist(string $buildPath): void
|
||||||
|
{
|
||||||
|
File::ensureDirectoryExists($buildPath.'/dist/js');
|
||||||
|
File::put($buildPath.'/dist/js/components.iife.js', '(function(){})()');
|
||||||
|
File::ensureDirectoryExists($buildPath.'/dist/vendor/lib/1.0.0');
|
||||||
|
File::put($buildPath.'/dist/vendor/lib/1.0.0/lib.js', 'vendor');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `_bundled` 경로는 종전대로 정리한다 (동봉 vendor 는 보존).
|
||||||
|
*
|
||||||
|
* @effects prune_cleans_bundled_source_path
|
||||||
|
*/
|
||||||
|
public function test_bundled_path_is_pruned_but_vendor_preserved(): void
|
||||||
|
{
|
||||||
|
$path = $this->root.'/templates/_bundled/vendor-tpl';
|
||||||
|
$this->seedDist($path);
|
||||||
|
|
||||||
|
$harness = $this->harness();
|
||||||
|
$removed = $harness->prune($path);
|
||||||
|
|
||||||
|
$this->assertContains('js', $removed);
|
||||||
|
$this->assertDirectoryDoesNotExist($path.'/dist/js');
|
||||||
|
$this->assertDirectoryExists($path.'/dist/vendor', '동봉 vendor 가 삭제됐다');
|
||||||
|
$this->assertSame([], $harness->warnings);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `_pending` 경로도 소스 보관소다 — 정리 대상.
|
||||||
|
*
|
||||||
|
* @effects prune_cleans_pending_source_path
|
||||||
|
*/
|
||||||
|
public function test_pending_path_is_pruned(): void
|
||||||
|
{
|
||||||
|
$path = $this->root.'/modules/_pending/vendor-mod';
|
||||||
|
$this->seedDist($path);
|
||||||
|
|
||||||
|
$removed = $this->harness()->prune($path);
|
||||||
|
|
||||||
|
$this->assertContains('js', $removed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 활성 디렉토리는 정리하지 않고 경고한다 — 서빙 공백 차단.
|
||||||
|
*
|
||||||
|
* @effects prune_skips_active_serving_path
|
||||||
|
*/
|
||||||
|
public function test_active_directory_is_not_pruned_and_warns(): void
|
||||||
|
{
|
||||||
|
$path = $this->root.'/templates/vendor-tpl';
|
||||||
|
$this->seedDist($path);
|
||||||
|
|
||||||
|
$harness = $this->harness();
|
||||||
|
$removed = $harness->prune($path);
|
||||||
|
|
||||||
|
$this->assertSame([], $removed);
|
||||||
|
$this->assertDirectoryExists(
|
||||||
|
$path.'/dist/js',
|
||||||
|
'활성 디렉토리의 산출물이 삭제됐다 — 빌드 완료까지 서빙이 끊긴다'
|
||||||
|
);
|
||||||
|
$this->assertNotEmpty($harness->warnings, '조용히 건너뛰면 stale 누적을 알 수 없다');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Windows 역슬래시 경로도 같은 판정을 받는다.
|
||||||
|
*
|
||||||
|
* @effects prune_scope_normalizes_path_separators
|
||||||
|
*/
|
||||||
|
public function test_backslash_paths_are_normalized(): void
|
||||||
|
{
|
||||||
|
// 백슬래시 리터럴을 소스에 직접 두지 않는다 — 도구 체인마다 이스케이프 해석이
|
||||||
|
// 달라 픽스처 자신이 조용히 다른 문자열을 검사하게 된다.
|
||||||
|
$sep = chr(92);
|
||||||
|
$path = $this->root.'/templates/_bundled/vendor-tpl';
|
||||||
|
$this->seedDist($path);
|
||||||
|
|
||||||
|
$windowsStyle = str_replace('/', $sep, $path);
|
||||||
|
|
||||||
|
$removed = $this->harness()->prune($windowsStyle);
|
||||||
|
|
||||||
|
$this->assertContains('js', $removed, '역슬래시 경로가 _bundled 로 인식되지 않았다');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* dist 가 없으면 아무 일도 하지 않는다 (경고도 없다).
|
||||||
|
*
|
||||||
|
* @effects prune_is_noop_without_dist
|
||||||
|
*/
|
||||||
|
public function test_missing_dist_is_noop(): void
|
||||||
|
{
|
||||||
|
$harness = $this->harness();
|
||||||
|
|
||||||
|
$this->assertSame([], $harness->prune($this->root.'/templates/vendor-tpl'));
|
||||||
|
$this->assertSame([], $harness->warnings);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Dashboard;
|
||||||
|
|
||||||
|
use App\Extension\HookManager;
|
||||||
|
use App\Listeners\StaticPublishFailureAlertListener;
|
||||||
|
use App\Services\ExtensionStaticCacheService;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 정적 게시 실패의 운영자 도달 통로 테스트 (#122 O3).
|
||||||
|
*
|
||||||
|
* 게시 실패는 사이트를 멈추지 않는다 — API 폴백으로 넘어가 화면은 정상이고 서버 로그에만
|
||||||
|
* warning 이 쌓인다. 그래서 쓰기 불가 환경에서 정적 fast path 가 영구히 꺼진 채 운영되는
|
||||||
|
* 상태를 아무도 눈치채지 못했다(제보 본건). 이 테스트는 그 사실이 관리자 대시보드에
|
||||||
|
* 도달하는지, 그리고 **소음이 되지 않는지**(1회 실패는 알리지 않는다) 를 함께 잠근다.
|
||||||
|
*/
|
||||||
|
class StaticPublishAlertTest extends TestCase
|
||||||
|
{
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
Cache::forget('g7:core:ext.static.publish_failure');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
Cache::forget('g7:core:ext.static.publish_failure');
|
||||||
|
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function listener(): StaticPublishFailureAlertListener
|
||||||
|
{
|
||||||
|
return new StaticPublishFailureAlertListener;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 실패 마커를 심습니다.
|
||||||
|
*
|
||||||
|
* @param int $count 연속 실패 횟수
|
||||||
|
* @param string $reason 사유 코드
|
||||||
|
*/
|
||||||
|
private function seedMarker(int $count, string $reason = 'parent_not_writable'): void
|
||||||
|
{
|
||||||
|
Cache::put('g7:core:ext.static.publish_failure', [
|
||||||
|
'version' => 123,
|
||||||
|
'at' => now()->toIso8601String(),
|
||||||
|
'reason' => $reason,
|
||||||
|
'count' => $count,
|
||||||
|
'message' => 'denied',
|
||||||
|
], 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마커가 없으면 알림도 없다.
|
||||||
|
*
|
||||||
|
* @effects dashboard_has_no_alert_when_publishing_is_healthy
|
||||||
|
*/
|
||||||
|
public function test_no_alert_when_no_failure_marker(): void
|
||||||
|
{
|
||||||
|
$this->assertSame([], $this->listener()->addStaticPublishAlert([]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1회 실패는 알리지 않는다 — 배포 중 일시적 경합일 수 있고 자가 치유가 해소한다.
|
||||||
|
*
|
||||||
|
* @effects dashboard_alert_suppressed_below_threshold
|
||||||
|
*/
|
||||||
|
public function test_single_failure_does_not_alert(): void
|
||||||
|
{
|
||||||
|
$this->seedMarker(1);
|
||||||
|
|
||||||
|
$this->assertSame([], $this->listener()->addStaticPublishAlert([]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 2회 연속 실패부터 대시보드 알림이 노출된다.
|
||||||
|
*
|
||||||
|
* @effects publish_failure_reaches_dashboard
|
||||||
|
*/
|
||||||
|
public function test_repeated_failure_surfaces_alert(): void
|
||||||
|
{
|
||||||
|
$this->seedMarker(2);
|
||||||
|
|
||||||
|
$alerts = $this->listener()->addStaticPublishAlert([]);
|
||||||
|
|
||||||
|
$this->assertCount(1, $alerts);
|
||||||
|
$this->assertSame('static_publish_failure', $alerts[0]['id']);
|
||||||
|
$this->assertSame('warning', $alerts[0]['type']);
|
||||||
|
$this->assertSame('static_publish_parent_not_writable', $alerts[0]['subtype']);
|
||||||
|
|
||||||
|
// 문구가 키 그대로 새어 나가지 않는지 — 번역 누락은 화면에 키 문자열을 노출한다.
|
||||||
|
$this->assertStringNotContainsString('extensions.alerts.', $alerts[0]['title']);
|
||||||
|
$this->assertStringNotContainsString('extensions.alerts.', $alerts[0]['message']);
|
||||||
|
$this->assertStringNotContainsString(':count', $alerts[0]['message']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사유별로 subtype 과 문구가 갈린다 — 조치할 곳이 다르기 때문이다.
|
||||||
|
*
|
||||||
|
* @effects publish_failure_alert_distinguishes_reason
|
||||||
|
*/
|
||||||
|
public function test_alert_distinguishes_failure_reason(): void
|
||||||
|
{
|
||||||
|
$messages = [];
|
||||||
|
|
||||||
|
foreach (['parent_not_writable', 'write_failed', 'lock_unavailable'] as $reason) {
|
||||||
|
$this->seedMarker(3, $reason);
|
||||||
|
|
||||||
|
$alerts = $this->listener()->addStaticPublishAlert([]);
|
||||||
|
|
||||||
|
$this->assertSame('static_publish_'.$reason, $alerts[0]['subtype']);
|
||||||
|
$this->assertStringNotContainsString('extensions.alerts.', $alerts[0]['message']);
|
||||||
|
|
||||||
|
$messages[$reason] = $alerts[0]['message'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertCount(3, array_unique($messages), '사유별 문구가 구분되지 않는다');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 알 수 없는 사유도 일반 문구로 떨어진다 — 키 문자열이 화면에 노출되지 않는다.
|
||||||
|
*
|
||||||
|
* @effects publish_failure_alert_falls_back_for_unknown_reason
|
||||||
|
*/
|
||||||
|
public function test_unknown_reason_falls_back_to_generic_message(): void
|
||||||
|
{
|
||||||
|
$this->seedMarker(2, 'something_new');
|
||||||
|
|
||||||
|
$alerts = $this->listener()->addStaticPublishAlert([]);
|
||||||
|
|
||||||
|
$this->assertCount(1, $alerts);
|
||||||
|
$this->assertStringNotContainsString('extensions.alerts.', $alerts[0]['message']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기존 알림을 지우지 않고 덧붙인다 (필터 훅 계약).
|
||||||
|
*
|
||||||
|
* @effects publish_failure_alert_appends_to_existing
|
||||||
|
*/
|
||||||
|
public function test_alert_appends_without_dropping_existing(): void
|
||||||
|
{
|
||||||
|
$this->seedMarker(2);
|
||||||
|
|
||||||
|
$alerts = $this->listener()->addStaticPublishAlert([['id' => 'other']]);
|
||||||
|
|
||||||
|
$this->assertCount(2, $alerts);
|
||||||
|
$this->assertSame('other', $alerts[0]['id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 리스너가 `core.dashboard.alerts` 에 **실제로 등록**된다.
|
||||||
|
*
|
||||||
|
* 등록 실패는 예외도 로그도 남기지 않는다 — 알림이 그냥 안 뜰 뿐이라, 리스너 단위
|
||||||
|
* 테스트만으로는 배선 누락이 드러나지 않는다.
|
||||||
|
*
|
||||||
|
* @effects publish_failure_listener_is_registered_on_hook
|
||||||
|
*/
|
||||||
|
public function test_listener_is_wired_into_dashboard_alerts_hook(): void
|
||||||
|
{
|
||||||
|
$this->seedMarker(2);
|
||||||
|
|
||||||
|
$alerts = HookManager::applyFilters('core.dashboard.alerts', []);
|
||||||
|
|
||||||
|
$ids = array_column($alerts, 'id');
|
||||||
|
|
||||||
|
$this->assertContains(
|
||||||
|
'static_publish_failure',
|
||||||
|
$ids,
|
||||||
|
'리스너가 훅에 등록되지 않았다 — 알림이 조용히 뜨지 않는다'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마커 접근자가 서비스의 기록과 같은 키를 본다 (키 오타 회귀 가드).
|
||||||
|
*
|
||||||
|
* @effects failure_marker_key_is_shared_between_writer_and_reader
|
||||||
|
*/
|
||||||
|
public function test_marker_accessor_reads_what_service_writes(): void
|
||||||
|
{
|
||||||
|
$this->seedMarker(4, 'write_failed');
|
||||||
|
|
||||||
|
$marker = ExtensionStaticCacheService::failureMarker();
|
||||||
|
|
||||||
|
$this->assertIsArray($marker);
|
||||||
|
$this->assertSame(4, $marker['count']);
|
||||||
|
$this->assertSame('write_failed', $marker['reason']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Extension;
|
||||||
|
|
||||||
|
use App\Extension\Helpers\ExtensionBackupHelper;
|
||||||
|
use App\Extension\Helpers\ExtensionPendingHelper;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 확장 설치/업데이트 복사의 디렉토리 소유권 상속 테스트 (#122 P3).
|
||||||
|
*
|
||||||
|
* 파일은 `FilePermissionHelper::copyFile` 이 부모 소유권을 상속시키는데 **디렉토리는
|
||||||
|
* 그렇지 않았다.** sudo 로 실행된 설치/업데이트가 만든 디렉토리가 root 소유로 남으면
|
||||||
|
* 이후 웹 프로세스의 쓰기가 그 디렉토리에서 막힌다. 형제 구현
|
||||||
|
* `ExtensionBackupHelper::copyDirectoryWithProgress` 는 이미 같은 방어를 갖고 있었다
|
||||||
|
* (계층 불균형).
|
||||||
|
*
|
||||||
|
* 소유권 자체는 CI/개발 환경에서 단일 계정이라 값으로 단언할 수 없다 — 그래서 **복사
|
||||||
|
* 경로 세 곳 모두가 상속 호출을 갖는지**를 소스에서 검사하고, 복사 자체가 정상 동작하는지를
|
||||||
|
* 실제 파일시스템으로 확인한다. 한 경로만 고치면 나머지가 조용히 옛 동작으로 남기 때문이다.
|
||||||
|
*/
|
||||||
|
class ExtensionPendingOwnershipTest extends TestCase
|
||||||
|
{
|
||||||
|
private string $root;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->root = storage_path('framework/testing/pending-ownership');
|
||||||
|
File::deleteDirectory($this->root);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
File::deleteDirectory($this->root);
|
||||||
|
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 디렉토리를 만드는 복사 경로 전부가 부모 소유권을 상속시킨다.
|
||||||
|
*
|
||||||
|
* 대상 3곳: `copyDirectoryWithProgress`(rename 경로) / `syncDirectoryContents` ·
|
||||||
|
* `overlayDirectory`(제자리 동기화 폴백). 폴백만 빠뜨리면 파일 잠금으로 그 경로로
|
||||||
|
* 떨어진 교체에서만 소유권이 어긋나, Windows 잠금 상황에서만 재현되는 결함이 된다.
|
||||||
|
*
|
||||||
|
* @effects extension_copy_paths_inherit_directory_ownership
|
||||||
|
*/
|
||||||
|
public function test_every_directory_creating_copy_path_inherits_ownership(): void
|
||||||
|
{
|
||||||
|
$source = (new \ReflectionClass(ExtensionPendingHelper::class))->getFileName();
|
||||||
|
$content = (string) file_get_contents($source);
|
||||||
|
|
||||||
|
$methods = ['copyDirectoryWithProgress', 'syncDirectoryContents', 'overlayDirectory'];
|
||||||
|
|
||||||
|
foreach ($methods as $method) {
|
||||||
|
$body = $this->methodBody($content, $method);
|
||||||
|
|
||||||
|
$this->assertNotSame('', $body, "{$method} 을 소스에서 찾지 못했다 — 검사가 공허하다");
|
||||||
|
$this->assertStringContainsString(
|
||||||
|
'ensureDirectoryExists($dest',
|
||||||
|
$body,
|
||||||
|
"{$method} 이 디렉토리를 만들지 않는다 — 대상 목록이 낡았다"
|
||||||
|
);
|
||||||
|
$this->assertStringContainsString(
|
||||||
|
'FilePermissionHelper::inheritOwnershipFromParent($dest)',
|
||||||
|
$body,
|
||||||
|
"{$method} 이 디렉토리 소유권을 상속시키지 않는다 — sudo 설치 후 root 소유로 남는다"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 형제 구현(`ExtensionBackupHelper`)과 방어 수준이 같다 — 계층 불균형이 남지 않는다.
|
||||||
|
*
|
||||||
|
* @effects extension_copy_ownership_matches_sibling_helper
|
||||||
|
*/
|
||||||
|
public function test_matches_sibling_backup_helper_defense(): void
|
||||||
|
{
|
||||||
|
$sibling = (string) file_get_contents(
|
||||||
|
(new \ReflectionClass(ExtensionBackupHelper::class))->getFileName()
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertStringContainsString(
|
||||||
|
'FilePermissionHelper::inheritOwnershipFromParent($dest)',
|
||||||
|
$sibling,
|
||||||
|
'기준으로 삼은 형제 구현이 바뀌었다 — 이 테스트의 전제를 다시 확인할 것'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상속 호출을 추가한 뒤에도 실제 복사가 정상 동작한다 (회귀 가드).
|
||||||
|
*
|
||||||
|
* @effects extension_copy_still_produces_expected_tree
|
||||||
|
*/
|
||||||
|
public function test_copy_still_produces_expected_tree(): void
|
||||||
|
{
|
||||||
|
$source = $this->root.'/src';
|
||||||
|
$dest = $this->root.'/dest';
|
||||||
|
|
||||||
|
File::ensureDirectoryExists($source.'/nested/deep');
|
||||||
|
File::put($source.'/root.txt', 'a');
|
||||||
|
File::put($source.'/nested/deep/leaf.txt', 'b');
|
||||||
|
|
||||||
|
$method = new \ReflectionMethod(ExtensionPendingHelper::class, 'copyDirectoryWithProgress');
|
||||||
|
$method->setAccessible(true);
|
||||||
|
$method->invoke(null, $source, $dest, $source, null);
|
||||||
|
|
||||||
|
$this->assertFileExists($dest.'/root.txt');
|
||||||
|
$this->assertFileExists($dest.'/nested/deep/leaf.txt');
|
||||||
|
$this->assertSame('b', file_get_contents($dest.'/nested/deep/leaf.txt'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메서드 본문을 대략 잘라냅니다 (다음 메서드 선언 직전까지).
|
||||||
|
*
|
||||||
|
* @param string $content 파일 전문
|
||||||
|
* @param string $method 메서드명
|
||||||
|
* @return string 본문 (못 찾으면 빈 문자열)
|
||||||
|
*/
|
||||||
|
private function methodBody(string $content, string $method): string
|
||||||
|
{
|
||||||
|
$start = strpos($content, "function {$method}(");
|
||||||
|
|
||||||
|
if ($start === false) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$rest = substr($content, $start);
|
||||||
|
$next = preg_match(
|
||||||
|
'/\n (?:public|protected|private)\s+(?:static\s+)?function\s/',
|
||||||
|
substr($rest, 1),
|
||||||
|
$m,
|
||||||
|
PREG_OFFSET_CAPTURE
|
||||||
|
);
|
||||||
|
|
||||||
|
return $next === 1 ? substr($rest, 0, $m[0][1] + 1) : $rest;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace Tests\Feature\Http;
|
namespace Tests\Feature\Http;
|
||||||
|
|
||||||
|
use App\Support\StaticExtensionPattern;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
@@ -42,4 +43,57 @@ class BuildPathCatchAllExclusionTest extends TestCase
|
|||||||
|
|
||||||
$this->assertContains('GET', $route->methods());
|
$this->assertContains('GET', $route->methods());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 에셋 서빙이 허용하는 **모든** 확장자가 catch-all 에서 제외된다 (R1).
|
||||||
|
*
|
||||||
|
* 회귀 가드: 제외 목록이 라우트 파일에 손으로 적혀 있던 동안 `mjs` · `webp` · `otf`
|
||||||
|
* 세 가지가 빠져 있었다. 없는 `.mjs` 가 SPA 셸 HTML 200 을 받으면 브라우저는 그것을
|
||||||
|
* 스크립트로 파싱하다 죽는데, 응답이 성공이라 `onerror` 가 발화하지 않아 태그 복구기도
|
||||||
|
* 뜨지 않는다. `.json` 이 무사했던 것은 lookahead 에 끝 앵커가 없어 `.js` 에 부분일치한
|
||||||
|
* **우연**이었을 뿐이다. 이제 목록은 화이트리스트에서 파생되므로 우연에 기대지 않는다.
|
||||||
|
*
|
||||||
|
* @effects static_miss_returns_404_for_every_servable_extension
|
||||||
|
*/
|
||||||
|
public function test_모든_서빙_확장자가_catch_all_에서_제외된다(): void
|
||||||
|
{
|
||||||
|
$extensions = StaticExtensionPattern::servedExtensions();
|
||||||
|
|
||||||
|
$this->assertNotEmpty($extensions, '모집단이 비었다 — 검사가 공허하게 통과한다');
|
||||||
|
|
||||||
|
// 과거에 빠져 있던 세 가지가 모집단에 실제로 포함되는지 먼저 고정한다.
|
||||||
|
foreach (['mjs', 'webp', 'otf'] as $regressed) {
|
||||||
|
$this->assertContains($regressed, $extensions, "{$regressed} 가 모집단에서 빠졌다");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($extensions as $extension) {
|
||||||
|
$path = "/build/ext/1787637589/templates/sirsoft-basic/assets/missing.{$extension}";
|
||||||
|
|
||||||
|
try {
|
||||||
|
app('router')->getRoutes()->match(Request::create($path, 'GET'));
|
||||||
|
$this->fail(".{$extension} 미스가 SPA catch-all 에 매칭됐다 — HTML 200 이 나간다");
|
||||||
|
} catch (NotFoundHttpException) {
|
||||||
|
$this->addToAssertionCount(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* admin catch-all 도 같은 제외 목록을 쓴다 — 두 목록이 갈라지지 않는다.
|
||||||
|
*
|
||||||
|
* @effects admin_catch_all_shares_static_exclusion
|
||||||
|
*/
|
||||||
|
public function test_admin_catch_all_도_같은_제외_목록을_쓴다(): void
|
||||||
|
{
|
||||||
|
foreach (StaticExtensionPattern::servedExtensions() as $extension) {
|
||||||
|
$path = "/admin/build/missing.{$extension}";
|
||||||
|
|
||||||
|
try {
|
||||||
|
app('router')->getRoutes()->match(Request::create($path, 'GET'));
|
||||||
|
$this->fail("admin: .{$extension} 미스가 catch-all 에 매칭됐다");
|
||||||
|
} catch (NotFoundHttpException) {
|
||||||
|
$this->addToAssertionCount(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,9 +13,13 @@ use App\Services\ExtensionBundleService;
|
|||||||
use App\Services\ExtensionStaticCacheService;
|
use App\Services\ExtensionStaticCacheService;
|
||||||
use App\Services\LanguagePackService;
|
use App\Services\LanguagePackService;
|
||||||
use App\Services\TemplateService;
|
use App\Services\TemplateService;
|
||||||
|
use Illuminate\Cache\ArrayStore;
|
||||||
|
use Illuminate\Cache\Repository;
|
||||||
|
use Illuminate\Filesystem\Filesystem;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\File;
|
use Illuminate\Support\Facades\File;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,6 +61,9 @@ class ExtensionStaticCacheServiceTest extends TestCase
|
|||||||
Cache::lock('ext-static.publish.'.self::VERSION, 300)->forceRelease();
|
Cache::lock('ext-static.publish.'.self::VERSION, 300)->forceRelease();
|
||||||
Cache::lock('ext-static.publish.'.(self::VERSION + 1), 300)->forceRelease();
|
Cache::lock('ext-static.publish.'.(self::VERSION + 1), 300)->forceRelease();
|
||||||
|
|
||||||
|
// 실패 마커 격리 — 남아 있으면 백오프가 걸려 다음 테스트의 게시 예약이 조용히 스킵된다.
|
||||||
|
Cache::forget('g7:core:ext.static.publish_failure');
|
||||||
|
|
||||||
ExtensionStaticCacheService::resetPublishScheduleForTesting();
|
ExtensionStaticCacheService::resetPublishScheduleForTesting();
|
||||||
$this->cleanBaseDir();
|
$this->cleanBaseDir();
|
||||||
}
|
}
|
||||||
@@ -95,10 +102,17 @@ class ExtensionStaticCacheServiceTest extends TestCase
|
|||||||
}
|
}
|
||||||
$store = config('cache.default');
|
$store = config('cache.default');
|
||||||
|
|
||||||
|
// 실패 마커까지 보여 준다 — 프리플라이트/쓰기 실패는 마커에 사유를 남기므로,
|
||||||
|
// 마커가 있으면 어느 분기였는지 즉시 알 수 있고 없으면 그 두 분기가 아니었다는 뜻이다.
|
||||||
|
// (마커 없이 false 면 kill-switch 또는 락 미획득이다.)
|
||||||
|
$marker = ExtensionStaticCacheService::failureMarker();
|
||||||
|
|
||||||
return sprintf(
|
return sprintf(
|
||||||
'enabled=%s version=%d expected=%d lockFree=%s store=%s published=%s baseDir=%s',
|
'enabled=%s version=%d expected=%d lockFree=%s store=%s published=%s marker=%s baseDir=%s',
|
||||||
var_export($svc->isEnabled(), true), $v, self::VERSION, var_export($free, true),
|
var_export($svc->isEnabled(), true), $v, self::VERSION, var_export($free, true),
|
||||||
$store, var_export($svc->isPublished($v), true), $svc->baseDir()
|
$store, var_export($svc->isPublished($v), true),
|
||||||
|
$marker === null ? 'none' : ($marker['reason'].':'.$marker['message']),
|
||||||
|
$svc->baseDir()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,7 +172,7 @@ class ExtensionStaticCacheServiceTest extends TestCase
|
|||||||
/**
|
/**
|
||||||
* 활성 템플릿 0개(설치 직전)여도 예외 없이 빈 게시가 성립한다.
|
* 활성 템플릿 0개(설치 직전)여도 예외 없이 빈 게시가 성립한다.
|
||||||
*
|
*
|
||||||
* @scenario publish_state=unpublished, environment=production, trigger=manual_command, process_user=web
|
* @scenario publish_state=unpublished, artifact_integrity=intact, filesystem_writable=writable, environment=production, trigger=manual_command, process_user=web
|
||||||
*/
|
*/
|
||||||
public function test_publishes_empty_tree_when_no_active_templates(): void
|
public function test_publishes_empty_tree_when_no_active_templates(): void
|
||||||
{
|
{
|
||||||
@@ -388,7 +402,7 @@ class ExtensionStaticCacheServiceTest extends TestCase
|
|||||||
/**
|
/**
|
||||||
* 멱등 — 게시 완료 상태에서 재호출은 skip (기존 게시물 유지), force 는 재게시.
|
* 멱등 — 게시 완료 상태에서 재호출은 skip (기존 게시물 유지), force 는 재게시.
|
||||||
*
|
*
|
||||||
* @scenario publish_state=published, environment=production, trigger=manual_command, process_user=web
|
* @scenario publish_state=published, artifact_integrity=intact, filesystem_writable=writable, environment=production, trigger=manual_command, process_user=web
|
||||||
*
|
*
|
||||||
* @effects publish_is_idempotent_until_forced
|
* @effects publish_is_idempotent_until_forced
|
||||||
*/
|
*/
|
||||||
@@ -413,7 +427,7 @@ class ExtensionStaticCacheServiceTest extends TestCase
|
|||||||
/**
|
/**
|
||||||
* 쓰기 단계 실패 시 예외를 삼키고 false + tmp 잔존물 정리 + manifest 부재.
|
* 쓰기 단계 실패 시 예외를 삼키고 false + tmp 잔존물 정리 + manifest 부재.
|
||||||
*
|
*
|
||||||
* @scenario publish_state=partial, environment=production, trigger=lifecycle, process_user=web
|
* @scenario publish_state=partial, artifact_integrity=intact, filesystem_writable=writable, environment=production, trigger=lifecycle, process_user=web
|
||||||
*
|
*
|
||||||
* @effects write_failure_cleans_tmp_and_falls_back
|
* @effects write_failure_cleans_tmp_and_falls_back
|
||||||
*/
|
*/
|
||||||
@@ -447,7 +461,204 @@ class ExtensionStaticCacheServiceTest extends TestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GC — 현재 버전 + 직전 1개 보존, 그 외 삭제 (tmp 잔존물 포함).
|
* 게시 루트를 만들 수 없으면 **병합을 시작하기 전에** 끊는다 (P2 프리플라이트).
|
||||||
|
*
|
||||||
|
* `public/build` 가 다른 계정 소유 + `g-w` 면 웹 프로세스는 `ext` 를 mkdir 조차 하지
|
||||||
|
* 못한다. 그 사실을 전 로케일 lang 병합 + 전 템플릿 dist 복사를 다 헛돈 뒤에 알게 되면
|
||||||
|
* 그 비용이 **모든 프로덕션 요청**에서 반복된다.
|
||||||
|
*
|
||||||
|
* @scenario publish_state=unpublished, artifact_integrity=intact, filesystem_writable=parent_denied, environment=production, trigger=lifecycle, process_user=web
|
||||||
|
*
|
||||||
|
* @effects parent_denied_short_circuits_before_merge
|
||||||
|
*/
|
||||||
|
public function test_unwritable_parent_short_circuits_before_merge(): void
|
||||||
|
{
|
||||||
|
$this->createActiveTemplate();
|
||||||
|
|
||||||
|
// 병합 SSoT 가 호출되면 실패 — 프리플라이트가 그 앞에서 끊어야 한다.
|
||||||
|
$mock = $this->partialMock(TemplateService::class, function ($mock) {
|
||||||
|
$mock->shouldReceive('getLanguageDataWithModules')
|
||||||
|
->never();
|
||||||
|
});
|
||||||
|
|
||||||
|
$service = new ExtensionStaticCacheService(
|
||||||
|
$mock,
|
||||||
|
app(TemplateRepositoryInterface::class),
|
||||||
|
app(ModuleRepositoryInterface::class),
|
||||||
|
app(PluginRepositoryInterface::class),
|
||||||
|
app(ExtensionBundleService::class),
|
||||||
|
app(LanguagePackService::class),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 게시 루트도, 그 부모도 만들 수 없는 상태를 만든다 — public 루트를 파일로 막아
|
||||||
|
// 어떤 하위 디렉토리도 생성될 수 없게 한다 (Windows/Unix 공통으로 성립).
|
||||||
|
$blocked = $this->isolatedPublicPath.'/blocked-public';
|
||||||
|
File::put($blocked, 'not a directory');
|
||||||
|
$this->app->usePublicPath($blocked);
|
||||||
|
|
||||||
|
$this->assertFalse($service->publishCurrent());
|
||||||
|
$this->assertFalse($service->isPublished(self::VERSION));
|
||||||
|
|
||||||
|
$marker = ExtensionStaticCacheService::failureMarker();
|
||||||
|
|
||||||
|
$this->assertIsArray($marker, '프리플라이트 실패가 마커에 남지 않았다');
|
||||||
|
$this->assertSame('parent_not_writable', $marker['reason']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게시 루트 자체가 쓰기 불가면 같은 지점에서 끊긴다 (P1).
|
||||||
|
*
|
||||||
|
* @scenario publish_state=unpublished, artifact_integrity=intact, filesystem_writable=tree_denied, environment=production, trigger=lifecycle, process_user=web
|
||||||
|
*
|
||||||
|
* @effects parent_denied_short_circuits_before_merge
|
||||||
|
*/
|
||||||
|
public function test_unwritable_publish_tree_short_circuits(): void
|
||||||
|
{
|
||||||
|
$this->createActiveTemplate();
|
||||||
|
|
||||||
|
$service = $this->service();
|
||||||
|
|
||||||
|
// 게시 루트 자리에 파일을 놓아 디렉토리로 쓸 수 없게 만든다.
|
||||||
|
$base = $service->baseDir();
|
||||||
|
File::ensureDirectoryExists(dirname($base));
|
||||||
|
File::deleteDirectory($base);
|
||||||
|
File::put($base, 'not a directory');
|
||||||
|
|
||||||
|
$this->assertFalse($service->publishCurrent());
|
||||||
|
$this->assertFalse($service->isPublished(self::VERSION));
|
||||||
|
|
||||||
|
$marker = ExtensionStaticCacheService::failureMarker();
|
||||||
|
$this->assertIsArray($marker);
|
||||||
|
$this->assertSame('parent_not_writable', $marker['reason']);
|
||||||
|
|
||||||
|
@unlink($base);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 쓰기 실패는 실패 마커에 사유와 함께 기록된다 (O1 백오프의 근거).
|
||||||
|
*
|
||||||
|
* @scenario publish_state=partial, artifact_integrity=intact, filesystem_writable=writable, environment=production, trigger=lifecycle, process_user=web
|
||||||
|
*
|
||||||
|
* @effects publish_failure_recorded_in_marker
|
||||||
|
*/
|
||||||
|
public function test_write_failure_records_failure_marker(): void
|
||||||
|
{
|
||||||
|
$this->createActiveTemplate();
|
||||||
|
Cache::forget('g7:core:ext.static.publish_failure');
|
||||||
|
|
||||||
|
$mock = $this->partialMock(TemplateService::class, function ($mock) {
|
||||||
|
$mock->shouldReceive('getLanguageDataWithModules')
|
||||||
|
->andThrow(new \RuntimeException('disk full'));
|
||||||
|
});
|
||||||
|
|
||||||
|
$service = new ExtensionStaticCacheService(
|
||||||
|
$mock,
|
||||||
|
app(TemplateRepositoryInterface::class),
|
||||||
|
app(ModuleRepositoryInterface::class),
|
||||||
|
app(PluginRepositoryInterface::class),
|
||||||
|
app(ExtensionBundleService::class),
|
||||||
|
app(LanguagePackService::class),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertFalse($service->publishCurrent());
|
||||||
|
|
||||||
|
$marker = ExtensionStaticCacheService::failureMarker();
|
||||||
|
|
||||||
|
$this->assertIsArray($marker, '실패 마커가 기록되지 않았다 — 진단 표면이 실패를 볼 수 없다');
|
||||||
|
$this->assertSame('write_failed', $marker['reason']);
|
||||||
|
$this->assertSame(self::VERSION, $marker['version']);
|
||||||
|
$this->assertSame(1, $marker['count']);
|
||||||
|
$this->assertStringContainsString('disk full', $marker['message']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게시 성공은 실패 마커를 제거한다 — 백오프가 영구 고착되지 않는다.
|
||||||
|
*
|
||||||
|
* @effects publish_success_clears_failure_marker
|
||||||
|
*/
|
||||||
|
public function test_successful_publish_clears_failure_marker(): void
|
||||||
|
{
|
||||||
|
Cache::put('g7:core:ext.static.publish_failure', [
|
||||||
|
'version' => self::VERSION,
|
||||||
|
'at' => now()->toIso8601String(),
|
||||||
|
'reason' => 'write_failed',
|
||||||
|
'count' => 3,
|
||||||
|
'message' => 'stale',
|
||||||
|
], 300);
|
||||||
|
|
||||||
|
$service = $this->service();
|
||||||
|
$this->assertTrue($service->publishCurrent(), $this->publishDiagnostics($service));
|
||||||
|
|
||||||
|
$this->assertNull(
|
||||||
|
ExtensionStaticCacheService::failureMarker(),
|
||||||
|
'성공했는데 실패 마커가 남아 있다 — 이후 예약이 영구 억제된다'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 신선한 실패 마커가 있으면 terminating 게시를 재예약하지 않는다 (O1 백오프).
|
||||||
|
*
|
||||||
|
* 쓰기 불가 환경에서 모든 프로덕션 요청이 전 로케일 병합 + 전 템플릿 dist 복사를
|
||||||
|
* 헛도는 것을 막는다.
|
||||||
|
*
|
||||||
|
* @effects publish_failure_backoff_suppresses_reschedule
|
||||||
|
*/
|
||||||
|
public function test_fresh_failure_marker_suppresses_publish_scheduling(): void
|
||||||
|
{
|
||||||
|
$this->app['env'] = 'production';
|
||||||
|
ExtensionStaticCacheService::resetPublishScheduleForTesting();
|
||||||
|
ExtensionStaticCacheService::fakeRootProcessForTesting(false);
|
||||||
|
|
||||||
|
Cache::put('g7:core:ext.static.publish_failure', [
|
||||||
|
'version' => self::VERSION,
|
||||||
|
'at' => now()->toIso8601String(),
|
||||||
|
'reason' => 'parent_not_writable',
|
||||||
|
'count' => 2,
|
||||||
|
'message' => 'denied',
|
||||||
|
], 300);
|
||||||
|
|
||||||
|
ExtensionStaticCacheService::schedulePublishOnTerminate();
|
||||||
|
$this->app->terminate();
|
||||||
|
|
||||||
|
$this->assertFalse(
|
||||||
|
$this->service()->isPublished(self::VERSION),
|
||||||
|
'실패 마커가 신선한데 게시가 재시도됐다 — 백오프가 걸리지 않는다'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 절단 쓰기는 예외로 잡히고 manifest 가 기록되지 않는다 (A1).
|
||||||
|
*
|
||||||
|
* `File::put()` 은 디스크 풀/quota 에서 **짧은 int** 를 돌려주며 성공한 것처럼 보인다.
|
||||||
|
* `=== false` 만 보면 절단 JSON 이 200 으로 서빙되고, 프론트는 `response.ok` 만 보므로
|
||||||
|
* 폴백하지 않은 채 `response.json()` 이 던져 부팅 전체가 실패한다.
|
||||||
|
*
|
||||||
|
* @scenario publish_state=partial, artifact_integrity=truncated, filesystem_writable=writable, environment=production, trigger=lifecycle, process_user=web
|
||||||
|
*
|
||||||
|
* @effects truncated_artifact_rejected_before_manifest
|
||||||
|
*/
|
||||||
|
public function test_truncated_write_is_rejected_before_manifest(): void
|
||||||
|
{
|
||||||
|
$this->createActiveTemplate();
|
||||||
|
|
||||||
|
// File::put 이 실제보다 짧은 바이트 수를 반환하는 상황 (디스크 풀/quota).
|
||||||
|
// partialMock 이라 ensureDirectoryExists 등 나머지 파일 연산은 그대로 동작한다.
|
||||||
|
File::partialMock()->shouldReceive('put')->andReturn(1);
|
||||||
|
|
||||||
|
$service = $this->service();
|
||||||
|
|
||||||
|
$this->assertFalse($service->publishCurrent());
|
||||||
|
$this->assertFalse(
|
||||||
|
$service->isPublished(self::VERSION),
|
||||||
|
'절단 산출물이 게시 완료로 표시됐다 — 손상 JSON 이 200 으로 서빙된다'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GC — 현재 버전 + 직전 1개 보존, 그 외 삭제.
|
||||||
|
*
|
||||||
|
* 갓 만들어진 `.tmp` 는 살아남는다 — 락은 버전별이라 다른 버전의 게시가 동시에
|
||||||
|
* 진행 중일 수 있고, 나이 무관 삭제는 그 순간 살아 있는 남의 tmp 를 파괴한다.
|
||||||
*
|
*
|
||||||
* @effects cleanup_keeps_current_and_previous_versions
|
* @effects cleanup_keeps_current_and_previous_versions
|
||||||
*/
|
*/
|
||||||
@@ -456,7 +667,7 @@ class ExtensionStaticCacheServiceTest extends TestCase
|
|||||||
$service = $this->service();
|
$service = $this->service();
|
||||||
$this->assertTrue($service->publishCurrent(), $this->publishDiagnostics($service));
|
$this->assertTrue($service->publishCurrent(), $this->publishDiagnostics($service));
|
||||||
|
|
||||||
// 과거 버전 3개 + 고아 tmp 시뮬레이션
|
// 과거 버전 3개 + 진행 중으로 보이는 신규 tmp 시뮬레이션
|
||||||
foreach ([100, 200, 300] as $old) {
|
foreach ([100, 200, 300] as $old) {
|
||||||
File::ensureDirectoryExists($service->versionDir($old));
|
File::ensureDirectoryExists($service->versionDir($old));
|
||||||
File::put($service->versionDir($old).'/manifest.json', '{}');
|
File::put($service->versionDir($old).'/manifest.json', '{}');
|
||||||
@@ -465,19 +676,85 @@ class ExtensionStaticCacheServiceTest extends TestCase
|
|||||||
|
|
||||||
$deleted = $service->cleanup();
|
$deleted = $service->cleanup();
|
||||||
|
|
||||||
// 보존: 현재(VERSION) + 직전(300). 삭제: 100, 200, 400.tmp
|
// 보존: 현재(VERSION) + 직전(300) + 신규 400.tmp. 삭제: 100, 200
|
||||||
$this->assertSame(3, $deleted);
|
$this->assertSame(2, $deleted);
|
||||||
$this->assertDirectoryExists($service->versionDir(self::VERSION));
|
$this->assertDirectoryExists($service->versionDir(self::VERSION));
|
||||||
$this->assertDirectoryExists($service->versionDir(300));
|
$this->assertDirectoryExists($service->versionDir(300));
|
||||||
$this->assertDirectoryDoesNotExist($service->versionDir(200));
|
$this->assertDirectoryDoesNotExist($service->versionDir(200));
|
||||||
$this->assertDirectoryDoesNotExist($service->versionDir(100));
|
$this->assertDirectoryDoesNotExist($service->versionDir(100));
|
||||||
$this->assertDirectoryDoesNotExist($service->baseDir().'/400.tmp');
|
$this->assertDirectoryExists(
|
||||||
|
$service->baseDir().'/400.tmp',
|
||||||
|
'진행 중일 수 있는 신규 tmp 는 보존되어야 한다'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GC — 나이 가드: 10분을 넘긴 `.tmp` / `.old` 만 삭제된다 (A4).
|
||||||
|
*
|
||||||
|
* @effects cleanup_removes_only_stale_work_directories
|
||||||
|
*/
|
||||||
|
public function test_cleanup_removes_only_stale_work_directories(): void
|
||||||
|
{
|
||||||
|
$service = $this->service();
|
||||||
|
$base = $service->baseDir();
|
||||||
|
File::ensureDirectoryExists($base);
|
||||||
|
|
||||||
|
$fresh = $base.'/500.tmp';
|
||||||
|
$staleTmp = $base.'/600.tmp';
|
||||||
|
$staleOld = $base.'/700.old';
|
||||||
|
|
||||||
|
foreach ([$fresh, $staleTmp, $staleOld] as $dir) {
|
||||||
|
File::ensureDirectoryExists($dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 락 TTL(300초)의 2배가 기준 — 그보다 확실히 오래된 시각으로 되돌린다.
|
||||||
|
touch($staleTmp, time() - 3600);
|
||||||
|
touch($staleOld, time() - 3600);
|
||||||
|
clearstatcache();
|
||||||
|
|
||||||
|
$deleted = $service->cleanup();
|
||||||
|
|
||||||
|
$this->assertDirectoryExists($fresh, '신규 tmp 가 삭제되었다 — 진행 중 게시가 파괴된다');
|
||||||
|
$this->assertDirectoryDoesNotExist($staleTmp, '늙은 tmp 가 정리되지 않았다');
|
||||||
|
$this->assertDirectoryDoesNotExist($staleOld, '늙은 .old 가 정리되지 않았다');
|
||||||
|
$this->assertSame(2, $deleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GC — 현재 버전 디렉토리가 실존하지 않으면 실존 최신 2개를 보존한다 (D2).
|
||||||
|
*
|
||||||
|
* `cache:clear` 후 첫 호출자가 CLI 면 포인터만 새 버전으로 점프하고 산출물은 아직
|
||||||
|
* 없다. 그 상태에서 없는 버전을 보존 슬롯으로 쓰면 진짜 직전 버전이 삭제된다.
|
||||||
|
*
|
||||||
|
* @effects gc_preserves_previous_when_current_absent
|
||||||
|
*/
|
||||||
|
public function test_cleanup_preserves_two_newest_when_current_version_absent(): void
|
||||||
|
{
|
||||||
|
$service = $this->service();
|
||||||
|
$base = $service->baseDir();
|
||||||
|
File::ensureDirectoryExists($base);
|
||||||
|
|
||||||
|
// 현재 버전(VERSION) 디렉토리는 만들지 않는다 — 포인터만 앞선 상태.
|
||||||
|
foreach ([100, 200, 300] as $old) {
|
||||||
|
File::ensureDirectoryExists($service->versionDir($old));
|
||||||
|
File::put($service->versionDir($old).'/manifest.json', '{}');
|
||||||
|
}
|
||||||
|
|
||||||
|
$deleted = $service->cleanup();
|
||||||
|
|
||||||
|
$this->assertDirectoryExists($service->versionDir(300), '실존 최신이 보존되지 않았다');
|
||||||
|
$this->assertDirectoryExists(
|
||||||
|
$service->versionDir(200),
|
||||||
|
'진짜 직전 버전이 삭제됐다 — 없는 현재 버전이 보존 슬롯을 잡고 있다'
|
||||||
|
);
|
||||||
|
$this->assertDirectoryDoesNotExist($service->versionDir(100));
|
||||||
|
$this->assertSame(1, $deleted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* kill-switch(core.static_cache.enabled=false) — 게시 자체가 중단된다.
|
* kill-switch(core.static_cache.enabled=false) — 게시 자체가 중단된다.
|
||||||
*
|
*
|
||||||
* @scenario publish_state=unpublished, environment=production, trigger=kill_switch, process_user=web
|
* @scenario publish_state=unpublished, artifact_integrity=intact, filesystem_writable=writable, environment=production, trigger=kill_switch, process_user=web
|
||||||
*
|
*
|
||||||
* @effects kill_switch_disables_publish_and_gate
|
* @effects kill_switch_disables_publish_and_gate
|
||||||
*/
|
*/
|
||||||
@@ -494,7 +771,7 @@ class ExtensionStaticCacheServiceTest extends TestCase
|
|||||||
/**
|
/**
|
||||||
* terminating 트리거 — 비프로덕션(testing)에서는 예약되지 않는다.
|
* terminating 트리거 — 비프로덕션(testing)에서는 예약되지 않는다.
|
||||||
*
|
*
|
||||||
* @scenario publish_state=unpublished, environment=dev, trigger=lifecycle, process_user=web
|
* @scenario publish_state=unpublished, artifact_integrity=intact, filesystem_writable=writable, environment=dev, trigger=lifecycle, process_user=web
|
||||||
*
|
*
|
||||||
* @effects terminating_publish_gated_to_production
|
* @effects terminating_publish_gated_to_production
|
||||||
*/
|
*/
|
||||||
@@ -510,7 +787,7 @@ class ExtensionStaticCacheServiceTest extends TestCase
|
|||||||
/**
|
/**
|
||||||
* terminating 트리거 — 프로덕션에서는 종료 시점의 현재 버전으로 게시된다.
|
* terminating 트리거 — 프로덕션에서는 종료 시점의 현재 버전으로 게시된다.
|
||||||
*
|
*
|
||||||
* @scenario publish_state=unpublished, environment=production, trigger=lifecycle, process_user=web
|
* @scenario publish_state=unpublished, artifact_integrity=intact, filesystem_writable=writable, environment=production, trigger=lifecycle, process_user=web
|
||||||
*
|
*
|
||||||
* @effects terminating_publish_uses_final_version_after_burst
|
* @effects terminating_publish_uses_final_version_after_burst
|
||||||
*/
|
*/
|
||||||
@@ -538,7 +815,7 @@ class ExtensionStaticCacheServiceTest extends TestCase
|
|||||||
* (실사례: sudo 코어 업데이트 직후 전면 500). 게시는 다음 웹 렌더의
|
* (실사례: sudo 코어 업데이트 직후 전면 500). 게시는 다음 웹 렌더의
|
||||||
* 자가 치유(웹 계정)가 수행한다.
|
* 자가 치유(웹 계정)가 수행한다.
|
||||||
*
|
*
|
||||||
* @scenario publish_state=unpublished, environment=production, trigger=lifecycle, process_user=root_cli
|
* @scenario publish_state=unpublished, artifact_integrity=intact, filesystem_writable=writable, environment=production, trigger=lifecycle, process_user=root_cli
|
||||||
*
|
*
|
||||||
* @effects root_cli_defers_publish_to_web_self_heal
|
* @effects root_cli_defers_publish_to_web_self_heal
|
||||||
*/
|
*/
|
||||||
@@ -565,7 +842,7 @@ class ExtensionStaticCacheServiceTest extends TestCase
|
|||||||
* 않은 채 플래그만 true 라 그 워커에서 영구 미게시가 되고, `AssetUrl` 자가 치유도
|
* 않은 채 플래그만 true 라 그 워커에서 영구 미게시가 되고, `AssetUrl` 자가 치유도
|
||||||
* 같은 플래그를 쓰므로 복구 경로가 없다. FPM(요청=프로세스)에서는 무영향.
|
* 같은 플래그를 쓰므로 복구 경로가 없다. FPM(요청=프로세스)에서는 무영향.
|
||||||
*
|
*
|
||||||
* @scenario publish_state=published, environment=production, trigger=lifecycle, process_user=web
|
* @scenario publish_state=published, artifact_integrity=intact, filesystem_writable=writable, environment=production, trigger=lifecycle, process_user=web
|
||||||
*
|
*
|
||||||
* @effects terminating_schedule_rearms_after_execution
|
* @effects terminating_schedule_rearms_after_execution
|
||||||
*/
|
*/
|
||||||
@@ -588,7 +865,7 @@ class ExtensionStaticCacheServiceTest extends TestCase
|
|||||||
* 게시 디렉토리가 스스로 압축을 선언해야 종전 API 대비 전송량 회귀가 없다
|
* 게시 디렉토리가 스스로 압축을 선언해야 종전 API 대비 전송량 회귀가 없다
|
||||||
* (실측: lang/ko.json 524,915B 비압축 전송). nginx 는 규정 문서의 gzip 스니펫이 담당한다.
|
* (실측: lang/ko.json 524,915B 비압축 전송). nginx 는 규정 문서의 gzip 스니펫이 담당한다.
|
||||||
*
|
*
|
||||||
* @scenario publish_state=published, environment=production, trigger=manual_command, process_user=web
|
* @scenario publish_state=published, artifact_integrity=intact, filesystem_writable=writable, environment=production, trigger=manual_command, process_user=web
|
||||||
*
|
*
|
||||||
* @effects published_htaccess_declares_compression
|
* @effects published_htaccess_declares_compression
|
||||||
*/
|
*/
|
||||||
@@ -603,4 +880,442 @@ class ExtensionStaticCacheServiceTest extends TestCase
|
|||||||
$this->assertStringContainsString('mod_deflate.c', $htaccess);
|
$this->assertStringContainsString('mod_deflate.c', $htaccess);
|
||||||
$this->assertStringContainsString('application/json', $htaccess);
|
$this->assertStringContainsString('application/json', $htaccess);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* manifest 기록이 실패하면 **이미 앉은 최종 디렉토리**를 정리한다 (A2).
|
||||||
|
*
|
||||||
|
* rename 이 이미 성공한 뒤라 `$tmp` 는 존재하지 않는다 — tmp 만 지우는 정리는 no-op 이고,
|
||||||
|
* manifest 없는 완성 디렉토리가 영구 잔존한다. 그 상태에서 `isPublished()` 는 영원히
|
||||||
|
* false 라 요청마다 트리를 지웠다 만들기를 반복하며, 실패는 로그에만 남는다.
|
||||||
|
*
|
||||||
|
* @scenario publish_state=partial, artifact_integrity=truncated, filesystem_writable=writable, environment=production, trigger=lifecycle, process_user=web
|
||||||
|
*
|
||||||
|
* @effects manifest_failure_cleans_final_directory
|
||||||
|
*/
|
||||||
|
public function test_manifest_failure_cleans_final_directory(): void
|
||||||
|
{
|
||||||
|
$this->createActiveTemplate();
|
||||||
|
|
||||||
|
$service = $this->service();
|
||||||
|
$final = $service->versionDir(self::VERSION);
|
||||||
|
$base = $service->baseDir();
|
||||||
|
|
||||||
|
// manifest 쓰기 **시점만** 실패시킨다 — 첫 파일 쓰기에서 던지면 rename 까지 가지
|
||||||
|
// 못해 이 경로(A2)가 아니라 tmp 정리 경로(P1)를 검사하게 된다.
|
||||||
|
$real = new Filesystem;
|
||||||
|
$fs = File::partialMock();
|
||||||
|
$fs->shouldReceive('put')
|
||||||
|
->withArgs(fn ($path) => str_ends_with(str_replace('\\', '/', (string) $path), '/manifest.json'))
|
||||||
|
->andReturn(false);
|
||||||
|
$fs->shouldReceive('put')
|
||||||
|
->andReturnUsing(fn ($path, $contents, $lock = false) => $real->put($path, $contents, $lock));
|
||||||
|
|
||||||
|
$this->assertFalse($service->publishCurrent());
|
||||||
|
$this->assertFalse($service->isPublished(self::VERSION));
|
||||||
|
|
||||||
|
$this->assertDirectoryDoesNotExist(
|
||||||
|
$final,
|
||||||
|
'manifest 없는 완성 디렉토리가 잔존했다 — isPublished 가 영원히 false 라 매 요청이 트리를 다시 만든다'
|
||||||
|
);
|
||||||
|
|
||||||
|
$leftovers = File::isDirectory($base) ? array_map('basename', File::directories($base)) : [];
|
||||||
|
$this->assertSame([], $leftovers, '게시 작업 디렉토리가 정리되지 않았다');
|
||||||
|
|
||||||
|
// 실패 지점이 manifest 였음을 고정한다 — 그보다 앞에서 끊겼다면 이 테스트는
|
||||||
|
// rename 이후 경로(A2)가 아니라 tmp 정리 경로를 검사한 셈이라 공허하다.
|
||||||
|
$marker = ExtensionStaticCacheService::failureMarker();
|
||||||
|
$this->assertIsArray($marker);
|
||||||
|
$this->assertSame('write_failed', $marker['reason']);
|
||||||
|
$this->assertStringContainsString('manifest.json', $marker['message']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 재게시 중 `rename($tmp, $final)` 이 실패하면 비켜 둔 기존 버전이 복원된다 (A3).
|
||||||
|
*
|
||||||
|
* 실측 근거: Windows 재게시에서 이 경로가 실제로 발동했고(마커 상세
|
||||||
|
* `Failed to rename publish directory: …{v}.tmp -> …{v}`), 기존 게시본은 온전히
|
||||||
|
* 보존됐다. 복원이 없으면 이미 배달된 HTML 이 참조하는 CSS/JS/폰트가 전부 404 가
|
||||||
|
* 되고 폰트에는 복구기가 없다.
|
||||||
|
*
|
||||||
|
* @scenario publish_state=published, artifact_integrity=intact, filesystem_writable=writable, environment=production, trigger=manual_command, process_user=web
|
||||||
|
*
|
||||||
|
* @effects rename_failure_restores_previous_published_version
|
||||||
|
*/
|
||||||
|
public function test_rename_failure_restores_previous_published_version(): void
|
||||||
|
{
|
||||||
|
$service = $this->service();
|
||||||
|
$this->assertTrue($service->publishCurrent(), $this->publishDiagnostics($service));
|
||||||
|
|
||||||
|
$final = $service->versionDir(self::VERSION);
|
||||||
|
$old = $final.'.old';
|
||||||
|
$base = $service->baseDir();
|
||||||
|
|
||||||
|
// 기존 게시본의 증거 — 복원되면 이 파일이 그대로 살아 있다.
|
||||||
|
File::put($final.'/previous-version-marker', 'v1');
|
||||||
|
|
||||||
|
// 새 트리를 앉힐 자리를 **비어 있지 않은 디렉토리**로 점유해 rename 을 실패시킨다.
|
||||||
|
// 점유 시점은 기존 버전을 `.old` 로 비켜낸 직후여야 하므로(그 전이면 기존 버전에
|
||||||
|
// 파일 하나가 늘 뿐이다), 스왑 판정 호출(`File::isDirectory($old)`)에 훅을 건다.
|
||||||
|
$fired = false;
|
||||||
|
$fs = File::partialMock();
|
||||||
|
$fs->shouldReceive('isDirectory')
|
||||||
|
->withArgs(fn ($path) => (string) $path === $old)
|
||||||
|
->andReturnUsing(function ($path) use (&$fired, $final) {
|
||||||
|
if (! $fired && ! is_dir($final)) {
|
||||||
|
$fired = true;
|
||||||
|
mkdir($final, 0775, true);
|
||||||
|
file_put_contents($final.DIRECTORY_SEPARATOR.'blocker', 'occupied');
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_dir($path);
|
||||||
|
});
|
||||||
|
$fs->shouldReceive('isDirectory')->andReturnUsing(fn ($path) => is_dir($path));
|
||||||
|
|
||||||
|
$this->assertFalse($service->publishCurrent(force: true), '스왑 실패가 성공으로 보고됐다');
|
||||||
|
$this->assertTrue($fired, 'rename 실패를 유도하지 못했다 — 이 테스트는 공허 통과다');
|
||||||
|
|
||||||
|
$this->assertFileExists(
|
||||||
|
$final.'/previous-version-marker',
|
||||||
|
'스왑 실패 후 기존 게시본이 복원되지 않았다 — 배달된 HTML 의 자산이 전부 404 가 된다'
|
||||||
|
);
|
||||||
|
$this->assertFileExists($final.'/manifest.json');
|
||||||
|
$this->assertTrue($service->isPublished(self::VERSION));
|
||||||
|
$this->assertDirectoryDoesNotExist($old, '비켜 둔 디렉토리가 제자리로 돌아가지 않았다');
|
||||||
|
|
||||||
|
$leftovers = array_filter(
|
||||||
|
array_map('basename', File::directories($base)),
|
||||||
|
fn ($name) => str_ends_with($name, '.tmp')
|
||||||
|
);
|
||||||
|
$this->assertSame([], array_values($leftovers), 'tmp 잔존물이 정리되지 않았다');
|
||||||
|
|
||||||
|
$marker = ExtensionStaticCacheService::failureMarker();
|
||||||
|
$this->assertIsArray($marker);
|
||||||
|
$this->assertSame('write_failed', $marker['reason']);
|
||||||
|
$this->assertStringContainsString('Failed to rename publish directory', $marker['message']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 디렉토리 rename 은 포기 전에 재시도한다 — 일시 거부를 게시 실패로 만들지 않는다.
|
||||||
|
*
|
||||||
|
* 실측 근거: 실패 순간 목적지는 **부재**였고 `.old` 스왑도 관여하지 않았는데
|
||||||
|
* `rename` 이 false 를 돌려줬으며, 상태를 바꾸지 않고 다시 호출하면 전부 성공했다
|
||||||
|
* (즉시 1건 / 200ms 후 3건). 재시도가 없으면 그 한 번이 그대로 게시 실패가 되어
|
||||||
|
* 실패 마커와 대시보드 알림까지 올라간다.
|
||||||
|
*
|
||||||
|
* 재시도 자체는 native `rename` 이라 주입할 수 없으므로, **끝내 실패하는** 경우의
|
||||||
|
* 소요 시간으로 재시도 발생을 관측한다 — 단발 호출이면 대기 없이 즉시 끝난다.
|
||||||
|
* 이 단언이 없으면 상수를 1 로 되돌려도 다른 케이스는 전부 통과한다.
|
||||||
|
*
|
||||||
|
* @scenario publish_state=published, artifact_integrity=intact, filesystem_writable=writable, environment=production, trigger=manual_command, process_user=web
|
||||||
|
*
|
||||||
|
* @effects rename_failure_restores_previous_published_version
|
||||||
|
*/
|
||||||
|
public function test_directory_rename_is_retried_before_giving_up(): void
|
||||||
|
{
|
||||||
|
$service = $this->service();
|
||||||
|
$this->assertTrue($service->publishCurrent(), $this->publishDiagnostics($service));
|
||||||
|
|
||||||
|
$final = $service->versionDir(self::VERSION);
|
||||||
|
$old = $final.'.old';
|
||||||
|
|
||||||
|
// 위 A3 케이스와 같은 방식으로 자리를 **지속적으로** 점유해 rename 을 끝까지
|
||||||
|
// 실패시킨다 — 그래야 재시도가 전부 소진되고 그 대기가 소요 시간에 드러난다.
|
||||||
|
$fired = false;
|
||||||
|
$fs = File::partialMock();
|
||||||
|
$fs->shouldReceive('isDirectory')
|
||||||
|
->withArgs(fn ($path) => (string) $path === $old)
|
||||||
|
->andReturnUsing(function ($path) use (&$fired, $final) {
|
||||||
|
if (! $fired && ! is_dir($final)) {
|
||||||
|
$fired = true;
|
||||||
|
mkdir($final, 0775, true);
|
||||||
|
file_put_contents($final.DIRECTORY_SEPARATOR.'blocker', 'occupied');
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_dir($path);
|
||||||
|
});
|
||||||
|
$fs->shouldReceive('isDirectory')->andReturnUsing(fn ($path) => is_dir($path));
|
||||||
|
|
||||||
|
$startedAt = microtime(true);
|
||||||
|
$this->assertFalse($service->publishCurrent(force: true));
|
||||||
|
$elapsed = microtime(true) - $startedAt;
|
||||||
|
|
||||||
|
$this->assertTrue($fired, 'rename 실패를 유도하지 못했다 — 이 테스트는 공허 통과다');
|
||||||
|
|
||||||
|
// 시도 3회 = 재시도 대기 2회. 롤백 rename 도 같은 헬퍼를 타므로 실제 대기는
|
||||||
|
// 이보다 길지만, 하한만 단언해 머신 속도에 좌우되지 않게 한다.
|
||||||
|
$this->assertGreaterThanOrEqual(
|
||||||
|
0.4,
|
||||||
|
$elapsed,
|
||||||
|
'rename 이 재시도 없이 즉시 포기했다 — 일시 거부가 그대로 게시 실패가 된다'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GC — 삭제에 실패한 디렉토리는 경고를 남기고 **삭제 카운트에서 제외**된다 (A5).
|
||||||
|
*
|
||||||
|
* 반환값을 검사하지 않으면 소유권 불일치로 삭제가 실패해도 "N개 삭제" 로 보고되어,
|
||||||
|
* 구버전·고아 tmp 가 무한 누적되는 동안 운영자에게는 정상으로 보인다.
|
||||||
|
*
|
||||||
|
* @effects cleanup_excludes_failed_deletions_from_count
|
||||||
|
*/
|
||||||
|
public function test_cleanup_excludes_failed_deletion_from_count(): void
|
||||||
|
{
|
||||||
|
$service = $this->service();
|
||||||
|
$this->assertTrue($service->publishCurrent(), $this->publishDiagnostics($service));
|
||||||
|
|
||||||
|
foreach ([100, 200, 300] as $old) {
|
||||||
|
File::ensureDirectoryExists($service->versionDir($old));
|
||||||
|
File::put($service->versionDir($old).'/manifest.json', '{}');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 삭제 대상은 100·200 두 개. 그중 100 의 삭제만 실패시킨다.
|
||||||
|
$undeletable = $service->versionDir(100);
|
||||||
|
$real = new Filesystem;
|
||||||
|
|
||||||
|
Log::spy();
|
||||||
|
|
||||||
|
$fs = File::partialMock();
|
||||||
|
$fs->shouldReceive('deleteDirectory')
|
||||||
|
->withArgs(fn ($dir) => (string) $dir === $undeletable)
|
||||||
|
->andReturn(false);
|
||||||
|
$fs->shouldReceive('deleteDirectory')
|
||||||
|
->andReturnUsing(fn ($dir, $preserve = false) => $real->deleteDirectory($dir, $preserve));
|
||||||
|
|
||||||
|
$deleted = $service->cleanup();
|
||||||
|
|
||||||
|
$this->assertSame(1, $deleted, '삭제에 실패한 디렉토리가 삭제 건수에 집계됐다 — 잔존물 누적이 정상으로 보인다');
|
||||||
|
$this->assertDirectoryDoesNotExist($service->versionDir(200));
|
||||||
|
$this->assertDirectoryExists($service->versionDir(self::VERSION));
|
||||||
|
$this->assertDirectoryExists($service->versionDir(300));
|
||||||
|
|
||||||
|
Log::shouldHaveReceived('warning')->withArgs(
|
||||||
|
fn ($message, $context = []) => str_contains((string) $message, '정적 게시 디렉토리 삭제 실패')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 락 미획득(경합) — `Log::debug` 만 남고 실패 마커는 기록하지 않는다 (O2 a).
|
||||||
|
*
|
||||||
|
* 다른 프로세스가 게시 중인 것은 정상 상황이다. 마커를 남기면 정상적인 동시 요청
|
||||||
|
* 경합이 실패로 집계돼 진단 표면에 거짓 장애가 뜬다.
|
||||||
|
*
|
||||||
|
* @scenario publish_state=unpublished, artifact_integrity=intact, filesystem_writable=writable, environment=production, trigger=lifecycle, process_user=web
|
||||||
|
*
|
||||||
|
* @effects lock_contention_skips_without_failure_marker
|
||||||
|
*/
|
||||||
|
public function test_lock_contention_skips_without_failure_marker(): void
|
||||||
|
{
|
||||||
|
// 다른 프로세스가 이미 게시 중인 상태 — 같은 버전의 락을 선점한다.
|
||||||
|
$holder = Cache::lock('ext-static.publish.'.self::VERSION, 300);
|
||||||
|
$this->assertTrue($holder->get(), '테스트가 락을 선점하지 못했다 — 경합 상황을 만들 수 없다');
|
||||||
|
|
||||||
|
Log::spy();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$service = $this->service();
|
||||||
|
|
||||||
|
$this->assertFalse($service->publishCurrent());
|
||||||
|
$this->assertFalse($service->isPublished(self::VERSION));
|
||||||
|
$this->assertNull(
|
||||||
|
ExtensionStaticCacheService::failureMarker(),
|
||||||
|
'정상 경합이 실패 마커로 집계됐다 — 진단 표면에 거짓 장애가 뜬다'
|
||||||
|
);
|
||||||
|
|
||||||
|
Log::shouldHaveReceived('debug')->withArgs(
|
||||||
|
fn ($message, $context = []) => str_contains((string) $message, '정적 게시 락 미획득')
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
$holder->release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 락 획득이 **예외를 던지면** 경고 + `lock_unavailable` 마커를 남긴다 (O2 b).
|
||||||
|
*
|
||||||
|
* 락을 제공하지 못하는 캐시 저장소(락 미지원 드라이버, 파일 캐시 디렉토리 권한
|
||||||
|
* 불일치)에서는 게시가 매 요청 조용히 스킵된다 — 사유가 마커에 남지 않으면 운영자가
|
||||||
|
* 그 사실을 알 방법이 로그뿐이다.
|
||||||
|
*
|
||||||
|
* @scenario publish_state=unpublished, artifact_integrity=intact, filesystem_writable=writable, environment=production, trigger=lifecycle, process_user=web
|
||||||
|
*
|
||||||
|
* @effects lock_provider_failure_recorded_in_marker
|
||||||
|
*/
|
||||||
|
public function test_lock_provider_failure_records_marker(): void
|
||||||
|
{
|
||||||
|
// 마커 스토어를 먼저 확정시킨다(프로세스 1회 메모이즈) — 아래에서 기본 스토어를
|
||||||
|
// 갈아끼워도 마커 기록/조회는 같은 스토어를 계속 쓴다.
|
||||||
|
$this->assertSame(self::VERSION, ExtensionStaticCacheService::getExtensionCacheVersion());
|
||||||
|
|
||||||
|
Cache::extend('g7-lockless', fn () => new class(new ArrayStore) extends Repository
|
||||||
|
{
|
||||||
|
public function lock($name, $seconds = 0, $owner = null)
|
||||||
|
{
|
||||||
|
throw new \BadMethodCallException('This cache store does not support locking.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
config([
|
||||||
|
'cache.stores.g7-lockless' => ['driver' => 'g7-lockless'],
|
||||||
|
'cache.default' => 'g7-lockless',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Log::spy();
|
||||||
|
|
||||||
|
$service = $this->service();
|
||||||
|
|
||||||
|
$this->assertFalse($service->publishCurrent());
|
||||||
|
$this->assertFalse($service->isPublished(self::VERSION));
|
||||||
|
|
||||||
|
$marker = ExtensionStaticCacheService::failureMarker();
|
||||||
|
|
||||||
|
$this->assertIsArray($marker, '락 저장소 장애가 마커에 남지 않았다 — 진단 표면이 영구 침묵한다');
|
||||||
|
$this->assertSame('lock_unavailable', $marker['reason']);
|
||||||
|
$this->assertSame(self::VERSION, $marker['version']);
|
||||||
|
$this->assertStringContainsString('locking', $marker['message']);
|
||||||
|
|
||||||
|
Log::shouldHaveReceived('warning')->withArgs(
|
||||||
|
fn ($message, $context = []) => str_contains((string) $message, '정적 게시 락 획득 불가')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게시 트리는 그룹 쓰기(`g+w`)로 정합화된다 (P2 후반).
|
||||||
|
*
|
||||||
|
* 제보 본건은 **비-root CLI 계정 ≠ 웹 계정** 이었다. CLI 최초 게시가 트리를
|
||||||
|
* `0755 deploy:deploy` 로 굳히면 이후 웹(php-fpm) 재게시가 영구 실패한다.
|
||||||
|
*
|
||||||
|
* POSIX 미지원 환경(Windows)에서는 권한 비트를 관측할 수 없다 — 그 경우에도 검사를
|
||||||
|
* 건너뛰지 않고 **계약 자체**(디렉토리 생성이 명시 chmod 를 거치는가, 정합화가 root
|
||||||
|
* 갈래와 항상-실행 갈래를 모두 갖는가)를 소스 수준에서 고정한다. 항상-실행 갈래가
|
||||||
|
* root 조건 안으로 들어가면 종전(비-root no-op) 동작으로 조용히 되돌아간다.
|
||||||
|
*
|
||||||
|
* @scenario publish_state=published, artifact_integrity=intact, filesystem_writable=writable, environment=production, trigger=manual_command, process_user=web
|
||||||
|
*
|
||||||
|
* @effects published_tree_is_group_writable
|
||||||
|
*/
|
||||||
|
public function test_published_tree_is_group_writable(): void
|
||||||
|
{
|
||||||
|
$this->createActiveTemplate();
|
||||||
|
|
||||||
|
$service = $this->service();
|
||||||
|
$this->assertTrue($service->publishCurrent(), $this->publishDiagnostics($service));
|
||||||
|
|
||||||
|
$source = (string) file_get_contents(
|
||||||
|
(new \ReflectionClass(ExtensionStaticCacheService::class))->getFileName()
|
||||||
|
);
|
||||||
|
|
||||||
|
// ① 디렉토리 생성은 umask 를 무력화하는 명시 chmod 를 거친다.
|
||||||
|
$makeDirectory = $this->methodBody($source, 'makeDirectory');
|
||||||
|
$this->assertNotSame('', $makeDirectory, 'makeDirectory 를 소스에서 찾지 못했다 — 검사가 공허하다');
|
||||||
|
$this->assertStringContainsString(
|
||||||
|
'@chmod($dir, self::PUBLISH_DIR_MODE)',
|
||||||
|
$makeDirectory,
|
||||||
|
'ensureDirectoryExists 의 mode 는 umask 로 깎인다 — 명시 chmod 가 없으면 0755 로 굳는다'
|
||||||
|
);
|
||||||
|
$this->assertStringContainsString(
|
||||||
|
'FilePermissionHelper::inheritOwnershipFromParent($dir)',
|
||||||
|
$makeDirectory
|
||||||
|
);
|
||||||
|
|
||||||
|
// ② 정합화는 root 갈래와 항상-실행 갈래를 모두 갖는다.
|
||||||
|
$normalize = $this->methodBody($source, 'normalizeOwnership');
|
||||||
|
$this->assertNotSame('', $normalize, 'normalizeOwnership 을 소스에서 찾지 못했다 — 검사가 공허하다');
|
||||||
|
$this->assertStringContainsString('posix_geteuid() === 0', $normalize, 'root 갈래(sudo CLI 게시 대응)가 사라졌다');
|
||||||
|
$this->assertStringContainsString('FilePermissionHelper::chownRecursive(', $normalize);
|
||||||
|
|
||||||
|
// 들여쓰기 8칸 = 메서드 본문 최상위. root 조건 블록(12칸) 안으로 들어가면
|
||||||
|
// 비-root CLI 계정에서 종전처럼 no-op 이 된다.
|
||||||
|
$this->assertStringContainsString(
|
||||||
|
"\n FilePermissionHelper::inheritOwnershipFromParent(\$base);",
|
||||||
|
$normalize,
|
||||||
|
'소유권 상속이 root 갈래 안으로 들어갔다 — 비-root CLI 게시가 다시 무방비가 된다'
|
||||||
|
);
|
||||||
|
$this->assertStringContainsString(
|
||||||
|
"\n FilePermissionHelper::syncGroupWritabilityDetailed(\$base, force: true);",
|
||||||
|
$normalize,
|
||||||
|
'g+w 승격이 root 갈래 안으로 들어갔다 — 그룹을 공유하는 웹 계정이 재게시할 수 없다'
|
||||||
|
);
|
||||||
|
|
||||||
|
// ③ POSIX 환경에서는 실제 권한 비트까지 확인한다.
|
||||||
|
if (windows_os() || ! function_exists('posix_geteuid')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearstatcache();
|
||||||
|
$versionDir = $service->versionDir(self::VERSION);
|
||||||
|
$nested = $versionDir.'/templates/sirsoft-admin_basic';
|
||||||
|
|
||||||
|
foreach (array_filter([$service->baseDir(), $versionDir, is_dir($nested) ? $nested : null]) as $dir) {
|
||||||
|
$this->assertSame(
|
||||||
|
0020,
|
||||||
|
@fileperms($dir) & 0020,
|
||||||
|
"게시 트리에 그룹 쓰기 비트가 없다: {$dir} — 웹 계정의 재게시가 영구 실패한다"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 실패 마커의 백오프는 **같은 버전**만 억제한다.
|
||||||
|
*
|
||||||
|
* 버전이 오르면 그 버전은 아직 한 번도 시도된 적이 없다. 이전 버전의 마커로 막으면
|
||||||
|
* "캐시 버전 갱신 → 게시" 규율이 마커 TTL 창(최대 300초) 동안 도로 끊긴다.
|
||||||
|
*
|
||||||
|
* @scenario publish_state=unpublished, artifact_integrity=intact, filesystem_writable=writable, environment=production, trigger=lifecycle, process_user=web
|
||||||
|
*
|
||||||
|
* @effects publish_failure_backoff_is_scoped_to_version
|
||||||
|
*/
|
||||||
|
public function test_previous_version_failure_marker_does_not_suppress_new_version(): void
|
||||||
|
{
|
||||||
|
$this->app['env'] = 'production';
|
||||||
|
ExtensionStaticCacheService::resetPublishScheduleForTesting();
|
||||||
|
ExtensionStaticCacheService::fakeRootProcessForTesting(false);
|
||||||
|
|
||||||
|
// 이전 버전(VERSION)의 신선한 실패 마커
|
||||||
|
Cache::put('g7:core:ext.static.publish_failure', [
|
||||||
|
'version' => self::VERSION,
|
||||||
|
'at' => now()->toIso8601String(),
|
||||||
|
'reason' => 'parent_not_writable',
|
||||||
|
'count' => 2,
|
||||||
|
'message' => 'denied',
|
||||||
|
], 300);
|
||||||
|
|
||||||
|
// 버전 bump — 이 버전은 아직 한 번도 시도된 적이 없다.
|
||||||
|
Cache::put('g7:core:ext.cache_version', self::VERSION + 1);
|
||||||
|
|
||||||
|
ExtensionStaticCacheService::schedulePublishOnTerminate();
|
||||||
|
|
||||||
|
$this->assertTrue(
|
||||||
|
ExtensionStaticCacheService::isPublishScheduledForTesting(),
|
||||||
|
'이전 버전의 마커가 새 버전의 게시 예약을 막았다 — 버전 갱신 → 게시 규율이 TTL 창 동안 끊긴다'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->app->terminate();
|
||||||
|
|
||||||
|
$this->assertTrue($this->service()->isPublished(self::VERSION + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 소스에서 메서드 본문을 잘라냅니다 (다음 메서드 선언 직전까지).
|
||||||
|
*
|
||||||
|
* @param string $content 파일 전체 내용
|
||||||
|
* @param string $method 메서드명
|
||||||
|
* @return string 본문 (찾지 못하면 빈 문자열)
|
||||||
|
*/
|
||||||
|
private function methodBody(string $content, string $method): string
|
||||||
|
{
|
||||||
|
$start = strpos($content, "function {$method}(");
|
||||||
|
|
||||||
|
if ($start === false) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$rest = substr($content, $start);
|
||||||
|
$next = preg_match(
|
||||||
|
'/\n (?:public|protected|private)\s+(?:static\s+)?function\s/',
|
||||||
|
substr($rest, 1),
|
||||||
|
$m,
|
||||||
|
PREG_OFFSET_CAPTURE
|
||||||
|
);
|
||||||
|
|
||||||
|
return $next === 1 ? substr($rest, 0, $m[0][1] + 1) : $rest;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -586,7 +586,7 @@ class TemplateAssetServingTest extends TestCase
|
|||||||
/**
|
/**
|
||||||
* 개발 환경에서 components.json 은 no-cache (파일 수정 즉시 반영 — F10)
|
* 개발 환경에서 components.json 은 no-cache (파일 수정 즉시 반영 — F10)
|
||||||
*
|
*
|
||||||
* @scenario publish_state=unpublished, environment=dev, trigger=self_heal, process_user=web
|
* @scenario publish_state=unpublished, artifact_integrity=intact, filesystem_writable=writable, environment=dev, trigger=self_heal, process_user=web
|
||||||
*
|
*
|
||||||
* @effects fallback_api_no_cache_in_dev
|
* @effects fallback_api_no_cache_in_dev
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ class TemplateLanguageServingTest extends TestCase
|
|||||||
/**
|
/**
|
||||||
* 개발 환경에서 lang 은 no-cache (파일 수정 즉시 반영 — F10)
|
* 개발 환경에서 lang 은 no-cache (파일 수정 즉시 반영 — F10)
|
||||||
*
|
*
|
||||||
* @scenario publish_state=unpublished, environment=dev, trigger=manual_command, process_user=web
|
* @scenario publish_state=unpublished, artifact_integrity=intact, filesystem_writable=writable, environment=dev, trigger=manual_command, process_user=web
|
||||||
*/
|
*/
|
||||||
public function test_language_no_cache_in_development(): void
|
public function test_language_no_cache_in_development(): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* ~500KB 중복 / 부트 ~1.3s 연장이 발생했다. blade 주입 cache_version 시드가
|
* ~500KB 중복 / 부트 ~1.3s 연장이 발생했다. blade 주입 cache_version 시드가
|
||||||
* 이를 제거했음을 실브라우저에서 잠근다.
|
* 이를 제거했음을 실브라우저에서 잠근다.
|
||||||
*
|
*
|
||||||
* @scenario publish_state=published, environment=production, trigger=lifecycle, process_user=web
|
* @scenario publish_state=published, artifact_integrity=intact, filesystem_writable=writable, environment=production, trigger=lifecycle, process_user=web
|
||||||
* @effects no_duplicate_boot_requests, versioned_boot_urls
|
* @effects no_duplicate_boot_requests, versioned_boot_urls
|
||||||
*/
|
*/
|
||||||
import { test, expect, type Page } from '@playwright/test';
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
* env 가드 — 대상 사이트가 정적 게시 미적용(비프로덕션/kill-switch/미게시)이면
|
* env 가드 — 대상 사이트가 정적 게시 미적용(비프로덕션/kill-switch/미게시)이면
|
||||||
* staticBase 미주입으로 skip 된다 (첫 방문이 자가 치유를 예약하므로 워밍 1회 수행).
|
* staticBase 미주입으로 skip 된다 (첫 방문이 자가 치유를 예약하므로 워밍 1회 수행).
|
||||||
*
|
*
|
||||||
* @scenario publish_state=partial, environment=production, trigger=self_heal, process_user=web
|
* @scenario publish_state=partial, artifact_integrity=intact, filesystem_writable=writable, environment=production, trigger=self_heal, process_user=web
|
||||||
* @effects static_first_fetch_falls_back_to_api_on_miss, fallback_is_observable_via_console_warn
|
* @effects static_first_fetch_falls_back_to_api_on_miss, fallback_is_observable_via_console_warn
|
||||||
*/
|
*/
|
||||||
import { test, expect, type Page } from '@playwright/test';
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
@@ -33,7 +33,7 @@ async function probeStaticBase(page: Page): Promise<string | null> {
|
|||||||
|
|
||||||
test.describe('정적 게시 fast path + 폴백 (#122)', () => {
|
test.describe('정적 게시 fast path + 폴백 (#122)', () => {
|
||||||
/**
|
/**
|
||||||
* @scenario publish_state=published, environment=production, trigger=self_heal, process_user=web
|
* @scenario publish_state=published, artifact_integrity=intact, filesystem_writable=writable, environment=production, trigger=self_heal, process_user=web
|
||||||
* @effects versioned_boot_urls
|
* @effects versioned_boot_urls
|
||||||
*/
|
*/
|
||||||
test('@smoke 정적 URL 로 부트 리소스를 수신한다 (static-first)', async ({ page }) => {
|
test('@smoke 정적 URL 로 부트 리소스를 수신한다 (static-first)', async ({ page }) => {
|
||||||
@@ -109,4 +109,96 @@ test.describe('정적 게시 fast path + 폴백 (#122)', () => {
|
|||||||
// 폴백은 조용하지 않다 — console warn 으로 관측 가능
|
// 폴백은 조용하지 않다 — console warn 으로 관측 가능
|
||||||
expect(warns.some((w) => w.includes('fetchStaticFirst')), `warns: ${warns.join(' | ')}`).toBe(true);
|
expect(warns.some((w) => w.includes('fetchStaticFirst')), `warns: ${warns.join(' | ')}`).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 정적 응답이 **200 인데 본문이 손상**된 경우에도 폴백해 화면이 정상이어야 한다.
|
||||||
|
*
|
||||||
|
* 디스크 풀/quota 로 절단된 게시본은 웹서버가 정상 200 으로 서빙한다. `response.ok`
|
||||||
|
* 만 보던 종전 구현은 폴백하지 못한 채 `response.json()` 이 던져 부팅 전체가 실패했다 —
|
||||||
|
* 3층 폴백이 유일하게 개입하지 못하던 경로다.
|
||||||
|
*
|
||||||
|
* @scenario publish_state=partial, artifact_integrity=absent, filesystem_writable=writable, environment=production, trigger=self_heal, process_user=web
|
||||||
|
* @effects truncated_artifact_rejected_before_manifest, static_first_fetch_falls_back_to_api_on_miss
|
||||||
|
*/
|
||||||
|
test('@smoke 정적 200 + 손상 본문이어도 폴백으로 화면이 정상 렌더된다', async ({ page }) => {
|
||||||
|
const base = await probeStaticBase(page);
|
||||||
|
test.skip(base === null, '대상 사이트에 정적 게시 미적용 (staticBase 미주입)');
|
||||||
|
|
||||||
|
const warns: string[] = [];
|
||||||
|
page.on('console', (message) => {
|
||||||
|
if (message.type() === 'warning') warns.push(message.text());
|
||||||
|
});
|
||||||
|
|
||||||
|
let corrupted = 0;
|
||||||
|
const apiFallbacks: string[] = [];
|
||||||
|
|
||||||
|
// 404 가 아니라 **200 + 잘린 JSON** 을 돌려준다 — 종전 구현이 통과시키던 형태.
|
||||||
|
await page.route(
|
||||||
|
(url) => /\/build\/ext\/\d+\/templates\/[^/]+\/(routes|components|lang\/[a-z-]+)\.json$/.test(url.pathname),
|
||||||
|
(route) => {
|
||||||
|
corrupted += 1;
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: '{"success":true,"data":{"messa',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
page.on('request', (request) => {
|
||||||
|
const path = new URL(request.url()).pathname;
|
||||||
|
if (/^\/api\/templates\/[^/]+\/(routes|components|lang\/[a-z-]+)(\.json)?$/.test(path)) {
|
||||||
|
apiFallbacks.push(request.url());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => (document.querySelector('#app')?.childElementCount ?? 0) > 0,
|
||||||
|
{ timeout: 30_000 }
|
||||||
|
);
|
||||||
|
await page.waitForLoadState('networkidle', { timeout: 30_000 });
|
||||||
|
|
||||||
|
expect(corrupted).toBeGreaterThan(0);
|
||||||
|
expect(apiFallbacks.length, `API 폴백: ${apiFallbacks.join(', ')}`).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const text = await page.evaluate(() => document.body.innerText.trim());
|
||||||
|
expect(text).not.toMatch(/초기화 실패|페이지 로딩 실패/);
|
||||||
|
expect(text.length).toBeGreaterThan(20);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
warns.some((w) => w.includes('malformed JSON')),
|
||||||
|
`warns: ${warns.join(' | ')}`
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 정적 미스는 SPA 셸 HTML 이 아니라 404 를 받아야 한다 — 확장자 변종 포함.
|
||||||
|
*
|
||||||
|
* `mjs` · `webp` · `otf` 는 에셋 서빙이 허용하는데 catch-all 제외 목록에는 없었다.
|
||||||
|
* 없는 `.mjs` 가 `Content-Type: text/html` 인 200 을 받으면 브라우저는 그것을
|
||||||
|
* 스크립트로 파싱하다 죽는데, 응답이 성공이라 `onerror` 도 발화하지 않는다.
|
||||||
|
*
|
||||||
|
* @effects static_miss_returns_404_for_every_servable_extension
|
||||||
|
*/
|
||||||
|
test('@smoke 없는 정적 자산은 확장자 변종에서도 404 다 (HTML 200 아님)', async ({ page }) => {
|
||||||
|
const base = await probeStaticBase(page);
|
||||||
|
test.skip(base === null, '대상 사이트에 정적 게시 미적용 (staticBase 미주입)');
|
||||||
|
|
||||||
|
for (const extension of ['mjs', 'webp', 'otf', 'js', 'css', 'json']) {
|
||||||
|
const response = await page.request.get(
|
||||||
|
`${base}/templates/sirsoft-basic/assets/__missing__.${extension}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const contentType = response.headers()['content-type'] ?? '';
|
||||||
|
|
||||||
|
// 계약은 **상태 코드**다. 404 응답의 본문이 HTML 인 것은 정상이며(라라벨 오류 페이지),
|
||||||
|
// 문제는 "없는 자산에 200 이 나가는 것" 이었다 — 200 이면 브라우저가 그 HTML 을
|
||||||
|
// 스크립트로 파싱하다 죽고, 응답이 성공이라 onerror 도 발화하지 않는다.
|
||||||
|
expect(
|
||||||
|
response.status(),
|
||||||
|
`.${extension} 가 ${response.status()} (content-type: ${contentType}) — SPA 셸 200 이 반환되면 스크립트 파싱이 죽는다`
|
||||||
|
).toBe(404);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Build;
|
||||||
|
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 빌드 산출물 디렉토리 계약 테스트 (#122 B1).
|
||||||
|
*
|
||||||
|
* vite 의 `build.emptyOutDir` 기본값은 `true` 다 — 산출물 디렉토리를 통째로 비운다.
|
||||||
|
* 그런데 그 디렉토리에는 vite 가 만들지 않는 **서빙 자산**이 함께 산다:
|
||||||
|
* `public/build/core/` 3번들(폴백 없는 동기 classic 스크립트), `public/build/ext/{v}/`
|
||||||
|
* 게시본(이미 배달된 HTML 의 immutable URL), 확장 `dist/vendor/`(동봉 제3자 자산).
|
||||||
|
* 소실은 예외도 서버 로그도 남기지 않고 브라우저 404 로만 나타난다.
|
||||||
|
*
|
||||||
|
* 픽스처 기반 정적 검사가 판정식을 잠그고, 이 테스트는 **저장소의 실제 상태**를
|
||||||
|
* 계약으로 고정한다 — git 추적 대상 vite config 전부가 `emptyOutDir` 을 명시 선언하고
|
||||||
|
* 그 값이 `false` 여야 한다.
|
||||||
|
*/
|
||||||
|
class ViteOutDirContractTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 모든 git 추적 vite config 가 `emptyOutDir: false` 를 명시 선언한다.
|
||||||
|
*
|
||||||
|
* @effects vite_configs_declare_empty_outdir_false
|
||||||
|
*/
|
||||||
|
public function test_every_tracked_vite_config_declares_empty_outdir_false(): void
|
||||||
|
{
|
||||||
|
$configs = $this->trackedViteConfigs();
|
||||||
|
|
||||||
|
$this->assertNotEmpty($configs, 'vite config 를 하나도 찾지 못했다 — 이 계약 검사가 공허하다');
|
||||||
|
$this->assertContains(
|
||||||
|
'vite.config.js',
|
||||||
|
$configs,
|
||||||
|
'루트 vite config 가 목록에 없다 — 열거가 저장소 실제 상태를 반영하지 못한다'
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($configs as $relative) {
|
||||||
|
$source = $this->stripComments((string) file_get_contents(base_path($relative)));
|
||||||
|
|
||||||
|
$this->assertMatchesRegularExpression(
|
||||||
|
'/\bemptyOutDir\s*:/',
|
||||||
|
$source,
|
||||||
|
"{$relative} 이 emptyOutDir 을 선언하지 않았다 — 기본값 true 로 산출물 디렉토리를 통째로 비운다"
|
||||||
|
);
|
||||||
|
|
||||||
|
preg_match_all('/\bemptyOutDir\s*:\s*([A-Za-z0-9_.]+)/', $source, $matches);
|
||||||
|
|
||||||
|
foreach ($matches[1] as $value) {
|
||||||
|
$this->assertSame(
|
||||||
|
'false',
|
||||||
|
$value,
|
||||||
|
"{$relative} 의 emptyOutDir 이 false 가 아니다 — 함께 사는 서빙 자산(코어 3번들 · 게시본 · 동봉 vendor)이 빌드마다 소실된다"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* git 추적 대상 vite config 의 저장소 상대 경로 목록을 반환합니다.
|
||||||
|
*
|
||||||
|
* git 을 쓸 수 없는 환경에서는 추적 대상이 실제로 놓이는 위치(루트 · `_bundled`)를
|
||||||
|
* 직접 훑는다 — 활성 확장 디렉토리 사본(git 미추적)은 계약 대상이 아니다.
|
||||||
|
*
|
||||||
|
* @return array<int, string> 상대 경로 (구분자 `/`)
|
||||||
|
*/
|
||||||
|
private function trackedViteConfigs(): array
|
||||||
|
{
|
||||||
|
$output = [];
|
||||||
|
$status = 1;
|
||||||
|
|
||||||
|
@exec('git -C '.escapeshellarg(base_path()).' ls-files -- '.escapeshellarg('*vite.config*'), $output, $status);
|
||||||
|
|
||||||
|
if ($status === 0 && $output !== []) {
|
||||||
|
$tracked = array_values(array_filter(
|
||||||
|
array_map(fn ($line) => trim(str_replace('\\', '/', (string) $line)), $output),
|
||||||
|
fn ($line) => (bool) preg_match('#(^|/)vite\.config[^/]*\.(js|ts)$#', $line)
|
||||||
|
));
|
||||||
|
|
||||||
|
if ($tracked !== []) {
|
||||||
|
sort($tracked);
|
||||||
|
|
||||||
|
return $tracked;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$patterns = [
|
||||||
|
base_path('vite.config*.js'),
|
||||||
|
base_path('vite.config*.ts'),
|
||||||
|
base_path('modules/_bundled/*/vite.config*.js'),
|
||||||
|
base_path('modules/_bundled/*/vite.config*.ts'),
|
||||||
|
base_path('plugins/_bundled/*/vite.config*.js'),
|
||||||
|
base_path('plugins/_bundled/*/vite.config*.ts'),
|
||||||
|
base_path('templates/_bundled/*/vite.config*.js'),
|
||||||
|
base_path('templates/_bundled/*/vite.config*.ts'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$found = [];
|
||||||
|
|
||||||
|
foreach ($patterns as $pattern) {
|
||||||
|
foreach (glob($pattern) ?: [] as $path) {
|
||||||
|
$found[] = ltrim(str_replace('\\', '/', substr($path, strlen(base_path()))), '/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort($found);
|
||||||
|
|
||||||
|
return $found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 주석을 제거합니다 — 문서 주석 안의 예시 표기가 선언으로 오인되지 않게 한다.
|
||||||
|
*
|
||||||
|
* @param string $source 원본 소스
|
||||||
|
* @return string 주석이 제거된 소스
|
||||||
|
*/
|
||||||
|
private function stripComments(string $source): string
|
||||||
|
{
|
||||||
|
$withoutBlocks = preg_replace('#/\*.*?\*/#s', '', $source) ?? $source;
|
||||||
|
|
||||||
|
return preg_replace('#//[^\n]*#', '', $withoutBlocks) ?? $withoutBlocks;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ use App\Enums\ExtensionStatus;
|
|||||||
use App\Extension\Cache\PluginCacheDriver;
|
use App\Extension\Cache\PluginCacheDriver;
|
||||||
use App\Extension\Traits\ClearsTemplateCaches;
|
use App\Extension\Traits\ClearsTemplateCaches;
|
||||||
use App\Models\Template;
|
use App\Models\Template;
|
||||||
|
use App\Services\ExtensionStaticCacheService;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
@@ -285,4 +286,39 @@ class ClearsTemplateCachesTest extends TestCase
|
|||||||
$this->assertGreaterThan(0, $newVersion);
|
$this->assertGreaterThan(0, $newVersion);
|
||||||
$this->assertLessThanOrEqual(time(), $newVersion);
|
$this->assertLessThanOrEqual(time(), $newVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 버전 **재생성** 경로도 정적 게시를 예약한다 (D1).
|
||||||
|
*
|
||||||
|
* 회귀 가드: `incrementExtensionCacheVersion()` 에만 예약이 있고 재생성 경로에는
|
||||||
|
* 없었다. 그래서 `cache:clear` 후 첫 호출자가 CLI 면 포인터만 새 버전으로 점프하고
|
||||||
|
* 산출물은 옛 버전에 남았다 — "캐시 버전 갱신 → 게시" 단일 지점 규율의 유일한 구멍.
|
||||||
|
* 스케줄 GC 가 바로 그 첫 호출자라 매일 재현됐다.
|
||||||
|
*
|
||||||
|
* @effects regenerate_path_schedules_publish
|
||||||
|
*/
|
||||||
|
public function test_regenerate_path_schedules_static_publish(): void
|
||||||
|
{
|
||||||
|
$this->app['env'] = 'production';
|
||||||
|
ExtensionStaticCacheService::resetPublishScheduleForTesting();
|
||||||
|
ExtensionStaticCacheService::fakeRootProcessForTesting(false);
|
||||||
|
Cache::forget('g7:core:ext.static.publish_failure');
|
||||||
|
|
||||||
|
// 키 부재 → getExtensionCacheVersion 이 재생성 경로를 탄다.
|
||||||
|
Cache::forget('g7:core:ext.cache_version');
|
||||||
|
|
||||||
|
$this->assertFalse(
|
||||||
|
ExtensionStaticCacheService::isPublishScheduledForTesting(),
|
||||||
|
'사전 조건 오염 — 예약 플래그가 이미 서 있다'
|
||||||
|
);
|
||||||
|
|
||||||
|
ClearsTemplateCaches::getExtensionCacheVersion();
|
||||||
|
|
||||||
|
$this->assertTrue(
|
||||||
|
ExtensionStaticCacheService::isPublishScheduledForTesting(),
|
||||||
|
'재생성 경로가 게시를 예약하지 않았다 — 포인터만 점프하고 산출물이 옛 버전에 남는다'
|
||||||
|
);
|
||||||
|
|
||||||
|
ExtensionStaticCacheService::resetPublishScheduleForTesting();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,11 +100,74 @@ class ExtensionBundleServiceTest extends TestCase
|
|||||||
return $ext;
|
return $ext;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 에셋 **매니페스트 선언**(`getAssets()`)을 노출하는 가짜 확장 인스턴스를 만든다.
|
||||||
|
*
|
||||||
|
* 산출물 경로(`getBuiltAssetAbsolutePaths`)는 존재하지 않는 파일을 가리킨다 —
|
||||||
|
* "선언은 있는데 산출물이 없다"(dist 소실) 상태를 그대로 재현하기 위해서다.
|
||||||
|
*
|
||||||
|
* @param string $identifier 확장 식별자
|
||||||
|
* @param int $priority 로딩 우선순위
|
||||||
|
* @param array<string, mixed> $assets 매니페스트 assets 선언
|
||||||
|
* @param string $strategy 로딩 전략
|
||||||
|
*/
|
||||||
|
private function fakeExtensionWithAssets(string $identifier, int $priority, array $assets, string $strategy = 'global'): object
|
||||||
|
{
|
||||||
|
$ext = Mockery::mock();
|
||||||
|
$ext->shouldReceive('hasAssets')->andReturn($assets !== []);
|
||||||
|
$ext->shouldReceive('getIdentifier')->andReturn($identifier);
|
||||||
|
$ext->shouldReceive('getAssetLoadingConfig')->andReturn([
|
||||||
|
'strategy' => $strategy,
|
||||||
|
'priority' => $priority,
|
||||||
|
'dependencies' => [],
|
||||||
|
]);
|
||||||
|
$ext->shouldReceive('getAssets')->andReturn($assets);
|
||||||
|
$ext->shouldReceive('getBuiltAssetAbsolutePaths')->andReturn(
|
||||||
|
array_map(fn () => $this->fixtureDir.'/missing-'.$identifier.'.out', $assets)
|
||||||
|
);
|
||||||
|
|
||||||
|
return $ext;
|
||||||
|
}
|
||||||
|
|
||||||
private function service(): ExtensionBundleService
|
private function service(): ExtensionBundleService
|
||||||
{
|
{
|
||||||
return new ExtensionBundleService($this->moduleManager, $this->pluginManager);
|
return new ExtensionBundleService($this->moduleManager, $this->pluginManager);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 선언 수는 **매니페스트 기준**이다 — 산출물 파일 존재 여부와 무관하다 (E2).
|
||||||
|
*
|
||||||
|
* 이 값이 "실제로 병합된 확장 수" 로 계산되면 dist 가 통째로 비어 있을 때
|
||||||
|
* `기대 0 = 결과 0` 이 되어 장애가 정상(빈 200)으로 위장된다. 실측 A/B: dist 를
|
||||||
|
* 비우면 수정 전 `modules/bundle/js` 가 빈 200, 수정 후 503 이다.
|
||||||
|
*
|
||||||
|
* @effects asset_declaration_count_reads_manifest_not_built_files
|
||||||
|
*/
|
||||||
|
public function test_counts_asset_declaring_extensions_from_manifest_not_built_output(): void
|
||||||
|
{
|
||||||
|
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
|
||||||
|
'ext-js' => $this->fakeExtensionWithAssets('ext-js', 10, ['js' => ['output' => 'dist/js/ext-js.iife.js']]),
|
||||||
|
'ext-both' => $this->fakeExtensionWithAssets('ext-both', 20, [
|
||||||
|
'js' => ['output' => 'dist/js/ext-both.iife.js'],
|
||||||
|
'css' => ['output' => 'dist/css/ext-both.css'],
|
||||||
|
]),
|
||||||
|
'ext-none' => $this->fakeExtensionWithAssets('ext-none', 30, []),
|
||||||
|
// 병합 대상 모집단과 같은 필터 — global 전략이 아니면 세지 않는다
|
||||||
|
'ext-layout' => $this->fakeExtensionWithAssets('ext-layout', 40, [
|
||||||
|
'js' => ['output' => 'dist/js/ext-layout.iife.js'],
|
||||||
|
], 'layout'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service = $this->service();
|
||||||
|
|
||||||
|
$this->assertSame(2, $service->countAssetDeclaringExtensions('module', 'js'));
|
||||||
|
$this->assertSame(1, $service->countAssetDeclaringExtensions('module', 'css'));
|
||||||
|
|
||||||
|
// 산출물은 하나도 존재하지 않는다 → 병합 결과는 빈 문자열.
|
||||||
|
// "선언 > 0 && 결과 0" 이 곧 컨트롤러의 503 조건이다.
|
||||||
|
$this->assertSame('', $service->getBundleFilePath('module', 'js', 12345));
|
||||||
|
}
|
||||||
|
|
||||||
public function test_orders_global_assets_by_priority_ascending(): void
|
public function test_orders_global_assets_by_priority_ascending(): void
|
||||||
{
|
{
|
||||||
$a = $this->writeFixture('a.js', '(function(){})()');
|
$a = $this->writeFixture('a.js', '(function(){})()');
|
||||||
@@ -324,6 +387,69 @@ class ExtensionBundleServiceTest extends TestCase
|
|||||||
$this->assertFileExists($bundleDir.'/plugin.100.js');
|
$this->assertFileExists($bundleDir.'/plugin.100.js');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* clearBundles 는 **현재 버전을 보존**한다 (E3).
|
||||||
|
*
|
||||||
|
* 현재 버전까지 지우면 같은 순간 서빙 중인 웹 요청이 "존재함" 판정 직후
|
||||||
|
* `filemtime()` 에서 500 을 낸다(bump 직후 TOCTOU). `cleanupStaleBundles` 와
|
||||||
|
* 정책이 갈라져 있던 것이 원인이었다.
|
||||||
|
*
|
||||||
|
* @effects clear_bundles_preserves_current_version
|
||||||
|
*/
|
||||||
|
public function test_clear_bundles_preserves_current_version(): void
|
||||||
|
{
|
||||||
|
$bundleDir = storage_path('app/ext-bundles');
|
||||||
|
File::ensureDirectoryExists($bundleDir);
|
||||||
|
|
||||||
|
$current = $this->service()->getCurrentVersion();
|
||||||
|
File::put($bundleDir."/module.{$current}.js", 'current');
|
||||||
|
File::put($bundleDir.'/module.100.js', 'old');
|
||||||
|
|
||||||
|
$deleted = $this->service()->clearBundles('module');
|
||||||
|
|
||||||
|
$this->assertSame(1, $deleted);
|
||||||
|
$this->assertFileExists(
|
||||||
|
$bundleDir."/module.{$current}.js",
|
||||||
|
'현재 버전이 삭제됐다 — 서빙 중인 요청이 filemtime 에서 500 을 낸다'
|
||||||
|
);
|
||||||
|
$this->assertFileDoesNotExist($bundleDir.'/module.100.js');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GC 는 원자적 쓰기의 임시 파일(`*.tmp.{pid}`)도 정리한다 — 단 나이 가드를 지킨다 (E4).
|
||||||
|
*
|
||||||
|
* 종전에는 이 파일명이 번들 파일 패턴에 맞지 않아 GC 대상에서 통째로 빠졌고,
|
||||||
|
* rename 이 실패한 만큼 영구 잔존했다(실측 560개).
|
||||||
|
*
|
||||||
|
* @effects cleanup_removes_stale_atomic_write_temp_files
|
||||||
|
*/
|
||||||
|
public function test_cleanup_removes_stale_temp_bundle_files_only(): void
|
||||||
|
{
|
||||||
|
$bundleDir = storage_path('app/ext-bundles');
|
||||||
|
File::ensureDirectoryExists($bundleDir);
|
||||||
|
|
||||||
|
$fresh = $bundleDir.'/module.200.js.tmp.11111';
|
||||||
|
$stale = $bundleDir.'/module.200.js.tmp.22222';
|
||||||
|
File::put($fresh, 'writing now');
|
||||||
|
File::put($stale, 'orphan');
|
||||||
|
touch($stale, time() - 3600);
|
||||||
|
clearstatcache();
|
||||||
|
|
||||||
|
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$deleted = $this->service()->cleanupStaleBundles(200);
|
||||||
|
|
||||||
|
$this->assertFileExists($fresh, '진행 중인 쓰기의 임시 파일이 삭제됐다');
|
||||||
|
$this->assertFileDoesNotExist($stale, '고아 임시 파일이 정리되지 않았다 — 영구 누적된다');
|
||||||
|
$this->assertSame(1, $deleted);
|
||||||
|
} finally {
|
||||||
|
// 번들 디렉토리는 테스트 간 공유된다 — 남기면 형제 케이스의 삭제 건수가 틀어진다.
|
||||||
|
@unlink($fresh);
|
||||||
|
@unlink($stale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function test_plugin_bundle_orders_by_priority_gdpr_first_when_lowest(): void
|
public function test_plugin_bundle_orders_by_priority_gdpr_first_when_lowest(): void
|
||||||
{
|
{
|
||||||
// gdpr 가 priority 50 으로 최상단(제약 1 회귀 가드) — 이름 하드코딩 아닌 선언 결과
|
// gdpr 가 priority 50 으로 최상단(제약 1 회귀 가드) — 이름 하드코딩 아닌 선언 결과
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ class AssetUrlTest extends TestCase
|
|||||||
* 정적 게이트 3조건 — 프로덕션 + enabled + 게시 완료(manifest) 를 전부
|
* 정적 게이트 3조건 — 프로덕션 + enabled + 게시 완료(manifest) 를 전부
|
||||||
* 통과해야만 base 가 반환된다.
|
* 통과해야만 base 가 반환된다.
|
||||||
*
|
*
|
||||||
* @scenario publish_state=unpublished, environment=production, trigger=self_heal, process_user=web
|
* @scenario publish_state=unpublished, artifact_integrity=intact, filesystem_writable=writable, environment=production, trigger=self_heal, process_user=web
|
||||||
*
|
*
|
||||||
* @effects static_gate_requires_manifest, kill_switch_disables_publish_and_gate
|
* @effects static_gate_requires_manifest, kill_switch_disables_publish_and_gate
|
||||||
*/
|
*/
|
||||||
@@ -261,7 +261,7 @@ class AssetUrlTest extends TestCase
|
|||||||
* 태그 계층 파일 단위 게이트 — manifest 는 있어도 그 자산의 실파일이 없으면
|
* 태그 계층 파일 단위 게이트 — manifest 는 있어도 그 자산의 실파일이 없으면
|
||||||
* 그 자산만 종전 API URL 로 방출된다 (나머지는 정적 URL).
|
* 그 자산만 종전 API URL 로 방출된다 (나머지는 정적 URL).
|
||||||
*
|
*
|
||||||
* @scenario publish_state=partial, environment=production, trigger=manual_command, process_user=web
|
* @scenario publish_state=partial, artifact_integrity=intact, filesystem_writable=writable, environment=production, trigger=manual_command, process_user=web
|
||||||
*
|
*
|
||||||
* @effects tag_layer_checks_individual_file_existence
|
* @effects tag_layer_checks_individual_file_existence
|
||||||
*/
|
*/
|
||||||
@@ -373,7 +373,7 @@ class AssetUrlTest extends TestCase
|
|||||||
/**
|
/**
|
||||||
* base null(게이트 미통과) 이면 기존 URL 과 바이트 동일해야 한다 (호출부 무변경 계약).
|
* base null(게이트 미통과) 이면 기존 URL 과 바이트 동일해야 한다 (호출부 무변경 계약).
|
||||||
*
|
*
|
||||||
* @scenario publish_state=unpublished, environment=dev, trigger=kill_switch, process_user=web
|
* @scenario publish_state=unpublished, artifact_integrity=intact, filesystem_writable=writable, environment=dev, trigger=kill_switch, process_user=web
|
||||||
*/
|
*/
|
||||||
public function test_게이트_미통과시_종전_ur_l_바이트_동일(): void
|
public function test_게이트_미통과시_종전_ur_l_바이트_동일(): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ axes:
|
|||||||
# 7.0.10 업그레이드 실사례: root terminating 게시가 캐시 락 샤드를 root 소유로 남겨
|
# 7.0.10 업그레이드 실사례: root terminating 게시가 캐시 락 샤드를 root 소유로 남겨
|
||||||
# 웹 캐시 쓰기가 전면 500. 이 축이 없어 24축 매트릭스가 그 조합을 못 잡았다.
|
# 웹 캐시 쓰기가 전면 500. 이 축이 없어 24축 매트릭스가 그 조합을 못 잡았다.
|
||||||
process_user: [web, root_cli]
|
process_user: [web, root_cli]
|
||||||
|
# 산출물 무결성 축 — 200 인데 본문이 온전하지 않은 상태를 축으로 세운다.
|
||||||
|
# File::put 은 디스크 풀/quota 에서 짧은 int 를 돌려주며 성공한 것처럼 보이고,
|
||||||
|
# 절단된 JSON 은 웹서버가 정상 200 으로 서빙한다 — response.ok 만 보는 프론트가
|
||||||
|
# 폴백하지 못한 채 부팅 전체가 실패하던 유일한 사각이다 (#122 A1).
|
||||||
|
artifact_integrity: [intact, truncated, absent]
|
||||||
|
# 쓰기 가능성 축 — 제보 본건. CLI 계정과 웹 계정이 다르면 게시 트리 소유권이
|
||||||
|
# 최초 게시자에게 고정되어 이후 재게시가 영구 실패한다 (P1/P2).
|
||||||
|
filesystem_writable: [writable, parent_denied, tree_denied]
|
||||||
|
|
||||||
exclusions:
|
exclusions:
|
||||||
- { environment: dev, publish_state: published, reason: "dev 는 staticBase 미주입 — 게시 상태와 무관하게 전 리소스 API 직행" }
|
- { environment: dev, publish_state: published, reason: "dev 는 staticBase 미주입 — 게시 상태와 무관하게 전 리소스 API 직행" }
|
||||||
@@ -39,6 +47,44 @@ exclusions:
|
|||||||
- { process_user: root_cli, trigger: manual_command, reason: "명시적 ext-static:publish 는 root 게이트 밖 (운영자 책임 — 규정 §6). 게시 로직 자체는 web 축 케이스가 검증" }
|
- { process_user: root_cli, trigger: manual_command, reason: "명시적 ext-static:publish 는 root 게이트 밖 (운영자 책임 — 규정 §6). 게시 로직 자체는 web 축 케이스가 검증" }
|
||||||
- { process_user: root_cli, publish_state: published, reason: "root 게이트는 게시 상태 판정 이전에 위치 — 상태와 직교, 대표 조합(unpublished×lifecycle)으로 고정" }
|
- { process_user: root_cli, publish_state: published, reason: "root 게이트는 게시 상태 판정 이전에 위치 — 상태와 직교, 대표 조합(unpublished×lifecycle)으로 고정" }
|
||||||
- { process_user: root_cli, publish_state: partial, reason: "동일 — 상태 직교" }
|
- { process_user: root_cli, publish_state: partial, reason: "동일 — 상태 직교" }
|
||||||
|
# --- artifact_integrity: 기본값 intact 외의 값은 대표 조합에 고정한다 ---
|
||||||
|
# 무결성은 "게시된 산출물을 소비할 때" 만 의미가 있다. 다른 축과 곱하면 조합만 늘고
|
||||||
|
# 검증 대상은 같아지므로, root_cli 축과 같은 방식으로 대표 조합에 못박는다.
|
||||||
|
- { artifact_integrity: truncated, environment: dev, reason: "dev 는 정적 경로가 없어 절단 산출물을 참조할 경로 자체가 없다" }
|
||||||
|
- { artifact_integrity: absent, environment: dev, reason: "동일" }
|
||||||
|
- { artifact_integrity: truncated, process_user: root_cli, reason: "무결성 판정은 소비 시점 — 게시 주체(uid)와 직교" }
|
||||||
|
- { artifact_integrity: absent, process_user: root_cli, reason: "동일" }
|
||||||
|
- { artifact_integrity: truncated, filesystem_writable: parent_denied, reason: "쓸 수 없으면 절단 산출물도 남지 않는다 — 두 실패 모드는 배타적" }
|
||||||
|
- { artifact_integrity: truncated, filesystem_writable: tree_denied, reason: "동일" }
|
||||||
|
- { artifact_integrity: absent, filesystem_writable: parent_denied, reason: "산출물 부재는 쓰기 불가의 결과 — 별도 조합으로 세지 않는다" }
|
||||||
|
- { artifact_integrity: absent, filesystem_writable: tree_denied, reason: "동일" }
|
||||||
|
- { artifact_integrity: truncated, publish_state: unpublished, reason: "미게시 상태에는 산출물 자체가 없다 — 판정 대상 부재" }
|
||||||
|
- { artifact_integrity: truncated, publish_state: published, reason: "절단은 manifest 기록 전에 예외로 차단된다 — published 로 확정될 수 없다" }
|
||||||
|
- { artifact_integrity: absent, publish_state: published, reason: "게시 완료(manifest 존재)와 산출물 부재는 동시에 성립하지 않는다" }
|
||||||
|
- { artifact_integrity: absent, publish_state: unpublished, reason: "미게시와 중복 — publish_state 축이 이미 표현한다" }
|
||||||
|
- { artifact_integrity: truncated, trigger: self_heal, reason: "대표 조합(partial×lifecycle)으로 고정 — 트리거와 직교" }
|
||||||
|
- { artifact_integrity: truncated, trigger: manual_command, reason: "동일" }
|
||||||
|
- { artifact_integrity: truncated, trigger: kill_switch, reason: "kill-switch 는 게시 자체를 막는 선행 게이트" }
|
||||||
|
- { artifact_integrity: absent, trigger: lifecycle, reason: "대표 조합(partial×self_heal)으로 고정 — 산출물 부재는 소비 시점 폴백이 대상이다" }
|
||||||
|
- { artifact_integrity: absent, trigger: manual_command, reason: "동일" }
|
||||||
|
- { artifact_integrity: absent, trigger: kill_switch, reason: "kill-switch 는 정적 경로 자체를 차단" }
|
||||||
|
|
||||||
|
# --- filesystem_writable: 기본값 writable 외의 값은 대표 조합에 고정한다 ---
|
||||||
|
# 쓰기 불가는 게시 시도 시점에만 의미가 있다(프리플라이트). 소비 축과 곱할 이유가 없다.
|
||||||
|
- { filesystem_writable: parent_denied, environment: dev, reason: "dev 는 게시 자체를 하지 않는다 (terminating 게이트가 프로덕션 한정)" }
|
||||||
|
- { filesystem_writable: tree_denied, environment: dev, reason: "동일" }
|
||||||
|
- { filesystem_writable: parent_denied, publish_state: published, reason: "부모 쓰기 불가면 게시가 성립한 적이 없다" }
|
||||||
|
- { filesystem_writable: tree_denied, publish_state: published, reason: "동일" }
|
||||||
|
- { filesystem_writable: parent_denied, publish_state: partial, reason: "프리플라이트가 병합 전에 끊으므로 부분 산출물이 생기지 않는다" }
|
||||||
|
- { filesystem_writable: tree_denied, publish_state: partial, reason: "동일" }
|
||||||
|
- { filesystem_writable: parent_denied, trigger: kill_switch, reason: "kill-switch 는 쓰기 판정 이전의 선행 게이트 — 권한 무관" }
|
||||||
|
- { filesystem_writable: tree_denied, trigger: kill_switch, reason: "동일" }
|
||||||
|
- { filesystem_writable: parent_denied, trigger: self_heal, reason: "대표 조합(unpublished×lifecycle)으로 고정 — 트리거와 직교" }
|
||||||
|
- { filesystem_writable: parent_denied, trigger: manual_command, reason: "동일" }
|
||||||
|
- { filesystem_writable: tree_denied, trigger: self_heal, reason: "동일" }
|
||||||
|
- { filesystem_writable: tree_denied, trigger: manual_command, reason: "동일" }
|
||||||
|
- { filesystem_writable: parent_denied, process_user: root_cli, reason: "root 게이트가 쓰기 판정 이전에 예약을 막는다 — uid 와 직교" }
|
||||||
|
- { filesystem_writable: tree_denied, process_user: root_cli, reason: "동일" }
|
||||||
|
|
||||||
effects:
|
effects:
|
||||||
- published_lang_matches_api_payload
|
- published_lang_matches_api_payload
|
||||||
@@ -70,6 +116,19 @@ effects:
|
|||||||
- published_htaccess_declares_compression
|
- published_htaccess_declares_compression
|
||||||
- bundle_script_static_miss_falls_back_to_api
|
- bundle_script_static_miss_falls_back_to_api
|
||||||
- root_cli_defers_publish_to_web_self_heal
|
- root_cli_defers_publish_to_web_self_heal
|
||||||
|
- truncated_artifact_rejected_before_manifest
|
||||||
|
- parent_denied_short_circuits_before_merge
|
||||||
|
- regenerate_path_schedules_publish
|
||||||
|
- gc_preserves_previous_when_current_absent
|
||||||
|
- publish_failure_reaches_dashboard
|
||||||
|
- publish_failure_recorded_in_marker
|
||||||
|
- publish_success_clears_failure_marker
|
||||||
|
- publish_failure_backoff_suppresses_reschedule
|
||||||
|
- cleanup_removes_only_stale_work_directories
|
||||||
|
- static_miss_returns_404_for_every_servable_extension
|
||||||
|
- admin_catch_all_shares_static_exclusion
|
||||||
|
- prune_skips_active_serving_path
|
||||||
|
- extension_copy_paths_inherit_directory_ownership
|
||||||
|
|
||||||
test_files:
|
test_files:
|
||||||
- tests/Feature/Services/ExtensionStaticCacheServiceTest.php
|
- tests/Feature/Services/ExtensionStaticCacheServiceTest.php
|
||||||
@@ -79,6 +138,13 @@ test_files:
|
|||||||
- tests/Feature/Template/TemplateLanguageServingTest.php
|
- tests/Feature/Template/TemplateLanguageServingTest.php
|
||||||
- tests/Feature/Api/Public/LayoutServingTest.php
|
- tests/Feature/Api/Public/LayoutServingTest.php
|
||||||
- tests/Feature/Http/BuildPathCatchAllExclusionTest.php
|
- tests/Feature/Http/BuildPathCatchAllExclusionTest.php
|
||||||
|
- tests/Feature/Api/Public/ExtensionBundleServingTest.php
|
||||||
|
- tests/Feature/Dashboard/StaticPublishAlertTest.php
|
||||||
|
- tests/Feature/Console/BuildCommandPruneScopeTest.php
|
||||||
|
- tests/Feature/Extension/ExtensionPendingOwnershipTest.php
|
||||||
|
- tests/Unit/Extension/ClearsTemplateCachesTest.php
|
||||||
|
- tests/Unit/Services/ExtensionBundleServiceTest.php
|
||||||
|
- tests/Unit/Build/ViteOutDirContractTest.php
|
||||||
- tests/Unit/Support/AssetUrlTest.php
|
- tests/Unit/Support/AssetUrlTest.php
|
||||||
- tests/Unit/Seo/SeoRendererStaticAssetBypassTest.php
|
- tests/Unit/Seo/SeoRendererStaticAssetBypassTest.php
|
||||||
- resources/js/core/__tests__/TemplateApp.cacheVersionSeed.test.ts
|
- resources/js/core/__tests__/TemplateApp.cacheVersionSeed.test.ts
|
||||||
|
|||||||
@@ -3,6 +3,15 @@ import laravel from 'laravel-vite-plugin';
|
|||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
// 산출물 디렉토리를 비우지 않는다 — `public/build/` 에는 vite 가 만들지 않는
|
||||||
|
// 서빙 자산이 함께 산다: 폴백이 없는 코어 3번들(`build/core/`, 동기 classic
|
||||||
|
// 스크립트라 소실 = 사이트 부팅 불가)과 부트스트랩 정적 게시본(`build/ext/{v}/`,
|
||||||
|
// 이미 배달된 HTML 의 immutable URL 이 참조한다). 기본값 true 는 빌드 때마다
|
||||||
|
// 이들을 통째로 지워 404 를 만든다 (공개 #122). 잔존 구 해시 파일은
|
||||||
|
// laravel-vite-plugin 이 `manifest.json` 으로 산출물을 선택하므로 참조되지 않는다.
|
||||||
|
build: {
|
||||||
|
emptyOutDir: false,
|
||||||
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
laravel({
|
laravel({
|
||||||
input: ['resources/css/app.css', 'resources/js/app.js'],
|
input: ['resources/css/app.css', 'resources/js/app.js'],
|
||||||
|
|||||||
Reference in New Issue
Block a user