Files
Gnuboard7/app/Console/Commands/Core/CoreUpdateCommand.php
T
HeuJung 0c4f52fc96 fix(core): 업데이트 트리 argv 판정을 명령줄 SAPI 로 한정하고 매니페스트 삭제 실패를 로그에 남김
7.0.11 인스톨러·코어 업데이트 변경(e60a82d73)에 대해 과거 회귀 22건을 부류별로 대조한
결과, 신규 노출면 1건과 테스트 위생 1건이 나와 인터뷰 결정대로 조치했다.

1. argv 채널의 SAPI 게이트 — CGI/FPM 은 register_argc_argv=On 이면 $_SERVER['argv'] 를
 쿼리스트링을 '+' 로 쪼개 채우므로(`GET /?x+core:update` → argv[1]==='core:update', php-cgi
 실측) 비인증 웹 요청이 업데이트 트리로 판정되어 bootstrap/app.php 자가 치유가 요청마다
 패키지 매니페스트를 지우고 다시 만들었다. CoreUpdateContext 와 bootstrap/app.php 복제본
 모두 argv 를 cli·phpdbg 에서만 읽는다. env 플래그 채널은 웹에서 주입할 수 없으므로 그대로
 두어 웹 요청 안에서 시작하는 업데이트 흐름(7.1.0)에 영향이 없다. 동형성 테스트에 SAPI 축을
 더했다.

2. 매니페스트 삭제 실패 기록 — PackageManifestCacheHelper::clear 가 지우지 못한 파일의
 경로를 돌려주고, spawn 직전 호출부가 업그레이드 로그·콘솔에 경고로 남긴다. 권한·소유권
 불일치면 자식의 자가 치유도 같은 이유로 실패해 증상은 제보와 같은 「Class not found」 인데,
 이 경고가 원인이 권한이라는 유일한 흔적이다.

3. 테스트 격리 — tests/bootstrap.php 가 APP_PACKAGES_CACHE/APP_SERVICES_CACHE 를 테스트
 전용 경로로 돌린다. proc_open 으로 자식을 띄우는 기존 테스트 2종의 자식이 개발 클론의
 실제 bootstrap/cache 매니페스트를 지우고 다시 쓰던 것(stat 실측)을 부모·자식 함께 막는다.

관리자 [시스템 최적화] 경로(withPreservedContainer 파사드 복원의 미실측 형제 호출처)는
임시 설치본에서 API 로 실측했다 — 200, 설정·라우트 캐시 재생성, 후속 요청 200, 로그 오류 0.

4. 트러블슈팅 사례 ↔ 회귀 테스트 앵커 계약 — 신규 사례는 헤딩에 <!-- case:{영역}-{번호} -->
 앵커를 달고 같은 문자열을 그 사례를 잠그는 회귀 테스트에도 남겨야 한다. 사례 번호는
 문서마다 1부터 재시작하고 병합으로 중복되므로(이번 리베이스에서도 우리 사례가 develop 과
 같은 29 였다가 31 로 밀렸다), 개수만 대조하면 다른 사례를 덮는 테스트도 초록이 된다.

 그런데 판정기 check-troubleshooting-test-coverage.cjs 를 부르는 지점이 저장소에 하나도
 없었다 — 스크립트 자체 주석에만 실행법이 적혀 있어 아무도 부르지 않으면 영원히 돌지
 않았고, 그 사이 위반이 9건 쌓였다(backend 26~31, cache 17~19). 돌지 않는 대조는 아무것도
 잠그지 못하므로 위반 해소와 실행 지점 부여를 함께 한다.

 9건 전부에 앵커를 부착하고(각 사례가 선언한 회귀 테스트 중 가장 구체적인 파일에 배치,
 한 파일이 두 사례에 선언된 경우는 갈라 배치), stop-guard 7.2 에 앵커 계약 + 미커버
 baseline ratchet 두 축으로 등록했다. 트러블슈팅 사례 추가 프로토콜에 6단계를
 더하고 coverage 에 troubleshooting-case-anchor-contract(manual-only, 전용 판정기 위임)를
 등재했다. 판정기 종료코드 1 → 0, 미커버 건수는 전 문서 baseline 그대로다.
2026-09-08 10:42:19 +09:00

1482 lines
76 KiB
PHP

<?php
namespace App\Console\Commands\Core;
use App\Console\Commands\Core\Concerns\BundledExtensionUpdatePrompt;
use App\Console\Commands\Traits\HasUnifiedConfirm;
use App\Exceptions\UpgradeHandoffException;
use App\Extension\CoreVersionChecker;
use App\Extension\Helpers\CoreBackupHelper;
use App\Extension\Helpers\FilePermissionHelper;
use App\Extension\Helpers\StorageLinkHelper;
use App\Extension\ModuleManager;
use App\Extension\PluginManager;
use App\Extension\TemplateManager;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Extension\Vendor\Exceptions\VendorInstallException;
use App\Extension\Vendor\VendorMode;
use App\Services\CoreUpdateService;
use App\Support\ComposerInstallInfo;
use App\Support\ConfigCacheHelper;
use App\Support\PackageManifestCacheHelper;
use App\Support\RouteCacheHelper;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class CoreUpdateCommand extends Command
{
use BundledExtensionUpdatePrompt;
use ClearsTemplateCaches;
use HasUnifiedConfirm;
protected $signature = 'core:update
{--force : 버전 비교 없이 강제 업데이트}
{--no-backup : 백업 생성 건너뛰기}
{--prune : 코어가 제거한 파일 정리 + targets 전체 덮어쓰기(기존 방식). 미지정 시 코어가 실제 변경/추가한 파일만 적용하고 나머지는 보존}
{--no-maintenance : 유지보수 모드 활성화 건너뛰기}
{--local : 로컬 코드베이스를 업데이트 소스로 사용 (GitHub 스킵)}
{--source= : 수동 업데이트용 소스 디렉토리 경로 (GitHub 다운로드 대신 지정 디렉토리 사용)}
{--zip= : 수동 업데이트용 ZIP 파일 경로 (GitHub 다운로드 대신 지정 ZIP 추출 사용)}
{--vendor-mode=auto : vendor 설치 모드 (auto|composer|bundled)}
{--rebuild-search-index : 번들 확장 일괄 업데이트 후 색인이 누락된 검색 인덱스를 재생성 (인덱스가 잠기거나 재색인됩니다 — 운영 중에는 유지보수 시간에 수행하세요)}';
protected $description = '그누보드7 코어를 최신 버전으로 업데이트합니다';
private const TOTAL_STEPS = 11;
/**
* 커맨드를 실행합니다.
*
* @param CoreUpdateService $service 코어 업데이트 서비스
* @return int 종료 코드
*/
public function handle(CoreUpdateService $service): int
{
// 코어 업데이트 진행 플래그: 업데이트 중에는 일시적 버전 불일치로 인한 자동 비활성화를
// 차단해야 한다 (CoreServiceProvider::validateAndDeactivateIncompatibleExtensions 및
// spawn 자식이 모두 이 플래그를 감지). 커맨드 종료 시 프로세스 env 도 함께 소멸하므로
// 정리 로직 불필요.
$_ENV['G7_UPDATE_IN_PROGRESS'] = '1';
$_SERVER['G7_UPDATE_IN_PROGRESS'] = '1';
putenv('G7_UPDATE_IN_PROGRESS=1');
$backupPath = null;
$maintenanceEnabled = false;
$fromVersion = CoreVersionChecker::getCoreVersion();
$toVersion = $fromVersion;
$secret = null;
$logEntries = [];
$ownershipSnapshot = [];
$log = function (string $message) use (&$logEntries) {
$logEntries[] = '['.date('H:i:s').'] '.$message;
};
$this->warnIfFileOwnerMismatch($log);
$sourceDir = $this->option('source');
$zipPath = $this->option('zip');
try {
// --source 와 --zip 동시 지정 금지
if ($sourceDir && $zipPath) {
$this->error('--source 와 --zip 은 동시에 지정할 수 없습니다. 하나만 선택하세요.');
return Command::FAILURE;
}
if (($sourceDir || $zipPath) && $this->option('local')) {
$this->error('--local 은 --source / --zip 과 동시에 사용할 수 없습니다.');
return Command::FAILURE;
}
// --zip 옵션 검증 (zip 은 ZipArchive/unzip 추출이 필요하므로 시스템 요구사항 검증 대상)
if ($zipPath) {
$resolvedZip = realpath($zipPath);
if (! $resolvedZip || ! is_file($resolvedZip)) {
$this->error('지정된 ZIP 파일이 존재하지 않습니다: '.$zipPath);
return Command::FAILURE;
}
$zipPath = $resolvedZip;
}
// ── 시스템 요구사항 검증 (--source / --local 모드에서는 스킵, --zip 은 필요) ──
if (! $sourceDir && ! $this->option('local')) {
$requirements = $service->checkSystemRequirements();
if (! $requirements['valid']) {
$this->error(__('settings.core_update.system_requirements_failed'));
foreach ($requirements['errors'] as $error) {
$this->error(" - {$error}");
}
$this->newLine();
$this->info(__('settings.core_update.manual_update_guide'));
return Command::FAILURE;
}
$log('사용 가능한 추출 방법: '.implode(', ', $requirements['available_methods']));
}
// --source 옵션 검증
if ($sourceDir) {
$sourceDir = realpath($sourceDir);
if (! $sourceDir || ! is_dir($sourceDir)) {
$this->error('지정된 소스 디렉토리가 존재하지 않습니다: '.($this->option('source')));
return Command::FAILURE;
}
$log("수동 업데이트 모드: 소스 디렉토리 = {$sourceDir}");
}
if ($zipPath) {
$log("수동 업데이트 모드: ZIP 파일 = {$zipPath}");
}
// ── Step 1: 업데이트 확인 (프로그레스바 없이) ──
$this->info(__('settings.core_update.step_check').'...');
$log('업데이트 확인 시작');
if ($sourceDir || $this->option('local') || $zipPath) {
// --source / --local / --zip 모드: GitHub 스킵, 소스의 버전 읽기
if ($sourceDir) {
$sourceConfigPath = $sourceDir.DIRECTORY_SEPARATOR.'config'.DIRECTORY_SEPARATOR.'app.php';
if (file_exists($sourceConfigPath)) {
// env() 호출을 우회하여 소스의 default 버전값을 직접 파싱
$configContent = file_get_contents($sourceConfigPath);
if (preg_match("/['\"]version['\"]\s*=>\s*env\s*\(\s*['\"]APP_VERSION['\"]\s*,\s*['\"]([^'\"]+)['\"]\s*\)/", $configContent, $versionMatch)) {
$toVersion = $versionMatch[1];
} else {
$sourceConfig = include $sourceConfigPath;
$toVersion = $sourceConfig['version'] ?? $fromVersion;
}
}
$log("수동 업데이트 모드: {$fromVersion} → {$toVersion}");
} elseif ($zipPath) {
// --zip 모드: 버전은 추출 후에만 확인 가능. 일단 fromVersion 유지하고 Step 4 다운로드 후 재판별.
$toVersion = $fromVersion;
$log("ZIP 업데이트 모드: 버전은 추출 후 판별 ({$zipPath})");
} else {
// --local 모드: 현재 코드베이스의 config/app.php에서 버전을 직접 파싱
$localConfigPath = base_path('config'.DIRECTORY_SEPARATOR.'app.php');
$configContent = file_get_contents($localConfigPath);
if (preg_match("/['\"]version['\"]\s*=>\s*env\s*\(\s*['\"]APP_VERSION['\"]\s*,\s*['\"]([^'\"]+)['\"]\s*\)/", $configContent, $versionMatch)) {
$toVersion = $versionMatch[1];
}
$log("로컬 모드: {$fromVersion} → {$toVersion}");
}
} else {
$updateInfo = $service->checkForUpdates();
$toVersion = $updateInfo['latest_version'];
if (! empty($updateInfo['check_failed'])) {
$this->error('업데이트 확인 실패: '.($updateInfo['error'] ?? __('settings.core_update.unknown_error')));
return Command::FAILURE;
}
if (! $updateInfo['update_available'] && ! $this->option('force')) {
$this->info("현재 최신 버전입니다: {$fromVersion}");
return Command::SUCCESS;
}
}
// 사용자 확인
$this->newLine();
$this->info("현재 버전: {$fromVersion}");
if ($zipPath) {
$this->info('업데이트 버전: (ZIP 추출 후 판별)');
} else {
$this->info("업데이트 버전: {$toVersion}");
}
$this->newLine();
if (! $this->unifiedConfirm('코어를 업데이트하시겠습니까?', false)) {
return Command::SUCCESS;
}
// Step 2~10 프로그레스바 (9단계)
$remainingSteps = self::TOTAL_STEPS - 1;
$bar = $this->output->createProgressBar($remainingSteps);
$bar->setFormat(' %current%/%max% [%bar%] %message%');
$bar->start();
$onProgress = function (?string $step, ?string $detail) use ($bar) {
if ($detail) {
$bar->setMessage($detail);
}
$bar->display();
};
// ── Step 2: _pending 경로 검증 ──
$bar->setMessage(__('settings.core_update.step_validate_pending'));
$bar->advance();
$log('_pending 경로 검증');
$validation = $service->validatePendingPath();
if (! $validation['valid']) {
$bar->finish();
$this->newLine(2);
$this->error('_pending 디렉토리 문제:');
foreach ($validation['errors'] as $error) {
$this->error(" - {$error}");
}
$this->info("경로: {$validation['path']}");
$this->info("소유자: {$validation['owner']}, 그룹: {$validation['group']}, 퍼미션: {$validation['permissions']}");
return Command::FAILURE;
}
// ── Step 3: Maintenance 모드 ──
$bar->setMessage(__('settings.core_update.step_maintenance'));
$bar->advance();
if (! $this->option('no-maintenance')) {
$secret = $service->enableMaintenanceMode();
$maintenanceEnabled = true;
$log("유지보수 모드 활성화 (secret: {$secret})");
}
// ── Step 4: 다운로드 ──
if ($sourceDir) {
$bar->setMessage('소스 디렉토리 검증 중...');
} elseif ($zipPath) {
$bar->setMessage('ZIP 파일 추출 중...');
} elseif ($this->option('local')) {
$bar->setMessage('로컬 소스 복제 중...');
} else {
$bar->setMessage(__('settings.core_update.step_download'));
}
$bar->advance();
if ($sourceDir) {
$log('수동 업데이트: 소스 디렉토리 검증');
$service->validatePendingUpdate($sourceDir);
// 원본 소스를 _pending으로 복제 (원본 보호)
$pendingPath = $service->copySourceToPending($sourceDir, $onProgress);
$log("소스 디렉토리를 _pending으로 복제 완료: {$pendingPath}");
} elseif ($zipPath) {
$log("ZIP 추출 시작: {$zipPath}");
$pendingPath = $service->extractZipToPending($zipPath, $onProgress);
$log("ZIP 추출 완료: {$pendingPath}");
// 추출된 소스의 config/app.php 에서 toVersion 갱신
$zipConfigPath = $pendingPath.DIRECTORY_SEPARATOR.'config'.DIRECTORY_SEPARATOR.'app.php';
if (file_exists($zipConfigPath)) {
$zipConfigContent = file_get_contents($zipConfigPath);
if (preg_match("/['\"]version['\"]\s*=>\s*env\s*\(\s*['\"]APP_VERSION['\"]\s*,\s*['\"]([^'\"]+)['\"]\s*\)/", $zipConfigContent, $versionMatch)) {
$toVersion = $versionMatch[1];
$log("ZIP 소스 버전 확인: {$toVersion}");
}
}
} elseif ($this->option('local')) {
$log('로컬 소스 복제 시작');
$pendingPath = $service->prepareLocalSource($onProgress);
$log('로컬 소스 복제 완료');
} else {
$log("버전 {$toVersion} 다운로드 시작");
$pendingPath = $service->downloadUpdate($toVersion, $onProgress);
$log('다운로드 및 검증 완료');
}
// ── Step 5: 백업 ──
$bar->setMessage(__('settings.core_update.step_backup'));
$bar->advance();
if (! $this->option('no-backup')) {
$backupPath = $service->createBackup($onProgress);
$log("백업 생성 완료: {$backupPath}");
}
// 원본 소유권 스냅샷 — 이후 Step 11 에서 composer 등이 오염시킨 소유권을
// 각 경로의 업데이트 전 원본으로 복원하기 위해 백업 직후 시점에 수집한다.
$ownershipSnapshot = $service->snapshotOwnership();
if (! empty($ownershipSnapshot)) {
$log('원본 소유권 스냅샷 수집: '.implode(',', array_keys($ownershipSnapshot)));
}
// PHP-FPM 쓰기 영역의 항목별 정확 스냅샷 (owner/group/perms). Stage 4.
// sudo update 가 root 로 만들 수 있는 좁은 영역(storage/logs, storage/framework,
// storage/app/core_pending, bootstrap/cache) 의 모든 하위 항목을 재귀 stat 하여
// 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 쓰기 영역)');
}
// ── Step 6: _pending에서 Vendor 설치 (composer 또는 bundled) ──
$vendorMode = VendorMode::fromStringOrAuto((string) $this->option('vendor-mode'));
$composerSkipped = $vendorMode !== VendorMode::Bundled
&& $service->isComposerUnchangedForCore($pendingPath);
// 운영 vendor 에 개발용(require-dev) 패키지가 섞여 있는지 알린다. 그대로 두면
// 이전 설치본이 만든 패키지 매니페스트가 새 vendor 와 어긋나 이후 부팅이 깨지고,
// 그 사실은 오류 메시지 어디에도 "dev 설치본" 으로 드러나지 않는다.
$devPackages = ComposerInstallInfo::devPackageNames(base_path('vendor'));
if ($devPackages !== []) {
$devCount = count($devPackages);
$devSample = implode(', ', array_slice($devPackages, 0, 5)).($devCount > 5 ? ' …' : '');
if ($composerSkipped) {
$this->warn("운영 vendor 에 개발용 패키지 {$devCount}개 감지 — composer.json/lock 변경이 없어 vendor 를 재설치하지 않으므로 그대로 남습니다. 업데이트 후 'composer install --no-dev --optimize-autoloader' 실행을 권장합니다.");
$log("운영 vendor 개발용 패키지 {$devCount}개 잔존 (composer 스킵): {$devSample}");
} else {
$this->line("운영 vendor 에 개발용 패키지 {$devCount}개 감지 — 이번 업데이트가 --no-dev vendor 로 교체합니다.");
$log("운영 vendor 개발용 패키지 {$devCount}개 감지 — --no-dev vendor 로 교체: {$devSample}");
}
}
if ($composerSkipped) {
$bar->setMessage('Composer 의존성 변경 없음 — 스킵');
$bar->advance();
$log('composer.json/lock 변경 없음, vendor 재설치 스킵');
$vendorResult = null;
} else {
$bar->setMessage(__('settings.core_update.step_composer'));
$bar->advance();
$log("_pending vendor 설치 시작 (mode: {$vendorMode->value})");
try {
$vendorResult = $service->runVendorInstallInPending($pendingPath, $vendorMode, $onProgress);
$log(sprintf(
'_pending vendor 설치 완료 (strategy: %s, packages: %d)',
$vendorResult->strategy,
$vendorResult->packageCount,
));
} catch (VendorInstallException $e) {
$this->error('Vendor 설치 실패: '.$e->getMessage());
throw $e;
}
}
// ── Step 6.5: 신 버전 신규 파일 manifest 생성 ──
//
// applyUpdate 직전 시점에 백업 디렉토리(= 활성 디렉토리의 사전 스냅샷) 와
// _pending(= 신 버전 소스) 을 비교해 신 버전이 추가하는 파일/디렉토리 목록을
// `_new_files_manifest.json` 으로 백업 디렉토리에 기록한다. 자동 롤백 시
// `restoreFromBackup()` 이 본 manifest 를 참조해 활성 디렉토리에서 정확히 그
// 항목만 prune. 사용자가 활성 디렉토리에 직접 추가한 파일은 백업에 포함되어
// 있으므로 manifest 에서 제외되어 보존된다.
//
// `--no-backup` 모드면 backupPath 가 null 이므로 manifest 생성을 스킵 — 롤백
// 자체가 불가능한 모드이므로 기존 동작 유지.
// 증분 적용(3-way) 대상 목록. 백업 있음 + --prune 미지정 시에만 산출.
// null 로 남으면(백업 부재 또는 --prune) applyUpdate 가 전체 덮어쓰기로 회귀.
$applyList = null;
$applyStats = null;
$prune = (bool) $this->option('prune');
if ($backupPath !== null) {
$bar->setMessage('신규 파일 manifest 생성 중...');
$log('신규 파일 manifest 생성 시작');
try {
$manifestStats = CoreBackupHelper::writeNewFilesManifest(
$backupPath,
$pendingPath,
(array) config('app.update.targets', []),
(array) config('app.update.protected_paths', []),
(array) config('app.update.excludes', []),
$fromVersion,
$toVersion,
);
$log(sprintf(
'신규 파일 manifest 작성 완료 (files=%d, dirs=%d)',
$manifestStats['new_files_count'],
$manifestStats['new_dirs_count'],
));
} catch (\Throwable $manifestError) {
// manifest 작성 실패는 fatal 이 아님 — 롤백 시 기존 overlay 만 수행 (기존 동작)
$log("신규 파일 manifest 작성 실패 (계속 진행): {$manifestError->getMessage()}");
Log::warning('코어 업데이트: manifest 작성 실패', [
'error' => $manifestError->getMessage(),
]);
}
// ── 증분 적용 대상 산출 (3-way) ──
// --prune 이면 전체 덮어쓰기이므로 산출 자체를 스킵.
if (! $prune) {
try {
$applyStats = CoreBackupHelper::computeApplyList(
$backupPath,
$pendingPath,
(array) config('app.update.targets', []),
(array) config('app.update.protected_paths', []),
(array) config('app.update.excludes', []),
);
$applyList = $applyStats['apply'];
$log(sprintf(
'증분 적용 대상 산출 완료 (added=%d, changed=%d, total=%d)',
$applyStats['added_count'],
$applyStats['changed_count'],
count($applyList),
));
} catch (\Throwable $applyError) {
// 산출 실패 시 안전하게 전체 덮어쓰기로 회귀 (applyList=null 유지)
$applyList = null;
$applyStats = null;
$log("증분 적용 대상 산출 실패 (전체 덮어쓰기로 회귀): {$applyError->getMessage()}");
Log::warning('코어 업데이트: 증분 적용 대상 산출 실패', [
'error' => $applyError->getMessage(),
]);
}
}
}
// ── Step 7: 파일 적용 ──
$bar->setMessage(__('settings.core_update.step_apply'));
$bar->advance();
if ($prune) {
$log('코어 파일 덮어쓰기 시작 (--prune: 전체 덮어쓰기 + orphan 정리)');
} elseif ($applyList !== null) {
$log(sprintf('코어 파일 적용 시작 (증분: 코어 변경분 %d건만 적용, 나머지 보존)', count($applyList)));
} else {
$log('코어 파일 덮어쓰기 시작 (백업 부재 — 전체 덮어쓰기로 회귀)');
}
$service->applyUpdate($pendingPath, $onProgress, prune: $prune, applyList: $applyList);
$log('코어 파일 적용 완료');
// ── Step 8: vendor 디렉토리 복사 (_pending → 운영) ──
if ($composerSkipped) {
$bar->setMessage('vendor 복사 스킵');
$bar->advance();
$log('composer 스킵 → vendor 디렉토리 복사 불필요');
} else {
$bar->setMessage(__('settings.core_update.step_composer_prod'));
$bar->advance();
$log('vendor 디렉토리 복사 시작');
// composer/bundled 모드 모두 _pending/vendor/ 가 구성되어 있으므로 공통 복사
$service->copyVendorFromPending($pendingPath, $onProgress);
$log('vendor 디렉토리 복사 완료');
}
// ── Step 9: Migration + 역할/메뉴 동기화 ──
//
// 동기화는 반드시 reloadCoreConfigAndResync() 로 호출한다. 본 메서드는 부모
// 프로세스가 부팅 시점에 캐시한 stale config 를 우회해 디스크의 fresh
// config/core.php 를 require → Config Repository 에 재주입한 뒤 syncCore* 를
// 호출한다. 디스크는 Step 7(applyUpdate) 에서 이미 신버전으로 교체되어 있다.
//
// syncCoreRolesAndPermissions / syncCoreMenus 직접 호출 금지 — 부모 메모리의
// 구버전 config 로 sync 가 돌면 신규 권한/메뉴가 누락된다 (#326 회귀).
$bar->setMessage(__('settings.core_update.step_migration'));
$bar->advance();
$log('마이그레이션, 역할/메뉴 동기화 실행');
$service->runMigrations();
$service->reloadCoreConfigAndResync();
$log('마이그레이션, 역할/메뉴 동기화 완료');
// ── Step 10: Upgrade Steps ──
//
// 경로 B (beta.3+): 별도 프로세스 spawn — 새 PHP 프로세스가 최신 클래스·config 를
// 로드하므로 upgrade step 이 신규 Service/Model/Repository 등을 자유롭게 사용 가능.
//
// 경로 A (beta.1 → beta.2): beta.1 CoreUpdateCommand 가 in-process 로 실행되는
// 상황에서는 본 재작성된 로직 자체가 활성화되지 않는다. 해당 경로의 후처리는
// upgrade step 파일(Upgrade_7_0_0_beta_2) 내부 로컬 로직으로 수행된다.
//
// proc_open 미지원 / 실행 실패 시 in-process fallback 으로 안전하게 전환.
$bar->setMessage(__('settings.core_update.step_upgrade'));
$bar->advance();
$log('업그레이드 스텝 별도 프로세스 실행 시도');
// progress bar 라인과 spawn stdout / step 로그가 같은 줄에 섞이지 않도록
// bar 를 잠시 지우고, 상세 로그를 별도 줄로 출력한 뒤 다시 표시한다.
$bar->clear();
$this->newLine();
$this->info('── 업그레이드 스텝 실행 ──');
$spawned = $this->spawnUpgradeStepsProcess(
$fromVersion,
$toVersion,
(bool) $this->option('force'),
$log,
);
if (! $spawned) {
$log('별도 프로세스 실행 실패 — in-process fallback');
$service->runUpgradeSteps($fromVersion, $toVersion, function (string $version) use ($log) {
$this->line(" • upgrade step 실행: {$version}");
$log("업그레이드 스텝 실행: {$version}");
}, (bool) $this->option('force'));
$service->reloadCoreConfigAndResync();
$log('fallback 완료 (config 재로드 + 권한/메뉴 재동기화)');
}
$log('업그레이드 스텝 완료');
$this->newLine();
$bar->display();
// ── Step 11: Cleanup ──
$bar->setMessage(__('settings.core_update.step_cleanup'));
$bar->advance();
$service->updateVersionInEnv($toVersion);
$service->clearAllCaches();
// 상주 큐 워커는 부팅이 한 번뿐이라 옛 코드를 물고 있다 — 캐시가 새 코드 기준으로
// 정리된 직후 재시작 신호를 보낸다.
$service->signalQueueRestart();
$log('큐 워커 재시작 신호 전송 (queue:restart)');
// 코어 업데이트 후 프론트엔드가 새 lang/routes/layout 자원으로 fetch 하도록
// `ext.cache_version` bump. 코어 lang JSON 변경이 프론트엔드 캐시에
// 반영되지 않는 회귀를 차단한다 (core-frontend-i18n-infrastructure 계획서).
$this->incrementExtensionCacheVersion();
// sudo 실행 시 composer 등 외부 프로세스가 root 로 생성한 파일의 소유권을
// 백업 직후 수집한 원본 스냅샷 기준으로 복원 (각 경로 고유 소유자 유지).
// detailedSnapshot 동시 전달 — PHP-FPM 쓰기 영역의 owner/group/perms 를 항목별
// 정확 복원하여 #282 (sudo update 후 traversal 비트 손실) 회귀 차단.
$service->restoreOwnership($ownershipSnapshot, $onProgress, $detailedOwnershipSnapshot);
$this->surfacePermissionWarnings($service, $log);
$log('업데이트 경로 소유권 복원 완료');
// 소유권 복원 완료 후 · 유지보수 해제 전에 `public/storage` symlink 를 방어 복구한다.
// `--prune` 이 릴리즈 소스에 없는 symlink 를 orphan 으로 삭제했거나(#43) 손상된
// 경우에도, 첫 요청부터 `/storage/...` 가 200 을 반환하도록 링크를 재생성한다.
StorageLinkHelper::ensurePublicStorageLink();
$log('public/storage symlink 확인/복구 완료');
$service->cleanupPending($pendingPath);
if ($backupPath) {
CoreBackupHelper::deleteBackup($backupPath);
}
if ($maintenanceEnabled) {
$service->disableMaintenanceMode();
$maintenanceEnabled = false;
}
// 모든 파일(코어 config/lang/vendor)이 안착한 뒤 config 캐시를 재생성한다.
// clearAllCaches() 는 흐름 중간에서 stale 캐시를 비우기만 하므로, 여기서
// 재생성하지 않으면 업데이트 후 config:cache 가 비활성 상태로 남아 이후 모든
// 요청이 config 파일을 재파싱한다(성능 손실). ConfigCacheHelper 는 설치 미완료
// 상태를 가드하고 실패를 안전하게 흡수한다.
ConfigCacheHelper::rebuild();
// 라우트 캐시도 같은 이유로 되살린다. 코어 업데이트는 routes/*.php 와 vendor 를
// 교체하므로 흐름 중간에 비우는 것이 맞지만, 비운 채로 끝내면 이후 모든 요청이
// 라우트를 다시 등록한다. 재생성은 파일이 전부 안착한 이 지점에서만 안전하다.
RouteCacheHelper::rebuild();
$log('정리 완료');
$bar->finish();
$this->newLine(2);
// 설치 로그 저장
$this->saveUpdateLog($logEntries, $fromVersion, $toVersion, true);
$this->info("그누보드7 코어가 {$toVersion} 버전으로 업데이트되었습니다!");
$this->newLine();
// ── 파일 적용 방식 요약 (부수의무: 설명의무) ──
$this->summarizeApplyMode($prune, $applyList, $applyStats);
// _bundled 확장 일괄 업데이트 프롬프트 (번들에 신버전이 있을 때만 표시).
//
// 주의: Step 11 restoreOwnership 이후에 실행되므로, 프롬프트 내 실제
// 업데이트 실행 시 composer / updateComposerAutoload 등으로 생성되는
// bootstrap/cache/autoload-extensions.php, {modules|plugins|templates}/{id}/vendor,
// storage/app/* 등이 sudo 컨텍스트에서 root 로 오염될 수 있다.
// 실제 업데이트가 수행된 경우 restoreOwnership 을 한 번 더 호출해 재복원한다.
$promptResult = $this->runBundledExtensionUpdatePrompt(
app(ModuleManager::class),
app(PluginManager::class),
app(TemplateManager::class),
(bool) $this->option('force'),
);
if (($promptResult['success'] ?? 0) > 0) {
$this->newLine();
$this->info('일괄 업데이트로 생성된 파일의 소유권을 복원하는 중...');
// 일괄 확장 update 가 sudo 컨텍스트에서 root 로 만들 수 있는 PHP-FPM 쓰기
// 영역의 owner/group/perms 를 항목별 정확 복원 (Stage 4).
$service->restoreOwnership($ownershipSnapshot, $onProgress, $detailedOwnershipSnapshot);
// restoreOwnership 의 진행 표시($onProgress → $bar->display())가
// 개행 없이 끝나므로 다음 셸 프롬프트가 같은 줄에 붙는 것을 방지.
$this->newLine(2);
$this->surfacePermissionWarnings($service, $log);
$log('일괄 확장 업데이트 후 소유권 재복원 완료');
}
// fallback(spawn 실패)로 부모가 upgrade step 을 직접 실행한 경우, 부모가 만든
// upgrade 로그가 root 로 남는다 — 모든 로그 쓰기가 끝난 이 시점에 정합한다.
$this->restoreUpgradeLogOwnership();
// root 업데이트가 종료 시점까지 만든 캐시/번들 산출물 소유권 정상화 —
// restoreOwnership(흐름 중간) 이후의 root 쓰기가 웹 캐시 쓰기를 죽이는
// 전면 500 차단 (7.0.9→7.0.10 실사례)
app(CoreUpdateService::class)->normalizeRuntimeOwnershipAfterRootRun();
return Command::SUCCESS;
} catch (UpgradeHandoffException $e) {
// 업그레이드 스텝이 새 PHP 프로세스 재진입을 요청한 경우.
//
// 롤백하지 않음 — 파일 교체 / migration / composer 결과까지는 이미 `toVersion` 상태로 유효.
// Step 11 cleanup 을 축소 실행하여 중간 상태를 확정하고, 사용자에게 스텝 전용 재실행 안내.
//
// - updateVersionInEnv(toVersion): 디스크가 이미 toVersion 이므로 .env 도 toVersion 으로
// 일치시킨다. afterVersion 이 아님 — afterVersion 으로 두면 사용자가 다시 core:update
// 를 돌릴 때 GitHub 재다운로드부터 전체가 반복된다. toVersion 으로 고정 + 사용자에게는
// execute-upgrade-steps 만 실행하도록 안내하여 재다운로드를 회피한다.
// - clearAllCaches: 신규 코드·config 반영
// - restoreOwnership: sudo 생성 파일 소유권 복원
// - cleanupPending: _pending 정리
// - 백업은 유지 (재실행 중 실패해도 복구 가능)
// - maintenance 해제: 서비스 재개
//
// resumeCommand 는 예외에 명시되지 않았으면 `execute-upgrade-steps --from=<afterVersion>
// --to=<toVersion> --force` 로 자동 생성. step 만 단독 실행되어 재다운로드 없음.
$bar->finish();
$this->newLine(2);
$log("업그레이드 핸드오프 수신: {$e->afterVersion} 까지 완료 — {$e->reason}");
$resumeCommand = $e->resumeCommand ?? sprintf(
'php artisan core:execute-upgrade-steps --from=%s --to=%s --force',
$e->afterVersion,
$toVersion,
);
try {
$service->updateVersionInEnv($toVersion);
$service->clearAllCaches();
// 핸드오프로 멈춰도 파일은 이미 toVersion 이므로 워커는 새 코드로 재기동해야 한다.
$service->signalQueueRestart();
$log('큐 워커 재시작 신호 전송 (queue:restart)');
// 핸드오프 cleanup 에서도 프론트엔드 캐시 버전 bump — 사용자가 resume 명령
// (execute-upgrade-steps) 을 실행하기 전이라도 이미 toVersion 으로 반영된
// 코어 lang/routes/layout 자원이 프론트엔드 캐시 stale 로 가려지지 않도록.
$this->incrementExtensionCacheVersion();
// Stage 4 — handoff cleanup 도 detailed snapshot 으로 정확 복원
$service->restoreOwnership($ownershipSnapshot, $onProgress, $detailedOwnershipSnapshot);
$this->surfacePermissionWarnings($service, $log);
$log('핸드오프 cleanup 완료 (버전 toVersion 고정 + 캐시 clear + 소유권 복원)');
// 핸드오프 시에도 파일은 이미 toVersion 으로 반영돼 `public/storage` symlink 가
// 삭제/손상됐을 수 있으므로 유지보수 해제 전에 동일하게 방어 복구한다(#43).
StorageLinkHelper::ensurePublicStorageLink();
$log('public/storage symlink 확인/복구 완료 (핸드오프)');
if (! empty($pendingPath)) {
$service->cleanupPending($pendingPath);
}
if ($maintenanceEnabled) {
$service->disableMaintenanceMode();
$maintenanceEnabled = false;
$log('유지보수 모드 해제');
}
} catch (\Throwable $cleanupError) {
$log("핸드오프 cleanup 중 오류: {$cleanupError->getMessage()}");
$this->warn("핸드오프 cleanup 중 오류 발생 — 수동 점검 필요: {$cleanupError->getMessage()}");
}
$this->saveUpdateLog($logEntries, $fromVersion, $toVersion, true);
$this->newLine();
$this->warn('업그레이드 파일 반영은 완료되었으나, 일부 업그레이드 스텝이 현재 프로세스에서 실행될 수 없어 중단되었습니다.');
$this->newLine();
$this->line(" 완료 스텝 지점: {$e->afterVersion}");
$this->line(" 파일·설정 버전: {$toVersion} (이미 반영됨)");
$this->line(" 사유: {$e->reason}");
$this->newLine();
$this->info('나머지 스텝을 적용하려면 아래 명령을 실행하세요 (재다운로드 없이 스텝만 실행):');
$this->surfaceResumeCommandWithPermissionGuidance($resumeCommand);
$this->newLine();
$this->warn('⚠ 위 명령을 실행하지 않으면 버전 표시는 최신이지만 일부 스텝이 미실행 상태로 남습니다.');
$this->newLine();
if ($backupPath) {
$this->line("백업이 유지되었습니다: {$backupPath}");
$this->newLine();
}
$this->restoreUpgradeLogOwnership();
// root 업데이트가 종료 시점까지 만든 캐시/번들 산출물 소유권 정상화 —
// restoreOwnership(흐름 중간) 이후의 root 쓰기가 웹 캐시 쓰기를 죽이는
// 전면 500 차단 (7.0.9→7.0.10 실사례)
app(CoreUpdateService::class)->normalizeRuntimeOwnershipAfterRootRun();
return Command::SUCCESS;
} catch (\Throwable $e) {
$bar->finish();
$this->newLine(2);
$log("오류 발생: {$e->getMessage()}");
// 롤백
$restoreSuccess = false;
$failedTargets = [];
if ($backupPath) {
$this->warn('백업에서 복원 중...');
$log('백업 복원 시작');
try {
$failedTargets = $service->restoreFromBackup($backupPath, $onProgress);
if (empty($failedTargets)) {
$log('백업 복원 완료');
$this->info('백업에서 복원되었습니다.');
$restoreSuccess = true;
} else {
$log('백업 부분 복원 완료 (실패: '.implode(', ', $failedTargets).')');
$this->warn('백업에서 부분 복원되었습니다.');
$this->error('복원 실패 항목: '.implode(', ', $failedTargets));
}
} catch (\Throwable $restoreError) {
$log("백업 복원 실패: {$restoreError->getMessage()}");
$this->error("백업 복원 실패: {$restoreError->getMessage()}");
}
// 롤백 직후 캐시 자동 정리 — 신 코드가 `bootstrap/cache/*.php` 에 cache 된 채로
// 남아 부팅 실패하는 회귀 차단. 운영자의 수동 `php artisan optimize:clear` 단계 제거.
// 캐시 정리 실패는 fatal 아님 — warning 후 진행.
try {
$service->clearAllCaches();
$log('롤백 후 캐시 정리 완료');
} catch (\Throwable $cacheError) {
$log("롤백 후 캐시 정리 실패 (계속 진행): {$cacheError->getMessage()}");
$this->warn("캐시 정리 실패 — 부팅 후 'php artisan optimize:clear' 수동 실행 필요: {$cacheError->getMessage()}");
}
}
// _pending 정리 (실패 시에도)
if (! empty($pendingPath)) {
$service->cleanupPending($pendingPath);
}
// 실패 리포트
$reportPath = $service->generateFailureReport($e, $fromVersion, $toVersion);
// 설치 로그 저장
$this->saveUpdateLog($logEntries, $fromVersion, $toVersion, false);
$this->error("코어 업데이트 실패: {$e->getMessage()}");
$this->info("실패 리포트: {$reportPath}");
if ($maintenanceEnabled) {
$this->newLine();
if ($restoreSuccess) {
// 완전 복원 성공 → 자동 유지보수 해제
try {
$service->disableMaintenanceMode();
$maintenanceEnabled = false;
$this->info('복원 완료: 유지보수 모드가 해제되었습니다.');
} catch (\Throwable) {
$this->warn('유지보수 모드 해제 실패. 수동으로 해제하세요: php artisan up');
}
} else {
// 복원 실패 또는 부분 복원 → 수동 복구 안내
$this->warn('유지보수 모드가 유지됩니다.');
if (! empty($failedTargets) || ! $backupPath) {
$this->newLine();
$this->error('수동 복구가 필요합니다:');
if ($backupPath) {
$this->line(" 1. 백업에서 수동 복원: cp -r {$backupPath}/* ".base_path().'/');
}
$this->line(' '.($backupPath ? '2' : '1').'. Composer 재설치: composer install --no-dev --optimize-autoloader');
$this->line(' '.($backupPath ? '3' : '2').'. 유지보수 해제: php artisan up');
} else {
$this->info('이전 버전으로 사이트를 운영하려면: php artisan up');
}
if ($secret) {
$this->info("관리자 접근: {$secret}");
}
}
}
$this->restoreUpgradeLogOwnership();
// root 업데이트가 종료 시점까지 만든 캐시/번들 산출물 소유권 정상화 —
// restoreOwnership(흐름 중간) 이후의 root 쓰기가 웹 캐시 쓰기를 죽이는
// 전면 500 차단 (7.0.9→7.0.10 실사례)
app(CoreUpdateService::class)->normalizeRuntimeOwnershipAfterRootRun();
return Command::FAILURE;
}
}
/**
* 업그레이드 스텝을 별도 PHP 프로세스에서 실행합니다 (경로 B).
*
* proc_open 으로 `core:execute-upgrade-steps` 커맨드를 spawn 하여 새 PHP 프로세스가
* 최신 파일 기준으로 모든 클래스·config 를 로드하게 한다. 이로써 upgrade step 이
* 신규 Service/Repository/Controller 등을 자유롭게 참조할 수 있다.
*
* stdout 을 실시간으로 상위 콘솔에 전달하여 진행 상황을 보여주고, exit code === 0
* 일 때만 성공으로 판정한다. proc_open 미지원 환경·커맨드 미존재·비정상 종료는
* false 반환하여 호출자가 in-process fallback 으로 전환할 수 있게 한다.
*
* 핸드오프 신호: 자식이 exit=UpgradeHandoffException::EXIT_CODE 로 종료하면서
* stdout 에 `[HANDOFF] <json>` 라인을 남기면, 본 메서드는 UpgradeHandoffException 을
* 재구성하여 상위로 던진다 (CoreUpdateCommand::handle 의 catch 블록이 처리).
*
* @param string $fromVersion 시작 버전
* @param string $toVersion 대상 버전
* @param bool $force 동일 버전 강제 실행 여부
* @param \Closure $log 로그 엔트리 수집 콜백
* @return bool spawn 성공 여부 (false 면 fallback 실행 필요)
*
* @throws UpgradeHandoffException 자식이 핸드오프 신호를 보낸 경우
*/
private function spawnUpgradeStepsProcess(string $fromVersion, string $toVersion, bool $force, \Closure $log): bool
{
if (! function_exists('proc_open')) {
return $this->failSpawnWithMode(
'proc_open 비활성',
$log,
$fromVersion,
$toVersion,
);
}
$phpBinary = config('process.php_binary', PHP_BINARY);
$artisan = base_path('artisan');
$command = [
$phpBinary,
$artisan,
'core:execute-upgrade-steps',
'--from='.$fromVersion,
'--to='.$toVersion,
];
if ($force) {
$command[] = '--force';
}
// 부모는 이미 Step 9 (runMigrations + reloadCoreConfigAndResync, 라인 408-409),
// Step 11 (updateVersionInEnv + clearAllCaches, 라인 457-458), 번들 확장 일괄 업데이트
// prompt (라인 497-515) 를 자식 종료 후 실행하므로 자식 내부 중복 회피. 단독 실행 시
// (운영자 직접 호출) 옵션이 전달되지 않아 자식 기본값(5단계 모두 포함) 발동 →
// gnuboard/g7#34 의 운영자 수동 절차(migrate / resync / .env sed / cache:clear / module:update --source=bundled)
// 가 단일 명령으로 통합되어 단독 안전성 보장.
$command[] = '--skip-migrations';
$command[] = '--skip-resync';
$command[] = '--skip-version-env';
$command[] = '--skip-cache-clear';
$command[] = '--skip-bundled-updates';
$commandLine = implode(' ', array_map('escapeshellarg', $command)).' 2>&1';
$descriptors = [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];
// spawn 자식에 전달할 env 구성.
//
// 주의: `$_ENV` 는 `variables_order` php.ini 설정(기본 CLI 가 "EGPCS" 지만 호스팅에 따라
// "GPCS" 만 포함해 E 없을 수 있음) 에 따라 비어있을 수 있다. 이 경우 `$_ENV` 에 값을
// 할당해도 실제 프로세스 환경변수 테이블에는 반영 안 되며, `array_merge($_ENV, ...)` 결과도
// 불완전해져 spawn 자식이 G7_UPDATE_IN_PROGRESS 등 핵심 플래그를 받지 못한다.
//
// getenv() (인자 없이 호출, PHP 7.1+) 은 프로세스 환경변수 테이블을 직접 반환해
// putenv 로 설정한 값까지 확실히 포함한다. getenv() 를 기반으로 삼고 $_ENV 로 보완하여
// 양쪽 채널의 합집합을 자식에 전달한다.
//
// APP_VERSION: Step 11 updateVersionInEnv 는 아직 실행 전이라 .env 는 fromVersion.
// G7_UPDATE_IN_PROGRESS: CoreServiceProvider::validateAndDeactivate 가드가 참조.
$env = array_merge(getenv(), $_ENV, [
'APP_VERSION' => $toVersion,
'G7_UPDATE_IN_PROGRESS' => '1',
]);
// spawn 직전 config 캐시 제거. 자식은 새 프로세스라 `bootstrap/cache/config.php` 가 있으면
// 그 캐시로 부팅하는데, 그 캐시는 이전 버전 설치본이 만든 것이다(설치 마법사·설정 저장·
// 확장 업데이트). 캐시 부팅에서는 `.env` 도 읽지 않고 위 `$env` 의 APP_VERSION 오버라이드도
// config 에 반영되지 않으며, 신버전 `config/app.php` 가 추가한 update 목록(쓰기 권한
// 디렉토리 등)도 자식에게 보이지 않는다 (7.0.9→7.0.10 실사례: stale 가드 오판 +
// `public/build/ext` 권한 정상화 누락). 부모의 메모리 config 는 영향받지 않고, 캐시는
// Step 11 의 `ConfigCacheHelper::rebuild()` 가 모든 파일이 안착한 뒤 다시 만든다.
ConfigCacheHelper::clear();
// 같은 이유로 패키지 매니페스트(bootstrap/cache/packages.php · services.php)도 비운다.
// vendor 는 Step 6/8 에서 이미 교체됐지만 두 파일은 Step 11 clearAllCaches 까지 이전
// 설치본의 것이 남고, Laravel 은 packages.php 가 "없을 때만" 다시 만든다. 이전 설치본이
// dev composer(require-dev 전이 의존성 laravel/mcp 의 McpServiceProvider 포함)로 깔렸다면
// 자식은 새 vendor 에 없는 provider 를 new 하다 부팅 단계에서 죽는다 (7.0.9→7.0.10 실사례).
// 부모 메모리의 매니페스트는 영향받지 않고, Step 11 이 package:discover 로 다시 만든다.
//
// 지우지 못한 파일(권한·소유권 불일치)은 로그에 남긴다. 그 상태면 자식의 자가 치유도 같은
// 권한으로 실패하므로 증상은 이전 설치본 provider 의 「Class not found」 그대로이고, 이 기록이
// 권한이 원인이라는 유일한 흔적이다.
$remainingManifests = PackageManifestCacheHelper::clear();
if ($remainingManifests !== []) {
$manifestWarning = 'spawn 직전 패키지 매니페스트 삭제 실패 — 자식이 이전 설치본의 provider 목록으로 부팅할 수 있습니다 (권한·소유권 확인): '
.implode(', ', $remainingManifests);
$log($manifestWarning);
$this->warn($manifestWarning);
}
$process = proc_open($commandLine, $descriptors, $pipes, base_path(), $env);
if (! is_resource($process)) {
return $this->failSpawnWithMode(
'proc_open 자원 생성 실패',
$log,
$fromVersion,
$toVersion,
);
}
fclose($pipes[0]);
// stdout 실시간 전달 — 상위 콘솔에서 진행 상황 확인 가능
// 단, [HANDOFF] / [STEPS_EXECUTED] 접두사 라인은 상위 콘솔로 노출하지 않고
// 페이로드만 보관한다 (각각 UpgradeHandoffException 재구성 / silent skip 가드용).
$handoffPayload = null;
$stepsExecuted = null;
$stepsDiscovered = null;
while (! feof($pipes[1])) {
$line = fgets($pipes[1]);
if ($line !== false) {
$trimmed = rtrim($line);
if ($trimmed === '') {
continue;
}
if (str_starts_with($trimmed, '[HANDOFF] ')) {
$json = substr($trimmed, strlen('[HANDOFF] '));
$decoded = json_decode($json, true);
// 페이로드 값 검증 — array_key_exists 만으로는 비정상 입력(빈 문자열·
// shell metacharacter·과도 길이) 이 통과한다. resumeCommand 는 null
// 허용 (자식이 null 로 전달한 경우 부모가 from/to 기반 자동 생성).
if ($this->isValidHandoffPayload($decoded)) {
$handoffPayload = $decoded;
$log('[spawn] 핸드오프 신호 수신: after='.$decoded['afterVersion']);
continue;
}
// 구조/값이 깨진 핸드오프 라인 — 정상 출력으로 간주해 그대로 전달
if (is_array($decoded)) {
$log('[spawn] 핸드오프 페이로드 검증 실패 — 정상 출력으로 처리: '.json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
}
if (str_starts_with($trimmed, '[STEPS_EXECUTED] ')) {
$json = substr($trimmed, strlen('[STEPS_EXECUTED] '));
$decoded = json_decode($json, true);
if (is_array($decoded) && isset($decoded['count']) && is_int($decoded['count']) && $decoded['count'] >= 0) {
$stepsExecuted = $decoded['count'];
// discovered 는 신버전 자식만 발행하는 필드. 구버전 자식(필드 부재)은
// null 로 남아 handleSpawnExit 가 기존(레거시) 판정 경로를 탄다.
if (isset($decoded['discovered']) && is_int($decoded['discovered']) && $decoded['discovered'] >= 0) {
$stepsDiscovered = $decoded['discovered'];
}
$log('[spawn] 실행된 step 수: '.$stepsExecuted
.($stepsDiscovered !== null ? " (발견 {$stepsDiscovered})" : ''));
continue;
}
}
$this->line($trimmed);
$log('[spawn] '.$trimmed);
}
}
fclose($pipes[1]);
fclose($pipes[2]);
$exitCode = proc_close($process);
return $this->handleSpawnExit(
$exitCode,
$handoffPayload,
$stepsExecuted,
$stepsDiscovered,
$fromVersion,
$toVersion,
$log,
);
}
/**
* spawn 자식 종료 후 exit code · 신호를 해석해 성공/실패/핸드오프를 판정합니다.
*
* 판정 규칙 (exit=0 경우):
* - `[STEPS_EXECUTED]` 신호 미수신 (`$stepsExecuted === null`) → fail-fast.
* 이전 버전 자식 (신호 미발행) 또는 silent skip 의심.
* - executed=0 인데 **discovered>0** → fail-fast. 범위 내에 스텝 파일이 실제로
* 존재하는데 자식이 하나도 실행하지 못함 (gnuboard/g7#28 silent skip — 케이스 A).
* - executed=0 이고 **discovered=0** → 정상 통과. 해당 from~to 범위에 스텝 파일이
* 애초에 없는 릴리즈 (예: 데이터/설정 변경 없는 패치). from<to 여도 실패 아님 (케이스 B).
* - discovered 신호 부재 (`null`, 구버전 자식) + executed=0 + from<to → 레거시 판정 유지
* (fail-fast). 신버전 자식은 항상 discovered 를 발행하므로 이 경로는 구버전 자식 한정.
*
* @param int $exitCode 자식 프로세스 종료 코드
* @param array|null $handoffPayload 파싱된 [HANDOFF] 페이로드 (없으면 null)
* @param int|null $stepsExecuted 실행된 스텝 수 ([STEPS_EXECUTED] 미수신 시 null)
* @param int|null $stepsDiscovered 범위 내 발견된 스텝 파일 수 (구버전 자식은 null)
* @param string $fromVersion 업그레이드 시작 버전
* @param string $toVersion 업그레이드 대상 버전
* @param \Closure $log 로그 수집 콜백
* @return bool spawn 성공 여부 (mode=fallback 실패 시 false — 호출자 in-process fallback)
*
* @throws UpgradeHandoffException 핸드오프 수신 또는 mode=abort 실패 시
*/
private function handleSpawnExit(
int $exitCode,
?array $handoffPayload,
?int $stepsExecuted,
?int $stepsDiscovered,
string $fromVersion,
string $toVersion,
\Closure $log,
): bool {
if ($exitCode === UpgradeHandoffException::EXIT_CODE && $handoffPayload !== null) {
$log("spawn 핸드오프 종료 (exit={$exitCode})");
// resumeCommand 가 null 이면 null 그대로 전달 — 상위 catch 에서 자동 생성.
$resume = $handoffPayload['resumeCommand'];
throw new UpgradeHandoffException(
afterVersion: (string) $handoffPayload['afterVersion'],
reason: (string) $handoffPayload['reason'],
resumeCommand: is_string($resume) ? $resume : null,
);
}
if ($exitCode === 0) {
// 신호 미수신 — 이전 버전 자식 (신호 미발행) 또는 silent skip 의심. fail-fast.
if ($stepsExecuted === null) {
return $this->failSpawnWithMode(
'spawn 자식이 [STEPS_EXECUTED] 신호 미발행 — 이전 버전 자식 또는 silent skip 의심',
$log,
$fromVersion,
$toVersion,
);
}
// executed=0 + discovered=0 → 범위 내 스텝 파일 부재. 정상 통과 (케이스 B).
// 스텝이 필요 없는 릴리즈(데이터/설정 변경 없음)를 실패로 오판하지 않는다.
if ($stepsExecuted === 0 && $stepsDiscovered === 0) {
$log("spawn 완료 (exit=0, 실행할 스텝 없음 — 범위 내 스텝 파일 부재, from={$fromVersion} to={$toVersion})");
return true;
}
// executed=0 + (discovered>0 또는 discovered 신호 부재) + from<to → fail-fast.
// discovered>0: 스텝 파일이 있는데 자식이 실행 못함 (gnuboard/g7#28 케이스 A).
// discovered=null: 구버전 자식 (discovered 미발행) — 레거시 판정 유지.
if ($stepsExecuted === 0
&& $stepsDiscovered !== 0
&& version_compare($fromVersion, $toVersion, '<')) {
return $this->failSpawnWithMode(
sprintf(
'spawn 자식 exit=0 이지만 step 0건 실행 — 의도된 동작이 아님 (from=%s to=%s%s)',
$fromVersion,
$toVersion,
$stepsDiscovered !== null ? ", 발견 {$stepsDiscovered}건" : '',
),
$log,
$fromVersion,
$toVersion,
);
}
$log("spawn 완료 (exit=0, steps={$stepsExecuted})");
return true;
}
return $this->failSpawnWithMode(
"spawn 비정상 종료 (exit={$exitCode})",
$log,
$fromVersion,
$toVersion,
);
}
/**
* spawn 자식 프로세스 실패 시 `app.update.spawn_failure_mode` 에 따라 분기합니다.
*
* - 'abort' (기본): `UpgradeHandoffException` 을 throw 하여 부모의 in-process
* fallback 진입을 차단. 부모 catch 블록이 cleanup 후 운영자에게
* `core:execute-upgrade-steps` 수동 명령을 안내한다.
* - 'fallback' (호환): 기존 동작 — log 한 줄 남기고 false 반환. 호출자가
* in-process fallback 으로 진행. 부모 메모리 stale 시 fatal 위험.
*
* @param string $reason 실패 사유 (로그/안내 메시지에 포함)
* @param \Closure $log 로그 엔트리 수집 콜백
* @param string $fromVersion 업그레이드 시작 버전 (handoff afterVersion / resumeCommand 구성)
* @param string $toVersion 업그레이드 대상 버전 (resumeCommand 구성)
* @return false fallback 모드일 때만 반환. abort 모드는 throw 후 미반환.
*
* @throws UpgradeHandoffException mode=abort 일 때
*/
private function failSpawnWithMode(string $reason, \Closure $log, string $fromVersion, string $toVersion): bool
{
$mode = config('app.update.spawn_failure_mode', 'fallback');
if ($mode === 'abort') {
$resumeCommand = sprintf(
'php artisan core:execute-upgrade-steps --from=%s --to=%s --force',
$fromVersion,
$toVersion,
);
$log("spawn 자식 실패 — {$reason}. fail-fast 모드 abort.");
throw new UpgradeHandoffException(
afterVersion: $fromVersion,
reason: "spawn 자식 실패 — {$reason}. fail-fast 모드. 수동 재개로 진행하세요.",
resumeCommand: $resumeCommand,
);
}
$log("{$reason} — in-process fallback 진행 (stale 메모리 위험)");
return false;
}
/**
* [HANDOFF] 페이로드의 값을 검증합니다.
*
* spawn 자식의 stdout 을 부모가 그대로 신뢰하면 (a) 빈 afterVersion 으로 Step 11
* fromVersion 해석 혼란, (b) shell injection 문자열이 운영자 안내에 노출, (c) 과도한
* reason 길이로 로그 폭증 위험. 본 메서드는 다음 4가지 키/값을 동시에 검증한다:
*
* - `afterVersion`: 정규식 `/^\d+\.\d+\.\d+/` 매치 + 비어있지 않음
* - `reason`: string + 길이 < 500
* - `resumeCommand`: null 허용. string 일 때 길이 < 1000 + shell metacharacter(`;&|`$()<>`) 부재
*
* @param mixed $payload json_decode 결과
*/
private function isValidHandoffPayload($payload): bool
{
if (! is_array($payload)) {
return false;
}
foreach (['afterVersion', 'reason', 'resumeCommand'] as $key) {
if (! array_key_exists($key, $payload)) {
return false;
}
}
$afterVersion = $payload['afterVersion'];
if (! is_string($afterVersion) || $afterVersion === '' || ! preg_match('/^\d+\.\d+\.\d+/', $afterVersion)) {
return false;
}
$reason = $payload['reason'];
if (! is_string($reason) || strlen($reason) >= 500) {
return false;
}
$resumeCommand = $payload['resumeCommand'];
if ($resumeCommand !== null) {
if (! is_string($resumeCommand)) {
return false;
}
if (strlen($resumeCommand) >= 1000) {
return false;
}
if (preg_match('/[;&|`$()<>]/', $resumeCommand)) {
return false;
}
}
return true;
}
/**
* 파일 적용 방식(증분/prune/fallback)과 통계를 사용자에게 요약 출력합니다.
*
* @param bool $prune --prune 지정 여부
* @param array<int, string>|null $applyList 증분 적용 대상 목록 (null 이면 전체 덮어쓰기)
* @param array{apply:array<int,string>, added_count:int, changed_count:int, has_symlink:bool}|null $applyStats computeApplyList 산출 통계
*/
private function summarizeApplyMode(bool $prune, ?array $applyList, ?array $applyStats): void
{
if ($prune) {
$this->line(__('settings.core_update.apply_mode_prune'));
$this->newLine();
return;
}
if ($applyList === null) {
// 백업 부재 또는 산출 실패 → 전체 덮어쓰기로 회귀
$this->warn(__('settings.core_update.apply_mode_fallback'));
$this->newLine();
return;
}
// 증분 모드
$added = $applyStats['added_count'] ?? 0;
$changed = $applyStats['changed_count'] ?? 0;
$this->line(__('settings.core_update.apply_mode_incremental', [
'added' => $added,
'changed' => $changed,
]));
$this->line(__('settings.core_update.apply_mode_incremental_prune_hint'));
$this->newLine();
}
/**
* 업데이트 로그를 파일로 저장합니다.
*
* @param array $entries 로그 엔트리 목록
* @param string $fromVersion 시작 버전
* @param string $toVersion 종료 버전
* @param bool $success 성공 여부
*/
private function saveUpdateLog(array $entries, string $fromVersion, string $toVersion, bool $success): void
{
$timestamp = date('Ymd_His');
$status = $success ? 'success' : 'failed';
$logPath = storage_path("logs/core_update_{$status}_{$timestamp}.log");
$header = implode("\n", [
'=== 그누보드7 코어 업데이트 로그 ===',
'상태: '.($success ? '성공' : '실패'),
'날짜: '.date('Y-m-d H:i:s'),
"시작 버전: {$fromVersion}",
"대상 버전: {$toVersion}",
'',
'=== 실행 로그 ===',
]);
$content = $header."\n".implode("\n", $entries)."\n";
file_put_contents($logPath, $content);
// sudo 업데이트가 로그 파일을 root 소유로 만들면 이후 www-data 의 tinker/로그 쓰기가
// 거부된다. 부모(storage/logs) 소유권을 상속한다(멱등, sudo 없으면 silent no-op).
FilePermissionHelper::inheritOwnershipFromParent($logPath);
Log::info("코어 업데이트 로그 저장: {$logPath}");
}
/**
* 코어 업데이트(부모 프로세스)가 생성한 upgrade 로그 파일의 소유권을 부모(storage/logs)
* 로 정합합니다 — **spawn 실패로 부모가 upgrade step 을 in-process 로 직접 실행한 경우** 대비.
*
* upgrade 로그(`upgrade-YYYY-MM-DD.log`)를 만드는 주체는 두 갈래다:
* - spawn 성공: 자식(ExecuteUpgradeStepsCommand)이 로그 생성 → 자식이 자기 종료 직전 정합
* - spawn 실패(fallback): 부모가 runUpgradeSteps + reloadCoreConfigAndResync 를 직접 실행
* 하여 부모 프로세스가 upgrade 로그를 root 로 생성 → **부모가** 정합해야 한다.
*
* sudo 업데이트는 root 로 실행되어 로그를 root 소유로 만들고, 이후 www-data(php-fpm) 의
* module:update upgrade step 이 같은 날짜 로그에 append 하지 못해 Permission denied 로
* 실패한다. 본 메서드를 코어 업데이트의 모든 로그 쓰기가 끝난 종료 시점(각 return 직전)에
* 호출한다. **본 메서드 자체는 로그를 쓰지 않는다**(쓰면 다시 root 가 됨). silent no-op 멱등.
*/
private function restoreUpgradeLogOwnership(): void
{
$logsDir = storage_path('logs');
if (! is_dir($logsDir)) {
return;
}
// 코어('upgrade-*.log') + 확장('extension-upgrade-*.log') 두 채널의 daily 로그를 모두
// 정합한다. glob 'upgrade-*.log' 는 'extension-' 접두사 파일을 매칭하지 못하므로
// 두 패턴을 각각 순회한다.
foreach (['upgrade-*.log', 'extension-upgrade-*.log'] as $pattern) {
foreach (glob($logsDir.DIRECTORY_SEPARATOR.$pattern) ?: [] as $logFile) {
FilePermissionHelper::inheritOwnershipFromParent($logFile);
}
}
}
/**
* 현재 실행 사용자와 코어 파일 소유자가 다른 경우 경고를 표시합니다.
*
* 공유 호스팅에서 FTP 사용자(파일 소유자)와 SSH 사용자, 또는 SSH 사용자와
* www-data 가 다른 경우 권한 거부가 발생할 수 있습니다. 차단하지는 않고
* 사용자가 직접 판단하도록 경고만 출력합니다.
*/
private function warnIfFileOwnerMismatch(\Closure $log): void
{
if (! function_exists('posix_geteuid') || ! function_exists('posix_getpwuid')) {
return; // Windows 또는 posix 확장 미설치
}
$coreFile = base_path('composer.json');
if (! file_exists($coreFile)) {
return;
}
$currentUid = posix_geteuid();
$ownerUid = fileowner($coreFile);
if ($currentUid === $ownerUid) {
return;
}
$currentUser = posix_getpwuid($currentUid)['name'] ?? (string) $currentUid;
$ownerUser = posix_getpwuid($ownerUid)['name'] ?? (string) $ownerUid;
$this->newLine();
$this->warn('⚠ 실행 사용자와 코어 파일 소유자가 다릅니다.');
$this->warn(" 실행 사용자: {$currentUser} (uid={$currentUid})");
$this->warn(" 코어 소유자: {$ownerUser} (uid={$ownerUid})");
$this->warn(' → 권한 거부가 발생할 수 있습니다. 일반적으로 코어 파일 소유자로');
$this->warn(' SSH 로그인 후 실행해야 합니다. vendor/ 가 다른 사용자 소유인 경우');
$this->warn(" 'chown -R \$(whoami) vendor/' 후 재시도하세요.");
$this->newLine();
$log("권한 경고: 실행자={$currentUser}({$currentUid}) vs 소유자={$ownerUser}({$ownerUid})");
Log::warning('core:update 실행 사용자가 코어 파일 소유자와 다름', [
'current_uid' => $currentUid,
'current_user' => $currentUser,
'owner_uid' => $ownerUid,
'owner_user' => $ownerUser,
]);
}
/**
* 업그레이드 스텝 재실행 명령을 실행 사용자 권한에 맞는 안내와 함께 출력합니다.
*
* 배경: sudo(root) 로 `core:update` 를 실행하면 업그레이드 스텝 spawn 자식이 root 로
* 돌아가고, 그 자식이 실패(proc_open 미지원 · 비정상 종료 · silent skip)하면 스텝이
* 미실행 상태로 남아 운영자에게 재실행을 안내한다. 이때 운영자가 안내받은 명령을 그대로
* root 로 재실행하면 스텝이 만드는 파일·캐시가 root 소유로 생성되어, 이후 웹서버
* (php-fpm www-data 등) 요청이 그 경로에 쓰기 실패한다.
*
* 실행 환경을 4가지로 분기해 안내한다:
* 1. non-root (일반 SSH 사용자 = 파일 소유자) 또는 posix 미지원(Windows 등)
* → 명령만 그대로 안내 (권한 분기 불필요, 공유 호스팅 = 웹서버·PHP·실행 유저 동일 포함).
* 2. root 실행 + 웹서버 계정 식별 가능 + 실행 사용자와 다름
* → `sudo -u {webUser}` 로 재실행 안내 + root 그대로 실행 시 위험 경고.
* 3. root 실행 + 웹서버 계정을 root 로 추정 (root 로 서비스하는 비표준/컨테이너 구성)
* → 권한 문제 없으므로 명령만 그대로 안내.
* 4. root 실행 + 웹서버 계정 식별 불가 (스냅샷/추정 실패)
* → 명령 그대로 안내하되 "웹서버 계정으로 실행" 일반 경고 (계정명 미상).
*
* @param string $resumeCommand 재실행할 `core:execute-upgrade-steps` 명령
*/
private function surfaceResumeCommandWithPermissionGuidance(string $resumeCommand): void
{
[$mode, $webUser] = $this->classifyResumeExecutionContext();
$this->renderResumeGuidance($resumeCommand, $mode, $webUser);
}
/**
* 실행 환경 모드에 따라 재실행 명령과 권한 안내를 콘솔에 출력합니다.
*
* `classifyResumeExecutionContext()` 판정 결과를 입력으로 받아 출력만 담당한다 —
* posix/파일시스템 상태에 의존하지 않으므로 4가지 모드 전부 결정적으로 테스트 가능하다.
*
* @param string $resumeCommand 재실행할 `core:execute-upgrade-steps` 명령
* @param string $mode `classifyResumeExecutionContext()` 가 반환한 실행 환경 모드
* @param string|null $webUser 웹서버 계정명 (`root_web_known` 일 때만 유효)
*/
private function renderResumeGuidance(string $resumeCommand, string $mode, ?string $webUser): void
{
// 케이스 1·3: 권한 분기 불필요 — 명령만 그대로 안내.
// - non_root: 일반 SSH 사용자 / 공유 호스팅(웹서버=PHP=실행 유저 동일) / posix 미지원
// - root_web_symmetric: root 로 서비스하는 구성 → 재실행도 root 로 무해
if ($mode === 'non_root' || $mode === 'root_web_symmetric') {
$this->line(" {$resumeCommand}");
return;
}
// 케이스 2: root 실행 + 웹서버 계정 식별 → 웹서버 권한 재실행 안내.
if ($mode === 'root_web_known' && $webUser !== null) {
$this->line(" sudo -u {$webUser} {$resumeCommand}");
$this->newLine();
$this->warn('⚠ 현재 sudo(root) 로 실행 중입니다. 위 명령을 root 로 그대로 실행하면 업그레이드');
$this->warn(" 스텝이 생성하는 파일이 root 소유가 되어 이후 웹서버({$webUser}) 요청이 쓰기 실패할 수");
$this->warn(" 있습니다. 반드시 웹서버 계정({$webUser})으로 재실행하세요.");
return;
}
// 케이스 4: root 실행 + 웹서버 계정 식별 불가 — 계정명 미상 상태의 일반 경고.
$this->line(" sudo -u <웹서버계정> {$resumeCommand}");
$this->newLine();
$this->warn('⚠ 현재 sudo(root) 로 실행 중입니다. 웹서버 계정을 자동으로 식별하지 못했습니다.');
$this->warn(' 위 명령을 root 로 그대로 실행하면 업그레이드 스텝이 생성하는 파일이 root 소유가 되어');
$this->warn(' 이후 웹서버(php-fpm) 요청이 쓰기 실패할 수 있습니다. 웹서버 계정(예: www-data,');
$this->warn(' nginx, apache)을 확인해 그 계정으로 재실행하세요.');
}
/**
* 재실행 안내의 실행 환경을 분류합니다.
*
* 반환하는 모드:
* - `non_root` : posix 미지원 또는 현재 유효 사용자가 root 가 아님
* (일반 SSH 사용자 = 파일 소유자, 공유 호스팅 대칭 구성 포함).
* - `root_web_known` : root 실행 + 웹서버 계정 식별 성공 + 실행 사용자(root)와 다름.
* - `root_web_symmetric` : root 실행 + 웹서버 계정이 root 로 추정됨 (root 서비스 구성).
* - `root_web_unknown` : root 실행 + 웹서버 계정 추정 실패 (스냅샷/추정 불가).
*
* 판정은 `FilePermissionHelper::describeWebServerAccount()` 가 소유한다 — 정적 게시 상태·게시
* 명령(`ext-static:*`)의 root 경고와 같은 4분기를 공유하기 위해 옮겼다(#651 C1). 이 메서드는
* 그 결과를 종전 반환 형태(`[모드, 계정명]`)로 바꿔 주는 위임만 한다.
*
* @return array{0: string, 1: string|null} [모드, 웹서버 계정명 또는 null]
*/
private function classifyResumeExecutionContext(): array
{
$account = FilePermissionHelper::describeWebServerAccount();
return [$account['mode'], $account['name']];
}
/**
* `restoreOwnership()` 직후 누적된 권한 정상화 실패 경고를 콘솔/로그에 즉시 노출합니다.
*
* 운영자가 sudo 환경 결함(파일시스템 ACL, immutable 비트, NFS 권한 거부 등) 으로
* 일부 경로 chown / chmod 실패 시 그 경로와 운영자 수동 복구 명령을 즉시 보여준다.
* 본 메서드 호출 후 service 의 `lastPermissionWarnings` 가 다음 호출 시 초기화되므로
* 매 `restoreOwnership` 직후 1회 호출 패턴이 정합.
*
* @param callable $log 내부 로그 누적 콜백 (`saveUpdateLog` 입력용)
*/
private function surfacePermissionWarnings(CoreUpdateService $service, callable $log): void
{
$warnings = $service->getLastPermissionWarnings();
if (empty($warnings)) {
return;
}
$this->newLine();
$this->warn('⚠ 권한 정상화 실패 — 일부 경로의 소유권/그룹 쓰기 권한을 복원하지 못했습니다.');
foreach ($warnings as $w) {
$kind = $w['kind'] === 'chown' ? '소유권' : '그룹 쓰기';
$this->warn(sprintf(' - %s [%s]: %d 건 실패', $w['target'], $kind, $w['failed']));
foreach (array_slice($w['failed_paths'], 0, 5) as $p) {
$this->line(" · {$p}");
}
if (count($w['failed_paths']) > 5) {
$this->line(sprintf(' · … (총 %d건, 상위 5건만 표시)', $w['failed']));
}
}
$this->newLine();
$this->line(' 복구 예시:');
$this->line(' sudo chown -R <owner>:<group> <path>');
$this->line(' sudo chmod -R g+w <path>');
$this->newLine();
$log(sprintf('권한 정상화 실패 %d 건 — 운영자 수동 복구 필요', count($warnings)));
}
}