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 그대로다.
942 lines
56 KiB
Markdown
942 lines
56 KiB
Markdown
# 코어 업데이트 시스템 (Core Update System)
|
|
|
|
> 코어 버전 업데이트의 감지, 다운로드, 적용, 롤백, 업그레이드 스텝 실행을 관리하는 시스템
|
|
|
|
## TL;DR (5초 요약)
|
|
|
|
```text
|
|
1. 코어 업그레이드 스텝: upgrades/ 디렉토리 (프로젝트 루트), 네임스페이스 App\Upgrades
|
|
2. 마이그레이션 = 스키마 변경만, 데이터 백필/변환 = 업그레이드 스텝 (역할 분리 필수)
|
|
3. 업데이트 실행 흐름: 11단계 (감지 → 다운로드 → 백업 → 적용 → 마이그레이션 → 동기화 → 업그레이드 → 마무리)
|
|
4. 롤백: CoreBackupHelper로 백업 생성, 실패 시 자동 복원
|
|
5. 부트스트랩 호환성 검증: 코어 버전 < 확장 g7_version 시 자동 비활성화 (1시간 캐시)
|
|
6. vendor 를 교체한 뒤 새 프로세스를 띄우기 전에는 패키지 매니페스트를 비운다 (3계층 — 부모 선정리 / 자식 자가 치유 / 버전 판독 범위)
|
|
```
|
|
|
|
---
|
|
|
|
## 실행 환경 전제 (CRITICAL)
|
|
|
|
```text
|
|
⚠️ core:update 는 SSH/CLI 환경에서 코어 파일 소유자(보통 FTP/SSH 사용자)가
|
|
직접 실행한다고 가정합니다.
|
|
```
|
|
|
|
### 권한 모델
|
|
|
|
| 실행 환경 | 권한 가정 | 권장 사용 |
|
|
|----------|----------|----------|
|
|
| **SSH/CLI 직접 실행** | 사용자 = 파일 소유자 → 모든 코어 파일 쓰기 가능 | ✅ 정상 사용 (권장) |
|
|
| **웹 인스톨러 (`public/install/`)** | PHP-FPM 사용자 (www-data 등) — 권한 제한적 | ⚠️ vendor 번들 모드로만 신규 설치 가능. 코어 업데이트는 미지원 |
|
|
| **www-data 로 CLI 실행** | 코어 파일 소유자와 다를 수 있음 | ⚠️ 비권장 — 권한 거부 가능 |
|
|
|
|
### 권한 거부 발생 시 대처
|
|
|
|
`core:update` 시작 시 현재 실행 사용자 UID와 코어 파일(`composer.json`) 소유자 UID를 비교하여 불일치 시 경고 로그를 남깁니다. 다음과 같은 경우 발생할 수 있습니다:
|
|
|
|
1. **vendor/ 가 다른 사용자 소유**: 과거에 웹 인스톨러로 설치되어 vendor/ 가 www-data 소유인 상태에서 SSH 사용자로 `core:update` 실행
|
|
- **해결**: `chown -R $(whoami) vendor/` 후 재시도
|
|
|
|
2. **코어 파일이 다른 사용자 소유**: FTP 사용자와 SSH 사용자가 다른 환경
|
|
- **해결**: 호스팅 제공자에게 권한 정리 요청 또는 수동 업데이트 수행
|
|
|
|
### 웹 인스톨러는 별개
|
|
|
|
웹 인스톨러(`public/install/`)는 PHP-FPM 사용자로 실행되므로 권한 제약이 큽니다. vendor 번들 시스템(244 이슈)으로 신규 설치 시점의 vendor/ 권한 이슈는 해소되었으나, **코어 업데이트는 웹에서 지원하지 않으며 SSH/CLI 로만 수행해야 합니다**.
|
|
|
|
---
|
|
|
|
## 목차
|
|
|
|
1. [업데이트 감지](#1-업데이트-감지)
|
|
2. [업데이트 실행 흐름 (11단계)](#2-업데이트-실행-흐름-11단계)
|
|
3. [백업 및 롤백](#3-백업-및-롤백)
|
|
4. [마이그레이션 vs 업그레이드 스텝](#4-마이그레이션-vs-업그레이드-스텝)
|
|
5. [업그레이드 스텝 작성](#5-업그레이드-스텝-작성)
|
|
6. [자동 발견 및 실행 규칙](#6-자동-발견-및-실행-규칙)
|
|
7. [버전 관리](#7-버전-관리)
|
|
8. [CoreUpdateService 주요 메서드](#8-coreupdateservice-주요-메서드)
|
|
9. [Artisan 커맨드](#9-artisan-커맨드)
|
|
10. [API 엔드포인트](#10-api-엔드포인트)
|
|
11. [설정 (config/app.php)](#11-설정-configappphp)
|
|
12. [에러 처리 및 로깅](#12-에러-처리-및-로깅)
|
|
13. [유지보수 모드](#13-유지보수-모드)
|
|
14. [코어 vs 확장 업데이트 비교](#14-코어-vs-확장-업데이트-비교)
|
|
|
|
---
|
|
|
|
## 1. 업데이트 감지
|
|
|
|
> 파일: `app/Extension/CoreVersionChecker.php`, `app/Services/CoreUpdateService.php`
|
|
|
|
### 감지 흐름
|
|
|
|
```text
|
|
1. CoreVersionChecker::getCoreVersion() → config('app.version') 읽기
|
|
(코어 업데이트 트리 안에서만 env APP_VERSION 우선 — CoreUpdateContext::isInProgress())
|
|
2. CoreUpdateService::checkForUpdates() → GitHub API로 최신 릴리스 조회
|
|
3. version_compare(current, latest) → 업데이트 가용 여부 판단
|
|
4. 원격 CHANGELOG 캐시 → storage/app/temp/core_remote_changelog.md
|
|
```
|
|
|
|
### GitHub API 통합
|
|
|
|
```text
|
|
- config('app.update.github_url') 에서 리포지토리 URL 읽기
|
|
- config('app.update.github_token') 으로 인증 (선택)
|
|
- GitHub Releases API 호출 → 최신 릴리스 태그에서 버전 추출
|
|
- 모든 원격 HTTP 호출은 GithubHelper (Laravel Http 파사드 기반) 사용
|
|
→ file_get_contents + stream_context_create 금지 (allow_url_fopen=Off 환경 대응)
|
|
```
|
|
|
|
### 감지 결과
|
|
|
|
```php
|
|
[
|
|
'update_available' => true,
|
|
'current_version' => '7.0.0-alpha.14',
|
|
'latest_version' => '7.0.0-alpha.15',
|
|
'check_failed' => false, // GitHub API 실패 시 true
|
|
]
|
|
```
|
|
|
|
---
|
|
|
|
## 2. 업데이트 실행 흐름 (11단계)
|
|
|
|
> 파일: `app/Console/Commands/Core/CoreUpdateCommand.php`
|
|
|
|
```text
|
|
┌─ Step 1: 업데이트 확인 (GitHub API 또는 --source/--local 모드)
|
|
├─ Step 2: _pending 경로 검증 (디렉토리 존재/쓰기 권한)
|
|
├─ Step 3: 유지보수 모드 활성화 (--no-maintenance 시 스킵)
|
|
├─ Step 4: 다운로드 (GitHub zipball 또는 --source/--local에서 복사)
|
|
├─ Step 5: 백업 생성 (--no-backup 시 스킵)
|
|
├─ Step 6: Composer install (_pending에서 실행, 변경 없으면 스킵)
|
|
├─ Step 6.5: 신규 파일 manifest 생성 + 증분 적용 대상(3-way) 산출 (백업 있을 때)
|
|
├─ Step 7: 파일 적용 (_pending → base_path, 기본=코어 변경분만 / --prune=전체 덮어쓰기)
|
|
├─ Step 8: vendor 복사 (_pending/vendor → base_path/vendor, Step 6 스킵 시 함께 스킵)
|
|
├─ Step 9: 마이그레이션 + 동기화 (migrate, roles, permissions, menus, mail templates)
|
|
├─ Step 10: 업그레이드 스텝 실행 (upgrades/Upgrade_X_Y_Z.php)
|
|
└─ Step 11: 마무리 (.env 버전 갱신, 캐시 클리어, _pending 삭제, 유지보수 해제)
|
|
```
|
|
|
|
### Step 1: 업데이트 확인
|
|
|
|
| 모드 | 동작 |
|
|
|------|------|
|
|
| 일반 | `checkForUpdates()` → GitHub API 호출 → 사용자 확인 프롬프트 |
|
|
| `--source={path}` | 지정된 디렉토리를 소스로 사용 (GitHub 스킵) |
|
|
| `--zip={path}` | 지정된 ZIP 파일을 추출하여 소스로 사용 (GitHub 스킵). 추출 후 `config/app.php` 에서 버전 자동 판별. ZIP 구조가 GitHub zipball 의 `owner-repo-hash/` 래퍼이든 평탄 루트이든 모두 지원 |
|
|
| `--local` | 현재 코드베이스를 소스로 사용 (GitHub 스킵) |
|
|
| `--force` | 버전 비교 스킵, 동일 버전이어도 강제 실행 |
|
|
| `--vendor-mode=auto\|composer\|bundled` | vendor 설치 모드 지정 (기본 `auto` — composer 가능 시 composer, 불가 시 vendor-bundle.zip 추출). `bundled` 강제 시 공유 호스팅에서도 설치 가능 |
|
|
|
|
> `--source` / `--zip` / `--local` 은 **상호 배타적**입니다. 동시 지정 시 커맨드는 시작 전에 FAILURE(1) 로 종료됩니다.
|
|
|
|
### Step 4: 다운로드 및 추출
|
|
|
|
```text
|
|
추출 전략 (폴백 체인):
|
|
1. ZipArchive (PHP zip 확장) → class_exists(ZipArchive::class)
|
|
2. unzip CLI 명령어 → ZipArchive 미사용 시 폴백
|
|
|
|
v접두사 자동 감지 (resolveGithubArchiveUrl):
|
|
1. "v{version}" 태그로 HEAD 요청 시도 (예: v7.0.0-alpha.15)
|
|
2. 실패 시 "{version}" 태그로 HEAD 요청 시도 (예: 7.0.0-alpha.15)
|
|
3. HTTP 200/302 → 유효한 URL 반환
|
|
4. 모두 실패 → null (업데이트 불가)
|
|
```
|
|
|
|
### Step 5: 백업
|
|
|
|
```text
|
|
- CoreBackupHelper::createBackup() 사용
|
|
- 백업 대상: config('app.update.targets') + config('app.update.backup_only') + config('app.update.backup_extra')
|
|
- 제외 패턴: config('app.update.excludes')
|
|
- 백업 위치: storage 경로에 타임스탬프 디렉토리
|
|
```
|
|
|
|
### Step 6: Vendor 설치 (Composer 또는 Bundled)
|
|
|
|
`CoreUpdateService::runVendorInstallInPending()` 가 `VendorResolver` 경유로 모드에 따라 분기합니다.
|
|
|
|
```text
|
|
1. --vendor-mode 결정:
|
|
- composer 명시 / bundled 명시 → 그대로 사용
|
|
- auto (기본) → EnvironmentDetector 로 composer 실행 가능 여부 자동 감지
|
|
|
|
2. Composer 모드 (기존 흐름):
|
|
- composer.json + composer.lock 의 MD5 비교 (_pending vs base_path)
|
|
- 동일 → "composer 의존성 변경 없음 — 스킵" (Step 6 + Step 8 모두 스킵)
|
|
- 변경됨 → composer install --no-dev --optimize-autoloader --no-interaction --no-scripts
|
|
- 운영 vendor 의 개발용(require-dev) 패키지 감지 → 로그 기록
|
|
· 재설치 분기: "--no-dev vendor 로 교체합니다" (정보)
|
|
· 스킵 분기: "그대로 남습니다 … composer install --no-dev 실행 권장" (경고)
|
|
|
|
3. Bundled 모드 (신규, 공유 호스팅 대응):
|
|
- _pending/vendor-bundle.zip 무결성 검증 (SHA256)
|
|
- VendorBundleInstaller::install() 로 zip 추출 → _pending/vendor/ 생성
|
|
- 기존 vendor/ 를 vendor.old.{timestamp} 로 rename 후 추출 → 실패 시 복구
|
|
|
|
4. --no-scripts: post-autoload-dump 방지 (Step 11에서 package:discover로 처리)
|
|
```
|
|
|
|
> Vendor 번들 시스템 상세: [docs/extension/vendor-bundle.md](../extension/vendor-bundle.md)
|
|
|
|
### Step 6.5: 신규 파일 manifest + 증분 적용 대상 산출
|
|
|
|
```text
|
|
백업이 있을 때(--no-backup 아님)만 수행:
|
|
|
|
1. writeNewFilesManifest() — 자동 롤백용 `_new_files_manifest.json` 기록
|
|
(base=백업 vs theirs=_pending: 신 버전이 추가한 파일/디렉토리 목록)
|
|
|
|
2. computeApplyList() — 기본(증분) 모드의 3-way 적용 대상 산출 (--prune 시 스킵)
|
|
· base = 구버전 원본 = 백업 스냅샷
|
|
· theirs = 신 버전 = _pending
|
|
· base 없음 → added / size·md5 다름 → changed / 동일 → 제외(스킵)
|
|
· size 선필터 후 size 동일할 때만 md5 (mtime 비교 안 함 — _pending 은 추출 시각)
|
|
· symlink / excludes / protected_paths 하위 → 목록 제외
|
|
· 단, targets 에 더 구체적으로 명시된 경로는 상위 protected 를 오버라이드(아래 주석)
|
|
```
|
|
|
|
> **protected_paths 오버라이드 (공개 #64 / 내부 #452)**: `protected_paths` 에는 확장 부모(`modules`·`plugins`·`templates`·`lang-packs`)가 포함되지만, `targets` 에는 `{domain}/_bundled` 가 명시된다. 3-way 산출(`computeApplyList`)과 신규 파일 manifest(`writeNewFilesManifest`)는 "targets 에 더 구체적(하위)으로 명시된 경로가 상위 protected 를 오버라이드"하도록 판정한다. 이로써 코어 배포본에 포함된 번들 확장의 갱신 파일(`_bundled/{id}/composer.json`·`vendor-bundle.json` 등)이 코어 업데이트로 정상 반영된다. 오버라이드는 target 이 protected 보다 **더 깊을 때만** 적용되므로, `storage`(target) == `storage`(protected) 같은 동일 경로는 여전히 제외된다. 확장 부모를 protected 에 둔 원래 의도(자동 발견 폴백이 활성 서브디렉토리 `modules/sirsoft-*` 를 삭제하는 #347 방어)는 그대로 유지된다 — 자동 발견 폴백은 targets 순회가 아니므로 오버라이드 영향을 받지 않는다.
|
|
|
|
### Step 7: 파일 적용
|
|
|
|
```text
|
|
기본(증분) 모드 — --prune 미지정 + 백업 있음:
|
|
- Step 6.5 의 applyList(코어가 실제 변경/추가한 파일)에 있는 파일만 적용
|
|
- 코어가 건드리지 않은 파일은 복사·chmod·chown·mtime 갱신을 전부 스킵 → 현재 디스크
|
|
상태(사용자 수정 포함 가능)를 그대로 보존
|
|
- orphan(소스에 없는 대상 파일) 삭제 안 함 → 사용자가 추가한 신규 파일 보존
|
|
|
|
--prune 모드 (또는 백업 부재 fallback):
|
|
- targets 전체 무조건 덮어쓰기 + orphan 삭제 (기존 동작)
|
|
- 백업 부재 시 base 가 없어 3-way 불가 → 안전하게 전체 덮어쓰기로 회귀 + 안내 출력
|
|
- `public/storage` symlink 는 orphan 삭제에서 보호됨 — `public` 타깃 정리 시
|
|
`preserveLinkPaths: ['storage']` 화이트리스트로 링크/junction 을 보존 (#43, 아래 §참조)
|
|
|
|
공통:
|
|
- 자동 발견 폴백: targets 에 미등재된 source 최상위 항목도 스캔하여 적용
|
|
(config('app.update.protected_paths') 와 config('app.update.excludes') 매치 시 스킵)
|
|
- FilePermissionHelper::copyDirectory() 사용 → 원본 파일 권한 보존
|
|
- ExtensionPendingHelper::copyToActive() 미사용 (권한 유실 방지)
|
|
```
|
|
|
|
> **기본 동작 변경 배경 (공개 #64)**: 이전에는 Step 7 이 targets 전체를 무조건 재복사하고 orphan 을 삭제하여, 사용자가 수정한 `public/.htaccess` 커스텀 블록이나 `_bundled/` 아래 커스텀 확장이 소실되는 사고가 반복 제보되었다. 기본 동작을 "코어가 실제로 변경/추가한 파일만 적용(3-way)"으로 전환해 발생 표면을 제거했다. 전체 덮어쓰기 + 정리를 원하면 `--prune` 을 지정한다. "코어도 바꾸고 사용자도 바꾼" 파일은 코어 버전으로 갱신되지만 백업에 원본이 보존되어 복구 가능하다.
|
|
|
|
> **증분 모드 잔존 stale 파일 정리**: 기본(증분) 모드는 orphan 을 삭제하지 않으므로, 신 버전에서 제거된 파일이 활성 디렉토리에 잔존할 수 있다. 완료 요약이 잔존을 안내하며, 정리하려면 같은 업데이트를 `--prune` 으로 다시 실행한다. 단발성 정리 도구 `php artisan hotfix:rollback-stale-files --prune` 은 **자동 롤백 뒤**(백업 디렉토리와 `_new_files_manifest.json` 이 남아 있는 상태) 전용이다 — 성공한 업데이트는 Step 11 에서 백업을 지우므로 그 뒤에 실행하면 "사용 가능한 백업이 없습니다" 로 끝난다. 완료 안내문이 이 명령을 가리키던 것은 7.0.10 에서 걷어냈다. 상세 사용법: [docs/cheatsheet.md](../cheatsheet.md) "단발성 결함 보정 (hotfix)".
|
|
|
|
> **격리 디렉토리는 루트째 지우고, 이번 실행이 만든 것은 소유권 기준에서 뺀다 (7.0.10)**: 업데이트 소스는 `storage/app/core_pending/core_{Ymd_His}/` 격리 디렉토리 안에 놓이는데, ZIP·GitHub 경로는 그 안쪽 `extracted/{루트}/` 를, `--local` 은 `local_source/` 를 소스 경로로 돌려준다. 7.0.9 까지의 정리 단계는 그 소스 경로만 지워 `core_{ts}/extracted/` 껍데기가 업데이트마다 남았고, sudo 실행이면 root 소유(0770)라 운영자·웹서버 계정이 지울 수 없었다(같은 서버의 7.0.0 부터의 설치본마다 하나씩 실측). 세 층으로 닫았다.
|
|
>
|
|
> - **부모 정리**: `cleanupPending()` 이 `resolveStagingRoot()` 로 격리 디렉토리 루트까지 올라가 통째로 지운다. pending 기준 디렉토리 밖 경로(`--source` 외부 디렉토리)는 올라가지 않는다.
|
|
> - **자식 청소**: 부모는 구버전 클래스를 메모리에 들고 있어 이 수정이 다음 업데이트부터 효력이 있으므로, 신버전 코드로 도는 두 자식(`core:execute-upgrade-steps`, `core:execute-bundled-updates`)이 종료 직전 `sweepEmptyStagingDirectories()` 로 **파일이 하나도 없는** `core_*` 디렉토리만 치운다. 부모가 쓰는 중인 격리 디렉토리는 파일을 갖고 있어 술어상 제외된다. 구버전 부모에서 올라오는 업데이트(7.0.9→7.0.10)는 번들 일괄 업데이트 자식이 부모 정리 뒤에 돌므로 그 자리에서 껍데기가 사라진다.
|
|
> - **스냅샷 제외**: 항목별 소유권 스냅샷은 격리 디렉토리가 생긴 **뒤에** 찍힌다. 제외하지 않으면 root 가 만든 추출본이 "원본 소유권" 으로 기록되고 복원이 잔존물을 다시 root 로 되돌리므로, 부모는 `snapshotOwnershipDetailed(..., excludes: [격리 디렉토리 루트])` 로 이번 실행의 것을 뺀다. 그러면 잔존물이 생겨도 상위 `storage/app/core_pending` 의 재귀 chown 이 운영자 계정·웹서버 그룹으로 맞춰 지울 수 있다.
|
|
|
|
> **`public/storage` symlink 보존 + 종료 시 복구 (#43)**: 심층 방어 2층 구조로 `--prune` 실행 후에도 `public/storage` symlink 가 정상 유지된다.
|
|
> - **층 1 (예방)**: `--prune` 은 `public` 타깃에서 orphan(릴리즈 소스에 없는 항목)을 삭제하는데, 런타임 symlink 인 `public/storage` 는 릴리즈 소스에 없어 orphan 으로 판정되어 삭제되던 결함이 있었다(업로드 파일 404). `applyUpdate` 가 `public` 타깃 처리 시 `FilePermissionHelper::copyDirectory(..., preserveLinkPaths: ['storage'])` 로 화이트리스트를 전달해, 매칭되는 orphan symlink/junction 만 삭제에서 제외한다. 화이트리스트 밖 orphan 링크는 기존대로 삭제된다(정밀 보호 — 무조건 보존 아님).
|
|
> - **층 2 (복구)**: 업데이트 종료 시점(정상 Step 11 + 핸드오프 catch)에 `StorageLinkHelper::ensurePublicStorageLink()` 를 호출해 `public/storage` 가 정상 링크인지 확인하고, 부재/손상이면 `storage/app/public` 을 가리키는 링크를 (필요 시 `.broken.{YmdHis}` rename 백업 후) 재생성한다. 버전 무관 매 업데이트 실행. Windows `SeCreateSymbolicLink` 권한 부족 시 junction(`mklink /J`) 폴백까지 시도하고, 그래도 실패하면 rename 원복 + 수동 `storage:link` 안내(데이터 손실 없음). 롤백 catch 경로는 백업 복원이 링크를 원상 회복하므로 대상 아님.
|
|
> - **소유권 상속**: `symlink()` 은 소유권 인자가 없어 sudo 실행 시 링크가 root:root 로 생성된다. 재생성 직후 부모 `public/` 의 owner/group 을 기준으로 `lchown`/`lchgrp`(링크 자체 대상 — `chown` 은 target 을 따라감) 보정해 원래 앱 실행 유저 소유를 유지한다. 이 프로젝트의 "신규 항목은 부모 소유권 상속"(`FilePermissionHelper`) 컨벤션과 일치. 비-POSIX/함수 부재/권한 부족 시 무해하게 skip.
|
|
> - 이 복구 로직은 beta.5 DataMigration `RecoverPublicStorageSymlink` 와 `StorageLinkHelper` 로 일원화되어 있다(부재→재생성 케이스까지 상위호환).
|
|
|
|
> **자동 발견 폴백의 배경 (engine-v / beta.4 이후)**: Step 7 은 부모 프로세스의 `config('app.update.targets')` 를 사용한다. 부모는 업그레이드 *직전* 의 코드/메모리 상태이므로 신버전이 도입한 신규 최상위 디렉토리(예: beta.4 의 `lang-packs/`) 가 부모의 stale targets 에서 누락된다. 폴백은 이 결함을 안전망으로 차단하며, `config/app.php` 의 `update.protected_paths` 가 런타임 데이터(`storage`)·로컬 환경(`.env*`)·별도 파이프라인 산출물(`vendor`)·개발 메타(`.git`/`.claude`/`.serena` 등) 의 의도치 않은 덮어쓰기를 방지한다.
|
|
|
|
### Step 9: 마이그레이션 + 동기화
|
|
|
|
```text
|
|
1. php artisan migrate --force
|
|
2. syncCoreRolesAndPermissions() — config/core.php의 roles/permissions 동기화
|
|
3. syncCoreMenus() — config/core.php의 menus 동기화
|
|
```
|
|
|
|
> **변경 이력 (7.0.0-beta.2)**: `syncCoreMailTemplates()` 단계는 알림 시스템 통합(#146)으로 제거되었습니다. 메일 템플릿은 `notification_definitions` + `notification_templates` 로 통합되었으며, `Upgrade_7_0_0_beta_2` 가 운영 환경 데이터 이관과 알림 정의 시드를 처리합니다.
|
|
|
|
> 역할/권한/메뉴 동기화는 `user_overrides` 패턴을 사용하여 사용자 커스터마이징 보존.
|
|
> 상세: [extension-update-system.md § 13](../extension/extension-update-system.md#13-역할권한메뉴-동기화)
|
|
|
|
### Step 10: 업그레이드 스텝
|
|
|
|
```text
|
|
1. upgrades/ 디렉토리 스캔
|
|
2. Upgrade_X_Y_Z.php 패턴 매칭 → 버전 변환
|
|
3. fromVersion < stepVersion <= toVersion 범위 필터링
|
|
4. version_compare 자연 정렬 (오름차순)
|
|
5. 각 스텝 순차 실행 (UpgradeContext 전달)
|
|
```
|
|
|
|
> **spawn 자식 진입 시 PSR-4 autoload 갱신 (engine-v / beta.4 이후)**: `core:execute-upgrade-steps` (Step 10) 와 `core:execute-bundled-updates` (Step 12) 의 spawn 자식은 `handle()` 진입 직후 `app(ExtensionManager::class)->updateComposerAutoload()` 를 1회 호출한다. 부모 프로세스의 `bootstrap/cache/autoload-extensions.php` 가 stale 한 경우 자식이 그 매핑을 그대로 로드 → upgrade step 또는 bundled update 안에서 모듈/플러그인의 `Models`/`Services` 같은 다른 클래스를 lazy autoload 시 "Class not found" 발생. 진입 시점 1회 호출로 모든 후속 작업이 fresh autoload 환경에서 실행됨을 보장한다 (개별 step 마다 호출할 필요 없음). 본 진입점들은 자체가 spawn 자식 (별개 PHP 프로세스) 이라 디스크의 fresh `ExtensionManager` 클래스를 메모리에 로드한 상태 — 직접 메서드 호출도 stale 가능성 없음.
|
|
|
|
> **단독 실행 안전성 (beta.6 이후)**: `core:execute-upgrade-steps` 는 HANDOFF 안내 또는 수동 복구 목적으로 운영자가 직접 호출되는 경로가 있다. 단독 실행 시 자식은 기본값으로 부모 Step 9 (`runMigrations` + `reloadCoreConfigAndResync`), Step 11 (`updateVersionInEnv` + `clearAllCaches`), Step 12 (번들 확장 일괄 업데이트) 를 자체적으로 수행해 단일 명령으로 업그레이드를 완결한다. 부모 `CoreUpdateCommand::spawnUpgradeStepsProcess()` 는 자식 명령 라인에 `--skip-migrations`, `--skip-resync`, `--skip-version-env`, `--skip-cache-clear`, `--skip-bundled-updates` 5개를 무조건 추가해 중복 회피한다 — 부모가 자식 종료 후 동일 단계를 직접 수행하기 때문이다.
|
|
|
|
> **spawn 자식은 이전 버전의 config 캐시로 부팅한다**: 7.0.9 이하 부모는 Step 10(spawn) 전에 config 캐시를 비우지 않았다 — `clearAllCaches()` 는 Step 11 이다. 그래서 이전 버전 설치본에 `bootstrap/cache/config.php` 가 있으면(설치 마법사·설정 저장·확장 업데이트가 만든다) 자식은 그 캐시로 부팅하고, 자식의 `config('app.version')` 은 부모가 env 로 넘긴 `APP_VERSION={toVersion}` 이 아니라 캐시에 박힌 fromVersion 이다. 업데이트 흐름 안에서 "지금 프로세스의 코어 버전" 을 판정하는 코드는 `config('app.version')` 을 직접 읽지 않고 `CoreVersionChecker::getCoreVersion()`(env 우선, config 폴백)을 쓴다. `runUpgradeSteps()` 의 stale 메모리 가드가 config 만 읽던 시절에는 정상 spawn 자식을 stale 부모로 오판해 스텝이 0건인 릴리즈에서도 핸드오프로 중단됐다(7.0.9→7.0.10). 부모 in-process fallback 에서는 env 가 `.env` 의 fromVersion 이므로 가드는 그대로 발동한다.
|
|
>
|
|
> 같은 이유로 자식이 `config('app.update.*')` 로 읽는 목록(쓰기 권한 디렉토리 등)도 캐시에 박힌 옛 목록이다 — 신버전이 항목을 추가해도 자식에게 보이지 않는다. 방어는 두 겹이다: ① 부모(7.0.10+)는 `spawnUpgradeStepsProcess()` 가 `proc_open` 직전에 `ConfigCacheHelper::clear()` 로 캐시를 비워 자식이 디스크 config + `.env` + spawn env 로 부팅하게 한다(캐시는 Step 11 이 다시 만든다). ② 자식(7.0.10+)은 이전 버전 부모가 캐시를 남겨 둔 경우를 위해, 캐시 파일이 있으면 `CoreUpdateService::freshDiskUpdateConfig()` 로 디스크의 `config/app.php` 를 직접 읽는다 — 캐시 부팅에서는 `.env` 도 로드되지 않으므로 그 안에서 `.env` 를 먼저 불변 로드한다(프로세스 env 의 `APP_VERSION` 은 덮어쓰지 않는다).
|
|
|
|
#### spawn 전 캐시 정리 계약 (3계층)
|
|
|
|
config 캐시와 같은 문제가 **패키지 매니페스트**(`bootstrap/cache/packages.php` · `services.php`)에도 있고, 이쪽은 결과가 더 무겁다. Laravel 의 `PackageManifest` 는 `packages.php` 가 있으면 stale 여부를 검사하지 않고 그대로 읽고, `ProviderRepository` 가 거기 등재된 eager provider 를 `new` 한다. Step 6/8 이 vendor 를 `--no-dev` 로 교체해도 두 파일은 Step 11 까지 이전 설치본의 것이 남으므로, 이전 설치본이 `composer install`(옵션 없음)로 깔린 개발용 설치였다면 자식은 새 vendor 에 없는 provider 를 찾다 **부팅 단계에서** 죽는다. 예외는 앱 로그가 열리기 전이라 남지 않고, 부모에게는 자식의 비정상 종료로만 보인다 (7.0.9 → 7.0.10 실사례).
|
|
|
|
| 계층 | 위치 | 막는 실패 | 잠그는 테스트 |
|
|
|------|------|-----------|--------------|
|
|
| ① 부모 선정리 | `CoreUpdateCommand::spawnUpgradeStepsProcess()` 가 `proc_open` 직전 `PackageManifestCacheHelper::clear()`. 지우지 못한 파일이 있으면 그 경로를 업그레이드 로그에 경고로 남긴다 — 권한·소유권 불일치면 자식의 계층 ② 도 같은 이유로 실패해 증상은 제보와 같은 「Class not found」 인데, 이 경고가 원인을 가리키는 유일한 흔적이다 | 7.0.11+ 부모가 띄우는 자식의 부팅 실패 | `CoreUpdateCommandStalePackageManifestTest` |
|
|
| ② 자식 자가 치유 | `bootstrap/app.php` 가 `G7_UPDATE_IN_PROGRESS=1`(또는 명령줄 SAPI 에서의 업데이트 argv)이면 두 파일을 스스로 삭제 | **이미 배포된** 7.0.9·7.0.10 부모 아래에서 도는 신버전 자식 — 그 부모 코드는 고칠 수 없다 | 같은 테스트 (플래그 유·무 대조군 포함) |
|
|
| ③ 버전 판독 범위 | `CoreVersionChecker::getCoreVersion()` 의 env 우선은 `CoreUpdateContext::isInProgress()` 트리 안에서만 | 업데이트 **전에** 뜬 `php artisan serve`·큐 워커가 옛 `APP_VERSION` 을 물고 확장을 `incompatible_core` 로 끄는 것 | `CoreVersionCheckerEnvPriorityTest` · `CoreUpdateContextTest` |
|
|
|
|
계층 ②는 `config:cache`/`route:cache` 가 만드는 in-process 일회용 앱에도 발동한다 — 그 부팅도 `bootstrap/app.php` 를 다시 require 하고 플래그를 상속하기 때문이다. 웹 요청·`queue:work`·운영자 셸은 플래그가 없어 no-op 이다.
|
|
|
|
argv 채널은 명령줄 SAPI(`cli`·`phpdbg`)에서만 읽는다. CGI/FPM 은 `register_argc_argv=On` 이면 `$_SERVER['argv']` 를 쿼리스트링을 `+` 로 쪼갠 값으로 채우므로(`GET /?x+core:update` → `argv[1] === 'core:update'`), 그 게이트가 없으면 비인증 웹 요청이 요청마다 매니페스트를 지우고 다시 만들게 된다. env 플래그 채널은 웹 요청으로 주입할 수 없어 그대로 두며, 웹 요청 안에서 시작되는 업데이트 흐름은 그 플래그를 프로세스 안에서 세워 판정된다.
|
|
|
|
계층 ③의 판정은 `App\Support\CoreUpdateContext` 가 단독으로 소유하고 `CoreServiceProvider::isCoreUpdateInProgress()` 가 그리로 위임한다. 자동 비활성화 로그의 `core_version` 도 같은 게터를 쓴다 — 로그가 `config('app.version')` 을 적고 판정은 env 로 하면 운영자가 보는 근거와 실제 판정이 어긋난다.
|
|
|
|
#### 재실행 안내의 권한 분기 (핸드오프 catch)
|
|
|
|
spawn 자식이 실패(`proc_open` 미지원 · 비정상 종료 · silent skip)하고 `spawn_failure_mode=abort`(기본값) 이면, 파일·버전은 이미 `toVersion` 으로 반영되지만 업그레이드 스텝이 미실행 상태로 남아 운영자에게 `core:execute-upgrade-steps` 재실행을 안내한다. 이때 **sudo(root) 로 `core:update` 를 실행한 경우**, 안내받은 명령을 root 로 그대로 재실행하면 스텝이 만드는 파일·캐시가 root 소유로 생성되어 이후 웹서버(php-fpm www-data 등) 요청이 그 경로에 쓰기 실패한다.
|
|
|
|
`CoreUpdateCommand::surfaceResumeCommandWithPermissionGuidance()` 는 실행 환경을 4가지로 분류(`classifyResumeExecutionContext()`)하여 안내를 분기한다:
|
|
|
|
| 모드 | 조건 | 안내 |
|
|
|------|------|------|
|
|
| `non_root` | root 아님(일반 SSH 사용자 = 파일 소유자) / posix 미지원(Windows) / 공유 호스팅(웹서버·PHP·실행 유저 동일) | 명령만 그대로 출력 |
|
|
| `root_web_known` | root 실행 + 웹서버 계정 식별 가능 + 실행 사용자와 다름 | `sudo -u {계정} {명령}` + 계정명 명시 경고 |
|
|
| `root_web_symmetric` | root 실행 + 웹서버 계정이 root 로 추정 (root 서비스 구성) | 명령만 그대로 출력 |
|
|
| `root_web_unknown` | root 실행 + 웹서버 계정 추정 실패 | `sudo -u <웹서버계정>` placeholder + 계정 확인 안내 |
|
|
|
|
웹서버 계정은 `FilePermissionHelper::inferWebServerOwnership()` 이 `storage/*`·`bootstrap/cache` 쓰기 영역 소유자로 추정한다.
|
|
|
|
### Step 11: 마무리
|
|
|
|
```text
|
|
1. .env의 APP_VERSION 갱신
|
|
2. 캐시 클리어: config, cache, route, view (spawn 직전에도 config + 패키지 매니페스트 선정리)
|
|
3. bootstrap/cache 파일 삭제 (services.php, packages.php)
|
|
4. php artisan package:discover 재실행
|
|
5. php artisan extension:update-autoload (코어 업데이트로 _bundled 변경 가능)
|
|
6. _pending 격리 디렉토리(core_{ts}) 루트째 삭제
|
|
7. 성공 시 백업 삭제 (이후 `hotfix:rollback-stale-files` 는 대상이 없다)
|
|
8. 유지보수 모드 해제
|
|
9. 큐 워커 재시작 신호 (queue:restart)
|
|
```
|
|
|
|
> 3~4 는 `PackageManifestCacheHelper::rebuild()` 한 호출이다 — spawn 직전 선정리(계층 ①)와 같은 삭제 로직을 공유한다.
|
|
>
|
|
> 9 는 상주 큐 워커가 부팅 시점의 코어 코드·config 를 계속 쓰는 것을 막는다. 워커는 옛 코드로도 잡을 정상 처리하므로 오류가 나지 않고, 운영자가 손수 재시작할 때까지 조용히 어긋난 채 돈다. 핸드오프 cleanup 과 `core:execute-upgrade-steps` 단독 실행의 사후 단계도 같은 신호를 보낸다. 롤백 catch 는 제외다 — 백업으로 되돌린 옛 코드가 다시 도는 자리라 재기동시킬 이유가 없다.
|
|
|
|
### Step 12: _bundled 확장 일괄 업데이트 프롬프트 (인터랙티브)
|
|
|
|
> Trait: `App\Console\Commands\Core\Concerns\BundledExtensionUpdatePrompt`
|
|
|
|
코어 업데이트 완료 후, 번들(`_bundled/`) 에 설치된 확장보다 새 버전이 포함된 경우 일괄 업데이트를 제안합니다.
|
|
|
|
```text
|
|
1. checkAllModulesForUpdates() / checkAllPluginsForUpdates() / checkAllTemplatesForUpdates() 호출
|
|
2. update_source === 'bundled' 항목만 수집
|
|
3. 감지 결과 없으면 "활성 확장이 최신 번들과 일치합니다" 출력 후 종료
|
|
4. 감지 결과 있으면 목록 표시 + 일괄 업데이트 여부 확인 (기본값 yes)
|
|
5. 동의 시:
|
|
- 전역 레이아웃 전략 선택 (overwrite | keep) ※ 섹션 10 참조
|
|
- 예외 확장 지정 여부 확인, yes 이면 다중 선택으로 전략 오버라이드
|
|
- 매니페스트 JSON 직렬화 → `core:execute-bundled-updates` spawn 자식 실행
|
|
6. 결과 요약 출력 (성공/실패 건수)
|
|
|
|
--force 플래그가 코어 업데이트에 지정된 경우: 프롬프트 스킵 + 전역 overwrite 자동 적용 (CI 대응)
|
|
```
|
|
|
|
> **spawn 위임의 배경 (beta.4 이후)**: 부모(`core:update`) 프로세스는 코어 업그레이드 *직전* 버전의 메모리 상태를 보유한다. 부모가 직접 `ModuleManager::updateModule()` 등을 호출하면 신버전 코어가 도입한 sync 메서드(예: `syncModuleIdentityPolicies` `@since beta.4`)가 메모리에 부재해 호출 자체가 누락된다. `executeBulkUpdate` 는 사용자 선택만 매니페스트로 직렬화한 뒤 `proc_open` 으로 `core:execute-bundled-updates` 를 spawn — 자식은 fresh PHP 프로세스라 디스크의 신버전 코어 코드를 메모리에 로드한 상태에서 update 메서드 호출. proc_open 미지원 환경에서는 in-process fallback 으로 안전 전환. 자식 stdout 은 부모 콘솔로 forwarding 하며, 종료 직전 `[BUNDLED-RESULT]` 표식 라인으로 결과 카운트를 회신한다.
|
|
|
|
> **선언형 산출물 일괄 sync — `syncDeclarativeArtifacts`**: `ModuleManager::syncDeclarativeArtifacts(ModuleInterface)` / `PluginManager::syncDeclarativeArtifacts(PluginInterface)` 는 모듈/플러그인의 모든 declarative 데이터(역할·권한·메뉴·cleanup·IDV 정책·IDV 메시지·알림 정의) 를 한 묶음으로 동기화하는 public 진입점이다. `installModule` / `updateModule` 트랜잭션이 내부적으로 호출하며, 코어 업그레이드 사후 보정(`Upgrade_7_0_0_beta_4` 등) 도 활성 모듈/플러그인을 순회하며 이 메서드를 호출해 일회성 회복을 수행한다. 각 sync 메서드는 helper 의 user_overrides 보존 패턴을 따르므로 정상 환경 재호출 무해 (멱등).
|
|
|
|
---
|
|
|
|
## 3. 백업 및 롤백
|
|
|
|
> 파일: `app/Extension/Helpers/CoreBackupHelper.php`
|
|
|
|
### 백업 생성
|
|
|
|
```text
|
|
- CoreBackupHelper::createBackup() 호출
|
|
- 대상: config('app.update.targets') + config('app.update.backup_only') + config('app.update.backup_extra')
|
|
- 제외: config('app.update.excludes')
|
|
- 저장: storage 경로에 타임스탬프 디렉토리 (Ymd_His)
|
|
```
|
|
|
|
### 롤백 흐름 (실패 시)
|
|
|
|
```text
|
|
1. Exception 발생
|
|
2. 백업이 존재하면:
|
|
├─ CoreUpdateService::restoreFromBackup($backupPath)
|
|
├─ CoreBackupHelper::restoreFromBackup() → 파일 원복
|
|
└─ _pending 디렉토리 삭제
|
|
3. 실패 보고서 생성: storage/logs/core_update_failure_YYYYMMDD_HHMMSS.log
|
|
4. 업데이트 로그 저장: storage/logs/core_update_failed_YYYYMMDD_HHMMSS.log
|
|
5. 유지보수 모드 유지 (수동으로 php artisan up 필요)
|
|
```
|
|
|
|
### 롤백 실패 시
|
|
|
|
```text
|
|
- 복원 실패 자체는 예외를 전파하지 않음 (에러 로그만 기록)
|
|
- 관리자에게 안내: "이전 버전으로 사이트를 운영하려면: php artisan up"
|
|
- 유지보수 모드 bypass secret 출력
|
|
```
|
|
|
|
---
|
|
|
|
## 4. 마이그레이션 vs 업그레이드 스텝
|
|
|
|
```text
|
|
주의: 마이그레이션과 업그레이드 스텝의 역할을 혼동하지 않을 것
|
|
```
|
|
|
|
| 구분 | 마이그레이션 (`database/migrations/`) | 업그레이드 스텝 (`upgrades/`) |
|
|
|------|--------------------------------------|-------------------------------|
|
|
| **역할** | DB 스키마 변경 (컬럼 추가/삭제/변경, 인덱스, 테이블) | 데이터 백필/변환, 설정 마이그레이션 |
|
|
| **실행 시점** | `php artisan migrate` (설치/업데이트 모두) | `php artisan core:update` Step 10 (업데이트 시만) |
|
|
| **신규 설치** | 실행됨 | 실행되지 않음 (fromVersion == toVersion) |
|
|
| **버전 업그레이드** | 실행됨 | 실행됨 (범위 내 스텝만) |
|
|
| **예시** | `$table->uuid('uuid')->nullable()->after('id')` | 기존 레코드 UUID 백필 + NOT NULL 변환 |
|
|
|
|
### 역할 분리 원칙
|
|
|
|
```text
|
|
✅ 마이그레이션: 테이블/컬럼/인덱스 생성·변경·삭제 (스키마)
|
|
✅ 업그레이드 스텝: 기존 데이터 변환, 백필, 설정 구조 변경 (데이터)
|
|
|
|
❌ 마이그레이션에 데이터 백필 로직 포함 금지
|
|
❌ 업그레이드 스텝에 스키마 변경 포함 금지 (Schema::table 등)
|
|
단, 백필 후 NOT NULL 제약 추가처럼 데이터 완결성에 필수인 경우는 예외
|
|
```
|
|
|
|
### 신규 설치 시 데이터 초기화
|
|
|
|
신규 설치 시 업그레이드 스텝은 실행되지 않으므로, 모델의 `boot()` 이벤트나 Seeder에서 초기 데이터를 생성해야 합니다.
|
|
|
|
```php
|
|
// 예: User 모델 — 신규 레코드 생성 시 UUID 자동 할당
|
|
protected static function boot(): void
|
|
{
|
|
parent::boot();
|
|
|
|
static::creating(function (self $user) {
|
|
if (empty($user->uuid)) {
|
|
$user->uuid = app(UniqueIdServiceInterface::class)->generateUuid();
|
|
}
|
|
});
|
|
}
|
|
```
|
|
|
|
### 업그레이드 스텝이 필요한 경우
|
|
|
|
| 변경 유형 | 업그레이드 스텝 필요 | 예시 |
|
|
|----------|-------------------|------|
|
|
| 기존 데이터 백필/변환 | ✅ | UUID 백필, 형식 변환 |
|
|
| NOT NULL 제약 추가 (백필 후) | ✅ (예외) | 스키마이지만 데이터 완결성 의존 |
|
|
| 권한/역할/메뉴 추가·수정 | ❌ (자동 동기화) | config/core.php 수정만으로 충분 |
|
|
| 정적 권한/메뉴 제거 | ✅ (cleanup 명시 호출) | 기존 메뉴/권한 삭제 |
|
|
| PHP 코드만 변경 | ❌ | 버그 수정, 성능 개선 |
|
|
|
|
---
|
|
|
|
## 5. 업그레이드 스텝 작성
|
|
|
|
### 디렉토리 구조
|
|
|
|
```text
|
|
upgrades/ # 프로젝트 루트
|
|
├── .gitkeep
|
|
├── Upgrade_7_0_0_beta_4.php # 7.0.0-alpha.4
|
|
└── Upgrade_7_0_0_beta_15.php # 7.0.0-alpha.15
|
|
```
|
|
|
|
### 작성 예시
|
|
|
|
```php
|
|
<?php
|
|
|
|
namespace App\Upgrades;
|
|
|
|
use App\Contracts\Extension\UpgradeStepInterface;
|
|
use App\Extension\UpgradeContext;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Support\Str;
|
|
|
|
/**
|
|
* 코어 7.0.0-alpha.15 업그레이드 스텝
|
|
*
|
|
* 기존 사용자 레코드에 UUID v7을 백필합니다.
|
|
*/
|
|
class Upgrade_7_0_0_beta_15 implements UpgradeStepInterface
|
|
{
|
|
public function run(UpgradeContext $context): void
|
|
{
|
|
// 1. 사전 조건 확인
|
|
if (! Schema::hasColumn('users', 'uuid')) {
|
|
$context->logger->warning('uuid 컬럼 없음. 마이그레이션을 먼저 실행하세요.');
|
|
return;
|
|
}
|
|
|
|
// 2. 데이터 백필
|
|
$nullCount = DB::table('users')->whereNull('uuid')->count();
|
|
if ($nullCount > 0) {
|
|
$context->logger->info("UUID 백필 시작: {$nullCount}건");
|
|
DB::table('users')->whereNull('uuid')->orderBy('id')->chunk(100, function ($users) {
|
|
foreach ($users as $user) {
|
|
DB::table('users')
|
|
->where('id', $user->id)
|
|
->update(['uuid' => Str::orderedUuid()->toString()]);
|
|
}
|
|
});
|
|
$context->logger->info("UUID 백필 완료");
|
|
}
|
|
|
|
// 3. 백필 완료 후 NOT NULL 제약 (데이터 완결성 예외)
|
|
// raw SQL에서는 $context->table()로 프리픽스 적용 필수
|
|
$column = DB::selectOne("SHOW COLUMNS FROM {$context->table('users')} WHERE Field = 'uuid'");
|
|
if ($column && $column->Null === 'YES') {
|
|
Schema::table('users', function ($table) {
|
|
$table->uuid('uuid')->nullable(false)->unique()->change();
|
|
});
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### 작성 체크리스트
|
|
|
|
```text
|
|
□ UpgradeStepInterface 구현
|
|
□ 네임스페이스: App\Upgrades (코어) / Modules\Vendor\Module\Upgrades (모듈)
|
|
□ 파일명: Upgrade_X_Y_Z.php (버전에 맞는 언더스코어 표기)
|
|
□ 사전 조건 확인 (테이블/컬럼 존재 여부)
|
|
□ 로거를 통한 진행 상황 기록
|
|
□ 멱등성 보장 — 모든 DB 조작에 방어 로직 필수 (아래 참조)
|
|
□ 대량 데이터는 chunk() 사용
|
|
□ raw SQL 사용 시 $context->table()로 테이블 프리픽스 적용
|
|
```
|
|
|
|
### 멱등성 방어 로직 (필수)
|
|
|
|
업그레이드 스텝은 재실행될 수 있다 (롤백 후 재시도, `--force` 등). 모든 DB 조작은 이미 완료된 상태에서 재실행해도 안전해야 한다.
|
|
|
|
| 조작 유형 | 방어 패턴 | 비고 |
|
|
|----------|----------|------|
|
|
| 레코드 삽입 | `firstOrCreate` 또는 `updateOrCreate` | `insert` 단독 사용 금지 |
|
|
| 대량 데이터 이관 | 이관 완료 마커 확인 후 스킵 | 예: `source` 컬럼에 마커 기록 → `exists()` 체크 |
|
|
| 식별자 rename (unique 컬럼) | 신규 존재 여부 확인 후 분기 | 양쪽 다 존재하면 역할 이관 후 구 레코드 삭제 |
|
|
| 코드/상태값 변환 (update WHERE) | WHERE 조건이 이미 변환된 건을 제외 | 대부분 자동 방어됨 |
|
|
| 캐시 무효화 | 항상 안전 | — |
|
|
|
|
---
|
|
|
|
## 6. 자동 발견 및 실행 규칙
|
|
|
|
> 파일: `app/Services/CoreUpdateService.php` — `runUpgradeSteps()`
|
|
|
|
### 발견 프로세스
|
|
|
|
```text
|
|
1. base_path('upgrades') 디렉토리 스캔
|
|
2. .php 확장자 파일만 대상
|
|
3. 정규식 매칭: /^Upgrade_(\d+)_(\d+)_(\d+)(?:_([a-zA-Z]\w*(?:_\d+)*))?$/
|
|
4. 버전 변환: 1_0_0 → 1.0.0, 7_0_0_beta_4 → 7.0.0-alpha.4
|
|
5. require_once 로딩 → App\Upgrades\{filename} 클래스 인스턴스화
|
|
6. UpgradeStepInterface 구현 확인
|
|
```
|
|
|
|
### 실행 규칙
|
|
|
|
- **필터 조건**: `fromVersion < stepVersion <= toVersion` (`version_compare` 사용)
|
|
- **정렬**: `uksort($steps, 'version_compare')` — 자연 정렬 오름차순
|
|
- **실행**: 각 스텝에 `UpgradeContext::withCurrentStep($version)` 불변 복제 전달
|
|
|
|
### 버전 네이밍 규칙
|
|
|
|
파일명의 언더스코어가 버전 구분자로 변환됩니다:
|
|
|
|
| 파일명 | 변환 버전 | 설명 |
|
|
|--------|----------|------|
|
|
| `Upgrade_1_0_0.php` | `1.0.0` | 숫자 3자리 |
|
|
| `Upgrade_7_0_0_beta_4.php` | `7.0.0-alpha.4` | pre-release 포함 |
|
|
| `Upgrade_7_0_0_beta_15.php` | `7.0.0-alpha.15` | pre-release 2자리 |
|
|
|
|
**규칙**: 숫자 3자리(`X_Y_Z`) 후 알파벳으로 시작하는 부분은 `-`로 연결, 이후 `_`는 `.`로 변환
|
|
|
|
### UpgradeStepInterface
|
|
|
|
> 파일: `app/Contracts/Extension/UpgradeStepInterface.php`
|
|
|
|
```php
|
|
interface UpgradeStepInterface
|
|
{
|
|
public function run(UpgradeContext $context): void;
|
|
}
|
|
```
|
|
|
|
### UpgradeContext
|
|
|
|
> 파일: `app/Extension/UpgradeContext.php`
|
|
|
|
```php
|
|
// readonly 속성
|
|
public readonly LoggerInterface $logger; // upgrade 로그 채널
|
|
public readonly string $fromVersion; // 업그레이드 시작 버전
|
|
public readonly string $toVersion; // 업그레이드 목표 버전
|
|
public readonly string $currentStep; // 현재 실행 중인 스텝 버전
|
|
|
|
// 메서드
|
|
public function table(string $table): string // DB 프리픽스 적용 (raw SQL용)
|
|
public function withCurrentStep(string $stepVersion): self // 불변 복제
|
|
```
|
|
|
|
> **raw SQL에서 테이블명 사용 시 `$context->table()` 필수**
|
|
> - `Schema::hasColumn('users', ...)`, `DB::table('users')` → 프리픽스 자동 적용 (불필요)
|
|
> - `DB::selectOne("SHOW COLUMNS FROM ...")` 등 raw SQL → **`$context->table('users')` 필수**
|
|
|
|
---
|
|
|
|
## 7. 버전 관리
|
|
|
|
### 버전 소스
|
|
|
|
| 파일 | 역할 |
|
|
|------|------|
|
|
| `config/app.php` — `'version'` | SSoT (env 기본값 정의) |
|
|
| `.env` — `APP_VERSION` | 런타임 오버라이드 |
|
|
| `.env.example` | 배포 템플릿 |
|
|
|
|
### 버전 읽기
|
|
|
|
```php
|
|
CoreVersionChecker::getCoreVersion() // → config('app.version')
|
|
// (코어 업데이트 트리 안에서만 env APP_VERSION 우선
|
|
// — CoreUpdateContext::isInProgress())
|
|
```
|
|
|
|
### 버전 갱신 (Step 11)
|
|
|
|
```php
|
|
CoreUpdateService::updateVersionInEnv($version)
|
|
// .env에 APP_VERSION이 존재하면: 정규식으로 라인 교체
|
|
// .env에 APP_VERSION이 없으면: "APP_VERSION={version}\n" 추가
|
|
```
|
|
|
|
### 버전 변경 시 필수 동기화
|
|
|
|
```text
|
|
필수: 코어 버전 변경 시 3곳 동기화
|
|
|
|
1. config/app.php — env('APP_VERSION', '새버전')
|
|
2. .env.example — APP_VERSION=새버전
|
|
3. .env.testing — APP_VERSION=새버전 (테스트 환경)
|
|
4. CHANGELOG.md — 변경사항 기록
|
|
```
|
|
|
|
### 부트스트랩 호환성 자동 검증
|
|
|
|
> 파일: `app/Providers/CoreServiceProvider.php` — `validateAndDeactivateIncompatibleExtensions()`, `validateAndDeactivateIncompatibleTemplates()`
|
|
|
|
**애플리케이션 부트(boot) 시마다** CoreServiceProvider가 모든 활성 확장의 `g7_version` 요구사항을 검증합니다.
|
|
`config('app.version')`이 확장의 요구 버전을 충족하지 않으면 **해당 확장을 자동으로 비활성화**합니다.
|
|
|
|
#### 검증 흐름
|
|
|
|
```text
|
|
1. CoreServiceProvider::boot() 실행
|
|
2. CoreVersionChecker::getCacheKey($type) 캐시 확인 (TTL: 1시간)
|
|
3. 캐시 없으면 → 모든 활성 확장 순회
|
|
4. 각 확장의 g7_version (예: ">=7.0.0-beta.1") 확인
|
|
5. Semver::satisfies(config('app.version'), $constraint) 실행
|
|
6. false 반환 시 → Manager::deactivateModule/Plugin/Template() 호출
|
|
7. 캐시 저장 (1시간 유효)
|
|
```
|
|
|
|
#### 주의사항
|
|
|
|
| 항목 | 설명 |
|
|
|------|------|
|
|
| **검증 주기** | 캐시 만료(1시간)마다 재검증 |
|
|
| **비활성화 대상** | 호환되지 않는 **모든** 활성 확장 (모듈, 플러그인, 템플릿) |
|
|
| **activity_log** | 기록되지 않음 (Service 레이어를 거치지 않고 Manager 직접 호출) |
|
|
| **로그** | `storage/logs/laravel.log`에 warning 기록 |
|
|
|
|
#### 버전 확인 방법
|
|
|
|
```bash
|
|
php artisan tinker
|
|
> \Composer\Semver\Semver::satisfies(config('app.version'), '>=7.0.0-beta.1');
|
|
```
|
|
|
|
#### 버전 우선순위
|
|
|
|
```text
|
|
config('app.version') = env('APP_VERSION', 'config/app.php 기본값')
|
|
|
|
.env에 APP_VERSION이 있으면 → .env 값 사용 (config/app.php 기본값 무시)
|
|
.env에 APP_VERSION이 없으면 → config/app.php의 기본값 사용
|
|
```
|
|
|
|
---
|
|
|
|
## 8. CoreUpdateService 주요 메서드
|
|
|
|
> 파일: `app/Services/CoreUpdateService.php`
|
|
|
|
### 업데이트 감지
|
|
|
|
| 메서드 | 시그니처 | 설명 |
|
|
|--------|---------|------|
|
|
| `checkForUpdates()` | `(): array` | GitHub API로 최신 릴리스 조회 |
|
|
| `getChangelog()` | `(?string $from, ?string $to): array` | CHANGELOG.md 파싱, 버전 범위 필터 |
|
|
| `checkSystemRequirements()` | `(): array` | 추출 도구 검증 (ZipArchive/unzip) |
|
|
| `validatePendingPath()` | `(): array` | _pending 디렉토리 존재/권한 확인 |
|
|
|
|
### 다운로드 및 소스 준비
|
|
|
|
| 메서드 | 시그니처 | 설명 |
|
|
|--------|---------|------|
|
|
| `downloadUpdate()` | `(string $version, ?Closure $onProgress): string` | GitHub 다운로드 + 추출 (폴백 체인) |
|
|
| `copySourceToPending()` | `(string $sourceDir, ?Closure $onProgress): string` | --source 모드: 외부 디렉토리 → _pending 복사 |
|
|
| `prepareLocalSource()` | `(?Closure $onProgress): string` | --local 모드: 현재 코드 → _pending 복사 |
|
|
| `validatePendingUpdate()` | `(string $pendingPath): void` | 패키지 구조 검증 (composer.json, app/, config/app.php) |
|
|
| `createPendingDirectory()` | `(): string` | _pending/core_Ymd_His/ 디렉토리 생성 |
|
|
| `cleanupPending()` | `(string $pendingPath): void` | _pending 하위 디렉토리 삭제 |
|
|
|
|
### 백업 및 복원
|
|
|
|
| 메서드 | 시그니처 | 설명 |
|
|
|--------|---------|------|
|
|
| `createBackup()` | `(?Closure $onProgress): string` | CoreBackupHelper로 백업 생성 |
|
|
| `restoreFromBackup()` | `(string $backupPath, ?Closure $onProgress): void` | 백업에서 파일 복원 |
|
|
| `CoreBackupHelper::computeApplyList()` | `(string $backupPath, string $sourcePath, array $targets, array $protectedPaths, array $excludes): array` | 3-way 판정으로 증분 적용 대상(added/changed) 산출 (`apply`, `added_count`, `changed_count`, `has_symlink`). targets 에 명시된 `{domain}/_bundled` 는 상위 protected(`modules` 등)를 오버라이드해 목록에 포함 (공개 #64 / 내부 #452) |
|
|
|
|
### 적용 및 설치
|
|
|
|
| 메서드 | 시그니처 | 설명 |
|
|
|--------|---------|------|
|
|
| `applyUpdate()` | `(string $sourcePath, ?Closure $onProgress, bool $prune = false, ?array $applyList = null): void` | _pending → base_path 적용. `$applyList` 지정 + `!$prune` 이면 증분(코어 변경분만), 그 외 전체 덮어쓰기 + orphan 삭제. `public` 타깃은 `copyDirectory(..., preserveLinkPaths: ['storage'])` 로 `public/storage` symlink/junction 을 orphan 삭제에서 보호 (#43) |
|
|
| `StorageLinkHelper::ensurePublicStorageLink()` | `(?\Psr\Log\LoggerInterface $logger = null): void` | `public/storage` 멱등 복구. 정상 링크면 no-op, 부재/손상이면 `storage/app/public` 링크 재생성(부재 시 신규, 손상 디렉토리는 `.broken.{YmdHis}` 백업 후). 재생성 링크는 부모 `public/` 의 소유자/그룹을 `lchown`/`lchgrp` 상속(sudo 후 root:root 잔존 차단). Windows junction 폴백. `CoreUpdateCommand` 종료 시점 + migration 05 가 호출 (#43) |
|
|
| `runComposerInstallInPending()` | `(string $pendingPath, ?Closure $onProgress): void` | _pending에서 composer install (--no-scripts) |
|
|
| `isComposerUnchangedForCore()` | `(string $pendingPath): bool` | composer.json/lock MD5 비교 |
|
|
| `runComposerInstall()` | `(?Closure $onProgress): void` | base_path에서 composer install |
|
|
| `copyVendorFromPending()` | `(string $pendingPath, ?Closure $onProgress): void` | _pending/vendor → base_path/vendor |
|
|
|
|
### 마이그레이션 및 동기화
|
|
|
|
| 메서드 | 시그니처 | 설명 |
|
|
|--------|---------|------|
|
|
| `runMigrations()` | `(): void` | `php artisan migrate --force` |
|
|
| `syncCoreRolesAndPermissions()` | `(): void` | config/core.php roles/permissions 동기화 |
|
|
| `syncCoreMenus()` | `(): void` | config/core.php menus 동기화 |
|
|
|
|
### 업그레이드 및 마무리
|
|
|
|
| 메서드 | 시그니처 | 설명 |
|
|
|--------|---------|------|
|
|
| `runUpgradeSteps()` | `(string $from, string $to, ?Closure $onStep): void` | upgrades/ 자동 발견 + 실행 |
|
|
| `updateVersionInEnv()` | `(string $version): void` | .env의 APP_VERSION 갱신 |
|
|
| `clearAllCaches()` | `(): void` | config/cache/route/view 클리어 + 패키지 매니페스트 재생성(`PackageManifestCacheHelper::rebuild()`) |
|
|
| `signalQueueRestart()` | `(): void` | 상주 큐 워커에 재시작 신호 (실패는 경고만 — 업데이트를 되돌리지 않는다) |
|
|
|
|
### 유지보수 모드
|
|
|
|
| 메서드 | 시그니처 | 설명 |
|
|
|--------|---------|------|
|
|
| `enableMaintenanceMode()` | `(): string` | `php artisan down --secret` → bypass secret 반환 |
|
|
| `disableMaintenanceMode()` | `(): void` | `php artisan up` |
|
|
|
|
### 유틸리티
|
|
|
|
| 메서드 | 시그니처 | 설명 |
|
|
|--------|---------|------|
|
|
| `generateFailureReport()` | `(Throwable $e, string $from, string $to): string` | 에러 보고서 생성 → storage/logs/ |
|
|
|
|
---
|
|
|
|
## 9. Artisan 커맨드
|
|
|
|
### core:update
|
|
|
|
```bash
|
|
php artisan core:update [--force] [--no-backup] [--prune] [--no-maintenance] [--local] [--source={path}]
|
|
```
|
|
|
|
| 옵션 | 설명 |
|
|
|------|------|
|
|
| `--force` | 버전 비교 스킵, 동일 버전이어도 강제 업데이트 |
|
|
| `--no-backup` | 백업 생성 스킵 (Step 5). 증분 적용 불가 → 전체 덮어쓰기로 회귀 |
|
|
| `--prune` | 코어가 제거한 파일 정리 + targets 전체 덮어쓰기(기존 방식). 미지정 시 코어가 실제 변경/추가한 파일만 적용(3-way)하고 나머지는 보존 |
|
|
| `--no-maintenance` | 유지보수 모드 스킵 (Step 3) |
|
|
| `--local` | 현재 코드베이스를 소스로 사용 (GitHub 스킵) |
|
|
| `--source={path}` | 지정 디렉토리를 소스로 사용 (GitHub 스킵) |
|
|
|
|
**종료 코드**:
|
|
- `0` (SUCCESS): 업데이트 성공 또는 이미 최신 버전
|
|
- `1` (FAILURE): 요건 미충족 또는 업데이트 실패
|
|
|
|
### core:check-updates
|
|
|
|
```bash
|
|
php artisan core:check-updates
|
|
```
|
|
|
|
- GitHub API 호출 → 현재/최신 버전 표시
|
|
- "새로운 업데이트가 있습니다!" 또는 "현재 최신 버전입니다."
|
|
|
|
---
|
|
|
|
## 10. API 엔드포인트
|
|
|
|
> 파일: `app/Http/Controllers/Api/Admin/CoreUpdateController.php`
|
|
|
|
| 메서드 | 경로 | 설명 |
|
|
|--------|------|------|
|
|
| GET | `/api/admin/core-update/check-updates` | 코어 업데이트 확인 (422 시 check_failed) |
|
|
| POST | `/api/admin/core-update/changelog` | 코어 변경 로그 조회 (from_version, to_version) |
|
|
|
|
> ActivityLog: `checkForUpdates` 호출 시 `core_update.check` 액티비티 기록
|
|
|
|
---
|
|
|
|
## 11. 설정 (config/app.php)
|
|
|
|
### 버전
|
|
|
|
```php
|
|
'version' => env('APP_VERSION', '7.0.0-alpha.14'),
|
|
```
|
|
|
|
### 업데이트 설정
|
|
|
|
```php
|
|
'update' => [
|
|
'github_url' => '...', // GitHub 리포지토리 URL
|
|
'github_token' => env('GITHUB_TOKEN'), // GitHub Personal Access Token (선택)
|
|
'pending_path' => '...', // _pending 디렉토리 경로
|
|
'targets' => [...], // 업데이트 적용 + 백업 대상 파일/디렉토리
|
|
'backup_only' => ['vendor'], // 백업/복원 전용 (applyUpdate 제외)
|
|
'backup_extra' => [...], // 추가 백업 대상
|
|
'excludes' => [...], // 제외 패턴
|
|
'restore_ownership' => [...], // sudo 실행 후 소유권을 원상 복원할 경로 (Step 11)
|
|
'restore_ownership_group_writable' => [...], // 복원 직후 그룹 쓰기(g+w)까지 동기화할 경로
|
|
],
|
|
```
|
|
|
|
`restore_ownership` 복원은 흐름 **중간**(Step 11)이므로 그 뒤에 만들어지는 런타임 산출물은 흐름 **마지막**의 런타임 소유권 정상화가 덮는다. 대상은 다섯 곳이다: `storage/framework/cache`(캐시 키 인덱스·락 샤드), `bootstrap/cache`, `storage/app/ext-bundles`(병합 번들), `storage/app/temp`(확장 업데이트 임시 폴더 — 부모가 root 로 최초 생성되면 이후 관리자 화면의 확장 업데이트가 실패한다), `storage/logs`(daily 롤오버·신규 로그 파일). `storage/app/{modules,plugins}` 는 사용자 데이터 영역이라 의도적으로 제외되어 있으므로, 그 아래에 파일·디렉토리를 만드는 코드(설정 시드·업그레이드 마이그레이션)는 스스로 부모 소유권을 상속시킨다.
|
|
|
|
`.env` 에서 `G7_UPDATE_EXCLUDES` · `G7_UPDATE_TARGETS` · `G7_UPDATE_PROTECTED_PATHS` · `G7_UPDATE_RESTORE_OWNERSHIP` · `G7_UPDATE_RESTORE_OWNERSHIP_GROUP_WRITABLE` 로 재정의할 수 있다. 재정의 값은 기본 목록을 **통째로 대체**하므로 전체 목록을 다시 적는다 — 예를 들어 `G7_UPDATE_EXCLUDES` 에서 `build/ext` 가 빠지면 `--prune` 업데이트가 정적 게시본을 지운다. 기본값은 `.env.example` 에 주석으로 실려 있다.
|
|
|
|
---
|
|
|
|
## 12. 에러 처리 및 로깅
|
|
|
|
### 시스템 요건 검증
|
|
|
|
```text
|
|
- ZipArchive 또는 unzip 명령어 중 하나 이상 필요
|
|
- 미충족 시 커맨드 실패 (EXIT 1) + 사용 가능 방법 목록 출력
|
|
```
|
|
|
|
### _pending 검증
|
|
|
|
```text
|
|
- 디렉토리 미존재: File::ensureDirectoryExists() 시도
|
|
- 쓰기 불가: 경로, 소유자, 그룹, 권한 정보 포함 에러 출력
|
|
```
|
|
|
|
### 패키지 검증
|
|
|
|
```text
|
|
- 필수 파일: composer.json, app/ 디렉토리, config/app.php
|
|
- config/app.php에 'version' 키 필수
|
|
- 미충족 시 RuntimeException
|
|
```
|
|
|
|
### Composer 실패
|
|
|
|
```text
|
|
- exit code 비정상 → RuntimeException (마지막 5줄 출력 포함)
|
|
```
|
|
|
|
### 로그 위치
|
|
|
|
| 로그 | 경로 | 내용 |
|
|
|------|------|------|
|
|
| 성공 로그 | `storage/logs/core_update_success_YYYYMMDD_HHMMSS.log` | 전체 실행 타임라인 |
|
|
| 실패 로그 | `storage/logs/core_update_failed_YYYYMMDD_HHMMSS.log` | 실패까지의 실행 타임라인 |
|
|
| 실패 보고서 | `storage/logs/core_update_failure_YYYYMMDD_HHMMSS.log` | 예외 상세 + 시스템 정보 |
|
|
| 일반 로그 | `storage/logs/laravel.log` | Log 파사드 통한 기록 |
|
|
|
|
---
|
|
|
|
## 13. 유지보수 모드
|
|
|
|
### 활성화 (Step 3)
|
|
|
|
```php
|
|
Artisan::call('down', [
|
|
'--secret' => $secret, // UUID 토큰 (bypass 접근용)
|
|
'--retry' => 60, // Retry-After 헤더 (초)
|
|
'--refresh' => 15, // 브라우저 자동 새로고침 (초)
|
|
]);
|
|
```
|
|
|
|
### Bypass 접근
|
|
|
|
```text
|
|
URL: https://example.com/{secret}
|
|
→ 쿠키 발급 → 유지보수 모드에서도 접근 가능
|
|
```
|
|
|
|
### 해제
|
|
|
|
```text
|
|
- 성공 시: Step 11에서 자동 해제 (php artisan up)
|
|
- 실패 시: 유지보수 모드 유지 → 관리자에게 수동 해제 안내
|
|
"이전 버전으로 사이트를 운영하려면: php artisan up"
|
|
```
|
|
|
|
---
|
|
|
|
## 14. 코어 vs 확장 업데이트 비교
|
|
|
|
| 항목 | 코어 | 모듈/플러그인 | 템플릿 |
|
|
|------|------|-------------|--------|
|
|
| **소스** | GitHub releases (zipball) | GitHub 또는 _bundled | GitHub 또는 _bundled |
|
|
| **추출** | 폴백 체인 (ZipArchive → unzip) | ExtensionPendingHelper | ExtensionPendingHelper |
|
|
| **백업** | CoreBackupHelper (선택) | ExtensionBackupHelper (선택) | ExtensionBackupHelper (선택) |
|
|
| **유지보수 모드** | 전체 앱 down (secret 토큰) | 없음 | 없음 |
|
|
| **Composer** | MD5 비교 → 변경 시만 실행 | 확장별 독립 vendor/ | 해당 없음 |
|
|
| **파일 적용** | FilePermissionHelper (권한 보존) | ExtensionPendingHelper::copyToActive() | ExtensionPendingHelper::copyToActive() |
|
|
| **vendor 처리** | 변경 시 전체 복사 | 확장별 composer install | 해당 없음 |
|
|
| **마이그레이션** | 필수 (Step 9) | 선택 (확장별) | 없음 |
|
|
| **동기화** | roles + permissions + menus + mail templates | roles + permissions + menus | 없음 |
|
|
| **업그레이드 스텝** | `upgrades/Upgrade_X_Y_Z.php` | `{ext}/upgrades/Upgrade_X_Y_Z.php` | 없음 |
|
|
| **네임스페이스** | `App\Upgrades` | `Modules\Vendor\Module\Upgrades` | 해당 없음 |
|
|
| **실행 주체** | `CoreUpdateService` | `AbstractModule` | `TemplateManager` |
|
|
| **커맨드** | `core:update` | `module:update {id}` | `template:update {id}` |
|
|
| **레이아웃** | 해당 없음 | 자동 갱신 | 충돌 전략 (apply_new/keep_current) |
|
|
| **롤백** | 전체 복원 + 유지보수 유지 | 확장별 복원 + 상태 복원 | 확장별 복원 |
|
|
| **로깅** | 타임스탬프 성공/실패 로그 + 실패 보고서 | laravel.log | laravel.log |
|
|
|
|
---
|
|
|
|
## 참고 파일 위치
|
|
|
|
| 파일 | 경로 |
|
|
|------|------|
|
|
| CoreUpdateCommand | `app/Console/Commands/Core/CoreUpdateCommand.php` |
|
|
| CoreCheckUpdatesCommand | `app/Console/Commands/Core/CoreCheckUpdatesCommand.php` |
|
|
| CoreUpdateService | `app/Services/CoreUpdateService.php` |
|
|
| CoreUpdateController | `app/Http/Controllers/Api/Admin/CoreUpdateController.php` |
|
|
| CoreVersionChecker | `app/Extension/CoreVersionChecker.php` |
|
|
| CoreBackupHelper | `app/Extension/Helpers/CoreBackupHelper.php` |
|
|
| UpgradeStepInterface | `app/Contracts/Extension/UpgradeStepInterface.php` |
|
|
| UpgradeContext | `app/Extension/UpgradeContext.php` |
|
|
| 코어 업그레이드 디렉토리 | `upgrades/` |
|
|
| 코어 설정 | `config/app.php` (version, update 섹션) |
|
|
| 역할/권한/메뉴 정의 | `config/core.php` |
|