Files
Gnuboard7/tests/scenarios/ext-static-cache.yaml
T
HeuJung b4ed33aa44 feat(core): 정적 게시 수명주기 보완 — 빌드 파괴·권한·무결성
https://github.com/gnuboard/g7/issues/122 제보자의 추가 지적 2건(빌드가 게시본을
지움 / CLI 최초 게시 후 웹 재생성 불가)에 대응하고, 같은 근본 원인을 공유하는
결함을 전수조사로 함께 고친다.

- 빌드가 자기 산출물만 교체한다: 루트 vite `emptyOutDir: false`
 기본값 true 는 폴백 없는 코어 3번들과 배달된 immutable URL 의 게시본을 함께 지웠다
- 게시 트리 권한을 웹이 이어받는다: 병합 전 프리플라이트 + 부모 그룹 상속·g+w
 종전 소유권 정상화는 root 축만 덮어 비-root CLI 계정은 무방비였다
- 갱신 → 생성 → 완전성 확인을 한 묶음으로: 기록 바이트 대조, .old 원자 스왑,
 rename 일시 거부 재시도, 프론트 JSON 파싱 검증
- 실패를 억제하고 기록한다: 버전 한정 실패 마커 + 사유별 대시보드 알림
 + ext-static:status 점검 커맨드
- 파생: catch-all 제외 목록을 에셋 화이트리스트 합집합에서 파생(mjs·webp·otf 누락),
 번들 디스크 쓰기 fail-soft·빈 번들 503, 활성 디렉토리 prune 회피
2026-08-28 08:02:22 +09:00

168 lines
12 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
feature: ext-static-cache
description: |
부트스트랩 리소스 정적 게시(bake) — 공개 #122.
초기 부트스트랩 리소스(다국어 병합·컴포넌트 정의·라우트·확장 번들·템플릿 dist
에셋)를 캐시 버전 디렉토리(`public/build/ext/{v}/`)에 실파일로 게시해 웹서버가
rewrite 전에 직접 서빙한다. 병합 입력이 바뀌는 수명주기 이벤트마다
`incrementExtensionCacheVersion()` 단일 지점의 terminating 예약으로 재게시되고,
누락 시 blade 렌더의 자가 치유가 보충한다. 미게시/부분게시/GC 직후에는 프론트
fetch 계층(fetchStaticFirst)과 태그 계층(asset-url-recovery 파샬)이 종전 API 로
폴백한다.
핵심 동작:
- 게시 원자성: {v}.tmp 쓰기 → rename → manifest 마지막 기록 (manifest 존재 = 완료)
- 서빙 게이트 3조건: 프로덕션 + core.static_cache.enabled + 게시 완료
- 태그 계층은 개별 파일 file_exists 까지 확인 후 정적 URL 방출
- blade 주입 cache_version 시드로 stale localStorage 이중 로드 제거 (작업 A)
- components.json `?v` 부착 + 버전 키드 manifestCache (작업 B)
- 폴백 API 품질: ETag/304 + 환경 분기 (작업 C·D)
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]
# 산출물 무결성 축 — 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:
- { 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: "동일 — 상태 직교" }
# --- 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:
- published_lang_matches_api_payload
- published_routes_has_success_envelope
- published_tree_excludes_inactive_extensions
- published_tree_excludes_sourcemaps
- identifier_outside_pattern_rejected
- publish_is_idempotent_until_forced
- write_failure_cleans_tmp_and_falls_back
- cleanup_keeps_current_and_previous_versions
- kill_switch_disables_publish_and_gate
- terminating_publish_gated_to_production
- terminating_publish_uses_final_version_after_burst
- static_gate_requires_manifest
- tag_layer_checks_individual_file_existence
- static_first_fetch_falls_back_to_api_on_miss
- fallback_is_observable_via_console_warn
- static_asset_tag_recovers_to_api_url
- no_duplicate_boot_requests
- versioned_boot_urls
- handshake_reload_preserved_on_version_mismatch
- manifest_cache_keys_are_version_scoped
- fallback_api_serves_etag_304
- fallback_api_no_cache_in_dev
- degraded_snapshot_gets_no_public_cache
- seo_html_never_references_gc_target_static_path
- static_miss_skips_spa_catch_all
- 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
- 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:
- tests/Feature/Services/ExtensionStaticCacheServiceTest.php
- tests/Feature/Api/Public/PublicComponentsCachingTest.php
- tests/Feature/Template/PublicTemplateControllerTest.php
- tests/Feature/Template/TemplateAssetServingTest.php
- tests/Feature/Template/TemplateLanguageServingTest.php
- tests/Feature/Api/Public/LayoutServingTest.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/Seo/SeoRendererStaticAssetBypassTest.php
- resources/js/core/__tests__/TemplateApp.cacheVersionSeed.test.ts
- resources/js/core/support/__tests__/fetchStaticFirst.test.ts
- resources/js/core/support/__tests__/assetUrlRecovery.test.ts
- resources/js/core/template-engine/__tests__/ComponentRegistry.manifestVersion.test.ts
- resources/js/core/modules/__tests__/ModuleAssetLoader.test.ts
- tests/Playwright/specs/smoke/bootstrap-request-dedup.spec.ts
- tests/Playwright/specs/smoke/static-cache-fallback.spec.ts
notes: |
리소스 5종(lang/components/routes/번들/dist)은 축이 아니라 effects 로 커버한다 —
게시·게이트·폴백은 리소스 무관 공통 메커니즘이고, 리소스별 형상 차이는
published_* effects (payload/봉투/소스맵 제외) 가 각각 단언한다.
environment=dev 축은 PHPUnit(환경 분기 케이스)과 AssetUrl 게이트 단위가 담당한다 —
E2E 대상 사이트(g7_2.dev)는 production 이라 dev 축을 브라우저로 실측할 수 없다.
trigger=lifecycle 의 실기기 검증(확장 비활성/활성 → 재게시 → 새 staticBase)은
Chrome MCP 매트릭스 T10a~T10d 가 수행한다 (원상 복구 의무 포함).
publish_state=partial 은 태그 계층(개별 file_exists 게이트)과 fetch 계층
(fetchStaticFirst 404 폴백) 양쪽에서 단위/브라우저로 커버된다.