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 에서 제거.
1438 lines
72 KiB
PHP
1438 lines
72 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\ConfigCacheHelper;
|
|
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);
|
|
|
|
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();
|
|
|
|
// 코어 업데이트 후 프론트엔드가 새 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();
|
|
// 핸드오프 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();
|
|
|
|
$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)));
|
|
}
|
|
}
|