Files
HeuJung de18159986 fix(core,admin_basic): 정적 게시 재게시 누락 3건 수정 + 관리자 수동 복구 UI + sudo core:update 소유권 정합
- 템플릿 update 는 레이아웃 변경 여부와 무관하게 캐시 버전을 올리고(실패 복원 뒤에도), 자산 주소 방식 전환 3경로도 bump 한다
- config:cache 가 컨테이너 인스턴스를 덮어 이후 terminating 재게시가 사라지던 결함을 복원 헬퍼로 차단
- custom/ 변경 감지와 게시가 같은 열거자(재귀·크기 포함)를 쓰고, 서명은 호스트별로 저장
- 캐시 버전·서명 키를 만료시키지 않는다(기본 TTL 24h 로 매일 전체 재생성되던 문제)
- 관리자 > 환경설정 > 일반 「초기 화면 정적 파일」 카드: 상태 조회 + 지금 다시 만들기(확인 모달), 대시보드 알림 버튼 연결, CLI 와 같은 statusReport 소비
- sudo core:update 경로의 root 소유 잔존(설정 디렉토리·임시 폴더·로그·업그레이드 마이그레이션 산출물) 상속·정합화, ext-static 명령 root 경고와 sudo -u 힌트
- 안내·문서·트러블슈팅 4건·audit 룰 ext-cache-version-raw-read·ja 언어팩 동기
2026-09-06 15:22:52 +09:00

188 lines
13 KiB
YAML
Raw Permalink 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
# #651 — 재게시 누락 3건·버전 수명·root 안내
- template_update_always_bumps_cache_version
- template_update_failure_bumps_after_restore
- asset_url_mode_change_bumps_extension_cache_version
- cache_version_key_does_not_expire
- custom_signature_covers_publishable_tree
- custom_signature_scoped_per_host
- status_command_warns_root_and_hints_web_account
- publish_command_warns_root_but_continues
- status_command_consumes_service_report
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/Extension/TemplateUpdateConfigCacheInvalidationTest.php
- tests/Unit/Services/SettingsServiceSetSettingSideEffectsTest.php
- tests/Unit/Support/CustomAssetsPublishableFilesTest.php
- tests/Feature/View/CustomAssetInjectionTest.php
- tests/Feature/Console/ExtStaticStatusCommandTest.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)는 평소 local 이라, 브라우저 실측은 실측 창 동안만 `.env` 를
production 으로 전환해 수행하고 끝나면 원본을 복구한다(md5 대조). dev 축 자체는 브라우저로
실측하지 않는다.
관리자 수동 복구(상태 GET + 재게시 POST + 카드·모달·알림 버튼)는 축이 다르므로 별도
매니페스트 `tests/scenarios/static-cache-admin-rebuild.yaml` 이 소유한다 — 이 파일의 6축에
trigger 값을 더하면 108 조합의 exclusions 가 필요해진다.
trigger=lifecycle 의 실기기 검증(확장 비활성/활성 → 재게시 → 새 staticBase)은
Chrome MCP 매트릭스 T10a~T10d 가 수행한다 (원상 복구 의무 포함).
publish_state=partial 은 태그 계층(개별 file_exists 게이트)과 fetch 계층
(fetchStaticFirst 404 폴백) 양쪽에서 단위/브라우저로 커버된다.