*/ private const PREFLIGHT_FAILURE_SUMMARIES = [ 'ancestor_not_writable' => '게시 루트를 만들 상위 디렉토리에 쓸 수 없습니다', 'occupied_by_file' => '게시 루트 자리를 같은 이름의 파일이 차지하고 있습니다', 'create_failed' => '게시 루트를 만들지 못했습니다', 'not_writable' => '게시 루트에 쓸 수 없습니다', ]; /** 게시 트리 디렉토리 rename 시도 횟수 (일시 거부 흡수) */ private const RENAME_ATTEMPTS = 3; /** rename 재시도 간 대기 (마이크로초) — 실측 표본은 전부 1회 재시도로 해소됐다 */ private const RENAME_RETRY_DELAY_US = 200_000; /** terminating 게시 예약 플래그 (프로세스당 1회) */ private static bool $publishScheduled = false; /** 테스트 전용 — root 프로세스 판정 오버라이드 (null = 실판정) */ private static ?bool $rootProcessForTesting = null; /** isPublished 요청당 메모이즈 (version => 존재 여부) */ private array $publishedMemo = []; public function __construct( private TemplateService $templateService, private TemplateRepositoryInterface $templateRepository, private ModuleRepositoryInterface $moduleRepository, private PluginRepositoryInterface $pluginRepository, private ExtensionBundleService $bundleService, private LanguagePackService $languagePackService, ) {} /** * 현재 확장 캐시 버전 기준으로 게시합니다. * * 이미 게시 완료(manifest 존재) 상태면 skip(멱등). `Cache::lock` 으로 단일 * 실행을 보장하며, 락 미획득 시 다른 프로세스가 게시 중인 것으로 보고 skip. * * @param bool $force 게시 완료 상태여도 강제 재게시 * @return bool 게시 완료 상태로 끝났으면 true (skip 포함), 실패/비활성이면 false */ public function publishCurrent(bool $force = false): bool { if (! $this->isEnabled()) { return false; } $version = self::getExtensionCacheVersion(); if (! $force && $this->isPublished($version)) { return true; } // 락 미획득은 두 상황이고 조치가 다르다. // // (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'), ]); return false; } try { // 락 대기 중 다른 프로세스가 완료했을 수 있다 (멱등 재확인) unset($this->publishedMemo[$version]); if (! $force && $this->isPublished($version)) { return true; } return $this->publishVersion($version); } finally { $lock->release(); } } /** * 해당 버전이 게시 완료 상태인지 확인합니다 (manifest 존재 = 완료). * * AssetUrl 게이트가 요청당 여러 번 호출하므로 메모이즈한다. * * @param int $version 확장 캐시 버전 * @return bool 게시 완료 여부 */ public function isPublished(int $version): bool { return $this->publishedMemo[$version] ??= is_file( $this->versionDir($version).DIRECTORY_SEPARATOR.self::MANIFEST_FILE ); } /** * 현재 버전 + 직전 1개를 보존하고 나머지 게시 디렉토리를 삭제합니다. * * 직전 버전을 남기는 이유: 브라우저에 캐시된 직전 렌더 HTML 이 아직 구버전 * 정적 URL 을 참조할 수 있다 (asset-url-recovery 파샬이 최후 방어). * * @return int 삭제된 디렉토리 수 */ public function cleanup(): int { $base = $this->baseDir(); if (! File::isDirectory($base)) { return 0; } $current = self::getExtensionCacheVersion(); $versions = []; $deleted = 0; foreach (File::directories($base) as $dir) { $name = basename($dir); // 진행 중 게시의 작업 디렉토리(`{v}.tmp`)와 스왑 대기분(`{v}.old`)은 나이로 // 가른다 — 락은 **버전별**이라 서로 다른 버전의 게시가 동시에 진행될 수 있고, // 나이 무관 삭제는 그 순간 살아 있는 남의 tmp 를 파괴한다. 게시 락 TTL 이 // 300초이므로 그 두 배를 미완료 판정 기준으로 삼는다. if (str_ends_with($name, '.tmp') || str_ends_with($name, '.old')) { if (! $this->isStaleWorkDirectory($dir)) { continue; } $deleted += $this->deleteVersionDirectory($dir) ? 1 : 0; continue; } if (ctype_digit($name)) { $versions[(int) $name] = $dir; } } // 현재 버전과, 현재를 제외한 최신 1개(직전) 보존. // // 현재 버전 디렉토리가 **실존하지 않으면** 그 자리를 보존 슬롯으로 쓰지 않는다 — // 없는 것을 보존해 봐야 슬롯 하나를 버리는 셈이고, 그만큼 실존 버전이 한 개 더 // 지워진다. 그 경우 "실존하는 최신 2개" 를 보존한다. 게시는 GC 시점 이후 // (terminating)에 수행되므로 "포인터는 새 버전인데 산출물은 아직 없음" 은 // 정상 상태이며, 브라우저에 배달된 직전 HTML 은 여전히 옛 버전 URL 을 참조한다. $keep = File::isDirectory($this->versionDir($current)) ? [$current] : []; $others = array_keys($versions); rsort($others); foreach ($others as $v) { if (in_array($v, $keep, true)) { continue; } $keep[] = $v; if (count($keep) >= 2) { break; } } foreach ($versions as $v => $dir) { if (! in_array($v, $keep, true)) { $deleted += $this->deleteVersionDirectory($dir) ? 1 : 0; } } 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 게시 예약. * * `incrementExtensionCacheVersion()` 내부 단일 지점에서 호출된다. * 프로세스당 1회만 등록하며, 게시는 예약 시점이 아니라 **실행 시점의 현재 * 버전**으로 수행되어 연속 bump(일괄 업데이트)를 자연 병합한다. * * 프로덕션 전용 — 비프로덕션은 blade 가 정적 URL 을 방출하지 않으므로 * 게시 자체가 무의미하고(§2-2), testing 환경의 파일 쓰기 부수효과도 차단한다. */ public static function schedulePublishOnTerminate(): void { if (self::$publishScheduled || ! app()->environment('production')) { return; } // root 프로세스(sudo 코어 업데이트 등)에서는 예약하지 않는다 — 게시가 만드는 // 캐시 락 샤드 디렉토리(storage/framework/cache/data/xx)와 병합 번들 // (storage/app/ext-bundles)이 root 소유로 남아, 이후 웹 프로세스의 캐시 // 쓰기가 그 샤드에 해시되는 순간 Permission denied 로 죽는다 (실사례: // sudo 업데이트 직후 전면 500). normalizeOwnership 은 게시 트리(build/ext)만 // 다루므로 storage 측 부수 산출물은 회피가 정답이다. 게시는 다음 웹 렌더의 // 자가 치유(웹 계정)가 수행하고, 명시적 `ext-static:publish` 커맨드는 이 // 게이트를 거치지 않는다 (운영자 책임 — 규정 문서 §6). if (self::isRootProcess()) { return; } // 신선한 실패 마커가 있으면 예약하지 않는다 (백오프). // // 쓰기 불가 환경(게시 트리 소유권 불일치 등)에서는 게시가 **매 요청** 실패하는데, // 실패 전까지 전 로케일 lang 병합 + 전 템플릿 dist 복사를 이미 다 헛돈 뒤다. // 사이트는 API 폴백으로 살아 있어 아무도 눈치채지 못한 채 모든 프로덕션 요청이 // 그 비용을 낸다. 마커 TTL 창당 1회로 억제한다. // // 억제 대상은 **같은 버전**뿐이다. 버전이 오르면 그 버전은 아직 한 번도 시도된 // 적이 없으므로, 이전 버전의 마커로 막으면 "버전 갱신 → 게시" 규율(D1)이 TTL // 창(최대 300초) 동안 도로 끊긴다. if (self::hasFreshFailureMarker(self::getExtensionCacheVersion())) { return; } self::$publishScheduled = true; app()->terminating(static function (): void { // 실행 시점에 재무장 가능 상태로 복귀 — 요청마다 앱 인스턴스를 새로 쓰는 // 장수 프로세스(Octane 류)에서는 static 플래그만 살아남으므로, 리셋 없이는 // 2번째 이후 bump 가 새 앱에 콜백을 등록하지 못한 채 영구 미게시가 된다 // (자가 치유도 같은 플래그 공유). FPM(요청=프로세스)에서는 무영향. self::$publishScheduled = false; try { app(self::class)->publishCurrent(); } catch (\Throwable $e) { Log::warning('정적 게시 terminating 실행 실패 — 다음 렌더의 자가 치유가 재시도합니다', [ 'error' => $e->getMessage(), ]); } }); } /** * 테스트 전용 — terminating 예약 플래그의 현재 값을 반환합니다. * * 게시 예약은 부수효과가 `app()->terminating()` 콜백 등록뿐이라 밖에서 관측할 방법이 * 없다. "예약했는가" 를 단언하려면 이 플래그가 유일한 통로다. * * @return bool 예약 여부 */ public static function isPublishScheduledForTesting(): bool { return self::$publishScheduled; } /** * 테스트 격리용 — terminating 예약 플래그를 초기화합니다. */ public static function resetPublishScheduleForTesting(): void { self::$publishScheduled = false; 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 로 실판정 복귀). * * @param bool|null $isRoot 강제할 판정값 */ public static function fakeRootProcessForTesting(?bool $isRoot): void { self::$rootProcessForTesting = $isRoot; } /** * 현재 프로세스가 root(euid 0)로 실행 중인지 판정합니다. * * posix 확장이 없는 환경(Windows, 함수 비활성 호스팅)은 root 아님으로 본다. * * @return bool root 실행 여부 */ private static function isRootProcess(): bool { if (self::$rootProcessForTesting !== null) { return self::$rootProcessForTesting; } return function_exists('posix_geteuid') && posix_geteuid() === 0; } /** * 게시 루트 디렉토리 절대 경로를 반환합니다. * * @return string `public/build/ext` 절대 경로 */ public function baseDir(): string { return public_path('build/ext'); } /** * 버전 디렉토리 절대 경로를 반환합니다. * * @param int $version 확장 캐시 버전 * @return string 버전 디렉토리 절대 경로 */ public function versionDir(int $version): string { return $this->baseDir().DIRECTORY_SEPARATOR.$version; } /** * kill-switch 판정 (`core.static_cache.enabled`, .env `G7_STATIC_CACHE`). * * @return bool 정적 게시 활성 여부 */ public function isEnabled(): bool { return (bool) config('core.static_cache.enabled', true); } /** * 지금 이 설치에서 게시가 의미 있는지 판정합니다 — kill-switch 켜짐 + 프로덕션. * * `publishCurrent()` 자체에는 환경 게이트가 없다(명시적 `ext-static:publish` 가 어느 환경에서든 * 돌 수 있어야 하므로). 환경 판정은 자가 치유(`AssetUrl::staticExtBase`)와 terminating 예약 * (`schedulePublishOnTerminate`)에만 있었는데, 관리자 화면의 수동 복구는 두 판정을 **한 값**으로 * 보여 줘야 하므로 여기서 묶는다. 비프로덕션은 blade 가 정적 URL 을 방출하지 않아 게시해도 * 아무도 참조하지 않는다. * * @return bool 게시가 실제로 쓰이는 환경이면 true */ public function isPublishable(): bool { return $this->isEnabled() && app()->environment('production'); } /** * 게시 상태 보고서 — CLI(`ext-static:status`)·관리자 API·화면이 **같은 판정**을 소비한다. * * 판정을 소비처마다 다시 쓰면 한 곳만 어긋나도 서로 다른 답을 내놓는다(`TrustedProxyDiagnostic` * 이 CLI·대시보드·API 에 같은 판정을 공급하는 것과 같은 패턴). 문구·힌트는 소비처가 정한다. * * @return array{ * enabled: bool, * publishable: bool, * environment: string, * version: int, * published: bool, * files: int, * published_at: string|null, * tree_writable: bool, * process_user: string, * failure: array{version:int, at:string, reason:string, count:int, message:string}|null, * retained_versions: array * } 상태 보고서 */ public function statusReport(): array { $version = self::getExtensionCacheVersion(); $base = $this->baseDir(); unset($this->publishedMemo[$version]); $published = $this->isPublished($version); $files = 0; $publishedAt = null; if ($published) { $manifest = json_decode((string) @file_get_contents($this->versionDir($version).DIRECTORY_SEPARATOR.self::MANIFEST_FILE), true); if (is_array($manifest)) { $files = count($manifest['files'] ?? []); $publishedAt = is_string($manifest['published_at'] ?? null) ? $manifest['published_at'] : null; } } return [ 'enabled' => $this->isEnabled(), 'publishable' => $this->isPublishable(), 'environment' => (string) app()->environment(), 'version' => $version, 'published' => $published, 'files' => $files, 'published_at' => $publishedAt, 'tree_writable' => $this->isPublishTreeWritable($base), 'process_user' => self::currentProcessUser(), 'failure' => self::failureMarker(), 'retained_versions' => $this->publishedVersions($base), ]; } /** * 관리자 수동 복구 — 캐시 버전을 올리고 현재 버전을 **강제** 재게시합니다. * * 자동 경로(수명주기 bump → terminating 게시 · blade 자가 치유)가 놓친 갱신을 운영자가 즉시 * 되돌리는 통로다. 캐시 버전이 만료로 재생성되지 않으므로(`PERSISTENT_TTL_SECONDS`) 재게시 * 누락은 무기한 stale 이 되는데, 이 복구가 그 안전망이다. * * - 게시가 쓰이지 않는 환경(kill-switch 꺼짐 / 비프로덕션)이면 bump 하지 않고 사유만 돌려준다. * - `force: true` 가 필수다 — 직전 게시와 같은 초면 버전이 같아 manifest 존재로 skip 되어 * 아무것도 다시 만들지 않는다. * - 게시 실패는 예외가 아니라 결과다(`republished=false` + 실패 마커). 사이트는 API 폴백으로 정상. * - 715파일·40MB 복사 + 번들 재병합이 웹 요청 안에서 돈다 — 게시 락 TTL(300초)만큼 실행 시간을 * 확보하고, 클라이언트가 끊어도 게시는 끝낸다(중간에 죽으면 `.tmp` 만 남고 다음 GC 가 치운다). * * @return array `republished`·`version_changed`·`previous_version` + `statusReport()` */ public function republish(): array { $previous = self::getExtensionCacheVersion(); if (! $this->isPublishable()) { return [ 'republished' => false, 'version_changed' => false, 'previous_version' => $previous, 'reason' => $this->isEnabled() ? 'not_production' : 'disabled', ] + $this->statusReport(); } @set_time_limit(300); @ignore_user_abort(true); $this->incrementExtensionCacheVersion(); $republished = $this->publishCurrent(force: true); $current = self::getExtensionCacheVersion(); return [ 'republished' => $republished, 'version_changed' => $current !== $previous, 'previous_version' => $previous, 'reason' => $republished ? null : (self::failureMarker()['reason'] ?? 'publish_failed'), ] + $this->statusReport(); } /** * 게시 트리에 쓸 수 있는지(없으면 만들 수 있는지) 판정합니다. * * @param string $base 게시 루트 절대 경로 * @return bool 쓰기 가능 여부 */ private function isPublishTreeWritable(string $base): bool { if (File::isDirectory($base)) { return is_writable($base); } $parent = dirname($base); return File::isDirectory($parent) && is_writable($parent); } /** * 게시 루트에 남아 있는 버전 디렉토리 목록을 반환합니다 (오름차순). * * @param string $base 게시 루트 절대 경로 * @return array 버전 목록 */ 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; } /** * 한 버전의 게시를 실제 수행합니다 (tmp 쓰기 → rename → manifest → GC). * * @param int $version 게시할 확장 캐시 버전 * @return bool 성공 여부 */ private function publishVersion(int $version): bool { $base = $this->baseDir(); $tmp = $base.DIRECTORY_SEPARATOR.$version.'.tmp'; $final = $this->versionDir($version); $old = $final.'.old'; $swapped = false; // 프리플라이트 — 게시 트리를 만들 수 없는 환경이면 병합을 시작하기 전에 끊는다. // 전 로케일 lang 병합 + 전 템플릿 dist 복사를 다 헛돈 뒤 mkdir 에서 실패하는 // 것과, 시작 전에 사유를 남기고 끊는 것은 비용이 전혀 다르다. if (! $this->ensurePublishRootWritable($version)) { return false; } try { File::deleteDirectory($tmp); $this->makeDirectory($tmp); // custom 열거 메모를 비우고 디스크를 다시 읽는다 — 게시는 요청 뒤(terminating)나 장수 프로세스에서 // 돌 수 있어, 앉아 있는 메모가 이미 지워진 파일을 가리키면 복사가 실패해 게시 전체가 중단된다. CustomAssets::flushCache(); $files = []; $this->writeHtaccess($tmp, $files); $locales = $this->publishableLocales(); foreach ($this->templateRepository->getActive() as $template) { $this->publishTemplate($tmp, $template, $locales, $files); } $this->publishBundles($tmp, $version, $files); $this->publishExtensionCustomAssets($tmp, $files); // 원자적 스왑 — 기존 디렉토리를 **먼저 지우지 않는다.** 삭제 후 rename 사이의 // 창에서는 이미 배달된 HTML 이 참조하는 CSS/JS/폰트가 전부 404 가 되고, 폰트는 // 복구기가 없다. 대신 `.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}"); } $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}"); } // manifest 는 rename 후 마지막 기록 — 존재 = 게시 완료 $this->writeManifest($final, $version, $files); unset($this->publishedMemo[$version]); // 새 버전이 완성된 뒤에만 구버전을 치운다 (여기까지 오면 404 창이 없다) if ($swapped) { File::deleteDirectory($old); $swapped = false; } // sudo/root CLI 게시 대응 — terminating 게시는 코어 업데이트의 // restoreOwnership **이후**(프로세스 종료 시)에 실행되므로, root 소유로 // 남으면 이후 php-fpm 의 재게시·GC 가 영구 실패한다. 부모(public/build) // 소유권을 상속시킨다 (FilePermissionHelper::copyFile 의 sudo 대응 선례). $this->normalizeOwnership(); // 인라인 GC (현재 + 직전 1개 보존) $this->cleanup(); self::clearFailureMarker(); Log::info('부트스트랩 리소스 정적 게시 완료', [ 'version' => $version, 'files' => count($files), ]); return true; } catch (\Throwable $e) { // 예외 종류와 발생 위치까지 남긴다. 메시지만으로는 파일시스템 오류인지 병합 // 오류인지 구분되지 않아, 운영자도 개발자도 재현부터 다시 해야 한다 — // 게시 실패는 사이트를 멈추지 않고 폴백으로 넘어가므로 이 로그가 유일한 흔적이다. Log::warning('부트스트랩 리소스 정적 게시 실패 — API 폴백으로 동작합니다', [ 'version' => $version, 'exception' => $e::class, 'error' => $e->getMessage(), 'at' => $e->getFile().':'.$e->getLine(), ]); // 정리 대상은 **실제 존재하는 쪽**이다. rename 이 이미 성공했다면 `$tmp` 는 // 존재하지 않으므로 그것만 지우는 종전 코드는 no-op 이었고, manifest 기록이 // 실패한 경우 manifest 없는 완성 디렉토리가 영구 잔존했다 — // `isPublished()` 가 영원히 false 라 요청마다 지웠다 만들기를 반복한다. 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; } } /** * 게시 루트(`public/build/ext`)를 만들 수 있고 쓸 수 있는지 확인합니다. * * 부모(`public/build`)가 다른 계정 소유 + `g-w` 면 웹 프로세스는 `ext` 를 **mkdir 조차 * 하지 못한다.** 그 경우 최초 게시가 CLI 로 강제되고, 그 순간 트리 소유권이 CLI 계정으로 * 고정되어 이후 웹 재게시가 영구 실패한다 (제보 본건). * * @param int $version 게시 대상 버전 (실패 마커 기록용) * @return bool 게시를 진행해도 되는지 여부 */ private function ensurePublishRootWritable(int $version): bool { // 확보는 코어 공통 프리미티브가 맡는다 — 검사 대상은 `public/build` 고정이 아니라 // **실재하는 최근접 상위**이고(`public/build` 자체가 없는 신규 설치·테스트 격리 public // 경로에서 부모 존재를 요구하면 정상 상황을 실패로 만든다), 생성 실패는 예외가 아니라 // 사유로 올라온다. 실패 정책(프리플라이트 실패 마커 + API 폴백)은 이 자리가 정한다. if (FilePermissionHelper::ensureWritableDirectory($this->baseDir(), self::PUBLISH_DIR_MODE, $failure)) { return true; } $this->failPreflight($version, $failure['path'], self::PREFLIGHT_FAILURE_SUMMARIES[$failure['reason']]); return false; } /** * 프리플라이트 실패를 로그 + 실패 마커에 남깁니다. * * @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 와 무관하게 권한·소유권을 정합화합니다. * * 게시 파일마다 호출되는 뜨거운 경로라 쓰기 가능 여부를 매번 재판정하지 않는다 — 그 판정은 * 게시 시작 전 `ensurePublishRootWritable()` 이 루트에서 한 번 수행한다. 여기서는 생성만 * 하고, 실패는 **예외로 남긴다**: 호출부(`writeJson`/`copyFile`)가 게시 전체를 중단시키는 * 계약이 그 예외에 걸려 있다. * * 권한 정합화는 코어 공통 프리미티브에 위임한다 (umask 무력화 + POSIX setgid + 소유권 상속). * * @param string $dir 생성할 디렉토리 절대 경로 */ private function makeDirectory(string $dir): void { File::ensureDirectoryExists($dir, self::PUBLISH_DIR_MODE); FilePermissionHelper::hardenDirectory($dir, self::PUBLISH_DIR_MODE); } /** * 게시 트리의 디렉토리 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 가 아닌 프로세스는 chown 자체가 불가능하고 필요도 없다(자기 소유로 생성됨) * — 그 경우 즉시 no-op. 실패는 chownRecursive 가 경고 로그로 누적한다. * 게시 루트 전체(`build/ext`)를 대상으로 하므로 방금 게시된 버전 디렉토리와 * 잔존 구버전이 함께 정상화된다. */ private function normalizeOwnership(): void { $base = $this->baseDir(); if (! File::isDirectory($base)) { return; } // ① root 갈래 — 소유권을 부모 기준으로 되돌린다 (sudo CLI 게시 대응). if (function_exists('chown') && function_exists('posix_geteuid') && posix_geteuid() === 0) { $parent = dirname($base); $owner = @fileowner($parent); $group = @filegroup($parent); if ($owner !== false && $owner !== 0) { FilePermissionHelper::chownRecursive($base, $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); } /** * 활성 템플릿 1개의 게시물(lang/components/routes/assets)을 기록합니다. * * @param string $tmp tmp 디렉토리 절대 경로 * @param Template $template 활성 템플릿 * @param array $locales 게시 대상 로케일 * @param array $files 기록된 상대 경로 누적 (참조) */ private function publishTemplate(string $tmp, Template $template, array $locales, array &$files): void { $identifier = (string) $template->identifier; if (! preg_match(self::IDENTIFIER_PATTERN, $identifier)) { Log::warning('정적 게시 제외 — 식별자 패턴 불일치', ['identifier' => $identifier]); return; } $templateDir = $tmp.DIRECTORY_SEPARATOR.'templates'.DIRECTORY_SEPARATOR.$identifier; // 1. lang 병합 결과 (코어→템플릿→모듈→플러그인→언어팩 훅) — 현 lang API 와 동일 형상(raw) foreach ($locales as $locale) { $result = $this->templateService->getLanguageDataWithModules($identifier, $locale); if (! ($result['success'] ?? false) || ! is_array($result['data'] ?? null)) { continue; } $this->writeJson( $templateDir.DIRECTORY_SEPARATOR.'lang'.DIRECTORY_SEPARATOR.$locale.'.json', $result['data'], $files, $tmp ); } // 2. components.json 사본 (raw) $componentsPath = base_path("templates/{$identifier}/components.json"); if (is_file($componentsPath)) { $this->copyFile($componentsPath, $templateDir.DIRECTORY_SEPARATOR.'components.json', $files, $tmp); } // 3. routes 병합 결과 — 프론트 소비 코드 무변경을 위한 성공 봉투 포함. // 열화 스냅샷(확장 업데이트 진행 중 등)은 게시하지 않는다 — 정적 파일은 // 스스로 회복되지 않으므로 열화 상태가 다음 bump 까지 박제된다 (getRoutes 와 동일 규율) $routesResult = $this->templateService->getRoutesDataWithModules($identifier); if (($routesResult['success'] ?? false) && ! $this->templateService->lastRouteMergeWasDegraded()) { $this->writeJson( $templateDir.DIRECTORY_SEPARATOR.'routes.json', ['success' => true, 'message' => '', 'data' => $routesResult['data']], $files, $tmp ); } elseif ($routesResult['success'] ?? false) { Log::warning('정적 게시에서 routes 제외 — 라우트 병합 열화 상태 (확장 업데이트 진행 중 추정)', [ 'template' => $identifier, ]); } // 4. dist 에셋 사본 (확장자 화이트리스트, *.map 제외) $this->publishDistAssets( base_path("templates/{$identifier}/dist"), $templateDir.DIRECTORY_SEPARATOR.'assets', $files, $tmp ); // 5. 운영자 소유 디렉토리(`custom/`) 사본 // // 게시하지 않으면 이 자산만 API 경로에 남는데, 그 경로에서는 CSS 내부 상대 // `url()` 이 해석되지 않는다 — `?file=` 형태는 기준 URL 이 `/api/templates/assets/` // 라 `url('./font.woff2')` 가 그 디렉토리를 가리키고, 확장자 형태는 정적 최적화 // 서버가 먼저 가로챈다(그래서 `extensionless` 모드가 존재한다). 즉 정적 확장자 // URL 은 **public 아래 실제 파일일 때만** 200 이 되므로, 문서가 안내하는 // "폰트·이미지를 custom/ 에 두고 상대 경로로 참조" 를 성립시키는 방법은 게시뿐이다. // // 갱신 축은 확장 자산과 동일하다 — 운영자가 파일을 고치면 `CollectsCustomAssets` 가 // 그것을 감지해 `ext.cache_version` 을 올리고, 그 단일 지점이 재게시까지 예약한다. // 게시 집합은 감지와 **같은 열거자**(`CustomAssets::publishableFiles`)가 정한다. $this->publishCustomAssets( 'templates', $identifier, $templateDir.DIRECTORY_SEPARATOR.'assets'.DIRECTORY_SEPARATOR.CustomAssets::DIRECTORY, $files, $tmp ); } /** * 확장 하나의 운영자 소유 디렉토리(`custom/`)를 게시합니다. * * 파일 집합은 `CustomAssets::publishableFiles()` 가 정한다 — 변경 감지(`CollectsCustomAssets`) * 와 같은 열거자다. 게시와 감지가 서로 다른 코드로 범위를 정하면 어긋나고, 그 어긋남은 * "글꼴을 바꿨는데 반영되지 않는다" 로만 나타난다(#651 F7). * * @param string $extensionType `templates` | `modules` | `plugins` * @param string $identifier 확장 식별자 * @param string $targetDir 게시 대상 절대 경로 (`…/assets/custom`) * @param array $files 기록된 상대 경로 누적 (참조) * @param string $tmp tmp 루트 (상대 경로 계산용) */ private function publishCustomAssets( string $extensionType, string $identifier, string $targetDir, array &$files, string $tmp ): void { foreach (CustomAssets::publishableFiles($extensionType, $identifier) as $file) { $this->copyFile( $file['absolute'], $targetDir.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $file['relative']), $files, $tmp ); } } /** * 템플릿 dist 디렉토리를 재귀 복사합니다 (허용 확장자만, 소스맵 제외). * * @param string $sourceDir 원본 절대 경로 (dist 또는 custom) * @param string $targetDir 게시 대상 절대 경로 * @param array $files 기록된 상대 경로 누적 (참조) * @param string $tmp tmp 루트 (상대 경로 계산용) * @param bool $excludeCustom 원본 안의 `custom/` 하위를 건너뛸지 (dist 원본은 참 — `custom/` 은 * `publishCustomAssets()` 가 공유 열거자로 따로 게시한다) */ private function publishDistAssets( string $sourceDir, string $targetDir, array &$files, string $tmp, bool $excludeCustom = true ): void { $realSource = realpath($sourceDir); if ($realSource === false || ! is_dir($realSource)) { return; } // 소스맵은 배포 금지 정책(`*.map` gitignore)과 동일하게 제외 $allowed = array_diff(AllowedTemplateFileType::getAllowedExtensions(), ['map']); $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($realSource, \FilesystemIterator::SKIP_DOTS) ); /** @var \SplFileInfo $file */ foreach ($iterator as $file) { if (! $file->isFile()) { continue; } $extension = strtolower($file->getExtension()); if (! in_array($extension, $allowed, true)) { continue; } // 컨테인먼트 검증 — 심볼릭 링크 등으로 dist 밖을 가리키는 실경로 차단 $realFile = $file->getRealPath(); if ($realFile === false || ! str_starts_with($realFile, $realSource.DIRECTORY_SEPARATOR)) { continue; } $relative = substr($realFile, strlen($realSource) + 1); // dist 원본에서는 `custom/` 하위를 건너뛴다 — 운영자 파일은 자기 원본 // 루트로 따로 게시되므로, dist 를 통해 한 번 더 실리면 같은 파일이 두 경로에 // 놓여 어느 쪽이 유효한지가 갈린다. custom 원본으로 호출될 때는 끄고 들어온다 // (그러지 않으면 `custom/custom/…` 이 조용히 누락된다). if ($excludeCustom && $this->isCustomAssetPath($relative)) { continue; } $this->copyFile($realFile, $targetDir.DIRECTORY_SEPARATOR.$relative, $files, $tmp); } } /** * 상대 경로가 운영자 소유 디렉토리(`custom/`) 소속인지 판정합니다. * * @param string $relative 게시 원본 기준 상대 경로 * @return bool custom 소속이면 true */ private function isCustomAssetPath(string $relative): bool { $normalized = str_replace(DIRECTORY_SEPARATOR, '/', $relative); return $normalized === CustomAssets::DIRECTORY || str_starts_with($normalized, CustomAssets::DIRECTORY.'/'); } /** * 확장 병합 번들 4종(modules/plugins × js/css)을 게시합니다. * * @param string $tmp tmp 디렉토리 절대 경로 * @param int $version 확장 캐시 버전 * @param array $files 기록된 상대 경로 누적 (참조) */ private function publishBundles(string $tmp, int $version, array &$files): void { $map = ['module' => 'modules', 'plugin' => 'plugins']; foreach ($map as $type => $plural) { foreach (['js', 'css'] as $kind) { $path = $this->bundleService->getBundleFilePath($type, $kind, $version); if ($path === '' || ! is_file($path)) { continue; } $this->copyFile( $path, $tmp.DIRECTORY_SEPARATOR.'bundles'.DIRECTORY_SEPARATOR."{$plural}.{$kind}", $files, $tmp ); } } } /** * 활성 모듈·플러그인의 운영자 소유 디렉토리(`custom/`)를 게시합니다. * * 모듈·플러그인의 빌드 산출물은 **병합 번들**로만 게시되는데, `custom/` 은 그 번들에 * 들어가지 않는다(운영자 파일은 번들보다 뒤에 따로 로드되어야 재정의가 성립한다). * 그래서 게시하지 않으면 확장 자산 중 이것만 요청마다 PHP 를 거치고, 무엇보다 * CSS 내부 상대 `url()` 이 해석되지 않는다 — `?file=` 형태는 기준 URL 이 * `/api/modules/assets/` 라 `url('./font.woff2')` 가 그 디렉토리를 가리키고, * 확장자 형태는 정적 최적화 서버가 먼저 가로챈다. 정적 확장자 URL 은 **public 아래 * 실제 파일일 때만** 200 이 되므로 게시가 유일한 방법이다 (템플릿과 같은 사유). * * **활성 확장만** 게시한다. 자산 서빙이 활성 확장에만 응답하므로, 비활성 확장의 파일을 * 게시해 봐야 아무도 참조하지 않는 사본이 버전 디렉토리마다 쌓일 뿐이다. * * @param string $tmp tmp 디렉토리 절대 경로 * @param array $files 기록된 상대 경로 누적 (참조) */ private function publishExtensionCustomAssets(string $tmp, array &$files): void { // 활성 목록은 **레포지토리**에서 읽는다. 매니저(`getActiveModules()`)는 확장을 // 실제로 적재하는 부작용이 있어, 게시 중에 그 부작용을 끌어들이면 게시가 확장 // 부팅 상태에 좌우된다. 같은 메서드가 템플릿을 `templateRepository->getActive()` // 로 세는 것과 대칭이다. $sources = [ 'modules' => $this->moduleRepository->getActiveModuleIdentifiers(), 'plugins' => $this->pluginRepository->getActivePluginIdentifiers(), ]; foreach ($sources as $root => $identifiers) { foreach ($identifiers as $identifier) { $identifier = (string) $identifier; if (! preg_match(self::IDENTIFIER_PATTERN, $identifier)) { Log::warning('정적 게시 제외 — 식별자 패턴 불일치', [ 'type' => $root, 'identifier' => $identifier, ]); continue; } $this->publishCustomAssets( $root, $identifier, $tmp.DIRECTORY_SEPARATOR.$root.DIRECTORY_SEPARATOR.$identifier .DIRECTORY_SEPARATOR.'assets'.DIRECTORY_SEPARATOR.CustomAssets::DIRECTORY, $files, $tmp ); } } } /** * 게시 대상 로케일을 열거합니다 — `/api/locales/active` 와 동일 소스 * (언어팩이 추가한 로케일 포함). * * @return array 로케일 목록 (패턴 검증 통과분) */ private function publishableLocales(): array { $locales = $this->languagePackService->getActiveLocales(); return array_values(array_filter( is_array($locales) ? $locales : [], static fn ($locale) => is_string($locale) && preg_match(self::LOCALE_PATTERN, $locale) )); } /** * JSON 파일을 API 와 동일 인코딩 옵션으로 기록합니다. * * @param string $absolutePath 기록 대상 절대 경로 * @param mixed $data 직렬화할 데이터 * @param array $files 기록된 상대 경로 누적 (참조) * @param string $tmp tmp 루트 (상대 경로 계산용) */ private function writeJson(string $absolutePath, mixed $data, array &$files, string $tmp): void { $this->makeDirectory(dirname($absolutePath)); $json = json_encode($data, ResponseHelper::JSON_ENCODE_OPTIONS); if ($json === false) { throw new StaticCachePublishException("Failed to encode JSON payload: {$absolutePath}"); } $this->putVerified($absolutePath, $json); $files[] = $this->relativePath($absolutePath, $tmp); } /** * 파일 1개를 복사합니다. * * @param string $source 원본 절대 경로 * @param string $target 대상 절대 경로 * @param array $files 기록된 상대 경로 누적 (참조) * @param string $tmp tmp 루트 (상대 경로 계산용) */ private function copyFile(string $source, string $target, array &$files, string $tmp): void { $this->makeDirectory(dirname($target)); if (! File::copy($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); } /** * Apache 용 불변 캐시 헤더 + 압축 .htaccess 를 기록합니다. * * 정적 서빙은 Laravel 압축 미들웨어(GzipEncodeResponse)를 우회하므로 압축을 * 여기서 직접 선언한다 — 없으면 종전 API 대비 전송량 회귀다 (실측: lang/ko.json * 524,915B 비압축). nginx 는 서버 기본(ETag/Last-Modified) 재검증으로 충분하며 * 권장 gzip/expires 스니펫은 규정 문서(§8)에 안내한다. * * @param string $tmp tmp 디렉토리 절대 경로 * @param array $files 기록된 상대 경로 누적 (참조) */ private function writeHtaccess(string $tmp, array &$files): void { $content = <<<'HTACCESS' Header set Cache-Control "public, max-age=31536000, immutable" AddOutputFilterByType DEFLATE application/json application/javascript text/css image/svg+xml HTACCESS; $this->putVerified($tmp.DIRECTORY_SEPARATOR.'.htaccess', $content); $files[] = '.htaccess'; } /** * 게시 완료 마커(manifest.json)를 기록합니다. * * @param string $finalDir 최종 버전 디렉토리 절대 경로 * @param int $version 확장 캐시 버전 * @param array $files 게시된 상대 경로 목록 */ private function writeManifest(string $finalDir, int $version, array $files): void { $manifest = [ 'cache_version' => $version, 'published_at' => now()->toIso8601String(), 'files' => array_values($files), ]; $json = json_encode($manifest, ResponseHelper::JSON_ENCODE_OPTIONS); if ($json === false) { 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)" ); } } /** * tmp 루트 기준 상대 경로를 반환합니다. * * @param string $absolutePath 절대 경로 * @param string $tmp tmp 루트 * @return string 상대 경로 (구분자 `/` 정규화) */ private function relativePath(string $absolutePath, string $tmp): string { return str_replace('\\', '/', substr($absolutePath, strlen($tmp) + 1)); } }