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 언어팩 동기
This commit is contained in:
HeuJung committed 2026-09-06 15:22:52 +09:00
1 parent d263f74cae
commit de18159986
105 files changed
+4055 -208

No files matched your search

@@ -2,6 +2,7 @@
namespace App\Console\Commands;
use App\Extension\Helpers\FilePermissionHelper;
use App\Services\ExtensionStaticCacheService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
@@ -13,7 +14,10 @@ use Illuminate\Support\Facades\File;
* 운영 환경에서 실패를 확인할 방법이 사실상 없었다(`/dev` 대시보드는 `app.debug` 가 필요한데
* 게시는 프로덕션 전용이다). 이 커맨드가 그 통로다.
*
* 출력: 현재 버전 / 게시 여부 / manifest 파일 수 / 게시 트리 쓰기 가능성 /
* 판정은 `ExtensionStaticCacheService::statusReport()` 가 소유한다 — 관리자 화면의
* 「초기 화면 정적 파일」 카드와 같은 값을 소비하며, 이 커맨드는 문구·조치 힌트만 담당한다.
*
* 출력: 실행 계정 / 현재 버전 / 게시 여부 / manifest 파일 수 / 게시 트리 쓰기 가능성 /
* 최근 실패 마커 / 잔존 버전 목록.
*
* 종료 코드: 이상이 하나라도 있으면 비-0. 비-0 은 "커맨드 실행 실패" 가 아니라
@@ -41,23 +45,30 @@ class StatusExtensionStaticCacheCommand extends Command
{
$problems = [];
$enabled = $service->isEnabled();
$version = ExtensionStaticCacheService::getExtensionCacheVersion();
$report = $service->statusReport();
$base = $service->baseDir();
$versionDir = $service->versionDir($version);
$published = $service->isPublished($version);
$publishHint = self::publishCommandHint();
$this->line('');
$this->line('<options=bold>부트스트랩 리소스 정적 게시 상태</>');
$this->line('');
$this->line(' 실행 계정 : '.ExtensionStaticCacheService::currentProcessUser());
$this->line(' kill-switch : '.($enabled ? '활성 (게시함)' : '<comment>비활성 (게시 안 함)</comment>'));
$this->line(' 현재 캐시 버전 : '.$version);
$this->line(' 실행 계정 : '.$report['process_user']);
// root 로 실행 중이면 여기서 게시하지 말라고 먼저 알린다 — 캐시 락 샤드·병합 번들이 root
// 소유로 남아 이후 웹 요청의 캐시 쓰기가 Permission denied 로 죽는다(전면 500 실사례).
// 중단하지는 않는다 — 상태 점검은 읽기 전용이다. (#651 C1)
if (self::isRootProcess()) {
$this->line(' <comment>root 로 실행 중 — 이 계정으로 게시하면 캐시 폴더가 root 소유가 될 수 있습니다. '
.'웹 계정으로 실행하세요: '.$publishHint.'</comment>');
}
$this->line(' kill-switch : '.($report['enabled'] ? '활성 (게시함)' : '<comment>비활성 (게시 안 함)</comment>'));
$this->line(' 현재 캐시 버전 : '.$report['version']);
$this->line(' 게시 루트 : '.$base);
// kill-switch 가 꺼져 있으면 미게시는 정상이다 — 이상으로 세지 않는다.
if (! $enabled) {
if (! $report['enabled']) {
$this->line('');
$this->comment('kill-switch 가 꺼져 있어 게시 상태는 점검하지 않습니다 (core.static_cache.enabled).');
@@ -65,9 +76,7 @@ class StatusExtensionStaticCacheCommand extends Command
}
// 1) 게시 트리 쓰기 가능성 — 제보 본건(P1/P2)의 직접 지표
$writable = File::isDirectory($base) ? is_writable($base) : $this->parentIsWritable($base);
if ($writable) {
if ($report['tree_writable']) {
$this->line(' 트리 쓰기 : 가능');
} else {
$this->line(' 트리 쓰기 : <error>불가</error>');
@@ -81,28 +90,27 @@ class StatusExtensionStaticCacheCommand extends Command
}
// 2) 현재 버전 게시 여부
if ($published) {
$manifest = json_decode((string) @file_get_contents($versionDir.'/manifest.json'), true);
$fileCount = is_array($manifest) ? count($manifest['files'] ?? []) : 0;
if ($report['published']) {
$this->line(' 게시 상태 : 완료');
$this->line(' manifest 파일 : '.$fileCount.'건');
$this->line(' 게시 시각 : '.(is_array($manifest) ? ($manifest['published_at'] ?? '?') : '?'));
$this->line(' manifest 파일 : '.$report['files'].'건');
$this->line(' 게시 시각 : '.($report['published_at'] ?? '?'));
if ($fileCount === 0) {
if ($report['files'] === 0) {
$problems[] = 'manifest 에 기록된 파일이 0건입니다 — 게시가 비어 있습니다.';
}
} else {
$this->line(' 게시 상태 : <comment>미게시</comment>');
$problems[] = sprintf(
'현재 버전(%d)이 게시되지 않았습니다. 다음 웹 렌더의 자가 치유가 시도하며, '
.'즉시 게시하려면 `php artisan ext-static:publish` 를 실행하세요.',
$version
.'즉시 게시하려면 웹 계정으로 `%s` 를 실행하거나 관리자 > 환경설정 > 일반 의 '
.'「지금 다시 만들기」를 사용하세요.',
$report['version'],
$publishHint
);
}
// 3) 최근 실패 마커 — 원인별로 조치가 다르다
$marker = ExtensionStaticCacheService::failureMarker();
$marker = $report['failure'];
if ($marker !== null) {
$this->line('');
@@ -117,7 +125,7 @@ class StatusExtensionStaticCacheCommand extends Command
}
// 4) 잔존 버전 목록 — 누적은 삭제 실패(소유권 불일치)의 지표다
$versions = $this->publishedVersions($base);
$versions = $report['retained_versions'];
$this->line('');
$this->line(' 잔존 버전 : '.($versions === [] ? '(없음)' : implode(', ', $versions)));
@@ -148,16 +156,35 @@ class StatusExtensionStaticCacheCommand extends Command
}
/**
* 게시 루트가 아직 없을 때, 만들 수 있는지(부모 쓰기 가능) 판정합니다.
* 수동 게시 명령을 웹 계정 실행 형태로 안내합니다 — `ext-static:publish` 와 공유.
*
* @param string $base 게시 루트 절대 경로
* @return bool 생성 가능 여부
* root 가 아니면 명령만 그대로다(일반 SSH 사용자 = 파일 소유자 / 공유 호스팅 대칭 구성 / posix
* 미지원). root 이면 웹서버 계정을 식별해 `sudo -u {계정}` 을 붙이고, 식별하지 못하면
* placeholder 와 확인 방법을 함께 적는다 — 판정은 `FilePermissionHelper::describeWebServerAccount()`
* 가 소유한다(`core:update` 재실행 안내와 같은 4분기).
*
* @return string 안내 명령 문자열
*/
private function parentIsWritable(string $base): bool
public static function publishCommandHint(): string
{
$parent = dirname($base);
$command = 'php artisan ext-static:publish';
$account = FilePermissionHelper::describeWebServerAccount();
return File::isDirectory($parent) && is_writable($parent);
return match ($account['mode']) {
'root_web_known' => 'sudo -u '.$account['name'].' '.$command,
'root_web_unknown' => 'sudo -u <웹서버계정> '.$command.' (웹서버 계정은 storage 디렉토리 소유자로 확인)',
default => $command,
};
}
/**
* 현재 프로세스가 root(euid 0)로 실행 중인지 판정합니다 — 안내 분기 전용.
*
* @return bool root 실행 여부
*/
public static function isRootProcess(): bool
{
return FilePermissionHelper::describeWebServerAccount()['mode'] !== 'non_root';
}
/**
@@ -172,37 +199,11 @@ class StatusExtensionStaticCacheCommand extends Command
{
return match ($reason) {
'parent_not_writable' => '게시 트리 권한 문제입니다. CLI 계정과 웹 계정이 그룹을 공유하고 '
.'`public/build` 가 그룹 쓰기(g+w)인지 확인하세요.',
.'`public/build` 가 그룹 쓰기(g+w)인지 확인하세요. 수동 게시는 웹 계정으로: '
.self::publishCommandHint(),
'write_failed' => '쓰기 도중 실패했습니다. 디스크 여유 공간과 quota 를 확인하세요.',
'lock_unavailable' => '캐시 락을 얻지 못했습니다. 캐시 저장소(파일 캐시 디렉토리 권한 등)를 확인하세요.',
default => '상세 메시지를 확인하세요.',
};
}
/**
* 게시 루트에 남아 있는 버전 디렉토리 목록을 반환합니다 (오름차순).
*
* @param string $base 게시 루트 절대 경로
* @return array<int, int> 버전 목록
*/
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;
}
}