Merge pull request from gnuboard:HeuJung/issue651

fix(core): 코어 업데이트 격리 디렉토리 잔존·root 소유권·완료 안내문 수정
This commit is contained in:
정정홍
2026-09-06 19:35:05 +09:00
committed by GitHub
15 changed files with 527 additions and 14 deletions
+2
View File
@@ -88,6 +88,8 @@
- 서버가 빈 응답이나 알 수 없는 형식의 응답을 보냈을 때, 설치 마법사가 원인도 조치도 알 수 없는 오류 문구 대신 무엇을 확인하면 되는지 안내합니다. 설치 진행 화면도 응답을 계속 읽지 못하면 화면에 아무 표시 없이 기다리기만 하지 않고 사용자에게 알립니다. (#62 @kitrio 님께서 제보해주셨습니다.)
- 한국어 Windows 에서 관리자 환경설정의 시스템 정보가 서버 오류(500)가 될 수 있던 문제를 수정했습니다. CPU 정보를 조회하는 명령의 한글 출력이 원인이었습니다.
- 설치 완료·실패·중단 안내와 필수 파일 생성 안내가 나타날 때 화면이 그 위치로 부드럽게 이동합니다. 종전에는 설치 진행 로그를 보느라 화면이 아래쪽에 머물러 있으면 안내가 표시되어도 눈에 들어오지 않았습니다. 화면 움직임을 최소화하도록 설정한 사용자에게는 즉시 이동합니다. (Modern PHP User Group 박민권 님께서 제보해주셨습니다.)
- 코어 업데이트가 끝난 뒤 임시 작업 폴더의 껍데기(`storage/app/core_pending/core_*/extracted`)가 업데이트마다 남던 문제를 수정했습니다. `sudo` 로 실행한 경우 그 폴더가 root 소유로 남아 운영자 계정이나 웹서버 계정으로는 지울 수 없었습니다. 이제 임시 폴더를 통째로 정리하고, 이전 버전이 남긴 빈 껍데기도 업데이트 과정에서 함께 치웁니다. 소유권 복원 기준에서도 이번 실행이 만든 임시 폴더를 제외해, 남는 것이 있더라도 운영자가 지울 수 있는 소유권을 갖습니다.
- 코어 업데이트 완료 안내문이 성공한 업데이트 뒤에는 동작하지 않는 정리 명령(`hotfix:rollback-stale-files --prune`)을 함께 안내하던 것을 바로잡았습니다. 성공한 업데이트는 백업을 지우므로 그 명령은 "사용 가능한 백업이 없습니다" 로 끝났습니다. 이제 신 버전에서 제거된 파일을 정리하려면 같은 업데이트를 `--prune` 옵션으로 다시 실행하도록만 안내합니다.
## [7.0.9] - 2026-08-24
@@ -301,12 +301,16 @@ class CoreUpdateCommand extends Command
// Step 11/12 의 restoreOwnership 이 항목별 정확 복원하도록 전달한다.
// 사용자 데이터 영역(storage/app/{modules,plugins,attachments,public,settings})
// 은 본 스냅샷 대상이 아니며 chown 자체가 빠지므로 시드/업로드 owner 가 보존된다.
//
// 이번 실행이 방금 만든 격리 디렉토리(core_{ts})는 제외한다 — 스냅샷은 그 디렉토리가
// 생긴 뒤에 찍히므로, 제외하지 않으면 sudo 가 root 로 만든 추출본이 "원본" 으로
// 기록되고 복원이 잔존물을 다시 root 로 되돌린다(7.0.0~7.0.9 실사례).
$detailedOwnershipSnapshot = $service->snapshotOwnershipDetailed([
'storage/logs',
'storage/framework',
'storage/app/core_pending',
'bootstrap/cache',
]);
], excludes: [$service->resolveStagingRoot($pendingPath)]);
if (! empty($detailedOwnershipSnapshot)) {
$log('항목별 정확 스냅샷 수집: '.count($detailedOwnershipSnapshot).'개 항목 (PHP-FPM 쓰기 영역)');
}
@@ -8,6 +8,7 @@ use App\Extension\PluginManager;
use App\Extension\TemplateManager;
use App\Extension\Vendor\VendorMode;
use App\Search\SearchIndexMaintenanceManager;
use App\Services\CoreUpdateService;
use App\Services\LanguagePackService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
@@ -183,6 +184,15 @@ class ExecuteBundledUpdatesCommand extends Command
}
}
// 이전 버전 부모가 남긴 빈 격리 디렉토리(core_{ts}/extracted 껍데기) 청소 —
// 부모의 정리 단계는 이 자식보다 먼저 끝나므로, 구버전 부모에서 올라오는
// 업데이트도 이 자리에서 껍데기 없이 마무리된다. 실패해도 업데이트 결과와 무관.
try {
app(CoreUpdateService::class)->sweepEmptyStagingDirectories();
} catch (\Throwable $e) {
Log::channel('upgrade')->warning('[spawn] 빈 격리 디렉토리 청소 실패', ['error' => $e->getMessage()]);
}
// 부모 프로세스가 결과를 복원할 수 있도록 표식 라인으로 페이로드 출력
$this->line(self::RESULT_PREFIX.json_encode([
'success' => $success,
@@ -290,6 +290,7 @@ class ExecuteUpgradeStepsCommand extends Command
}
$this->restoreUpgradeLogOwnership();
$this->sweepEmptyStagingDirectories($service);
// 단독 실행(sudo core:execute-upgrade-steps)이 만든 캐시/번들 root 산출물
// 소유권 정상화 — spawn 자식 모드에서도 무해(멱등)하며, 부모(CoreUpdateCommand)
// 종료부의 동일 호출이 부모 측 후속 쓰기를 담당한다
@@ -298,6 +299,29 @@ class ExecuteUpgradeStepsCommand extends Command
return self::SUCCESS;
}
/**
* 이전 버전 부모가 남긴 빈 격리 디렉토리(`core_{ts}/extracted` 껍데기)를 청소합니다.
*
* 부모의 정리 단계는 소스 경로 안쪽만 지우던 결함(7.0.0~7.0.9)이 있어 업데이트마다
* 껍데기가 남았고, sudo 실행이면 root 소유라 운영자·웹서버 계정이 지울 수 없었다.
* 부모는 구버전 클래스를 메모리에 들고 있어 고쳐도 다음 업데이트부터 효력이 있으므로,
* 신버전 코드로 도는 이 자식이 치운다. 파일이 있는 디렉토리(부모가 쓰는 중인 격리
* 디렉토리)는 술어상 건드리지 않는다. 실패는 업데이트 결과와 무관하므로 경고로 흡수한다.
*
* @param CoreUpdateService $service 코어 업데이트 서비스
*/
private function sweepEmptyStagingDirectories(CoreUpdateService $service): void
{
try {
$swept = $service->sweepEmptyStagingDirectories();
if ($swept > 0) {
$this->info("[spawn] 빈 격리 디렉토리 청소: {$swept}개");
}
} catch (\Throwable $e) {
Log::channel('upgrade')->warning('[spawn] 빈 격리 디렉토리 청소 실패', ['error' => $e->getMessage()]);
}
}
/**
* spawn 자식이 root 로 만든 upgrade 로그 파일의 소유권을 부모(storage/logs) 로 정합합니다.
*
+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;
}
+9 -3
View File
@@ -222,7 +222,13 @@ v접두사 자동 감지 (resolveGithubArchiveUrl):
> **기본 동작 변경 배경 (공개 #64)**: 이전에는 Step 7 이 targets 전체를 무조건 재복사하고 orphan 을 삭제하여, 사용자가 수정한 `public/.htaccess` 커스텀 블록이나 `_bundled/` 아래 커스텀 확장이 소실되는 사고가 반복 제보되었다. 기본 동작을 "코어가 실제로 변경/추가한 파일만 적용(3-way)"으로 전환해 발생 표면을 제거했다. 전체 덮어쓰기 + 정리를 원하면 `--prune` 을 지정한다. "코어도 바꾸고 사용자도 바꾼" 파일은 코어 버전으로 갱신되지만 백업에 원본이 보존되어 복구 가능하다.
> **증분 모드 잔존 stale 파일 정리**: 기본(증분) 모드는 orphan 을 삭제하지 않으므로, 신 버전에서 제거된 파일이 활성 디렉토리에 잔존할 수 있다. 완료 요약이 잔존을 안내하며, 정리하려면 다음 업데이트를 `--prune` 으로 실행하거나 단발성 정리 도구 `php artisan hotfix:rollback-stale-files --prune` 을 사용한다 (진단 모드 기본, `--prune` 시 확인 프롬프트 후 정리, symlink/protected_paths 가드 적용). 상세 사용법: [docs/cheatsheet.md](../cheatsheet.md) "단발성 결함 보정 (hotfix)".
> **증분 모드 잔존 stale 파일 정리**: 기본(증분) 모드는 orphan 을 삭제하지 않으므로, 신 버전에서 제거된 파일이 활성 디렉토리에 잔존할 수 있다. 완료 요약이 잔존을 안내하며, 정리하려면 같은 업데이트를 `--prune` 으로 다시 실행한다. 단발성 정리 도구 `php artisan hotfix:rollback-stale-files --prune` 은 **자동 롤백 뒤**(백업 디렉토리와 `_new_files_manifest.json` 이 남아 있는 상태) 전용이다 — 성공한 업데이트는 Step 11 에서 백업을 지우므로 그 뒤에 실행하면 "사용 가능한 백업이 없습니다" 로 끝난다. 완료 안내문이 이 명령을 가리키던 것은 7.0.10 에서 걷어냈다. 상세 사용법: [docs/cheatsheet.md](../cheatsheet.md) "단발성 결함 보정 (hotfix)".
> **격리 디렉토리는 루트째 지우고, 이번 실행이 만든 것은 소유권 기준에서 뺀다 (7.0.10)**: 업데이트 소스는 `storage/app/core_pending/core_{Ymd_His}/` 격리 디렉토리 안에 놓이는데, ZIP·GitHub 경로는 그 안쪽 `extracted/{루트}/` 를, `--local` 은 `local_source/` 를 소스 경로로 돌려준다. 7.0.9 까지의 정리 단계는 그 소스 경로만 지워 `core_{ts}/extracted/` 껍데기가 업데이트마다 남았고, sudo 실행이면 root 소유(0770)라 운영자·웹서버 계정이 지울 수 없었다(같은 서버의 7.0.0 부터의 설치본마다 하나씩 실측). 세 층으로 닫았다.
>
> - **부모 정리**: `cleanupPending()` 이 `resolveStagingRoot()` 로 격리 디렉토리 루트까지 올라가 통째로 지운다. pending 기준 디렉토리 밖 경로(`--source` 외부 디렉토리)는 올라가지 않는다.
> - **자식 청소**: 부모는 구버전 클래스를 메모리에 들고 있어 이 수정이 다음 업데이트부터 효력이 있으므로, 신버전 코드로 도는 두 자식(`core:execute-upgrade-steps`, `core:execute-bundled-updates`)이 종료 직전 `sweepEmptyStagingDirectories()` 로 **파일이 하나도 없는** `core_*` 디렉토리만 치운다. 부모가 쓰는 중인 격리 디렉토리는 파일을 갖고 있어 술어상 제외된다. 구버전 부모에서 올라오는 업데이트(7.0.9→7.0.10)는 번들 일괄 업데이트 자식이 부모 정리 뒤에 돌므로 그 자리에서 껍데기가 사라진다.
> - **스냅샷 제외**: 항목별 소유권 스냅샷은 격리 디렉토리가 생긴 **뒤에** 찍힌다. 제외하지 않으면 root 가 만든 추출본이 "원본 소유권" 으로 기록되고 복원이 잔존물을 다시 root 로 되돌리므로, 부모는 `snapshotOwnershipDetailed(..., excludes: [격리 디렉토리 루트])` 로 이번 실행의 것을 뺀다. 그러면 잔존물이 생겨도 상위 `storage/app/core_pending` 의 재귀 chown 이 운영자 계정·웹서버 그룹으로 맞춰 지울 수 있다.
> **`public/storage` symlink 보존 + 종료 시 복구 (#43)**: 심층 방어 2층 구조로 `--prune` 실행 후에도 `public/storage` symlink 가 정상 유지된다.
> - **층 1 (예방)**: `--prune` 은 `public` 타깃에서 orphan(릴리즈 소스에 없는 항목)을 삭제하는데, 런타임 symlink 인 `public/storage` 는 릴리즈 소스에 없어 orphan 으로 판정되어 삭제되던 결함이 있었다(업로드 파일 404). `applyUpdate` 가 `public` 타깃 처리 시 `FilePermissionHelper::copyDirectory(..., preserveLinkPaths: ['storage'])` 로 화이트리스트를 전달해, 매칭되는 orphan symlink/junction 만 삭제에서 제외한다. 화이트리스트 밖 orphan 링크는 기존대로 삭제된다(정밀 보호 — 무조건 보존 아님).
@@ -286,8 +292,8 @@ spawn 자식이 실패(`proc_open` 미지원 · 비정상 종료 · silent skip)
3. bootstrap/cache 파일 삭제 (services.php, packages.php)
4. php artisan package:discover 재실행
5. php artisan extension:update-autoload (코어 업데이트로 _bundled 변경 가능)
6. _pending 디렉토리 삭제
7. 성공 시 백업 삭제
6. _pending 격리 디렉토리(core_{ts}) 루트째 삭제
7. 성공 시 백업 삭제 (이후 `hotfix:rollback-stale-files` 는 대상이 없다)
8. 유지보수 모드 해제
```
@@ -21,6 +21,10 @@
- 초기 화면 파일 생성 실패 알림의 [다시 만들기] 버튼 라벨과 복구 완료 안내의 일본어 번역을 추가했습니다.
- 운영자 추가 파일을 둘 폴더를 만들지 못했을 때 표시되는 실패 사유(같은 이름의 파일이 있음·상위 폴더에 쓸 수 없음 등)와 조치 안내의 일본어 번역을 추가했습니다.
### Changed
- 코어 업데이트 완료 안내문(`settings.core_update.apply_mode_incremental_prune_hint`)의 일본어 번역을 코어 문구 변경에 맞춰 갱신했습니다 — 성공한 업데이트 뒤에는 동작하지 않는 정리 명령 안내를 걷어내고 `--prune` 재실행 안내만 남깁니다.
## [1.0.8] - 2026-08-24
### Added
@@ -142,7 +142,7 @@ return [
'step_composer_prod' => '運用ディレクトリの composer install 中...',
'step_cleanup' => 'クリーンアップ中...',
'apply_mode_incremental' => 'ファイル適用: コアが実際に変更したファイルのみを適用しました (新規 :added 個、変更 :changed 個)。その他のファイルは現在の状態のまま保持されました。',
'apply_mode_incremental_prune_hint' => '新バージョンで削除されたファイルをあわせてクリーンアップするには、`--prune` オプションで再実行するか、`php artisan hotfix:rollback-stale-files --prune` を使用してください。',
'apply_mode_incremental_prune_hint' => '新バージョンで削除されたファイルをあわせてクリーンアップするには、同じアップデートを `--prune` オプションで再実行してください。',
'apply_mode_prune' => 'ファイル適用: 全体上書き + 削除ファイルのクリーンアップを実行しました (--prune)。',
'apply_mode_fallback' => 'ファイル適用: バックアップがないため、増分適用をスキップして全体上書きを実行しました。ユーザーのカスタムファイル保持をご希望の場合は、次回以降はバックアップを保持したままアップデートしてください。',
'github_url_not_configured' => 'GitHub リポジトリ URL が設定されていません。',
+1 -1
View File
@@ -170,7 +170,7 @@ return [
// File apply mode summary (Step 7)
'apply_mode_incremental' => 'File apply: only files actually changed by the core were applied (:added new, :changed changed). All other files were left untouched as-is.',
'apply_mode_incremental_prune_hint' => 'To also remove files deleted in the new version, re-run with the `--prune` option or use `php artisan hotfix:rollback-stale-files --prune`.',
'apply_mode_incremental_prune_hint' => 'To also remove files deleted in the new version, re-run the same update with the `--prune` option.',
'apply_mode_prune' => 'File apply: full overwrite plus cleanup of removed files was performed (--prune).',
'apply_mode_fallback' => 'File apply: no backup available, so incremental apply was skipped and a full overwrite was performed. To preserve custom files, keep the backup enabled on future updates.',
+1 -1
View File
@@ -170,7 +170,7 @@ return [
// 파일 적용 방식 요약 (Step 7)
'apply_mode_incremental' => '파일 적용: 코어가 실제 변경한 파일만 적용했습니다 (신규 :added개, 변경 :changed개). 그 외 파일은 현재 상태 그대로 보존되었습니다.',
'apply_mode_incremental_prune_hint' => '신 버전에서 제거된 파일을 함께 정리하려면 `--prune` 옵션으로 다시 실행하거나 `php artisan hotfix:rollback-stale-files --prune` 을 사용하세요.',
'apply_mode_incremental_prune_hint' => '신 버전에서 제거된 파일을 함께 정리하려면 같은 업데이트를 `--prune` 옵션으로 다시 실행하세요.',
'apply_mode_prune' => '파일 적용: 전체 덮어쓰기 + 제거된 파일 정리를 수행했습니다 (--prune).',
'apply_mode_fallback' => '파일 적용: 백업이 없어 증분 적용을 건너뛰고 전체 덮어쓰기를 수행했습니다. 사용자 커스텀 파일 보존을 원하면 다음부터는 백업을 유지한 채 업데이트하세요.',
@@ -30,10 +30,23 @@ class ExecuteUpgradeStepsStandaloneTest extends TestCase
{
private array $createdPaths = [];
/**
* 테스트 진입 전의 spawn 플래그 상태 (없으면 false) — tearDown 에서 되돌린다.
*/
private string|false $originalSpawnFlag = false;
protected function setUp(): void
{
parent::setUp();
// 이 클래스의 절반은 "spawn 플래그가 없는 단독 실행" 을 전제한다. 그런데 같은 프로세스에서
// 앞서 돈 테스트가 `core:update`(CoreUpdateCommand::handle) 를 한 번이라도 거치면 그 플래그가
// 프로세스 env 에 남아 여기 도착한다 — 부모 커맨드는 "프로세스 종료와 함께 소멸" 을 전제로
// 정리하지 않기 때문이다. 실행 순서에 따라 결과가 갈리지 않도록 진입 시 비우고 종료 시 되돌린다.
$this->originalSpawnFlag = getenv('G7_UPDATE_IN_PROGRESS');
putenv('G7_UPDATE_IN_PROGRESS');
unset($_ENV['G7_UPDATE_IN_PROGRESS'], $_SERVER['G7_UPDATE_IN_PROGRESS']);
// 빈 upgrade step 디렉토리 보장 — 본 테스트는 step 자체가 아닌 사전/사후 단계만 검증.
// 임시 dummy step 파일을 작성해 from < to 비교가 ">=" 조건을 통과하도록 한다.
$this->writeNoopStep('0.9.1', 'standalone_noop');
@@ -50,6 +63,15 @@ class ExecuteUpgradeStepsStandaloneTest extends TestCase
Mockery::close();
if ($this->originalSpawnFlag === false) {
putenv('G7_UPDATE_IN_PROGRESS');
unset($_ENV['G7_UPDATE_IN_PROGRESS'], $_SERVER['G7_UPDATE_IN_PROGRESS']);
} else {
putenv('G7_UPDATE_IN_PROGRESS='.$this->originalSpawnFlag);
$_ENV['G7_UPDATE_IN_PROGRESS'] = $this->originalSpawnFlag;
$_SERVER['G7_UPDATE_IN_PROGRESS'] = $this->originalSpawnFlag;
}
parent::tearDown();
}
@@ -183,6 +205,34 @@ class ExecuteUpgradeStepsStandaloneTest extends TestCase
$this->assertSame(0, $exitCode);
}
/**
* 부모(구버전 코드)의 정리 단계가 남긴 빈 격리 디렉토리(`core_{ts}/extracted` 껍데기)는
* 신버전 코드로 도는 이 자식이 치운다. spawn 자식 모드에서도, 단독 실행에서도 호출된다 —
* 구버전 부모에서 올라오는 업데이트(7.0.9→7.0.10)를 덮는 유일한 자리이기 때문이다.
*
* @effects ExecuteUpgradeStepsCommand_sweeps_empty_staging_directories_left_by_parent
*/
public function test_스텝_완료_후_빈_격리_디렉토리를_청소한다(): void
{
[$service, $module, $plugin, $template, $langPack] = $this->bindMocks();
$service->shouldReceive('runUpgradeSteps')->once();
$service->shouldReceive('sweepEmptyStagingDirectories')->once()->andReturn(1);
putenv('G7_UPDATE_IN_PROGRESS=1');
$_ENV['G7_UPDATE_IN_PROGRESS'] = '1';
$_SERVER['G7_UPDATE_IN_PROGRESS'] = '1';
try {
$exitCode = $this->runCommand([]);
} finally {
putenv('G7_UPDATE_IN_PROGRESS');
unset($_ENV['G7_UPDATE_IN_PROGRESS'], $_SERVER['G7_UPDATE_IN_PROGRESS']);
}
$this->assertSame(0, $exitCode);
}
/**
* 자식이 이전 버전 config 캐시로 부팅했으면(구버전 부모는 spawn 전에 캐시를 비우지 않는다)
* `config('app.update.restore_ownership_group_writable')` 은 옛 목록이다. 이때는 디스크의
@@ -404,6 +454,9 @@ class ExecuteUpgradeStepsStandaloneTest extends TestCase
// 종료부의 root 산출물 소유권 정상화(#615 도입)는 모든 경로에서 호출되며 본 테스트의 관심사가
// 아니다 — 기본 허용으로 두어 각 케이스가 자기 단계만 단언하게 한다.
$service->shouldReceive('normalizeRuntimeOwnershipAfterRootRun')->andReturnNull()->byDefault();
// 종료부의 빈 격리 디렉토리 청소(7.0.10 도입)도 모든 경로에서 호출된다 — 전용 케이스가
// once() 로 단언하고, 나머지 케이스는 기본 허용.
$service->shouldReceive('sweepEmptyStagingDirectories')->andReturn(0)->byDefault();
$module = Mockery::mock(ModuleManager::class);
$plugin = Mockery::mock(PluginManager::class);
$template = Mockery::mock(TemplateManager::class);
@@ -85,4 +85,36 @@ class ExecuteBundledUpdatesCommandTest extends TestCase
$this->assertStringContainsString('"success":0', $output);
$this->assertStringContainsString('"failed":0', $output);
}
/**
* 이 자식은 부모의 정리 단계가 끝난 **뒤에** 돈다. 구버전 부모(7.0.0~7.0.9)의 정리는
* 소스 경로 안쪽만 지워 `core_{ts}/extracted` 껍데기를 남기므로, 그 껍데기를 여기서
* 치워야 구버전에서 올라오는 업데이트도 잔존물 없이 끝난다. 파일이 있는 격리
* 디렉토리는 건드리지 않는다.
*
* @effects execute_bundled_updates_child_sweeps_empty_staging_directories_left_by_parent
*/
public function test_빈_매니페스트_처리_후_부모가_남긴_빈_격리_디렉토리를_치운다(): void
{
$base = storage_path('framework/testing/bundled-sweep-'.uniqid());
File::ensureDirectoryExists($base.DIRECTORY_SEPARATOR.'core_20260906_095309'.DIRECTORY_SEPARATOR.'extracted');
$live = $base.DIRECTORY_SEPARATOR.'core_20260906_120000'.DIRECTORY_SEPARATOR.'extracted';
File::ensureDirectoryExists($live);
File::put($live.DIRECTORY_SEPARATOR.'composer.json', '{}');
config(['app.update.pending_path' => $base]);
$path = storage_path('app/test_bundled_manifest_'.uniqid().'.json');
File::put($path, json_encode(['modules' => [], 'plugins' => [], 'templates' => [], 'lang_packs' => []]));
$this->tempFiles[] = $path;
try {
$exit = Artisan::call('core:execute-bundled-updates', ['--manifest' => $path]);
$this->assertSame(0, $exit);
$this->assertDirectoryDoesNotExist($base.DIRECTORY_SEPARATOR.'core_20260906_095309', '빈 껍데기는 치운다');
$this->assertFileExists($live.DIRECTORY_SEPARATOR.'composer.json', '파일이 있는 격리 디렉토리는 남긴다');
} finally {
File::deleteDirectory($base);
}
}
}
@@ -0,0 +1,230 @@
<?php
namespace Tests\Unit\Services;
use App\Services\CoreUpdateService;
use Illuminate\Support\Facades\File;
use Tests\TestCase;
/**
* 코어 업데이트 격리 디렉토리(`{pending_path}/core_{ts}`) 정리 회귀 테스트.
*
* 실사례(2026-09-06, 7.0.9 → 7.0.10 sudo 업데이트): ZIP 추출은 `core_{ts}/extracted/{루트}/`
* 를 소스로 돌려주는데 정리 단계가 그 안쪽만 지워 `core_{ts}/extracted/` 껍데기가 남았고,
* 스냅샷이 그 껍데기를 root 소유로 기록해 복원이 root 로 되돌렸다. 같은 껍데기가
* 7.0.0 부터의 설치본마다 하나씩 남아 있었다.
*
* 세 층을 잠근다:
* 1. cleanupPending 이 소스 경로를 받아도 격리 디렉토리 루트를 지운다 (부모 정리)
* 2. 자식이 부르는 sweep 이 빈 껍데기만 치우고 파일이 있는 격리 디렉토리는 남긴다
* 3. 스냅샷이 이번 실행의 격리 디렉토리를 제외한다 (잔존물이 root 로 되돌아가지 않는다)
*/
class CoreUpdateServiceStagingCleanupTest extends TestCase
{
private string $base;
private string $outside;
private CoreUpdateService $service;
protected function setUp(): void
{
parent::setUp();
$this->base = storage_path('framework/testing/staging-cleanup-'.uniqid());
$this->outside = storage_path('framework/testing/staging-outside-'.uniqid());
File::ensureDirectoryExists($this->base);
File::ensureDirectoryExists($this->outside);
config(['app.update.pending_path' => $this->base]);
$this->service = app(CoreUpdateService::class);
}
protected function tearDown(): void
{
File::deleteDirectory($this->base);
File::deleteDirectory($this->outside);
parent::tearDown();
}
// ── 1. 격리 디렉토리 루트 해석 + 정리 ─────────────────────────────────
public function test_resolve_staging_root_은_안쪽_소스_경로에서_격리_디렉토리_루트를_돌려준다(): void
{
$inner = $this->base.DIRECTORY_SEPARATOR.'core_20260906_095309'
.DIRECTORY_SEPARATOR.'extracted'.DIRECTORY_SEPARATOR.'dev-g7-develop';
$this->assertSame(
$this->base.DIRECTORY_SEPARATOR.'core_20260906_095309',
$this->service->resolveStagingRoot($inner),
);
$this->assertSame(
$this->base.DIRECTORY_SEPARATOR.'core_20260906_095309',
$this->service->resolveStagingRoot($this->base.DIRECTORY_SEPARATOR.'core_20260906_095309'),
'격리 디렉토리 자체를 넘기면 그대로',
);
}
public function test_resolve_staging_root_은_pending_기준_밖_경로와_기준_자체는_건드리지_않는다(): void
{
$external = $this->outside.DIRECTORY_SEPARATOR.'src';
$this->assertSame($external, $this->service->resolveStagingRoot($external), '--source 외부 경로는 위로 올라가지 않는다');
$this->assertSame($this->base, $this->service->resolveStagingRoot($this->base), 'pending 기준 디렉토리 자체는 그대로');
$this->assertSame($this->base.DIRECTORY_SEPARATOR.'..', $this->service->resolveStagingRoot($this->base.DIRECTORY_SEPARATOR.'..'));
}
/**
* @effects cleanupPending_removes_whole_staging_root_when_given_inner_source_path
*/
public function test_cleanup_pending_은_소스_경로를_받아도_격리_디렉토리를_통째로_지운다(): void
{
$staging = $this->base.DIRECTORY_SEPARATOR.'core_20260906_095309';
$source = $staging.DIRECTORY_SEPARATOR.'extracted'.DIRECTORY_SEPARATOR.'dev-g7-develop';
File::ensureDirectoryExists($source.DIRECTORY_SEPARATOR.'app');
File::put($source.DIRECTORY_SEPARATOR.'composer.json', '{}');
File::put($staging.DIRECTORY_SEPARATOR.'core_update.zip', 'zip');
$this->service->cleanupPending($source);
$this->assertDirectoryDoesNotExist($staging, '껍데기(extracted)까지 남지 않아야 한다');
$this->assertDirectoryExists($this->base, 'pending 기준 디렉토리는 남는다');
}
public function test_cleanup_pending_은_pending_기준_밖_경로를_넘기면_그_경로만_지운다(): void
{
$external = $this->outside.DIRECTORY_SEPARATOR.'src';
File::ensureDirectoryExists($external);
File::put($external.DIRECTORY_SEPARATOR.'a.txt', 'a');
File::put($this->outside.DIRECTORY_SEPARATOR.'keep.txt', 'keep');
$this->service->cleanupPending($external);
$this->assertDirectoryDoesNotExist($external);
$this->assertFileExists($this->outside.DIRECTORY_SEPARATOR.'keep.txt', '형제 항목이 남아야 한다 — 위로 올라가 지우지 않는다');
}
// ── 2. 자식 청소 ────────────────────────────────────────────────────────
/**
* @effects sweepEmptyStagingDirectories_removes_empty_core_dirs_and_keeps_dirs_with_files
*/
public function test_sweep_은_빈_격리_디렉토리만_치우고_파일이_있는_것과_core_외_디렉토리는_남긴다(): void
{
$empty = $this->base.DIRECTORY_SEPARATOR.'core_20260713_065730';
File::ensureDirectoryExists($empty.DIRECTORY_SEPARATOR.'extracted');
$emptyDeep = $this->base.DIRECTORY_SEPARATOR.'core_20260710_111102';
File::ensureDirectoryExists($emptyDeep.DIRECTORY_SEPARATOR.'extracted'.DIRECTORY_SEPARATOR.'nested');
$live = $this->base.DIRECTORY_SEPARATOR.'core_20260906_120000';
File::ensureDirectoryExists($live.DIRECTORY_SEPARATOR.'extracted'.DIRECTORY_SEPARATOR.'root');
File::put($live.DIRECTORY_SEPARATOR.'extracted'.DIRECTORY_SEPARATOR.'root'.DIRECTORY_SEPARATOR.'composer.json', '{}');
$foreign = $this->base.DIRECTORY_SEPARATOR.'local_source';
File::ensureDirectoryExists($foreign);
File::put($this->base.DIRECTORY_SEPARATOR.'bundled-updates-manifest_x.json', '{}');
$swept = $this->service->sweepEmptyStagingDirectories();
$this->assertSame(2, $swept);
$this->assertDirectoryDoesNotExist($empty);
$this->assertDirectoryDoesNotExist($emptyDeep);
$this->assertFileExists($live.DIRECTORY_SEPARATOR.'extracted'.DIRECTORY_SEPARATOR.'root'.DIRECTORY_SEPARATOR.'composer.json', '부모가 쓰는 중인 격리 디렉토리는 남는다');
$this->assertDirectoryExists($foreign, 'core_ 접두사가 아닌 디렉토리는 대상이 아니다');
$this->assertFileExists($this->base.DIRECTORY_SEPARATOR.'bundled-updates-manifest_x.json');
}
public function test_sweep_은_pending_기준_디렉토리가_없으면_0_을_돌려준다(): void
{
config(['app.update.pending_path' => $this->base.DIRECTORY_SEPARATOR.'missing']);
$this->assertSame(0, $this->service->sweepEmptyStagingDirectories());
}
// ── 3. 스냅샷 제외 ──────────────────────────────────────────────────────
/**
* @effects snapshotOwnershipDetailed_excludes_current_run_staging_root
*/
public function test_snapshot_detailed_은_제외_경로와_그_하위를_기록하지_않는다(): void
{
$staging = $this->base.DIRECTORY_SEPARATOR.'core_20260906_095309';
File::ensureDirectoryExists($staging.DIRECTORY_SEPARATOR.'extracted');
File::put($staging.DIRECTORY_SEPARATOR.'extracted'.DIRECTORY_SEPARATOR.'composer.json', '{}');
$older = $this->base.DIRECTORY_SEPARATOR.'core_20260713_065730';
File::ensureDirectoryExists($older);
File::put($this->base.DIRECTORY_SEPARATOR.'.gitignore', '*');
$snapshot = $this->service->snapshotOwnershipDetailed([$this->base], excludes: [$staging]);
$keys = array_map(static fn (string $k): string => str_replace('\\', '/', $k), array_keys($snapshot));
$stagingNorm = str_replace('\\', '/', $staging);
$this->assertNotEmpty($snapshot, '기준 디렉토리·형제 항목은 기록된다');
$this->assertContains(str_replace('\\', '/', $older), $keys);
$this->assertContains(str_replace('\\', '/', $this->base.DIRECTORY_SEPARATOR.'.gitignore'), $keys);
foreach ($keys as $key) {
$this->assertFalse(
$key === $stagingNorm || str_starts_with($key, $stagingNorm.'/'),
"제외 경로 하위가 스냅샷에 실렸다: {$key}",
);
}
$without = $this->service->snapshotOwnershipDetailed([$this->base]);
$this->assertGreaterThan(count($snapshot), count($without), '제외 인자를 빼면 격리 디렉토리가 기록된다 (되돌리면 red)');
}
public function test_부모_커맨드는_이번_실행의_격리_디렉토리를_스냅샷에서_제외한다(): void
{
$src = File::get(base_path('app/Console/Commands/Core/CoreUpdateCommand.php'));
$this->assertMatchesRegularExpression(
'/snapshotOwnershipDetailed\(\[[^\]]*\'storage\/app\/core_pending\'[^\]]*\],\s*excludes:\s*\[\$service->resolveStagingRoot\(\$pendingPath\)\]\)/s',
$src,
'CoreUpdateCommand 의 스냅샷 호출이 격리 디렉토리 루트를 excludes 로 넘겨야 한다',
);
}
public function test_자식_두_커맨드가_모두_빈_격리_디렉토리_청소를_부른다(): void
{
foreach ([
'app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php',
'app/Console/Commands/Core/ExecuteBundledUpdatesCommand.php',
] as $rel) {
$this->assertStringContainsString(
'sweepEmptyStagingDirectories()',
File::get(base_path($rel)),
"{$rel} 가 sweepEmptyStagingDirectories() 를 호출해야 한다 — 구버전 부모에서 올라오는 업데이트를 덮는 자리",
);
}
}
// ── 완료 안내문 ─────────────────────────────────────────────────────────
/**
* @effects apply_mode_incremental_prune_hint_does_not_reference_rollback_command
*/
public function test_완료_안내문은_성공_후_동작하지_않는_명령을_가리키지_않는다(): void
{
$files = [
'lang/ko/settings.php',
'lang/en/settings.php',
'lang-packs/_bundled/g7-core-ja/backend/ja/settings.php',
];
foreach ($files as $rel) {
$lang = require base_path($rel);
$hint = $lang['core_update']['apply_mode_incremental_prune_hint'] ?? null;
$this->assertIsString($hint, "{$rel} 에 안내문 키가 있어야 한다");
$this->assertStringContainsString('--prune', $hint, "{$rel}: --prune 재실행 안내는 남긴다");
$this->assertStringNotContainsString(
'hotfix:rollback-stale-files',
$hint,
"{$rel}: 성공 경로는 백업(manifest 포함)을 지우므로 이 명령은 '백업 없음' 으로 끝난다",
);
}
}
}
@@ -86,6 +86,7 @@ effects:
- ExecuteUpgradeStepsCommand_env_guard_dominates_over_force_flag_for_post_steps
- ExecuteUpgradeStepsCommand_standalone_invocation_without_env_runs_all_five_steps_intact
- execute_upgrade_steps_child_reads_update_config_from_disk_when_config_is_cached
- ExecuteUpgradeStepsCommand_sweeps_empty_staging_directories_left_by_parent
test_files:
- tests/Feature/Console/Commands/ExecuteUpgradeStepsStandaloneTest.php
@@ -49,6 +49,8 @@ axes:
upgrade_step_v_one_pattern: [app_service_call, app_manager_call, app_repository_call, local_only]
audit_allow_inline: [present, absent] # 면제 주석 적용 여부
symlink_target_state: [valid, broken, windows_no_privilege] # symlink 보존 분기 (§1 연동)
staging_source_shape: [inner_extracted_root, staging_root_itself, external_source_dir] # cleanupPending 이 받는 경로 형태 — ZIP·GitHub / --local·--source 복제 / --source 외부
staging_leftover_content: [empty_skeleton, has_files, non_core_dir] # 자식 청소 술어 — 빈 껍데기만 대상
resume_exec_context: [non_root, root_web_known, root_web_symmetric, root_web_unknown] # 핸드오프 재실행 권한 안내 분기 (실행 사용자 × 웹서버 계정 식별성)
exclusions:
@@ -105,6 +107,12 @@ effects:
- renderResumeGuidance_root_web_known_prefixes_sudo_u_and_warns_with_account_name
- renderResumeGuidance_root_web_symmetric_prints_command_verbatim
- renderResumeGuidance_root_web_unknown_uses_placeholder_and_generic_warning
# §9 격리 디렉토리 정리 3층 (부모 루트째 삭제 / 자식 빈 껍데기 청소 / 스냅샷 제외) + 완료 안내문
- cleanupPending_removes_whole_staging_root_when_given_inner_source_path
- sweepEmptyStagingDirectories_removes_empty_core_dirs_and_keeps_dirs_with_files
- snapshotOwnershipDetailed_excludes_current_run_staging_root
- execute_bundled_updates_child_sweeps_empty_staging_directories_left_by_parent
- apply_mode_incremental_prune_hint_does_not_reference_rollback_command
test_files:
- tests/Feature/Console/CoreUpdateCommandSpawnFailureTest.php
@@ -115,6 +123,8 @@ test_files:
- tests/Feature/Console/CoreUpdateResumeGuidanceTest.php
- tests/Feature/Upgrades/MultiVersionUpgradePathTest.php
- tests/Unit/Extension/Helpers/FilePermissionHelperSymlinkTest.php
- tests/Unit/Services/CoreUpdateServiceStagingCleanupTest.php
- tests/Feature/Console/ExecuteBundledUpdatesCommandTest.php
# 본 매니페스트의 axes cross product 는 800+ 케이스이나, 실제 회귀 가드는 test_files 의
# 통과 테스트들이 SSoT — 매니페스트는 매트릭스 SSoT 역할.