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 에서 제거.
205 lines
9.0 KiB
PHP
205 lines
9.0 KiB
PHP
<?php
|
|
|
|
namespace App\Console\Commands\Core;
|
|
|
|
use App\Extension\ExtensionManager;
|
|
use App\Extension\ModuleManager;
|
|
use App\Extension\PluginManager;
|
|
use App\Extension\TemplateManager;
|
|
use App\Extension\Vendor\VendorMode;
|
|
use App\Search\SearchIndexMaintenanceManager;
|
|
use App\Services\CoreUpdateService;
|
|
use App\Services\LanguagePackService;
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Support\Facades\File;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
/**
|
|
* 번들 확장(모듈/플러그인/템플릿/언어팩) 일괄 업데이트를 별도 PHP 프로세스에서 실행합니다.
|
|
*
|
|
* `core:update` 의 `BundledExtensionUpdatePrompt::executeBulkUpdate` 가 사용자 선택을
|
|
* 매니페스트로 직렬화한 후 본 커맨드를 `proc_open` 으로 spawn 한다. 자식은 fresh PHP
|
|
* 프로세스이므로 디스크에 막 적용된 신버전 코어 코드(특히 `ModuleManager` /
|
|
* `PluginManager` / `TemplateManager` / `LanguagePackService`) 를 메모리에 로드한 상태
|
|
* 에서 update 메서드를 호출한다.
|
|
*
|
|
* 부모 프로세스의 stale memory 가 신규 sync 메서드(`syncDeclarativeArtifacts` 등) 를
|
|
* 호출하지 못하던 결함의 영구 차단 (beta.4 의 `Upgrade_7_0_0_beta_4` 사후 보정과는 별개의
|
|
* 구조적 fix — 향후 모든 코어 업그레이드에서 동일 패턴 회귀를 차단).
|
|
*
|
|
* 매니페스트 형식:
|
|
* ```json
|
|
* {
|
|
* "modules": [{"identifier": "...", "strategy": "overwrite|keep"}, ...],
|
|
* "plugins": [{"identifier": "...", "strategy": "overwrite|keep"}, ...],
|
|
* "templates": [{"identifier": "...", "strategy": "overwrite|keep"}, ...],
|
|
* "lang_packs": [{"identifier": "..."}, ...]
|
|
* }
|
|
* ```
|
|
*
|
|
* 출력:
|
|
* - stdout 에 진행 라인을 그대로 출력 (부모 콘솔이 forwarding)
|
|
* - 종료 직전 `[BUNDLED-RESULT] {json}` 표식으로 최종 카운트 페이로드를 1회 출력 →
|
|
* 부모가 stdout 파싱해 결과 복원.
|
|
*/
|
|
class ExecuteBundledUpdatesCommand extends Command
|
|
{
|
|
public const RESULT_PREFIX = '[BUNDLED-RESULT] ';
|
|
|
|
protected $signature = 'core:execute-bundled-updates
|
|
{--manifest= : 업데이트 매니페스트 JSON 파일 경로 (필수)}
|
|
{--force : 강제 업데이트 플래그 (확장 매니저 update 호출에 전달)}
|
|
{--rebuild-search-index : 일괄 업데이트 후 검색 인덱스 재생성 (매니페스트의 선택보다 우선)}';
|
|
|
|
protected $description = '번들 확장 일괄 업데이트를 실행합니다 (CoreUpdateCommand 내부용 — fresh PHP 프로세스에서 호출)';
|
|
|
|
public function handle(): int
|
|
{
|
|
$manifestPath = (string) $this->option('manifest');
|
|
if ($manifestPath === '' || ! File::isFile($manifestPath)) {
|
|
$this->error('--manifest 옵션이 필수이며 존재하는 파일이어야 합니다.');
|
|
|
|
return self::INVALID;
|
|
}
|
|
|
|
// spawn 자식 진입 시 활성 모듈/플러그인의 PSR-4 매핑을 fresh 등록 — 자세한 배경은
|
|
// ExecuteUpgradeStepsCommand::handle 의 동일 호출 주석 참조.
|
|
// (Artisan::call 대신 직접 메서드 호출 — nested Artisan::call 이 outer 명령의
|
|
// output buffer 를 덮어쓰는 Laravel 동작 회피)
|
|
try {
|
|
app(ExtensionManager::class)->updateComposerAutoload();
|
|
} catch (\Throwable $e) {
|
|
Log::warning('bundled update spawn 자식: updateComposerAutoload 호출 실패', [
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
|
|
$manifest = json_decode(File::get($manifestPath), true);
|
|
if (! is_array($manifest)) {
|
|
$this->error('매니페스트 JSON 파싱 실패: '.$manifestPath);
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
// 부모(core:update)가 운영자 선택을 매니페스트에 실어 보낸다 — 자식이 임의로 정하지 않는다
|
|
$rebuildSearchIndex = (bool) $this->option('rebuild-search-index') || (bool) ($manifest['rebuild_search_index'] ?? false);
|
|
|
|
$modules = $manifest['modules'] ?? [];
|
|
$plugins = $manifest['plugins'] ?? [];
|
|
$templates = $manifest['templates'] ?? [];
|
|
$langPacks = $manifest['lang_packs'] ?? [];
|
|
|
|
$moduleManager = app(ModuleManager::class);
|
|
$pluginManager = app(PluginManager::class);
|
|
$templateManager = app(TemplateManager::class);
|
|
$langPackService = app(LanguagePackService::class);
|
|
|
|
$success = 0;
|
|
$failed = 0;
|
|
|
|
$this->info('── 번들 일괄 업데이트 실행 (spawn child) ──');
|
|
|
|
foreach ($modules as $entry) {
|
|
$id = (string) ($entry['identifier'] ?? '');
|
|
$strategy = (string) ($entry['strategy'] ?? 'overwrite');
|
|
if ($id === '') {
|
|
continue;
|
|
}
|
|
$this->line("→ [모듈] {$id} ({$strategy})");
|
|
try {
|
|
$moduleManager->updateModule($id, true, null, VendorMode::Auto, $strategy, null, 'bundled');
|
|
$success++;
|
|
} catch (\Throwable $e) {
|
|
$failed++;
|
|
$this->warn(" 실패: {$e->getMessage()}");
|
|
Log::error('번들 일괄 업데이트 실패 (spawn)', ['type' => 'module', 'id' => $id, 'error' => $e->getMessage()]);
|
|
}
|
|
}
|
|
|
|
foreach ($plugins as $entry) {
|
|
$id = (string) ($entry['identifier'] ?? '');
|
|
$strategy = (string) ($entry['strategy'] ?? 'overwrite');
|
|
if ($id === '') {
|
|
continue;
|
|
}
|
|
$this->line("→ [플러그인] {$id} ({$strategy})");
|
|
try {
|
|
$pluginManager->updatePlugin($id, true, null, VendorMode::Auto, $strategy, null, 'bundled');
|
|
$success++;
|
|
} catch (\Throwable $e) {
|
|
$failed++;
|
|
$this->warn(" 실패: {$e->getMessage()}");
|
|
Log::error('번들 일괄 업데이트 실패 (spawn)', ['type' => 'plugin', 'id' => $id, 'error' => $e->getMessage()]);
|
|
}
|
|
}
|
|
|
|
foreach ($templates as $entry) {
|
|
$id = (string) ($entry['identifier'] ?? '');
|
|
$strategy = (string) ($entry['strategy'] ?? 'overwrite');
|
|
if ($id === '') {
|
|
continue;
|
|
}
|
|
$this->line("→ [템플릿] {$id} ({$strategy})");
|
|
try {
|
|
$templateManager->updateTemplate($id, true, null, $strategy, 'bundled');
|
|
$success++;
|
|
} catch (\Throwable $e) {
|
|
$failed++;
|
|
$this->warn(" 실패: {$e->getMessage()}");
|
|
Log::error('번들 일괄 업데이트 실패 (spawn)', ['type' => 'template', 'id' => $id, 'error' => $e->getMessage()]);
|
|
}
|
|
}
|
|
|
|
foreach ($langPacks as $entry) {
|
|
$id = (string) ($entry['identifier'] ?? '');
|
|
if ($id === '') {
|
|
continue;
|
|
}
|
|
$this->line("→ [언어팩] {$id}");
|
|
try {
|
|
$pack = $langPackService->findByIdentifier($id);
|
|
if (! $pack) {
|
|
throw new \RuntimeException(__('language_packs.errors.identifier_not_found', ['identifier' => $id]));
|
|
}
|
|
$langPackService->performUpdate($pack, true);
|
|
$success++;
|
|
} catch (\Throwable $e) {
|
|
$failed++;
|
|
$this->warn(" 실패: {$e->getMessage()}");
|
|
Log::error('번들 일괄 업데이트 실패 (spawn)', ['type' => 'lang_pack', 'id' => $id, 'error' => $e->getMessage()]);
|
|
}
|
|
}
|
|
|
|
$this->newLine();
|
|
$this->info("업데이트 완료: 성공 {$success}, 실패 {$failed}");
|
|
|
|
// 검색 인덱스 재생성 — 운영자가 부모 단계에서 선택했을 때만 수행 (인덱스 잠금·재색인 비용)
|
|
if ($rebuildSearchIndex) {
|
|
$report = app(SearchIndexMaintenanceManager::class)->repairStale();
|
|
$this->newLine();
|
|
$this->info('검색 인덱스: '.$report->summary());
|
|
|
|
foreach ($report->failed as $identifier => $message) {
|
|
$this->warn(' '.__('search.index.rebuild_failed_item', ['index' => $identifier, 'error' => $message]));
|
|
}
|
|
}
|
|
|
|
// 이전 버전 부모가 남긴 빈 격리 디렉토리(core_{ts}/extracted 껍데기) 청소 —
|
|
// 부모의 정리 단계는 이 자식보다 먼저 끝나므로, 구버전 부모에서 올라오는
|
|
// 업데이트도 이 자리에서 껍데기 없이 마무리된다. 실패해도 업데이트 결과와 무관.
|
|
try {
|
|
app(CoreUpdateService::class)->sweepEmptyStagingDirectories();
|
|
} catch (\Throwable $e) {
|
|
Log::channel('upgrade')->warning('[spawn] 빈 격리 디렉토리 청소 실패', ['error' => $e->getMessage()]);
|
|
}
|
|
|
|
// 부모 프로세스가 결과를 복원할 수 있도록 표식 라인으로 페이로드 출력
|
|
$this->line(self::RESULT_PREFIX.json_encode([
|
|
'success' => $success,
|
|
'failed' => $failed,
|
|
], JSON_UNESCAPED_UNICODE));
|
|
|
|
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
|
}
|
|
}
|