Files
Gnuboard7/public/install/api/rollback-functions.php
T
HeuJung ffab451b4a fix(core,installer): dev vendor 설치본의 코어 업데이트 중단 수정 — stale 패키지 매니페스트 3계층
Laravel PackageManifest 는 bootstrap/cache/packages.php 가 있으면 stale 여부를 검사하지
않고 그대로 읽어 등재된 provider 를 new 한다. 코어 업데이트는 Step 6/8 에서 vendor 를
--no-dev 로 교체하지만 그 파일은 Step 11 까지 이전 설치본의 것이 남으므로, 옵션 없는
`composer install` 로 깔린 사이트에서는 Step 10 spawn 자식이 새 vendor 에 없는 provider 를
찾다 부팅 단계에서 죽는다. 부팅 전이라 앱 로그에 흔적이 없고 부모에게는 자식의 비정상
종료로만 보여, 운영자에게는 「Class ... not found」 와 수동 재개 안내만 남는다.
(sir.kr 커뮤니티 제보, 7.0.9 → 7.0.10)

3계층으로 막는다.

 1. 부모 — spawn 직전 PackageManifestCacheHelper::clear
 2. 자식 — bootstrap/app.php 가 G7_UPDATE_IN_PROGRESS 를 보고 스스로 정리한다.
 이미 배포된 7.0.9·7.0.10 부모는 고칠 수 없으므로 그 아래에서 도는 신버전
 자식의 유일한 방어다. App\ 클래스를 참조하지 않고 실패는 무시한다.
 3. 범위 — CoreVersionChecker 의 env APP_VERSION 우선을 CoreUpdateContext 트리 안으로
 축소한다. 업데이트 전에 뜬 artisan serve·큐 워커가 옛 값을 물고 확장을
 incompatible_core 로 끄던 경로를 닫는다(관리자 템플릿이 대상이면 복구 UI
 자체에 도달할 수 없다).

업데이트 트리 판정은 App\Support\CoreUpdateContext 가 단독 소유하고
CoreServiceProvider::isCoreUpdateInProgress 는 위임으로 남는다. bootstrap/app.php 의
복제본은 부팅 전이라 불가피한 예외이며, 두 조건의 동형성을 테스트가 단언한다.

실측 중 드러난 결함 2건을 함께 고쳤다.

 - ConfigCacheHelper::withPreservedContainer 가 파사드 애플리케이션을 되돌리지 않아
 Step 11 이 `Target class [command.tinker] does not exist` 로 실패·롤백했다.
 - updateVersionInEnv 가 프로세스 환경을 갱신하지 않아 config 캐시에 이전 버전이 구워졌다
 (Laravel env 저장소가 불변이라 재부팅으로도 덮이지 않는다).

인스톨러는 재사용 vendor 의 개발용 패키지를 installed.json 으로 감지해 설치 환경 확인
카드·설치 로그로 알리되 설치를 차단하지 않고( 결정 D1), 재사용 경로에서도 컴파일 캐시를
정리한다. 실행되는 명령만이 아니라 실패 시 안내하는 수동 명령까지 --no-dev 로 맞췄다.
코어 업데이트 완료·핸드오프·단독 재개 사후 단계에서 queue:restart 신호를 보낸다(D3).

코어 7.0.10 → 7.0.11.
2026-09-08 09:46:57 +09:00

576 lines
19 KiB
PHP

<?php
/**
* G7 인스톨러 - 롤백 함수 모음
*
* 설치 중단 시 각 작업을 롤백하는 함수들을 제공합니다.
*/
/**
* 중단된 작업을 롤백합니다.
*
* @param string $taskId 롤백할 작업 식별자
* @param array $state 현재 설치 상태
* @return array ['success' => bool, 'message' => string] 롤백 결과
*/
function rollbackTask(string $taskId, array $state): array
{
try {
switch ($taskId) {
case 'db_migrate':
return rollbackDbMigrate();
case 'db_seed':
return rollbackDbSeed($state);
case 'complete_flag':
return rollbackCompleteFlag();
// 롤백 불필요한 작업들 (새로 생성하면 되는 것들)
case 'composer_check':
case 'composer_install':
case 'env_create':
case 'env_update':
case 'key_generate':
case 'cache_clear':
case 'create_settings_json':
return [
'success' => true,
'message' => lang('rollback_not_needed_recreatable'),
];
// 롤백 불필요한 확장 작업 (재설치/재활성화 시 덮어쓰기 가능)
case 'template_install':
case 'template_activate':
case 'module_install':
case 'module_activate':
case 'plugin_install':
case 'plugin_activate':
case 'user_template_install':
case 'user_template_activate':
return [
'success' => true,
'message' => lang('rollback_not_needed_overwritable'),
];
default:
return [
'success' => false,
'message' => lang('rollback_unknown_task', ['task' => $taskId]),
];
}
} catch (Exception $e) {
addLog(lang('rollback_error', ['task' => $taskId, 'error' => $e->getMessage()]));
return [
'success' => false,
'message' => lang('rollback_exception', ['error' => $e->getMessage()]),
];
}
}
/**
* 마이그레이션 롤백을 실행합니다 (migrate:rollback --force)
*
* @param string|null $logPrefix 로그 접두사 (null이면 lang('log_prefix_rollback') 사용)
* @return array ['success' => bool, 'message' => string, 'output' => array]
*/
function executeMigrateRollback(?string $logPrefix = null): array
{
$logPrefix = $logPrefix ?? lang('log_prefix_rollback');
try {
addLog("{$logPrefix} ".lang('rollback_migrate_start'));
$output = [];
$returnCode = 0;
$phpBin = escapeshellarg(function_exists('getPhpBinary') ? getPhpBinary() : 'php');
$command = sprintf(
'cd /d %s && %s artisan migrate:rollback --force 2>&1',
escapeshellarg(BASE_PATH),
$phpBin
);
exec($command, $output, $returnCode);
addLog("{$logPrefix} ".lang('rollback_migrate_result', ['result' => implode("\n", $output)]));
if ($returnCode === 0) {
return [
'success' => true,
'message' => lang('rollback_migrate_success'),
'output' => $output,
];
}
addLog("{$logPrefix} ".lang('rollback_migrate_failed_code', ['code' => $returnCode]));
return [
'success' => false,
'message' => lang('rollback_migrate_failed'),
'output' => $output,
];
} catch (Exception $e) {
return [
'success' => false,
'message' => lang('rollback_migrate_error', ['error' => $e->getMessage()]),
'output' => [],
];
}
}
/**
* db_migrate 작업 롤백: migrate:rollback 실행
*
* @return array ['success' => bool, 'message' => string, 'output' => array]
*/
function rollbackDbMigrate(): array
{
return executeMigrateRollback(lang('log_prefix_rollback'));
}
/**
* 시드 데이터 TRUNCATE를 실행합니다 (PDO 세션 내 FOREIGN_KEY_CHECKS 제어)
*
* @param array $config DB 연결 설정
* @param bool $force true: 무조건 TRUNCATE, false: 미완료 시에만 TRUNCATE
* @param string|null $logPrefix 로그 접두사 (null이면 lang('log_prefix_rollback') 사용)
* @return array ['success' => bool, 'message' => string]
*/
function executeSeedTruncate(array $config, bool $force = false, ?string $logPrefix = null): array
{
// state.config 에는 DB 비밀번호가 기록되지 않으므로(이슈 #465) runtime.php 에서 복원한다.
// 호출자(rollbackDbSeed / forceRollbackDbSeed) 가 state['config'] 를 그대로 넘기므로
// 각 호출자 대신 함수 진입부 1곳에서 처리한다. 롤백은 env_update 이후에만 발생하여
// runtime.php 가 항상 존재하며, 부재 시 헬퍼가 원본 config 를 그대로 반환한다.
require_once __DIR__.'/../includes/installer-runtime.php';
$config = hydrateDbSecretsFromRuntime($config);
$logPrefix = $logPrefix ?? lang('log_prefix_rollback');
if (empty($config['db_write_host']) || empty($config['db_write_database'])) {
addLog("{$logPrefix} ".lang('rollback_seed_no_config'));
return ['success' => true, 'message' => lang('rollback_seed_no_config')];
}
// DSN 파라미터 주입 차단 — 정상 입력에는 ;/=/NUL/CRLF 가 들어갈 일이 없다.
foreach (['db_write_host', 'db_write_port', 'db_write_database'] as $field) {
$val = (string) ($config[$field] ?? '');
if ($val !== '' && preg_match('/[;=\0\r\n]/', $val)) {
addLog("{$logPrefix} ".lang('rollback_seed_no_config'));
return ['success' => true, 'message' => lang('rollback_seed_no_config')];
}
}
$dsn = sprintf(
'mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4',
$config['db_write_host'],
$config['db_write_port'] ?? '3306',
$config['db_write_database']
);
try {
$pdo = new PDO($dsn, $config['db_write_username'], $config['db_write_password'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
// 강제 모드가 아니면 완료 여부 확인
if (! $force) {
$stmt = $pdo->query("SHOW TABLES LIKE 'roles'");
if ($stmt->rowCount() > 0) {
$count = $pdo->query('SELECT COUNT(*) FROM roles')->fetchColumn();
if ($count > 0) {
addLog("{$logPrefix} ".lang('rollback_seed_already_done'));
return ['success' => true, 'message' => lang('rollback_seed_already_done')];
}
}
addLog("{$logPrefix} ".lang('rollback_seed_interrupted'));
} else {
addLog("{$logPrefix} ".lang('rollback_seed_force_truncate'));
}
$pdo->exec('SET FOREIGN_KEY_CHECKS = 0');
$tables = ['users', 'roles', 'permissions', 'role_user', 'permission_role'];
foreach ($tables as $table) {
try {
$pdo->exec("TRUNCATE TABLE `{$table}`");
addLog("{$logPrefix} ".lang('rollback_table_truncated', ['table' => $table]));
} catch (PDOException $e) {
addLog("{$logPrefix} ".lang('rollback_table_truncate_skipped', ['table' => $table, 'error' => $e->getMessage()]));
}
}
$pdo->exec('SET FOREIGN_KEY_CHECKS = 1');
return ['success' => true, 'message' => lang('rollback_seed_data_deleted')];
} catch (PDOException $e) {
if ($force) {
return ['success' => false, 'message' => lang('rollback_db_connection_failed', ['error' => $e->getMessage()])];
}
addLog("{$logPrefix} ".lang('rollback_db_connection_failed_skip'));
return ['success' => true, 'message' => lang('rollback_db_connection_failed_skip')];
}
}
/**
* db_seed 작업 롤백: 중단된 경우에만 TRUNCATE
*
* @param array $state 현재 설치 상태
* @return array ['success' => bool, 'message' => string]
*/
function rollbackDbSeed(array $state): array
{
try {
return executeSeedTruncate($state['config'] ?? [], false, lang('log_prefix_rollback'));
} catch (Exception $e) {
return ['success' => false, 'message' => lang('rollback_seed_error', ['error' => $e->getMessage()])];
}
}
/**
* db_migrate 작업 강제 롤백 (설정으로 돌아갈 때 사용)
*
* @return array ['success' => bool, 'message' => string, 'output' => array]
*/
function forceRollbackDbMigrate(): array
{
return executeMigrateRollback(lang('log_prefix_force_rollback'));
}
/**
* db_seed 작업 강제 롤백 (설정으로 돌아갈 때 사용)
*
* @param array $state 현재 설치 상태
* @return array ['success' => bool, 'message' => string]
*/
function forceRollbackDbSeed(array $state): array
{
try {
return executeSeedTruncate($state['config'] ?? [], true, lang('log_prefix_force_rollback'));
} catch (Exception $e) {
return ['success' => false, 'message' => lang('rollback_seed_error', ['error' => $e->getMessage()])];
}
}
/**
* complete_flag 작업 롤백: 설치 완료 플래그 제거
*
* runtime.php (설치 진행 중 마커), g7_installed (Step 5 완료 마커),
* .env 의 INSTALLER_COMPLETED (finalize 완료 마커) 셋 모두 정리한다.
* 어느 단계에서 실패했는지에 따라 일부만 존재할 수 있으므로 각각 독립적으로 처리.
*/
function rollbackCompleteFlag(): array
{
try {
require_once __DIR__.'/../includes/installer-runtime.php';
$envPath = BASE_PATH.'/.env';
$installedFlagPath = BASE_PATH.'/storage/app/g7_installed';
$results = [];
// runtime.php 삭제 (설치 진행 중 마커 — finalize 전에 롤백 시 존재)
if (is_file(INSTALLER_RUNTIME_PATH)) {
if (deleteInstallerRuntime()) {
addLog(lang('rollback_runtime_removed'));
$results[] = lang('rollback_runtime_removed');
}
}
// .env에서 INSTALLER_COMPLETED 제거 (finalize 후 롤백 시 존재)
if (file_exists($envPath)) {
$envContent = file_get_contents($envPath);
$envContent = preg_replace('/^INSTALLER_COMPLETED=.*$/m', '', $envContent);
$envContent = preg_replace("/\n\n+/", "\n\n", $envContent);
if (file_put_contents($envPath, $envContent) !== false) {
addLog(lang('rollback_env_flag_removed'));
$results[] = lang('rollback_env_flag_removed');
}
}
// g7_installed 파일 삭제
if (file_exists($installedFlagPath)) {
if (@unlink($installedFlagPath)) {
addLog(lang('rollback_installed_flag_removed'));
$results[] = lang('rollback_installed_flag_removed');
}
}
return [
'success' => true,
'message' => lang('rollback_complete_flag_removed', ['details' => implode(', ', $results)]),
];
} catch (Exception $e) {
return [
'success' => false,
'message' => lang('rollback_complete_flag_error', ['error' => $e->getMessage()]),
];
}
}
/**
* 디렉토리 내용을 삭제합니다.
*
* Git 추적 파일(.gitkeep 등)은 보존하고, 디렉토리 자체도 유지합니다.
* 이는 인스톨러가 프로젝트 구조에 필수적인 파일/디렉토리를
* 실수로 삭제하는 것을 방지합니다.
*
* @param string $dir 삭제할 디렉토리 경로
* @param bool $removeDir true이면 디렉토리 자체도 삭제 (기본: false, 내용만 삭제)
* @return bool 삭제 성공 여부
*/
function deleteDirectory(string $dir, bool $removeDir = false): bool
{
if (! is_dir($dir)) {
return false;
}
// 보존해야 할 파일 패턴 (Git 추적용 파일)
$preserveFiles = ['.gitkeep', '.gitignore'];
$items = array_diff(scandir($dir), ['.', '..']);
$hasPreservedFiles = false;
foreach ($items as $item) {
$path = $dir.DIRECTORY_SEPARATOR.$item;
if (is_dir($path)) {
deleteDirectory($path, true);
} else {
// 보존 대상 파일은 건너뛰기
if (in_array($item, $preserveFiles, true)) {
$hasPreservedFiles = true;
continue;
}
@unlink($path);
}
}
// 보존 파일이 남아있거나 removeDir이 false이면 디렉토리 유지
if ($hasPreservedFiles || ! $removeDir) {
return true;
}
return @rmdir($dir);
}
/**
* 현재 진행 중인 작업을 롤백합니다. (abort.php에서 사용)
*
* @param array $state 현재 설치 상태
* @return array ['success' => bool, 'message' => string, 'task' => string|null]
*/
function rollbackCurrentTask(array $state): array
{
$currentTask = $state['current_task'] ?? null;
if (! $currentTask) {
addLog(lang('rollback_no_current_task'));
return [
'success' => true,
'message' => lang('rollback_no_current_task'),
'task' => null,
];
}
if (in_array($currentTask, $state['completed_tasks'] ?? [])) {
addLog(lang('rollback_task_already_completed', ['task' => $currentTask]));
return [
'success' => true,
'message' => lang('rollback_task_already_completed', ['task' => $currentTask]),
'task' => $currentTask,
];
}
addLog(lang('rollback_current_task_start', ['task' => $currentTask]));
$rollbackResult = rollbackTask($currentTask, $state);
if ($rollbackResult['success']) {
addLog(lang('abort_rollback_success', ['message' => $rollbackResult['message']]));
return [
'success' => true,
'message' => lang('abort_rollback_success', ['message' => $rollbackResult['message']]),
'task' => $currentTask,
];
} else {
addLog(lang('abort_rollback_failed', ['message' => $rollbackResult['message']]));
return [
'success' => false,
'message' => lang('failed_rollback_failed', ['message' => $rollbackResult['message']]),
'task' => $currentTask,
];
}
}
/**
* 완료된 작업들을 역순으로 롤백합니다. (reset-state.php에서 사용)
*
* @param array $completedTasks 완료된 작업 목록
* @param array $rollbackableTasks 롤백 가능한 작업 목록
* @param array $state 현재 설치 상태
* @return array ['results' => array, 'errors' => array]
*/
function rollbackCompletedTasks(array $completedTasks, array $rollbackableTasks, array $state): array
{
$results = [];
$errors = [];
if (empty($completedTasks)) {
addLog(lang('rollback_no_completed_tasks'));
return ['results' => $results, 'errors' => $errors];
}
addLog(lang('rollback_checking_tasks'));
$tasksToRollback = array_reverse(
array_intersect($rollbackableTasks, $completedTasks)
);
if (empty($tasksToRollback)) {
addLog(lang('rollback_no_matching_tasks'));
return ['results' => $results, 'errors' => $errors];
}
addLog(lang('rollback_tasks_to_rollback', ['tasks' => implode(', ', $tasksToRollback)]));
foreach ($tasksToRollback as $taskId) {
addLog(lang('rollback_task_rolling_back', ['task' => $taskId]));
if ($taskId === 'db_migrate') {
$result = forceRollbackDbMigrate();
} elseif ($taskId === 'db_seed') {
$result = forceRollbackDbSeed($state);
} else {
$result = rollbackTask($taskId, $state);
}
if ($result['success']) {
$results[] = $taskId.': '.$result['message'];
addLog(lang('rollback_task_success', ['task' => $taskId]));
} else {
$errors[] = $taskId.': '.$result['message'];
addLog(lang('rollback_task_failed', ['task' => $taskId, 'error' => $result['message']]));
}
}
return ['results' => $results, 'errors' => $errors];
}
/**
* 실패한 작업에 대한 수동 명령어를 생성합니다.
*
* @param string $taskId 작업 ID
* @param string|null $target 대상 (확장 식별자)
* @return array 수동 명령어 배열
*/
function getManualCommands(string $taskId, ?string $target = null): array
{
$commands = [];
$php = getPhpBinary();
$composer = getComposerCommandForDisplay();
switch ($taskId) {
case 'composer_install':
// --no-dev 는 마법사가 실제로 실행하는 명령(task-runner.php)과 같아야 한다. 이 안내를
// 옵션 없이 따라 하면 개발용(require-dev) 패키지가 섞인 vendor 가 만들어지고, 그 상태는
// 이후 코어 업데이트가 vendor 를 교체할 때 이전 패키지 매니페스트와 어긋나 부팅을 깨뜨린다.
$commands[] = "{$composer} install --no-interaction --no-dev --prefer-dist --optimize-autoloader";
break;
case 'env_create':
$commands[] = getEnvCopyCommand();
break;
case 'key_generate':
$commands[] = "{$php} artisan key:generate";
break;
case 'db_migrate':
$commands[] = "{$php} artisan migrate --force";
break;
case 'db_seed':
$commands[] = "{$php} artisan db:seed --force";
break;
case 'template_install':
if ($target) {
$commands[] = "{$php} artisan template:install {$target}";
}
break;
case 'template_activate':
if ($target) {
$commands[] = "{$php} artisan template:activate {$target}";
}
break;
case 'module_install':
if ($target) {
$commands[] = "{$php} artisan module:install {$target}";
}
break;
case 'module_activate':
if ($target) {
$commands[] = "{$php} artisan module:activate {$target}";
}
break;
case 'plugin_install':
if ($target) {
$commands[] = "{$php} artisan plugin:install {$target}";
}
break;
case 'plugin_activate':
if ($target) {
$commands[] = "{$php} artisan plugin:activate {$target}";
}
break;
case 'user_template_install':
if ($target) {
$commands[] = "{$php} artisan template:install {$target}";
}
break;
case 'user_template_activate':
if ($target) {
$commands[] = "{$php} artisan template:activate {$target}";
}
break;
case 'create_settings_json':
$commands[] = '# '.lang('manual_cmd_settings_json');
break;
case 'cache_clear':
$commands[] = "{$php} artisan optimize:clear";
break;
case 'complete_flag':
if (isWindows()) {
$commands[] = 'echo INSTALLER_COMPLETED=true >> .env';
$commands[] = 'type nul > storage\\app\\g7_installed';
} else {
$commands[] = 'echo "INSTALLER_COMPLETED=true" >> .env';
$commands[] = 'touch storage/app/g7_installed';
}
break;
}
return $commands;
}