diff --git a/app/Services/ExtensionStaticCacheService.php b/app/Services/ExtensionStaticCacheService.php index b906cb31..5b76aa98 100644 --- a/app/Services/ExtensionStaticCacheService.php +++ b/app/Services/ExtensionStaticCacheService.php @@ -46,6 +46,9 @@ class ExtensionStaticCacheService /** terminating 게시 예약 플래그 (프로세스당 1회) */ private static bool $publishScheduled = false; + /** 테스트 전용 — root 프로세스 판정 오버라이드 (null = 실판정) */ + private static ?bool $rootProcessForTesting = null; + /** isPublished 요청당 메모이즈 (version => 존재 여부) */ private array $publishedMemo = []; @@ -184,6 +187,18 @@ class ExtensionStaticCacheService 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; + } + self::$publishScheduled = true; app()->terminating(static function (): void { @@ -209,6 +224,33 @@ class ExtensionStaticCacheService public static function resetPublishScheduleForTesting(): void { self::$publishScheduled = false; + self::$rootProcessForTesting = null; + } + + /** + * 테스트 전용 — 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; } /** diff --git a/docs/backend/static-asset-publishing.md b/docs/backend/static-asset-publishing.md index 33ff7304..5affeb26 100644 --- a/docs/backend/static-asset-publishing.md +++ b/docs/backend/static-asset-publishing.md @@ -75,7 +75,7 @@ public/build/ext/{cache_version}/ - **웹 프로세스 계정의 `public/build` 쓰기 권한**: 게시의 정상 주체는 terminating 훅/자가 치유 = php-fpm(웹 계정)이다. 시스템 요구사항의 그룹 공유(방식 A) 구성이라면 `public/build` 도 같은 원칙(g+w + 공용 그룹)을 적용한다. 게시 코드가 디렉토리를 0775 로 생성하므로 umask 동조 환경에서 그룹 쓰기가 유지된다. - **설치 시**: 설치기 완료 단계가 `ext-static:publish --force` 를 best-effort 로 실행한다 — 설치기는 웹 요청 컨텍스트에서 돌므로 산출물은 웹 계정 소유가 되어 이후 재게시와 자연 정합한다. 실패해도 설치는 완료되고 첫 방문의 자가 치유가 재시도한다. -- **sudo 코어 업데이트 시**: 업데이트가 root 로 실행되면 종료 시점의 terminating 게시가 root 소유 산출물을 만들 수 있다. 이는 코어 업데이트의 소유권 복원(`app.update.restore_ownership`) **이후**에 일어나므로, 게시 서비스가 직접 방어한다 — root 로 실행된 게시는 완료 직후 부모 디렉토리(`public/build`) 소유권을 산출물에 상속시킨다. `public/build/ext` 는 소유권 복원·그룹 쓰기 정상화 목록에도 포함되어 있다. +- **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 으로 삭제하지 않고, 백업 대상에서도 제외된다. 권한 문제로 게시가 계속 실패하는 환경(공유 호스팅 등)에서는 `G7_STATIC_CACHE=false` 로 기능을 끄면 경고 로그도 남지 않는다. diff --git a/tests/Feature/Services/ExtensionStaticCacheServiceTest.php b/tests/Feature/Services/ExtensionStaticCacheServiceTest.php index 6a6db31d..37180c90 100644 --- a/tests/Feature/Services/ExtensionStaticCacheServiceTest.php +++ b/tests/Feature/Services/ExtensionStaticCacheServiceTest.php @@ -92,7 +92,7 @@ class ExtensionStaticCacheServiceTest extends TestCase /** * 활성 템플릿 0개(설치 직전)여도 예외 없이 빈 게시가 성립한다. * - * @scenario publish_state=unpublished, environment=production, trigger=manual_command + * @scenario publish_state=unpublished, environment=production, trigger=manual_command, process_user=web */ public function test_publishes_empty_tree_when_no_active_templates(): void { @@ -239,7 +239,7 @@ class ExtensionStaticCacheServiceTest extends TestCase /** * 멱등 — 게시 완료 상태에서 재호출은 skip (기존 게시물 유지), force 는 재게시. * - * @scenario publish_state=published, environment=production, trigger=manual_command + * @scenario publish_state=published, environment=production, trigger=manual_command, process_user=web * * @effects publish_is_idempotent_until_forced */ @@ -264,7 +264,7 @@ class ExtensionStaticCacheServiceTest extends TestCase /** * 쓰기 단계 실패 시 예외를 삼키고 false + tmp 잔존물 정리 + manifest 부재. * - * @scenario publish_state=partial, environment=production, trigger=lifecycle + * @scenario publish_state=partial, environment=production, trigger=lifecycle, process_user=web * * @effects write_failure_cleans_tmp_and_falls_back */ @@ -326,7 +326,7 @@ class ExtensionStaticCacheServiceTest extends TestCase /** * kill-switch(core.static_cache.enabled=false) — 게시 자체가 중단된다. * - * @scenario publish_state=unpublished, environment=production, trigger=kill_switch + * @scenario publish_state=unpublished, environment=production, trigger=kill_switch, process_user=web * * @effects kill_switch_disables_publish_and_gate */ @@ -343,7 +343,7 @@ class ExtensionStaticCacheServiceTest extends TestCase /** * terminating 트리거 — 비프로덕션(testing)에서는 예약되지 않는다. * - * @scenario publish_state=unpublished, environment=dev, trigger=lifecycle + * @scenario publish_state=unpublished, environment=dev, trigger=lifecycle, process_user=web * * @effects terminating_publish_gated_to_production */ @@ -359,7 +359,7 @@ class ExtensionStaticCacheServiceTest extends TestCase /** * terminating 트리거 — 프로덕션에서는 종료 시점의 현재 버전으로 게시된다. * - * @scenario publish_state=unpublished, environment=production, trigger=lifecycle + * @scenario publish_state=unpublished, environment=production, trigger=lifecycle, process_user=web * * @effects terminating_publish_uses_final_version_after_burst */ @@ -378,6 +378,34 @@ class ExtensionStaticCacheServiceTest extends TestCase $this->assertFalse($this->service()->isPublished(self::VERSION)); } + /** + * root 프로세스(sudo CLI)에서는 terminating 게시가 예약되지 않는다. + * + * root 로 게시하면 캐시 락 샤드 디렉토리(`storage/framework/cache/data/xx`)와 + * 병합 번들(`storage/app/ext-bundles`)이 root 소유로 남고, 이후 웹 프로세스의 + * 캐시 쓰기가 그 샤드에 해시되는 순간 Permission denied 로 죽는다 + * (실사례: sudo 코어 업데이트 직후 전면 500). 게시는 다음 웹 렌더의 + * 자가 치유(웹 계정)가 수행한다. + * + * @scenario publish_state=unpublished, environment=production, trigger=lifecycle, process_user=root_cli + * + * @effects root_cli_defers_publish_to_web_self_heal + */ + public function test_terminating_publish_not_scheduled_for_root_process(): void + { + app()['env'] = 'production'; + ExtensionStaticCacheService::fakeRootProcessForTesting(true); + + try { + ExtensionStaticCacheService::schedulePublishOnTerminate(); + $this->app->terminate(); + + $this->assertDirectoryDoesNotExist($this->service()->baseDir()); + } finally { + ExtensionStaticCacheService::fakeRootProcessForTesting(null); + } + } + /** * terminating 게시 예약 플래그는 콜백 실행 시점에 리셋된다. * @@ -386,7 +414,7 @@ class ExtensionStaticCacheServiceTest extends TestCase * 않은 채 플래그만 true 라 그 워커에서 영구 미게시가 되고, `AssetUrl` 자가 치유도 * 같은 플래그를 쓰므로 복구 경로가 없다. FPM(요청=프로세스)에서는 무영향. * - * @scenario publish_state=published, environment=production, trigger=lifecycle + * @scenario publish_state=published, environment=production, trigger=lifecycle, process_user=web * * @effects terminating_schedule_rearms_after_execution */ @@ -409,7 +437,7 @@ class ExtensionStaticCacheServiceTest extends TestCase * 게시 디렉토리가 스스로 압축을 선언해야 종전 API 대비 전송량 회귀가 없다 * (실측: lang/ko.json 524,915B 비압축 전송). nginx 는 규정 문서의 gzip 스니펫이 담당한다. * - * @scenario publish_state=published, environment=production, trigger=manual_command + * @scenario publish_state=published, environment=production, trigger=manual_command, process_user=web * * @effects published_htaccess_declares_compression */ diff --git a/tests/Feature/Template/TemplateAssetServingTest.php b/tests/Feature/Template/TemplateAssetServingTest.php index 42fbaaa1..d53560e3 100644 --- a/tests/Feature/Template/TemplateAssetServingTest.php +++ b/tests/Feature/Template/TemplateAssetServingTest.php @@ -586,7 +586,7 @@ class TemplateAssetServingTest extends TestCase /** * 개발 환경에서 components.json 은 no-cache (파일 수정 즉시 반영 — F10) * - * @scenario publish_state=unpublished, environment=dev, trigger=self_heal + * @scenario publish_state=unpublished, environment=dev, trigger=self_heal, process_user=web * * @effects fallback_api_no_cache_in_dev */ diff --git a/tests/Feature/Template/TemplateLanguageServingTest.php b/tests/Feature/Template/TemplateLanguageServingTest.php index 252b6477..316c9aaf 100644 --- a/tests/Feature/Template/TemplateLanguageServingTest.php +++ b/tests/Feature/Template/TemplateLanguageServingTest.php @@ -193,7 +193,7 @@ class TemplateLanguageServingTest extends TestCase /** * 개발 환경에서 lang 은 no-cache (파일 수정 즉시 반영 — F10) * - * @scenario publish_state=unpublished, environment=dev, trigger=manual_command + * @scenario publish_state=unpublished, environment=dev, trigger=manual_command, process_user=web */ public function test_language_no_cache_in_development(): void { diff --git a/tests/Playwright/specs/smoke/bootstrap-request-dedup.spec.ts b/tests/Playwright/specs/smoke/bootstrap-request-dedup.spec.ts index f9d68a72..b180c9e6 100644 --- a/tests/Playwright/specs/smoke/bootstrap-request-dedup.spec.ts +++ b/tests/Playwright/specs/smoke/bootstrap-request-dedup.spec.ts @@ -6,7 +6,7 @@ * ~500KB 중복 / 부트 ~1.3s 연장이 발생했다. blade 주입 cache_version 시드가 * 이를 제거했음을 실브라우저에서 잠근다. * - * @scenario publish_state=published, environment=production, trigger=lifecycle + * @scenario publish_state=published, environment=production, trigger=lifecycle, process_user=web * @effects no_duplicate_boot_requests, versioned_boot_urls */ import { test, expect, type Page } from '@playwright/test'; diff --git a/tests/Playwright/specs/smoke/static-cache-fallback.spec.ts b/tests/Playwright/specs/smoke/static-cache-fallback.spec.ts index d082f668..65f08df6 100644 --- a/tests/Playwright/specs/smoke/static-cache-fallback.spec.ts +++ b/tests/Playwright/specs/smoke/static-cache-fallback.spec.ts @@ -8,7 +8,7 @@ * env 가드 — 대상 사이트가 정적 게시 미적용(비프로덕션/kill-switch/미게시)이면 * staticBase 미주입으로 skip 된다 (첫 방문이 자가 치유를 예약하므로 워밍 1회 수행). * - * @scenario publish_state=partial, environment=production, trigger=self_heal + * @scenario publish_state=partial, environment=production, trigger=self_heal, process_user=web * @effects static_first_fetch_falls_back_to_api_on_miss, fallback_is_observable_via_console_warn */ import { test, expect, type Page } from '@playwright/test'; @@ -33,7 +33,7 @@ async function probeStaticBase(page: Page): Promise { test.describe('정적 게시 fast path + 폴백 (#122)', () => { /** - * @scenario publish_state=published, environment=production, trigger=self_heal + * @scenario publish_state=published, environment=production, trigger=self_heal, process_user=web * @effects versioned_boot_urls */ test('@smoke 정적 URL 로 부트 리소스를 수신한다 (static-first)', async ({ page }) => { diff --git a/tests/Unit/Support/AssetUrlTest.php b/tests/Unit/Support/AssetUrlTest.php index be99f88d..ea0c80e6 100644 --- a/tests/Unit/Support/AssetUrlTest.php +++ b/tests/Unit/Support/AssetUrlTest.php @@ -223,7 +223,7 @@ class AssetUrlTest extends TestCase * 정적 게이트 3조건 — 프로덕션 + enabled + 게시 완료(manifest) 를 전부 * 통과해야만 base 가 반환된다. * - * @scenario publish_state=unpublished, environment=production, trigger=self_heal + * @scenario publish_state=unpublished, environment=production, trigger=self_heal, process_user=web * * @effects static_gate_requires_manifest, kill_switch_disables_publish_and_gate */ @@ -261,7 +261,7 @@ class AssetUrlTest extends TestCase * 태그 계층 파일 단위 게이트 — manifest 는 있어도 그 자산의 실파일이 없으면 * 그 자산만 종전 API URL 로 방출된다 (나머지는 정적 URL). * - * @scenario publish_state=partial, environment=production, trigger=manual_command + * @scenario publish_state=partial, environment=production, trigger=manual_command, process_user=web * * @effects tag_layer_checks_individual_file_existence */ @@ -373,7 +373,7 @@ class AssetUrlTest extends TestCase /** * base null(게이트 미통과) 이면 기존 URL 과 바이트 동일해야 한다 (호출부 무변경 계약). * - * @scenario publish_state=unpublished, environment=dev, trigger=kill_switch + * @scenario publish_state=unpublished, environment=dev, trigger=kill_switch, process_user=web */ public function test_게이트_미통과시_종전_ur_l_바이트_동일(): void { diff --git a/tests/scenarios/ext-static-cache.yaml b/tests/scenarios/ext-static-cache.yaml index 73dd28d3..45a9de8f 100644 --- a/tests/scenarios/ext-static-cache.yaml +++ b/tests/scenarios/ext-static-cache.yaml @@ -23,12 +23,22 @@ axes: publish_state: [published, unpublished, partial] environment: [production, dev] trigger: [lifecycle, self_heal, manual_command, kill_switch] + # 실행 주체(uid) 축 — sudo CLI(root)와 웹 계정이 갈리는 파일 산출물 기능의 필수 축. + # 7.0.10 업그레이드 실사례: root terminating 게시가 캐시 락 샤드를 root 소유로 남겨 + # 웹 캐시 쓰기가 전면 500. 이 축이 없어 24축 매트릭스가 그 조합을 못 잡았다. + process_user: [web, root_cli] exclusions: - { environment: dev, publish_state: published, reason: "dev 는 staticBase 미주입 — 게시 상태와 무관하게 전 리소스 API 직행" } - { environment: dev, publish_state: partial, reason: "동일 — dev 는 정적 경로 자체가 없다" } - { trigger: kill_switch, publish_state: published, reason: "kill-switch off 는 게이트에서 base 자체를 차단 — 게시 상태 무관" } - { trigger: kill_switch, publish_state: partial, reason: "동일" } + - { process_user: root_cli, trigger: self_heal, reason: "자가 치유는 웹 렌더 경로 전용 — root 웹 프로세스는 존재하지 않는 구성" } + - { process_user: root_cli, environment: dev, reason: "dev 는 게시 자체가 무의미 (terminating 게이트가 프로덕션 한정)" } + - { process_user: root_cli, trigger: kill_switch, reason: "kill-switch 는 uid 판정 이전의 선행 게이트 — uid 무관" } + - { 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: partial, reason: "동일 — 상태 직교" } effects: - published_lang_matches_api_payload @@ -59,6 +69,7 @@ effects: - terminating_schedule_rearms_after_execution - published_htaccess_declares_compression - bundle_script_static_miss_falls_back_to_api + - root_cli_defers_publish_to_web_self_heal test_files: - tests/Feature/Services/ExtensionStaticCacheServiceTest.php