Merge pull request from gnuboard:HeuJung/issue619

feat(core): 정적 게시 수명주기 보완 — 빌드 파괴·권한·무결성
This commit is contained in:
정정홍
2026-08-28 08:08:27 +09:00
committed by GitHub
52 changed files with 3495 additions and 147 deletions
+17 -1
View File
@@ -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` 모드는 실시간 개발용으로 활성 디렉토리를 자동 사용.
--- ---
+10
View File
@@ -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
View File
@@ -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>
+1 -1
View File
@@ -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',
};
}
}
+28 -16
View File
@@ -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);
}
} }
} }
} }
+14
View File
@@ -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;
}
/** /**
* 허용된 파일 타입인지 검증 * 허용된 파일 타입인지 검증
*/ */
+14
View File
@@ -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;
}
/** /**
* 허용된 파일 타입인지 검증 * 허용된 파일 타입인지 검증
*/ */
+14
View File
@@ -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;
}
/** /**
* 허용된 파일 타입인지 검증 * 허용된 파일 타입인지 검증
*/ */
+145 -10
View File
@@ -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;
+556 -34
View File
@@ -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)"
);
} }
} }
+64
View File
@@ -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(),
]));
}
}
+10
View File
@@ -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' => [
+2 -2
View File
@@ -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`) |
+23
View File
@@ -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 이중 모드".
+51 -1
View File
@@ -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) 이 소유한다. 본 게시는 그 산출물의 **사본**만 만든다.
+21
View File
@@ -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' => 'コアアップグレード必要',
+4
View File
@@ -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' => [
+4
View File
@@ -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' => [
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' }))
+29 -1
View File
@@ -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
+4
View File
@@ -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
View File
@@ -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);
}
});
}); });
+123
View File
@@ -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 회귀 가드) — 이름 하드코딩 아닌 선언 결과
+3 -3
View File
@@ -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
{ {
+66
View File
@@ -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
+9
View File
@@ -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'],