fix(core): 코어 업데이트 격리 디렉토리 잔존·root 소유권·완료 안내문 수정

7.0.9 → 7.0.10 sudo 업데이트 실측 후 점검에서 드러난 3건.

- 정리 단계가 소스 경로(core_{ts}/extracted/{루트}) 안쪽만 지워 껍데기가
 업데이트마다 남던 것을 격리 디렉토리 루트째 삭제로 바꾼다. 부모는 구버전
 클래스로 돌아 다음 업데이트부터 효력이 있으므로, 신버전 코드로 도는 두 자식
 (execute-upgrade-steps / execute-bundled-updates)이 파일이 없는 core_* 만
 골라 치운다 — 구버전 부모에서 올라오는 업데이트도 껍데기 없이 끝난다.
- 항목별 소유권 스냅샷이 격리 디렉토리 생성 뒤에 찍혀 root 가 만든 추출본을
 원본으로 기록하고 복원이 다시 root 로 되돌리던 것을, 이번 실행의 격리
 디렉토리를 스냅샷에서 제외해 닫는다.
- 완료 안내문이 성공 경로에서는 동작하지 않는 hotfix:rollback-stale-files 를
 가리키던 것을 --prune 재실행 안내만 남기도록 정정한다(ko/en/ja).

부수: ExecuteUpgradeStepsStandaloneTest 가 앞선 core:update 실행이 남긴
프로세스 env 플래그에 따라 결과가 갈리던 순서 의존을 setUp/tearDown 에서 제거.
This commit is contained in:
HeuJung committed 2026-09-06 19:33:34 +09:00
1 parent 24f9df9de4
commit 033d2e6d9b
15 files changed
+527 -14

No files matched your search

+144 -7
View File
@@ -1906,13 +1906,126 @@ class CoreUpdateService
/**
* _pending 하위 디렉토리를 정리합니다.
*
* 타임스탬프 기반 격리 디렉토리를 통째로 삭제합니다.
* 타임스탬프 기반 격리 디렉토리(`core_{Ymd_His}/`)를 통째로 삭제합니다.
*
* @param string $pendingPath 삭제할 pending 디렉토리 경로
* 호출자가 넘기는 경로는 격리 디렉토리 자체가 아니라 그 안쪽의 소스 경로일 수 있다 —
* ZIP·GitHub 경로는 `core_{ts}/extracted/{루트}/` 를, `--local` 은 `core_{ts}/local_source/`
* 를 소스로 돌려준다. 그 안쪽만 지우면 `core_{ts}/extracted/` 껍데기가 업데이트마다 남고,
* sudo 실행이면 root 소유라 운영자·웹서버 계정이 지울 수 없다(7.0.0 부터 누적된 실사례).
* 그래서 격리 디렉토리 루트로 올라가서 지운다.
*
* @param string $pendingPath 삭제할 pending 경로 (격리 디렉토리 또는 그 하위 소스 경로)
*/
public function cleanupPending(string $pendingPath): void
{
ExtensionPendingHelper::cleanupStaging($pendingPath);
ExtensionPendingHelper::cleanupStaging($this->resolveStagingRoot($pendingPath));
}
/**
* 경로가 속한 격리 디렉토리(`{pending_path}/core_*`) 루트를 돌려줍니다.
*
* 경로가 pending 기준 디렉토리 아래가 아니면 그대로 돌려준다 (`--source` 로 넘어온
* 외부 디렉토리처럼 우리가 만들지 않은 경로를 위로 올라가 지우는 일이 없도록).
*
* @param string $path 격리 디렉토리 또는 그 하위 경로
* @return string 격리 디렉토리 루트 또는 입력 경로 그대로
*/
public function resolveStagingRoot(string $path): string
{
$rawBase = rtrim((string) config('app.update.pending_path'), '/\\');
$base = str_replace('\\', '/', $rawBase);
$normalized = rtrim(str_replace('\\', '/', $path), '/');
if ($base === '' || $normalized === $base || ! str_starts_with($normalized, $base.'/')) {
return $path;
}
$relative = substr($normalized, strlen($base) + 1);
$first = explode('/', $relative, 2)[0];
if ($first === '' || $first === '.' || $first === '..') {
return $path;
}
return $rawBase.DIRECTORY_SEPARATOR.$first;
}
/**
* pending 기준 디렉토리에 남은 **빈** 격리 디렉토리(`core_*`)를 청소합니다.
*
* 이전 버전의 정리 단계가 소스 경로 안쪽만 지워 남긴 `core_{ts}/extracted/` 껍데기가
* 대상이다. 부모(구버전 코드)가 남긴 것을 새 코드가 도는 자식 프로세스가 치우므로,
* 이 결함을 가진 버전에서 올라오는 업데이트도 껍데기 없이 끝난다.
*
* 파일이 하나라도 있는 디렉토리는 건드리지 않는다 — 부모가 아직 쓰고 있는 격리
* 디렉토리(추출본·vendor)는 파일을 갖고 있으므로 이 술어만으로 안전하게 구분된다.
*
* @return int 삭제한 격리 디렉토리 수
*/
public function sweepEmptyStagingDirectories(): int
{
$base = (string) config('app.update.pending_path');
if ($base === '' || ! File::isDirectory($base)) {
return 0;
}
$swept = 0;
foreach (File::directories($base) as $dir) {
if (! str_starts_with(basename($dir), 'core_') || is_link($dir)) {
continue;
}
if (! $this->isDirectoryTreeEmpty($dir)) {
continue;
}
ExtensionPendingHelper::cleanupStaging($dir);
if (File::isDirectory($dir)) {
Log::channel('upgrade')->warning('코어 업데이트: 빈 격리 디렉토리 청소 실패 (권한)', ['path' => $dir]);
continue;
}
$swept++;
}
if ($swept > 0) {
Log::channel('upgrade')->info('코어 업데이트: 빈 격리 디렉토리 청소', ['swept' => $swept]);
}
return $swept;
}
/**
* 디렉토리 트리에 파일(또는 링크)이 하나도 없는지 판정합니다.
*
* 읽을 수 없는 하위 디렉토리가 있으면 "비어 있지 않다" 로 본다 — 내용을 모르는
* 디렉토리를 지우지 않기 위해서다.
*
* @param string $dir 판정할 디렉토리
*/
private function isDirectoryTreeEmpty(string $dir): bool
{
try {
$items = new \FilesystemIterator($dir, \FilesystemIterator::SKIP_DOTS);
} catch (\Throwable) {
return false;
}
foreach ($items as $item) {
if ($item->isLink() || ! $item->isDir()) {
return false;
}
if (! $this->isDirectoryTreeEmpty($item->getPathname())) {
return false;
}
}
return true;
}
/**
@@ -2140,10 +2253,16 @@ class CoreUpdateService
* - chown 미지원 환경(Windows 등) 은 빈 배열 반환
* - symbolic link 는 lstat 으로 처리하여 대상 따라가지 않음 (은닉 cycle 방어)
*
* 제외 경로(`$excludes`)는 이번 실행이 스스로 만든 격리 디렉토리를 넘기는 자리다.
* 스냅샷은 격리 디렉토리가 만들어진 **뒤에** 수집되므로, 제외하지 않으면 sudo 실행이
* root 로 만든 추출본이 "원본 소유권" 으로 기록되고 복원 단계가 그 항목을 다시 root
* 로 되돌린다 — 정리가 어떤 이유로든 실패하면 잔존물은 언제나 root 소유가 된다.
*
* @param array<int, string> $paths base_path 상대 또는 절대 경로 목록
* @param array<int, string> $excludes 스냅샷에서 제외할 경로 (그 하위 전체 포함)
* @return array<string, array{owner:int|false, group:int|false, perms:int|null, is_dir:bool, is_link:bool}>
*/
public function snapshotOwnershipDetailed(array $paths): array
public function snapshotOwnershipDetailed(array $paths, array $excludes = []): array
{
if (! function_exists('chown')) {
return [];
@@ -2152,6 +2271,14 @@ class CoreUpdateService
$snapshot = [];
$maxItems = 50000;
$truncated = false;
$excludePrefixes = [];
foreach ($excludes as $exclude) {
$exclude = rtrim(str_replace('\\', '/', trim((string) $exclude)), '/');
if ($exclude !== '') {
$excludePrefixes[] = $exclude;
}
}
foreach ($paths as $rawPath) {
$rawPath = trim((string) $rawPath);
@@ -2164,7 +2291,7 @@ class CoreUpdateService
continue;
}
$this->collectStatRecursively($absolute, $snapshot, $maxItems, $truncated);
$this->collectStatRecursively($absolute, $snapshot, $maxItems, $truncated, $excludePrefixes);
if ($truncated) {
break;
@@ -2206,8 +2333,9 @@ class CoreUpdateService
* 트리를 재귀 stat 하여 snapshot 배열에 누적합니다.
*
* @param array<string, array{owner:int|false, group:int|false, perms:int|null, is_dir:bool, is_link:bool}> $snapshot
* @param array<int, string> $excludePrefixes 제외 경로(슬래시 정규화, 끝 슬래시 없음) — 일치하거나 그 하위면 건너뛴다
*/
private function collectStatRecursively(string $path, array &$snapshot, int $maxItems, bool &$truncated): void
private function collectStatRecursively(string $path, array &$snapshot, int $maxItems, bool &$truncated, array $excludePrefixes = []): void
{
if ($truncated || count($snapshot) >= $maxItems) {
$truncated = true;
@@ -2215,6 +2343,15 @@ class CoreUpdateService
return;
}
if ($excludePrefixes !== []) {
$normalized = rtrim(str_replace('\\', '/', $path), '/');
foreach ($excludePrefixes as $prefix) {
if ($normalized === $prefix || str_starts_with($normalized, $prefix.'/')) {
return;
}
}
}
$isLink = is_link($path);
// symbolic link 는 lstat — 대상 추적 금지
$stat = $isLink ? @lstat($path) : @stat($path);
@@ -2237,7 +2374,7 @@ class CoreUpdateService
$items = new \FilesystemIterator($path, \FilesystemIterator::SKIP_DOTS);
foreach ($items as $item) {
$this->collectStatRecursively($item->getPathname(), $snapshot, $maxItems, $truncated);
$this->collectStatRecursively($item->getPathname(), $snapshot, $maxItems, $truncated, $excludePrefixes);
if ($truncated) {
return;
}