fix(core): root 프로세스의 terminating 정적 게시를 웹 자가 치유로 위임
sudo 코어 업데이트(7.0.9→7.0.10 실서버)에서 자식 프로세스(신코드, root)의 terminating 게시가 소유권 복원 이후 시점에 실행되며 캐시 락 샤드 디렉토리 (storage/framework/cache/data/xx)와 병합 번들(storage/app/ext-bundles)을 root 소유로 남겼고, 이후 웹 프로세스의 캐시 쓰기가 그 샤드에 해시되는 순간 Permission denied 로 죽어 전면 500 이 됐다 (laravel.log 무기록 — 로거 도달 전). normalizeOwnership 은 게시 트리(build/ext)만 다뤘다 — 게시의 간접 write-set (캐시 락·번들 빌드)이 사각이었다. 뒤쫓아 chown 하는 대신 root 프로세스에서는 terminating 자동 게시를 예약하지 않고 다음 웹 렌더의 자가 치유(웹 계정)에 위임한다. 명시적 ext-static:publish 는 게이트 밖(운영자 책임 — 규정 §6). - 시나리오 매니페스트에 process_user(web/root_cli) 축 신설 — uid 조합 실측 부재가 이 결함을 통과시킨 구조적 원인 (기존 마커 전수에 web 부여) - red 소급 증빙: 게이트 부재 커밋본에서 신규 케이스 실패 확인 → 15/15 green
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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` 로 기능을 끄면 경고 로그도 남지 않는다.
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<string | null> {
|
||||
|
||||
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 }) => {
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user