v7.0.0-beta.3 release
This commit is contained in:
+1
-1
@@ -3,7 +3,7 @@ APP_ENV=production
|
||||
APP_KEY=
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://localhost
|
||||
APP_VERSION=7.0.0-beta.2
|
||||
APP_VERSION=7.0.0-beta.3
|
||||
|
||||
APP_LOCALE=ko
|
||||
APP_FALLBACK_LOCALE=ko
|
||||
|
||||
@@ -3,7 +3,7 @@ APP_ENV=testing
|
||||
APP_KEY=
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://localhost
|
||||
APP_VERSION=7.0.0-beta.2
|
||||
APP_VERSION=7.0.0-beta.3
|
||||
|
||||
APP_LOCALE=ko
|
||||
APP_FALLBACK_LOCALE=ko
|
||||
|
||||
@@ -4,6 +4,21 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [7.0.0-beta.3] - 2026-04-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- CKEditor5 플러그인 활성 상태에서 게시판 글쓰기 저장 시 "제목은 필수입니다" 422 오류가 발생하던 호환성 문제 수정 — 제목·내용 입력 순서와 무관하게 정상 저장되도록 개선
|
||||
- 코어 업데이트가 sudo 로 실행된 환경에서 캐시·세션·확장 디렉토리의 그룹 쓰기 권한이 일부 손실되어 업데이트 직후 "Permission denied" 또는 플러그인 제거 검증 실패가 발생하던 문제 수정 — 업데이트 종료 시점에 그룹 쓰기 권한을 자동 정상화하며 기존 손실 분은 1회성 복구 스텝으로 회수
|
||||
- 업데이트 완료 후 Laravel 런타임이 새로 만드는 캐시·세션 하위 디렉토리가 기본 umask(022) 때문에 다시 그룹 쓰기 권한을 잃어 재차 "Permission denied" 가 발생하던 문제 수정 — 업그레이드 스텝이 업데이트 진행 프로세스의 umask 를 그룹 쓰기 친화적으로 전환하고, 이후 부팅 시점에도 `storage/` 의 현재 그룹 쓰기 설정을 감지해 프로세스 umask 를 자동 동조 (운영자가 그룹 공유를 비활성화한 환경은 그대로 보존)
|
||||
- 코어 업데이트 마지막 단계의 진행 표시줄이 끝난 뒤 줄바꿈 없이 다음 셸 프롬프트가 같은 줄에 붙어 표시되던 출력 문제 수정
|
||||
|
||||
### Notes
|
||||
|
||||
- 7.0.0-beta.1 에서 7.0.0-beta.3 로 직접 업그레이드하는 경우, 환경(opcache CLI 활성 등)에 따라 권한 복구 스텝이 자동 실행되지 않고 건너뛰어질 수 있습니다. 업데이트 후 `storage/framework/cache` 등에서 Permission denied 가 발생하면 아래 명령을 수동 실행해 주세요 — 7.0.0-beta.2 에서 올라오는 경로에서는 해당 없음
|
||||
- `php artisan core:execute-upgrade-steps --from=7.0.0-beta.2 --to=7.0.0-beta.3 --force`
|
||||
- 시스템 레벨에서도 일관된 그룹 공유 권한을 원하면 php-fpm pool 설정에 `umask = 002` (또는 systemd unit 의 `UMask=0002`) 추가를 권장합니다. 코드 레벨 동조와 병행하면 외부 프로세스(cron, composer 등) 도 동일 권한으로 파일을 생성합니다
|
||||
|
||||
## [7.0.0-beta.2] - 2026-04-20
|
||||
|
||||
### Added
|
||||
|
||||
+1
-1
@@ -238,7 +238,7 @@ unzip g7-release.zip
|
||||
|
||||
# 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경
|
||||
ls -la
|
||||
# (필요 시) mv g7-7.0.0-beta.2 g7
|
||||
# (필요 시) mv g7-7.0.0-beta.3 g7
|
||||
|
||||
# ZIP 파일 정리 (선택)
|
||||
rm g7-release.zip
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#"><img src="https://img.shields.io/badge/version-7.0.0--beta.2-blue" alt="Version"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/version-7.0.0--beta.3-blue" alt="Version"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/PHP-8.2%2B-777BB4?logo=php&logoColor=white" alt="PHP"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/Laravel-12.x-FF2D20?logo=laravel&logoColor=white" alt="Laravel"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black" alt="React"></a>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Console\Commands\Core;
|
||||
|
||||
use App\Console\Commands\Core\Concerns\BundledExtensionUpdatePrompt;
|
||||
use App\Exceptions\UpgradeHandoffException;
|
||||
use App\Extension\CoreVersionChecker;
|
||||
use App\Extension\Helpers\CoreBackupHelper;
|
||||
use App\Extension\ModuleManager;
|
||||
@@ -438,11 +439,85 @@ class CoreUpdateCommand extends Command
|
||||
$this->newLine();
|
||||
$this->info('일괄 업데이트로 생성된 파일의 소유권을 복원하는 중...');
|
||||
$service->restoreOwnership($ownershipSnapshot, $onProgress);
|
||||
// restoreOwnership 의 진행 표시($onProgress → $bar->display())가
|
||||
// 개행 없이 끝나므로 다음 셸 프롬프트가 같은 줄에 붙는 것을 방지.
|
||||
$this->newLine(2);
|
||||
$log('일괄 확장 업데이트 후 소유권 재복원 완료');
|
||||
}
|
||||
|
||||
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();
|
||||
$service->restoreOwnership($ownershipSnapshot, $onProgress);
|
||||
$log('핸드오프 cleanup 완료 (버전 toVersion 고정 + 캐시 clear + 소유권 복원)');
|
||||
|
||||
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->line(" {$resumeCommand}");
|
||||
$this->newLine();
|
||||
$this->warn('⚠ 위 명령을 실행하지 않으면 버전 표시는 최신이지만 일부 스텝이 미실행 상태로 남습니다.');
|
||||
$this->newLine();
|
||||
|
||||
if ($backupPath) {
|
||||
$this->line("백업이 유지되었습니다: {$backupPath}");
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$bar->finish();
|
||||
$this->newLine(2);
|
||||
@@ -538,11 +613,17 @@ class CoreUpdateCommand extends Command
|
||||
* 일 때만 성공으로 판정한다. 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
|
||||
{
|
||||
@@ -602,14 +683,37 @@ class CoreUpdateCommand extends Command
|
||||
fclose($pipes[0]);
|
||||
|
||||
// stdout 실시간 전달 — 상위 콘솔에서 진행 상황 확인 가능
|
||||
// 단, [HANDOFF] 접두사 라인은 상위 콘솔로 노출하지 않고 페이로드만 보관한다
|
||||
// (exit=UpgradeHandoffException::EXIT_CODE 감지 시 UpgradeHandoffException 재구성용).
|
||||
$handoffPayload = null;
|
||||
while (! feof($pipes[1])) {
|
||||
$line = fgets($pipes[1]);
|
||||
if ($line !== false) {
|
||||
$trimmed = rtrim($line);
|
||||
if ($trimmed !== '') {
|
||||
$this->line($trimmed);
|
||||
$log('[spawn] '.$trimmed);
|
||||
if ($trimmed === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_starts_with($trimmed, '[HANDOFF] ')) {
|
||||
$json = substr($trimmed, strlen('[HANDOFF] '));
|
||||
$decoded = json_decode($json, true);
|
||||
// resumeCommand 는 null 허용 (자식이 null 로 전달한 경우 부모가
|
||||
// CoreUpdateCommand catch 분기에서 from/to 기반으로 자동 생성)
|
||||
if (is_array($decoded)
|
||||
&& array_key_exists('afterVersion', $decoded)
|
||||
&& array_key_exists('reason', $decoded)
|
||||
&& array_key_exists('resumeCommand', $decoded)
|
||||
) {
|
||||
$handoffPayload = $decoded;
|
||||
$log('[spawn] 핸드오프 신호 수신: after='.$decoded['afterVersion']);
|
||||
|
||||
continue;
|
||||
}
|
||||
// 구조가 깨진 핸드오프 라인 — 정상 출력으로 간주해 그대로 전달
|
||||
}
|
||||
|
||||
$this->line($trimmed);
|
||||
$log('[spawn] '.$trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -617,6 +721,19 @@ class CoreUpdateCommand extends Command
|
||||
fclose($pipes[2]);
|
||||
$exitCode = proc_close($process);
|
||||
|
||||
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) {
|
||||
$log('spawn 완료 (exit=0)');
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Console\Commands\Core;
|
||||
|
||||
use App\Exceptions\UpgradeHandoffException;
|
||||
use App\Services\CoreUpdateService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -52,6 +53,31 @@ class ExecuteUpgradeStepsCommand extends Command
|
||||
fn (string $version) => $this->info("upgrade step 실행: {$version}"),
|
||||
$force,
|
||||
);
|
||||
} catch (UpgradeHandoffException $e) {
|
||||
// 업그레이드 스텝이 새 PHP 프로세스 재진입을 요청했다.
|
||||
// 부모 프로세스(spawnUpgradeStepsProcess)가 [HANDOFF] 라인을 stdout 에서
|
||||
// 읽어 페이로드를 복원하고 UpgradeHandoffException 재구성 후 상위로 던지도록,
|
||||
// JSON 페이로드를 표식과 함께 출력한다. 표식 문자열은 구분자 역할이므로 일반
|
||||
// step 출력과 충돌하지 않게 고정된 접두사를 사용한다.
|
||||
//
|
||||
// resumeCommand 는 step 작성자가 null 로 두는 것을 권장(CoreUpdateCommand 가
|
||||
// from/to 버전을 사용해 자동 생성). null 도 JSON 으로 그대로 전달.
|
||||
$payload = json_encode([
|
||||
'afterVersion' => $e->afterVersion,
|
||||
'reason' => $e->reason,
|
||||
'resumeCommand' => $e->resumeCommand,
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$this->line('[HANDOFF] '.$payload);
|
||||
|
||||
Log::info('core:execute-upgrade-steps 핸드오프', [
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'after' => $e->afterVersion,
|
||||
'reason' => $e->reason,
|
||||
]);
|
||||
|
||||
return UpgradeHandoffException::EXIT_CODE;
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('core:execute-upgrade-steps 실패', [
|
||||
'from' => $from,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* 업그레이드 스텝이 자신의 실행을 중단하고, 사용자가 새 PHP 프로세스로 재실행할
|
||||
* 것을 요청할 때 던지는 예외.
|
||||
*
|
||||
* 사용 사례: 현재 실행 중인 PHP 프로세스의 opcache / autoloader 가 이전 버전의
|
||||
* 코드를 들고 있어, 디스크에 덮여진 새 버전 코드를 참조할 수 없는 경우.
|
||||
*
|
||||
* 스텝 A 실행은 성공했지만, 스텝 B 부터는 현재 프로세스에서 안전하게 실행할 수
|
||||
* 없을 때 사용한다. 이 경우 스텝 B 파일 안에서 `throw new UpgradeHandoffException(...)`
|
||||
* 을 호출하면 상위 계층(CoreUpdateService → ExecuteUpgradeStepsCommand 또는
|
||||
* CoreUpdateCommand) 이 이를 감지해 다음을 수행한다:
|
||||
*
|
||||
* 1. 스텝 A 까지의 상태는 보존 (파일 교체·migration·composer 결과)
|
||||
* 2. `.env` 의 APP_VERSION 을 `$afterVersion` 으로 갱신 (다음 `core:update`
|
||||
* 재실행 시 해당 버전에서 다시 시작)
|
||||
* 3. maintenance 모드 해제
|
||||
* 4. 사용자에게 재실행 안내 메시지 출력
|
||||
* 5. 명령어는 성공 종료 (Command::SUCCESS) — spawn 체인에서는 exit code 75
|
||||
*
|
||||
* 이 예외를 받을 수 있는 코어 레벨은 7.0.0-beta.3 이후에 도입되었다.
|
||||
* 그 이전 코어 (beta.1 / beta.2) 는 이 클래스 자체를 알지 못하므로, 구 코어에서
|
||||
* 본 예외를 in-process 로 던져도 일반 uncaught exception 취급된다. 따라서
|
||||
* beta.1 에서 beta.3 직행 같은 하위 호환성이 필요한 스텝은 throw 대신 graceful
|
||||
* skip (`method_exists` 검사 등) 을 병행해야 한다.
|
||||
*
|
||||
* @see docs/extension/upgrade-step-guide.md 섹션 "업그레이드 핸드오프"
|
||||
*/
|
||||
class UpgradeHandoffException extends RuntimeException
|
||||
{
|
||||
/**
|
||||
* spawn 체인에서 핸드오프 신호로 사용하는 exit code (sysexits.h EX_TEMPFAIL).
|
||||
*/
|
||||
public const EXIT_CODE = 75;
|
||||
|
||||
/**
|
||||
* @param string $afterVersion 여기까지의 스텝은 완료되었다고 간주할 버전
|
||||
* (다음 재실행은 이 버전 기준으로 나머지 스텝만 실행)
|
||||
* @param string $reason 핸드오프가 필요한 이유 (로그·사용자 메시지)
|
||||
* @param string|null $resumeCommand 사용자에게 안내할 재실행 명령어.
|
||||
* null 이면 `CoreUpdateCommand` 가 catch 시점에
|
||||
* `php artisan core:execute-upgrade-steps --from=<afterVersion> --to=<toVersion> --force`
|
||||
* 형식으로 자동 생성한다.
|
||||
* 대부분의 step 작성자는 null 기본값 사용 권장 —
|
||||
* 전체 `core:update` 재다운로드를 피하고 스텝만 재실행.
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $afterVersion,
|
||||
public readonly string $reason,
|
||||
public readonly ?string $resumeCommand = null,
|
||||
) {
|
||||
parent::__construct(__('exceptions.core_update.handoff', [
|
||||
'after_version' => $afterVersion,
|
||||
'reason' => $reason,
|
||||
'resume_command' => $resumeCommand ?? '(auto)',
|
||||
]));
|
||||
}
|
||||
}
|
||||
@@ -312,6 +312,88 @@ class FilePermissionHelper
|
||||
return $report['changed'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 루트 디렉토리가 그룹 쓰기 권한을 가질 때, 하위 디렉토리·파일에 동일 권한을 승격합니다.
|
||||
*
|
||||
* 배경: sudo root 로 실행된 코어 업데이트가 umask 022 환경에서 신규 디렉토리/파일을
|
||||
* `0755/0644` 로 생성한 뒤 `chownRecursive` 로 소유자만 원본(`jjh:www-data`) 으로
|
||||
* 복원하면, 그룹(`www-data`) 에 쓰기 권한이 없는 비대칭이 영구 잔존한다. 결과적으로
|
||||
* php-fpm(www-data) 이 `storage/framework/cache/...` 같은 경로에 쓰기 실패.
|
||||
*
|
||||
* 본 메서드는 다음 정책으로 비대칭을 해소한다:
|
||||
* - 루트가 `g+w` 면 하위 항목 중 `g-w` 인 디렉토리·파일을 `g+w` 로 승격
|
||||
* - 루트가 `g-w` 면 no-op (운영자가 의도적으로 그룹 쓰기 차단한 정책 보존)
|
||||
* - 다른 비트(other, owner, sticky, setgid 등) 무변경 — `g+w` 만 OR
|
||||
* - symbolic link 는 링크 자체만 처리 (대상 미추적)
|
||||
* - 멱등 — 이미 정상인 항목은 changed 카운트에 포함 안 함
|
||||
* - silent fail — 권한 부족·chmod 미지원 환경에서도 예외 미발생
|
||||
*
|
||||
* @param string $root 대상 루트 (재귀 순회)
|
||||
* @return int 실제 chmod 한 항목 수
|
||||
*/
|
||||
public static function syncGroupWritability(string $root): int
|
||||
{
|
||||
if (! function_exists('chmod') || ! is_dir($root)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$rootPerms = @fileperms($root);
|
||||
if ($rootPerms === false) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 루트가 g+w 가 아니면 정책 보존 (no-op)
|
||||
if (($rootPerms & 0020) === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$report = ['changed' => 0];
|
||||
self::syncGroupWritabilityInternal($root, $report, true);
|
||||
|
||||
if ($report['changed'] > 0) {
|
||||
Log::info('syncGroupWritability: 그룹 쓰기 권한 정상화', [
|
||||
'root' => $root,
|
||||
'changed' => $report['changed'],
|
||||
]);
|
||||
}
|
||||
|
||||
return $report['changed'];
|
||||
}
|
||||
|
||||
/**
|
||||
* syncGroupWritability 내부 재귀.
|
||||
*
|
||||
* @param string $path 대상 경로
|
||||
* @param array{changed:int} $report 집계 (참조)
|
||||
* @param bool $isRoot 루트 자체는 정책 판정용으로만 사용 (chmod 안 함)
|
||||
*/
|
||||
private static function syncGroupWritabilityInternal(string $path, array &$report, bool $isRoot = false): void
|
||||
{
|
||||
// symbolic link 는 대상 추적 금지
|
||||
if (is_link($path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $isRoot) {
|
||||
$perms = @fileperms($path);
|
||||
if ($perms !== false && ($perms & 0020) === 0) {
|
||||
// g+w 만 추가, 다른 비트 무변경
|
||||
if (@chmod($path, $perms | 0020)) {
|
||||
$report['changed']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! is_dir($path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$items = new \FilesystemIterator($path, \FilesystemIterator::SKIP_DOTS);
|
||||
foreach ($items as $item) {
|
||||
self::syncGroupWritabilityInternal($item->getPathname(), $report, false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* chownRecursive 의 내부 재귀 구현. 실패 카운터를 참조 전달로 집계한다.
|
||||
*
|
||||
|
||||
@@ -1182,6 +1182,12 @@ class CoreUpdateService
|
||||
* 코어 업그레이드 스텝을 실행합니다.
|
||||
* 각 스텝에서 환경설정 파일 생성, 데이터 마이그레이션 등을 수행합니다.
|
||||
*
|
||||
* 예외 전파 정책:
|
||||
* - 일반 예외 (\Throwable): 그대로 상위 전파. CoreUpdateCommand 가 catch 후 롤백.
|
||||
* - UpgradeHandoffException: 그대로 상위 전파. CoreUpdateCommand 가 catch 후 롤백 없이
|
||||
* .env APP_VERSION 을 afterVersion 으로 고정, maintenance 해제, 사용자에게 재실행 안내.
|
||||
* 즉 "해당 스텝 직전까지의 상태를 확정 + 재진입 지점 지정" 시나리오에 사용.
|
||||
*
|
||||
* @param string $fromVersion 시작 버전
|
||||
* @param string $toVersion 종료 버전
|
||||
* @param \Closure|null $onStep 각 스텝 실행 시 콜백 (버전 문자열 전달)
|
||||
@@ -1532,9 +1538,13 @@ class CoreUpdateService
|
||||
continue;
|
||||
}
|
||||
|
||||
// 7.0.0-beta.3+: target 루트 퍼미션을 perms 필드로 추가 스냅샷.
|
||||
// restoreOwnership 이 sudo 업데이트로 인한 그룹 쓰기 권한 비대칭을
|
||||
// 정상화할 때 사용 (Laravel 런타임 쓰기 경로에 한해).
|
||||
$snapshot[$target] = [
|
||||
'owner' => @fileowner($path),
|
||||
'group' => @filegroup($path),
|
||||
'perms' => (@fileperms($path) & 0777) ?: null,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1618,6 +1628,35 @@ class CoreUpdateService
|
||||
}
|
||||
}
|
||||
|
||||
// 7.0.0-beta.3+: Laravel 런타임 쓰기 경로(storage/, bootstrap/cache/) 에 한해
|
||||
// 그룹 쓰기 권한 비대칭 정상화. sudo root 업데이트가 umask 022 로 신규 생성한
|
||||
// 하위 디렉토리(g-w) 가 chownRecursive 후에도 g-w 로 남아 php-fpm(www-data 그룹)
|
||||
// 이 cache 쓰기 실패하는 문제를 구조적으로 차단.
|
||||
//
|
||||
// 정책: 루트가 g+w 면 하위 g-w 항목을 g+w 로 승격, 그 외 비트 무변경.
|
||||
// 운영자가 의도적으로 그룹 쓰기를 차단한 경로는 보존됨.
|
||||
$groupWritableTargets = config('app.update.restore_ownership_group_writable', [
|
||||
'storage',
|
||||
'bootstrap/cache',
|
||||
]);
|
||||
$groupWritableChanged = 0;
|
||||
foreach ($groupWritableTargets as $target) {
|
||||
$path = base_path($target);
|
||||
if (! File::isDirectory($path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$onProgress?->__invoke('group_writable', $target);
|
||||
$groupWritableChanged += FilePermissionHelper::syncGroupWritability($path);
|
||||
}
|
||||
|
||||
if ($groupWritableChanged > 0) {
|
||||
Log::info('코어 업데이트: 그룹 쓰기 권한 정상화', [
|
||||
'targets' => $groupWritableTargets,
|
||||
'changed_entries' => $groupWritableChanged,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($restoredCount > 0) {
|
||||
Log::info('코어 업데이트: 소유권 복원 완료', [
|
||||
'restored_entries_total' => $restoredCount,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* 프로세스 umask 를 운영자 의도에 동조시키는 헬퍼.
|
||||
*
|
||||
* 배경: Laravel 의 `FileStore::ensureCacheDirectoryExists()` 는 신규 캐시 하위
|
||||
* 디렉토리를 `mkdir(0777, true, true)` 로 생성한다. 이 요청 모드는 프로세스 umask
|
||||
* 로 마스킹되어, 서버 기본 umask 022 환경에서는 실제 `0755` (drwxr-xr-x) 로 생성된다.
|
||||
*
|
||||
* 그 결과 G7 표준 구성(`storage/` 가 jjh:www-data `drwxrwxr-x`) 에서 www-data
|
||||
* 그룹의 php-fpm 이 새 하위 디렉토리(`cache/data/2c/...`) 에 쓸 수 없어
|
||||
* `Permission denied` 가 발생한다. 업데이트 시점의 1회성 `syncGroupWritability`
|
||||
* 로는 해결 불가 — 업데이트 직후 런타임이 재생성하는 새 디렉토리가 그 뒤
|
||||
* umask 022 로 다시 깨진다.
|
||||
*
|
||||
* 본 헬퍼는 **운영자 의도를 존중하는 방식** 으로 umask 를 조정한다:
|
||||
*
|
||||
* - `storage/` 디렉토리에 그룹 쓰기(`g+w`) 비트가 설정되어 있으면
|
||||
* → 운영자가 그룹 공유 정책을 선언한 것 → umask 를 `0002` 로 조정
|
||||
* → 이후 런타임이 만드는 모든 파일/디렉토리도 g+w 포함
|
||||
*
|
||||
* - `storage/` 에 g-w 면 → 운영자가 그룹 공유를 원하지 않음 → 기본 umask 보존
|
||||
*
|
||||
* - `umask` 함수 자체가 비활성(일부 강화된 호스팅) → 조용히 스킵
|
||||
*
|
||||
* 호출은 Laravel 부팅 최초 지점(`bootstrap/app.php` 최상단)에서 1회면 충분.
|
||||
* 이후 모든 런타임 파일 생성에 새 umask 가 적용된다.
|
||||
*
|
||||
* Windows 환경에서는 `fileperms` 결과가 POSIX 와 의미가 다르고 umask 자체가
|
||||
* 실효가 제한적이므로, 본 헬퍼는 POSIX 환경에서만 의미 있는 결과를 낸다.
|
||||
*/
|
||||
class UmaskHelper
|
||||
{
|
||||
/**
|
||||
* 그룹 공유 친화 umask(0002) 로 조정한다. 조건이 맞지 않으면 no-op.
|
||||
*
|
||||
* @param string $storagePath 판정 기준 디렉토리 (일반적으로 `base_path('storage')`)
|
||||
* @return int|null 새 umask 로 전환한 경우 이전 umask 값. 조건 불충족 시 null.
|
||||
*/
|
||||
public static function configureForGroupSharing(string $storagePath): ?int
|
||||
{
|
||||
if (! function_exists('umask')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! is_dir($storagePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$perms = @fileperms($storagePath);
|
||||
if ($perms === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 그룹 쓰기 비트(020) 가 없으면 운영자 의도 존중 → 건드리지 않는다
|
||||
if (($perms & 0020) === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return umask(0002);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,29 @@
|
||||
<?php
|
||||
|
||||
use App\Support\UmaskHelper;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
use Illuminate\Support\Env;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Group-Shared umask Alignment
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| 운영자가 `storage/` 를 그룹 쓰기(g+w) 로 설정한 경우, Laravel 이 런타임에
|
||||
| 생성하는 새 디렉토리(예: `storage/framework/cache/data/<hash>`) 도 g+w 를
|
||||
| 유지하도록 프로세스 umask 를 0002 로 조정한다. 이 동조가 없으면 기본 umask 022
|
||||
| 로 인해 `0755` (drwxr-xr-x) 로 만들어져 php-fpm(www-data) 그룹 쓰기가 실패한다.
|
||||
|
|
||||
| `storage/` 에 g-w 가 설정된 경우(일부 공유 호스팅 특수 환경) 에는 운영자
|
||||
| 의도를 존중하여 umask 를 건드리지 않는다. `umask` 함수 자체가 비활성인
|
||||
| 환경에서도 조용히 스킵한다. 상세: App\Support\UmaskHelper.
|
||||
|
|
||||
*/
|
||||
UmaskHelper::configureForGroupSharing(dirname(__DIR__).'/storage');
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Disable putenv() for Thread Safety
|
||||
|
||||
+27
-1
@@ -211,7 +211,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'version' => env('APP_VERSION', '7.0.0-beta.2'),
|
||||
'version' => env('APP_VERSION', '7.0.0-beta.3'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
@@ -266,6 +266,32 @@ return [
|
||||
'G7_UPDATE_RESTORE_OWNERSHIP',
|
||||
'storage,bootstrap/cache,vendor,modules,modules/_pending,plugins,plugins/_pending,templates,templates/_pending,storage/app/core_pending'
|
||||
)))),
|
||||
// 7.0.0-beta.3+: 그룹 쓰기 권한 비대칭 정상화 대상.
|
||||
// sudo root 로 실행된 업데이트가 umask 022 로 신규 생성한 하위 디렉토리/파일이
|
||||
// chownRecursive 후에도 g-w 로 남아 php-fpm(www-data 그룹) 이 쓰기 실패하는
|
||||
// 문제를 차단하기 위해, restoreOwnership 종료 직후 본 경로들에 한해
|
||||
// FilePermissionHelper::syncGroupWritability 를 호출한다.
|
||||
//
|
||||
// 정책: 루트가 g+w 면 하위 g-w 항목을 g+w 로 승격, 다른 비트 무변경.
|
||||
// 운영자가 의도적으로 그룹 쓰기를 차단한 경로(0755 등) 는 자동 보존됨.
|
||||
//
|
||||
// Laravel 런타임 그룹 쓰기 필요 경로 — 인스톨러 SSoT(public/install/includes/config.php
|
||||
// REQUIRED_DIRECTORIES) 와 1:1 정렬:
|
||||
// - storage, bootstrap/cache: 캐시·세션·로그
|
||||
// - vendor: composer/sudo 가 root 로 재생성한 후 일반 권한 사용자/php-fpm 이 후속 작업
|
||||
// - modules, plugins, templates: 확장 설치/업데이트/제거 시 php-fpm 이 디렉토리 조작
|
||||
// - modules/_pending, plugins/_pending, templates/_pending: 다운로드 대기소
|
||||
// - storage/app/core_pending: 코어 업데이트 _pending 영역 (storage 재귀로도 커버되지만
|
||||
// SSoT 정렬 위해 명시)
|
||||
//
|
||||
// _bundled/ 는 개발 시점 원본 배포본이므로 런타임 쓰기 불필요 — SSoT 에도 미포함.
|
||||
//
|
||||
// 자식 디렉토리(예: plugins/sirsoft-*)는 syncGroupWritability 가 재귀 순회하여
|
||||
// 자동 정상화되므로 상위 루트만 지정하면 충분. 환경변수로 재정의 가능.
|
||||
'restore_ownership_group_writable' => array_filter(array_map('trim', explode(',', env(
|
||||
'G7_UPDATE_RESTORE_OWNERSHIP_GROUP_WRITABLE',
|
||||
'storage,bootstrap/cache,vendor,modules,modules/_pending,plugins,plugins/_pending,templates,templates/_pending,storage/app/core_pending'
|
||||
)))),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -1206,6 +1206,105 @@ public function seoVariables(): array
|
||||
|
||||
---
|
||||
|
||||
## 폼 상태 정합성 — 이중 저장소 전제와 자동 동기화 (engine-v1.43.0+)
|
||||
|
||||
플러그인이 폼(Form) 내부에서 `G7Core.state.setLocal({render: false})` 패턴을 사용하는 경우, 엔진의 **이중 저장소 구조**를 이해하고 있어야 한다. 이 구조는 "단일화 실패 이력"의 결과이며 엔진 설계 전제이다.
|
||||
|
||||
### 이중 저장소 구조 개요
|
||||
|
||||
엔진은 폼 데이터를 두 저장소에 분리 관리한다:
|
||||
|
||||
| 저장소 | 실체 | 쓰는 곳 | 읽는 곳 |
|
||||
| ------ | ---- | ------- | ------- |
|
||||
| **A** | React `localDynamicState` (useState) | Form 자동바인딩 (Input/Textarea onChange) | DOM value, 부분 리렌더 |
|
||||
| **B** | `globalState._local` (TemplateApp 싱글톤) | `G7Core.state.setLocal/getLocal` | apiCall body 바인딩, 플러그인 동기화 |
|
||||
|
||||
두 저장소는 engine-v1.43.0부터 엔진이 자동으로 동기화한다. 일반 플러그인은 이 구조를 의식할 필요가 없다.
|
||||
|
||||
### 왜 저장소를 하나로 통합하지 않는가
|
||||
|
||||
B로 단일화하면 매 keystroke마다 TemplateApp 전체 리렌더가 발생해 대형 폼에서 타이핑 지연이 생긴다. 이를 완화할 구독 기반 선택적 리렌더(`StateSubscriptionManager`)는 2026-01에 필터 체크박스 253~295ms 지연으로 롤백된 실패 경로다. 이중 저장소 전제는 엔진 설계 결과이지 개선 대상이 아니다.
|
||||
|
||||
### 엔진 자동 동기화 메커니즘
|
||||
|
||||
1. **A→B 방향**: 자동바인딩의 `performStateUpdate`가 A에 쓸 때 B에도 `setLocal({render: false})`로 동기 기록
|
||||
2. **B→A 방향**: 엔진이 자동바인딩 활성 경로를 `__g7AutoBindingPaths: Map<string, number>`에 추적. 플러그인이 `setLocal({render: false})`로 그 경로를 건드리면 엔진이 **자동으로 `render: true`로 승격**하여 A 동기화 유도
|
||||
3. **예외**: `selfManaged: true`를 명시한 호출은 자동 승격에서 제외 (CKEditor5 등 자체 DOM 관리 플러그인용)
|
||||
|
||||
### `G7Core.state.setLocal` 옵션 레퍼런스
|
||||
|
||||
```typescript
|
||||
G7Core.state.setLocal(updates, options?: {
|
||||
scope?: 'current' | 'parent' | 'root'; // 스코프 지정
|
||||
merge?: 'replace' | 'shallow' | 'deep'; // 병합 방식 (기본 'deep')
|
||||
debounce?: number; // 디바운스 ms
|
||||
debounceKey?: string; // 디바운스 키
|
||||
render?: boolean; // React 리렌더 여부 (기본 true)
|
||||
selfManaged?: boolean; // 자동 승격 opt-out (기본 undefined/false)
|
||||
});
|
||||
```
|
||||
|
||||
### `selfManaged: true` 옵션 사용 규칙 (중요)
|
||||
|
||||
**역할**: 엔진의 자동 리렌더 승격 보호를 끄는 opt-out 스위치. "이 플러그인은 React 없이 자기가 DOM을 직접 관리하니 엔진은 자동 승격 오지랖을 끄고 원래 `render: false`를 유지해 달라"는 의도적 선언.
|
||||
|
||||
**기본값**: `undefined` (사실상 `false`). **옵션을 생략하면 엔진이 자동 승격 보호를 적용**한다.
|
||||
|
||||
**언제 `true`로 설정하는가**:
|
||||
|
||||
- 플러그인이 React 컴포넌트가 아니라 JavaScript로 직접 DOM 요소를 조작하는 독립 위젯을 쓸 때 (CKEditor5, Monaco Editor 등)
|
||||
- 매 keystroke마다 전체 트리 리렌더가 성능상 허용 안 되는 경우
|
||||
- 플러그인이 자체 `setData()`/`setValue()` 방식으로 UI를 직접 갱신하고 React 재렌더에 의존하지 않는 구조
|
||||
|
||||
**언제 생략하는가 (기본)**:
|
||||
|
||||
- 일반적인 모든 플러그인. 엔진이 자동으로 정합성 확보
|
||||
- **초보자는 `selfManaged`를 몰라도 되고 모르는 편이 안전** (safe-by-default)
|
||||
|
||||
### 플러그인 개발 체크리스트
|
||||
|
||||
새 플러그인이 폼 내부에서 `setLocal`을 쓰는 경우 다음을 확인:
|
||||
|
||||
- [ ] `setLocal({render: false})`를 호출하는가?
|
||||
- [ ] 해당 경로의 React 컴포넌트(자동바인딩 대상 Input/Textarea 등)가 폼 안에 존재하는가?
|
||||
- [ ] 플러그인이 React 밖에서 자체 DOM을 직접 관리하는가?
|
||||
- [ ] 위 세 질문 모두 YES → `selfManaged: true` 명시 (엔진 승격 예외, 성능 보존)
|
||||
- [ ] 위 중 하나라도 NO/모르겠음 → **옵션 생략** (엔진이 자동 승격으로 정합성 확보 — 안전한 기본값)
|
||||
- [ ] `merge: 'replace'` 사용 시 자동바인딩 값 유실 가능성 있음 → 필요 시 `getLocal()` 후 병합하여 `merge: 'deep'` 사용
|
||||
- [ ] `requires.g7_version`을 engine-v1.43.0 이상 내포 코어 버전으로 상향
|
||||
|
||||
### 예시
|
||||
|
||||
**일반 플러그인 (대다수)**:
|
||||
|
||||
```typescript
|
||||
G7Core.state.setLocal({ "form.category": "news" });
|
||||
// render 옵션 생략 → 엔진이 A↔B 동기화 자동 처리
|
||||
```
|
||||
|
||||
**WYSIWYG 에디터 등 자체 DOM 관리 플러그인**:
|
||||
|
||||
```typescript
|
||||
G7Core.state.setLocal(
|
||||
{ "form.content": editorInstance.getData() },
|
||||
{
|
||||
debounce: 300,
|
||||
debounceKey: `editor-sync-${name}`,
|
||||
render: false,
|
||||
selfManaged: true, // ← 자체 DOM 관리 명시. 자동 승격 제외
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### 절대 금지 사항
|
||||
|
||||
- `parentFormContext.setState`를 커스텀 핸들러에서 직접 호출하는 경로 우회 금지 — 엔진이 자동바인딩으로 호출하는 내부 API
|
||||
- 자동 승격 무력화를 위해 `selfManaged: true`를 남용 금지 — 의도적으로 자체 DOM 관리하는 경우에만 사용
|
||||
|
||||
> 상세 배경: [`docs/frontend/state-management.md`](../frontend/state-management.md) "이중 저장소 구조" 섹션 참조
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [index.md](index.md) - 확장 시스템 전체 개요
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
3. 인프라 재설계 릴리즈의 특수 경로 = 경로 C → docblock 에 `@upgrade-path C` 선언 + 로컬 로직 필수
|
||||
4. 경로 A (모듈/플러그인) · 경로 C 규율: 기존 클래스의 신규 메서드 호출 금지, 로컬 private 헬퍼 우선
|
||||
5. 모든 분기에 upgrade.log 출력 — 로그 없음 = 디버깅 단서 없음
|
||||
6. beta.3+ 타깃 step 이 중간에 새 프로세스 재진입이 필요하면 `UpgradeHandoffException` throw (섹션 10.5)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -25,6 +26,9 @@
|
||||
7. [생명주기와 제거 시점](#7-생명주기와-제거-시점)
|
||||
8. [실전 사례](#8-실전-사례)
|
||||
9. [업그레이드 경로별 규율 (모듈/플러그인 · 코어 beta.3+ · 코어 beta.2 특수)](#9-업그레이드-경로별-규율)
|
||||
10. [경로 C 내부 inline spawn 패턴](#10-경로-c-내부-inline-spawn-패턴)
|
||||
10.5. [업그레이드 핸드오프 (beta.3+ 인프라)](#105-업그레이드-핸드오프-beta3-인프라)
|
||||
11. [업그레이드 후 데이터 정합성 (완전 동기화)](#11-업그레이드-후-데이터-정합성-완전-동기화)
|
||||
|
||||
---
|
||||
|
||||
@@ -388,6 +392,94 @@ PHP;
|
||||
|
||||
---
|
||||
|
||||
## 10.5 업그레이드 핸드오프 (beta.3+ 인프라)
|
||||
|
||||
일부 릴리즈는 "여기서부터는 새 PHP 프로세스가 필요하다" 는 경계 지점을 가진다. 예컨대 upgrade step A 까지는 현재 프로세스에서 안전하지만, step B 는 이미 로드된 이전 클래스와 충돌한다면, A 까지 확정하고 B 는 사용자가 `core:update` 를 재실행할 때 새 프로세스에서 처리하도록 위임하는 편이 안전하다.
|
||||
|
||||
이를 위해 coreunit beta.3 에서 `UpgradeHandoffException` 인프라를 도입했다.
|
||||
|
||||
### 동작 흐름
|
||||
|
||||
```text
|
||||
Upgrade_X_Y_Z::run()
|
||||
└─ throw UpgradeHandoffException(afterVersion, reason)
|
||||
↓
|
||||
[spawn 경로] [in-process 경로]
|
||||
ExecuteUpgradeStepsCommand CoreUpdateService::runUpgradeSteps
|
||||
└─ catch → stdout "[HANDOFF] <json>" └─ 그대로 전파
|
||||
└─ exit 75 ↓
|
||||
CoreUpdateCommand::handle
|
||||
spawnUpgradeStepsProcess └─ catch (UpgradeHandoffException)
|
||||
└─ [HANDOFF] 페이로드 파싱 └─ updateVersionInEnv(toVersion)
|
||||
└─ exit 75 → UpgradeHandoffException └─ clearAllCaches
|
||||
└─ throw └─ restoreOwnership
|
||||
↓ └─ cleanupPending
|
||||
CoreUpdateCommand::handle └─ disableMaintenanceMode
|
||||
└─ (in-process 분기와 동일한 처리) └─ resumeCommand 자동 생성
|
||||
└─ 사용자에게 스텝 전용 재실행 안내
|
||||
└─ return Command::SUCCESS
|
||||
```
|
||||
|
||||
핸드오프 catch 는 `.env APP_VERSION` 을 `afterVersion` 이 아닌 **`toVersion`** 으로 올린다. 디스크의 파일·vendor·migration 은 이미 `toVersion` 상태이므로 .env 를 `toVersion` 으로 맞춰야 상태가 일치한다. 만약 `afterVersion` 으로 되돌리면 사용자가 다시 `core:update` 를 실행했을 때 GitHub 재다운로드부터 전체 프로세스가 반복된다.
|
||||
|
||||
대신 사용자에게는 **스텝 전용** 명령 (`php artisan core:execute-upgrade-steps --from=<afterVersion> --to=<toVersion> --force`) 만 실행하도록 안내한다. 이 명령은 재다운로드·vendor 재설치 없이 남은 upgrade step 만 실행한다.
|
||||
|
||||
### 사용 시점
|
||||
|
||||
upgrade step 파일에서 아래 조건이 모두 성립할 때 사용한다:
|
||||
|
||||
1. 현재 PHP 프로세스가 본 step 을 안전하게 실행할 수 없다고 판단 가능 (예: 필요한 클래스/메서드 미로드)
|
||||
2. **직전 step 까지의 상태는 유효** 하며 이 상태를 확정해도 무방
|
||||
3. 사용자가 `core:update` 를 한 번 더 실행하는 불편이 허용 범위
|
||||
|
||||
조건 2 가 충족되지 않으면 핸드오프 대신 일반 예외를 던져 롤백시키는 편이 안전하다.
|
||||
|
||||
### 사용 예
|
||||
|
||||
```php
|
||||
use App\Exceptions\UpgradeHandoffException;
|
||||
use App\Extension\Helpers\FilePermissionHelper;
|
||||
|
||||
public function run(UpgradeContext $context): void
|
||||
{
|
||||
if (! method_exists(FilePermissionHelper::class, 'syncGroupWritability')) {
|
||||
// resumeCommand 를 지정하지 않으면 CoreUpdateCommand 가 catch 시점에
|
||||
// `php artisan core:execute-upgrade-steps --from=<afterVersion> --to=<toVersion> --force`
|
||||
// 로 자동 생성한다. 대부분의 step 은 이 기본 동작을 사용하면 된다.
|
||||
throw new UpgradeHandoffException(
|
||||
afterVersion: '7.0.0-beta.2',
|
||||
reason: '신설 메서드 FilePermissionHelper::syncGroupWritability 가 현재 프로세스에 로드되지 않음',
|
||||
);
|
||||
}
|
||||
|
||||
// ... 정상 로직
|
||||
}
|
||||
```
|
||||
|
||||
커스텀 재실행 명령을 안내하고 싶다면 `resumeCommand` 를 명시 전달한다. 그러나 기본 자동 생성 명령이 표준 시나리오에 최적이므로 특별한 이유가 없는 한 생략이 권장된다.
|
||||
|
||||
### 주의: 이전 버전 상위 계층 호환성
|
||||
|
||||
`UpgradeHandoffException` 은 beta.3 에서 신설된 클래스다. 이전 버전 (beta.1, beta.2) 의 `CoreUpdateService` / `CoreUpdateCommand` 는 이 클래스를 모른다. 따라서 **이전 버전 in-process 에서 throw 하면 uncaught exception 취급되어 `catch (\Throwable)` 롤백 경로로 빠진다**.
|
||||
|
||||
즉, 본 인프라가 의도대로 작동하는 것은 **beta.3 이후 코어가 실행 주체인 경우** — 즉 beta.3 이후 업데이트에서 사용 가능. beta.1 / beta.2 로부터 beta.3 로 올라오는 시점의 step 은 이 인프라를 사용할 수 없다 (graceful skip 등 다른 전략 필요).
|
||||
|
||||
다음 표로 정리:
|
||||
|
||||
| 실행 주체 코어 | spawn 가능 | 핸드오프 인프라 사용 |
|
||||
| -------------- | ---------- | -------------------- |
|
||||
| beta.1 | ✗ | ✗ (uncaught → 롤백) |
|
||||
| beta.2 | ✓ (spawn) | ✗ (자식 catch 없음) |
|
||||
| beta.3+ | ✓ | ✓ |
|
||||
|
||||
beta.3+ 타깃을 가정할 수 있는 경우에만 사용한다.
|
||||
|
||||
### 인프라 도입 시점
|
||||
|
||||
- 인프라 자체는 beta.3 에서 도입. 첫 실사용은 beta.3+ 후속 릴리즈부터 (beta.3 본 릴리즈의 `Upgrade_7_0_0_beta_3.php` 는 graceful skip 사용 — beta.1 하위 호환 사유)
|
||||
|
||||
---
|
||||
|
||||
## 11. 업그레이드 후 데이터 정합성 (완전 동기화)
|
||||
|
||||
upgrade step 이 수행하는 데이터 변경은 단순 "마이그레이션" 이 아니라 **완전 동기화** 를 지향한다. 즉:
|
||||
|
||||
@@ -141,6 +141,50 @@ G7이 자동으로 주입하는 `_global` 속성입니다. 레이아웃에서
|
||||
✅ API 에러 저장: onError 핸들러에서 에러 데이터 저장에 활용
|
||||
```
|
||||
|
||||
### 이중 저장소 구조 (engine-v1.43.0+)
|
||||
|
||||
```text
|
||||
⚠️ CRITICAL (엔진 유지보수자 필독): _local은 내부적으로 두 저장소로 관리된다.
|
||||
이 구조는 "단일화 시도 실패" 이력의 결과이며 엔진 설계 전제이다.
|
||||
변경 제안 전 반드시 과거 경로 기반 구독 시스템 롤백 이력 검토.
|
||||
```
|
||||
|
||||
| 저장소 | 실체 | 쓰는 곳 | 읽는 곳 |
|
||||
| ------ | ---- | ------- | ------- |
|
||||
| **A** | React `localDynamicState` (useState) | Form 자동바인딩 (Input/Textarea onChange) | DOM value, 부분 리렌더 |
|
||||
| **B** | `globalState._local` (TemplateApp 싱글톤) | `G7Core.state.setLocal/getLocal` | apiCall body 바인딩, 플러그인 동기화 |
|
||||
|
||||
레이아웃 JSON·일반 플러그인 개발자는 이 구조를 **의식할 필요가 없다**. 엔진이 양방향 동기화를 자동 처리한다.
|
||||
|
||||
**엔진 자동 동기화 메커니즘 (요약)**:
|
||||
|
||||
- **A→B 방향**: 자동바인딩 `performStateUpdate`가 A에 쓸 때 B에도 `setLocal({render:false})`로 동기 기록
|
||||
- **B→A 방향**: 자동바인딩 활성 경로를 `__g7AutoBindingPaths: Map<string, number>`에 추적. 플러그인이 `setLocal({render:false})`로 그 경로를 건드리면 엔진이 **자동으로 `render:true`로 승격**
|
||||
- **예외**: `selfManaged: true` 명시한 호출은 자동 승격 제외 (CKEditor5 등 자체 DOM 관리 플러그인 전용)
|
||||
|
||||
### 엔진 수정 시 금지 사항 (CRITICAL)
|
||||
|
||||
```text
|
||||
❌ _local에 쓰는 새 경로를 추가하면서 A 또는 B 한쪽만 갱신
|
||||
❌ `parentFormContext.setState`를 직접 호출하는 우회 경로 추가 (자동바인딩 내부 API)
|
||||
❌ `__g7AutoBindingPaths` 레지스트리를 건드리지 않고 자동바인딩 변형 구현
|
||||
❌ setLocal의 `render:false` 자동 승격 분기를 임의로 제거하거나 조건 완화
|
||||
❌ 구독 기반 선택적 리렌더 재시도 (과거에 도입 후 롤백된 실패 경로 — 반드시 검토 후 논의)
|
||||
```
|
||||
|
||||
### 엔진 수정 시 필수 확인 사항
|
||||
|
||||
```text
|
||||
✅ _local 쓰기 경로 추가 시 A+B 양쪽 동기화 확인
|
||||
✅ 새 `setLocal({render:false})` 사용처가 자동바인딩 경로와 겹치는지 확인 (겹치면 selfManaged 필요)
|
||||
✅ DynamicRenderer의 레지스트리 useEffect 조건 변경 시 iteration/Strict Mode 이중 마운트 영향 검토
|
||||
✅ SPA 네비게이션 시 레지스트리 재초기화 (new Map()) 유지
|
||||
✅ 수정 후 이중 저장소 동기화 관련 회귀 테스트 전수 통과 확인
|
||||
```
|
||||
|
||||
- 상세 설명: [`docs/extension/plugin-development.md`](../extension/plugin-development.md) "폼 상태 정합성" 섹션
|
||||
- 구현 참고: [`DynamicRenderer.tsx`](../../resources/js/core/template-engine/DynamicRenderer.tsx) `performStateUpdate` 상단 주석 (~50줄)
|
||||
|
||||
### 라이프사이클 (SPA 네비게이션)
|
||||
|
||||
SPA navigate 시 `_local` 상태는 **레이아웃 이름 기준으로 선택적 초기화**됩니다:
|
||||
|
||||
@@ -87,6 +87,55 @@
|
||||
| `max_execution_time` | 60 | 120+ | 대량 데이터 처리 시 |
|
||||
| `max_input_vars` | 1000 | 5000+ | 복잡한 폼 데이터 처리 |
|
||||
|
||||
### 1.6 파일 권한 및 umask 운영 방식
|
||||
|
||||
G7 은 배포 환경에 따라 세 가지 대표 운영 방식을 지원한다. 본 섹션은 각 방식에서 `storage/` 등 런타임 쓰기 대상 디렉토리의 권한 설정과 umask 권장값을 정리한다.
|
||||
|
||||
#### 운영 방식 분류
|
||||
|
||||
| 운영 방식 | 소유자 : 그룹 | 권장 퍼미션 | 전형적 환경 |
|
||||
|-----------|--------------|-------------|-------------|
|
||||
| **A. 그룹 공유** | `사용자 : www-data` (서로 다른 UID) | `drwxrwxr-x` (0775) + g+w | SSH 로그인 사용자와 php-fpm 프로세스가 UID 가 다르고 `www-data` 같은 공용 그룹으로 파일 쓰기 권한을 공유하는 일반적인 Ubuntu/Debian 구성 |
|
||||
| **B. 단일 소유자** | `사용자 : 사용자` 또는 `www-data : www-data` (동일 UID) | `drwxr-xr-x` (0755) | suexec / mod_userdir / 단순 Apache 환경에서 파일 소유자·웹서버 프로세스가 같은 UID |
|
||||
| **C. suexec / cPanel** | 계정별 UID 격리 | `drwxr-xr-x` (0755) | 공유 호스팅, 계정마다 독립 UID/GID |
|
||||
|
||||
`storage/` 디렉토리의 실제 퍼미션을 확인:
|
||||
|
||||
```bash
|
||||
stat -c '%a %U:%G' storage
|
||||
```
|
||||
|
||||
#### 권장 설정
|
||||
|
||||
**방식 A (그룹 공유)**:
|
||||
|
||||
```bash
|
||||
# 인스톨러 완료 후 운영자가 1회 실행
|
||||
sudo chown -R $USER:www-data storage bootstrap/cache vendor modules plugins templates
|
||||
sudo chmod -R 775 storage bootstrap/cache vendor modules plugins templates
|
||||
```
|
||||
|
||||
추가로 php-fpm / systemd 의 umask 를 `002` 로 설정하면 cron·composer·수동 SSH artisan 등 외부 프로세스도 동일 권한으로 파일을 만든다.
|
||||
|
||||
| 설정 지점 | 값 | 위치 예시 |
|
||||
|-----------|----|-----------|
|
||||
| php-fpm pool | `umask = 002` | `/etc/php/8.x/fpm/pool.d/www.conf` |
|
||||
| systemd unit | `UMask=0002` | `/lib/systemd/system/php8.x-fpm.service` `[Service]` 섹션 |
|
||||
|
||||
시스템 레벨 설정이 없어도 코어 부팅 시 `storage/` 의 g+w 여부를 감지하여 프로세스 umask 를 자동으로 `0002` 로 동조하므로 Laravel 부팅 경로를 거치는 파일 생성은 정상 동작한다 (`public/index.php`, `artisan`, queue worker, scheduler 등). 시스템 레벨 설정은 **부팅 경로를 거치지 않는 외부 프로세스 대응용 권장 사항**.
|
||||
|
||||
**방식 B/C (단일 소유자)**:
|
||||
|
||||
```bash
|
||||
sudo chmod -R 755 storage bootstrap/cache vendor modules plugins templates
|
||||
```
|
||||
|
||||
그룹 쓰기 비트가 없으므로 코어 자동 umask 동조는 발동하지 않는다 (운영자 의도 존중). 추가 설정 불필요.
|
||||
|
||||
#### 인스톨러가 안내하는 기본 권한
|
||||
|
||||
인스톨러의 기본 안내 명령은 보수적으로 `chmod -R 755` 를 제시한다. 방식 A 로 운영하려면 인스톨 완료 후 `775` 로 재조정 + 소유자/그룹을 본인 계정 + `www-data` 로 변경. 인스톨러는 `chmod` 를 직접 호출하지 않으므로 운영자가 쉘에서 1회 실행.
|
||||
|
||||
---
|
||||
|
||||
## 2. 데이터베이스
|
||||
|
||||
@@ -42,6 +42,11 @@ return [
|
||||
'save_failed' => 'Failed to save settings: :category',
|
||||
],
|
||||
|
||||
// Core update related exceptions
|
||||
'core_update' => [
|
||||
'handoff' => 'Upgrade handoff: completed up to :after_version — :reason (resume with: :resume_command)',
|
||||
],
|
||||
|
||||
// Vendor bundle / installation related exceptions
|
||||
'vendor' => [
|
||||
'composer_not_available' => 'Composer cannot be executed in this environment. Use bundled mode instead.',
|
||||
|
||||
@@ -42,6 +42,11 @@ return [
|
||||
'save_failed' => '설정 저장에 실패했습니다: :category',
|
||||
],
|
||||
|
||||
// 코어 업데이트 관련 예외
|
||||
'core_update' => [
|
||||
'handoff' => '업그레이드 핸드오프: :after_version 까지 완료 — :reason (재실행: :resume_command)',
|
||||
],
|
||||
|
||||
// Vendor 번들/설치 관련 예외
|
||||
'vendor' => [
|
||||
'composer_not_available' => 'Composer를 실행할 수 없는 환경입니다. 번들 모드를 사용하세요.',
|
||||
|
||||
+4
-3
@@ -34,9 +34,10 @@
|
||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||
<env name="CACHE_STORE" value="array"/>
|
||||
<env name="DB_CONNECTION" value="mysql"/>
|
||||
<env name="DB_DATABASE" value="g7_testing"/>
|
||||
<env name="DB_WRITE_DATABASE" value="g7_testing"/>
|
||||
<env name="DB_READ_DATABASE" value="g7_testing"/>
|
||||
<!--
|
||||
DB_DATABASE / DB_WRITE_DATABASE / DB_READ_DATABASE 는 `.env.testing` 값을 그대로 사용.
|
||||
프로덕션 DB 오염 방지 안전망은 `tests/bootstrap.php` 의 "동일 DB 이름 중단 가드" 가 담당.
|
||||
-->
|
||||
<env name="MAIL_MAILER" value="array"/>
|
||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||
<env name="SESSION_DRIVER" value="array"/>
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.0-beta.2] - 2026-04-22
|
||||
|
||||
### Fixed
|
||||
|
||||
- CKEditor5 사용 게시판 글쓰기 화면에서 제목과 내용을 함께 입력해 저장할 때 "제목이 비어있다" 오류가 나던 문제 수정
|
||||
|
||||
## [1.0.0-beta.1] - 2026-04-09
|
||||
|
||||
### Added
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "plugins/sirsoft-ckeditor5",
|
||||
"description": "CKEditor 5 WYSIWYG Editor Plugin for Gnuboard7 platform",
|
||||
"type": "library",
|
||||
"version": "1.0.0-beta.1",
|
||||
"version": "1.0.0-beta.2",
|
||||
"authors": [
|
||||
{
|
||||
"name": "sirsoft",
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-ckeditor5",
|
||||
"version": "1.0.0-beta.1",
|
||||
"version": "1.0.0-beta.2",
|
||||
"description": "그누보드7 CKEditor 5 WYSIWYG 에디터 플러그인 프론트엔드 에셋",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
"ko": "CKEditor 5 WYSIWYG 에디터",
|
||||
"en": "CKEditor 5 WYSIWYG Editor"
|
||||
},
|
||||
"version": "1.0.0-beta.1",
|
||||
"version": "1.0.0-beta.2",
|
||||
"license": "MIT",
|
||||
"description": {
|
||||
"ko": "CKEditor 5를 이용한 WYSIWYG 에디터 플러그인입니다. 플러그인 설치만으로 기존 HtmlEditor가 교체됩니다.",
|
||||
"en": "WYSIWYG editor plugin using CKEditor 5. Installing this plugin replaces the default HtmlEditor."
|
||||
},
|
||||
"g7_version": ">=7.0.0-beta.1",
|
||||
"g7_version": ">=7.0.0-beta.3",
|
||||
"dependencies": {
|
||||
"modules": {},
|
||||
"plugins": {}
|
||||
|
||||
@@ -922,15 +922,19 @@ function syncToForm(
|
||||
updates[`form.${name}`] = value;
|
||||
}
|
||||
|
||||
// G7 표준 debounce + render: false 사용 (engine-v1.42.0)
|
||||
// G7 표준 debounce + render: false + selfManaged: true (engine-v1.43.0+)
|
||||
// - debounce: ActionDispatcher 타이머 인프라 활용, 컴포넌트 언마운트 시 자동 정리
|
||||
// - render: false: CKEditor가 자체 DOM을 관리하므로 React 리렌더 불필요
|
||||
// 타이핑 중 전체 폼 트리 리렌더 방지 (37,000+ 바인딩 평가 제거)
|
||||
// - selfManaged: true: engine-v1.43.0 자동 승격 예외 명시. HtmlEditor 내부 Textarea가
|
||||
// form.content에 자동바인딩되어 레지스트리에 등록되지만, CKEditor는 자체 DOM 관리이므로
|
||||
// render:false를 유지해야 성능 이점(37,000+ 바인딩 재평가 방지) 보존.
|
||||
// - 저장 시: flushPendingDebounceTimers → globalStateUpdater({}) 강제 렌더 1회
|
||||
G7Core.state.setLocal(updates, {
|
||||
debounce: 300,
|
||||
debounceKey: `ckeditor-sync-${name}`,
|
||||
render: false,
|
||||
selfManaged: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+29
-29
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -13,4 +13,4 @@
|
||||
"src": "resources/js/app.js",
|
||||
"isEntry": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -735,6 +735,10 @@ export class TemplateApp {
|
||||
(window as any).__g7LastSetLocalSnapshot = undefined;
|
||||
(window as any).__g7SetLocalOverrideKeys = undefined;
|
||||
(window as any).__g7SequenceLocalSync = undefined;
|
||||
// [engine-v1.43.0+] 자동바인딩 경로 레지스트리 — 이전 페이지 컴포넌트의 언마운트가 라우트 전환과
|
||||
// 경쟁할 수 있으므로 강제 재초기화. undefined 대신 빈 Map을 써서 경쟁 상태의 이전 페이지 cleanup이
|
||||
// 이후에 registry.delete() 시도할 때 참조 오류 방지.
|
||||
(window as any).__g7AutoBindingPaths = new Map<string, number>();
|
||||
|
||||
try {
|
||||
logger.log('Route changed:', route, 'requestId:', routeChangeId);
|
||||
|
||||
@@ -603,6 +603,88 @@ export class G7DevToolsCore {
|
||||
return [...this.stateHistory];
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장소 A(React localDynamicState) / B(globalState._local) 불일치 감지
|
||||
*
|
||||
* 이중 저장소 구조(engine-v1.43.0+)의 보조 안전망.
|
||||
* 엔진의 자동 동기화(DynamicRenderer performStateUpdate + setLocal 자동 승격)가 기본 방어책이지만,
|
||||
* 새로운 쓰기 경로가 추가되면서 한쪽 저장소만 갱신하는 실수를 조기 발견하기 위한 진단 도구.
|
||||
*
|
||||
* 저장소 A = `updateLocalState()` 로 전달된 localDynamicState
|
||||
* 저장소 B = `G7Core.state.get()._local` = globalState._local
|
||||
*
|
||||
* 불일치 leaf 경로를 반환 (hasMismatch=true면 구조적 동기화가 깨진 상태).
|
||||
*
|
||||
* @returns 불일치 상태 + 두 저장소 스냅샷
|
||||
*/
|
||||
getDualStorageMismatch(): {
|
||||
hasMismatch: boolean;
|
||||
mismatchedPaths: string[];
|
||||
storageA: Record<string, any>;
|
||||
storageB: Record<string, any>;
|
||||
} {
|
||||
const storageA = this.currentLocalState || {};
|
||||
let storageB: Record<string, any> = {};
|
||||
try {
|
||||
const g7Core = (window as any).G7Core;
|
||||
const globalState = g7Core?.state?.get?.() || {};
|
||||
storageB = globalState._local || {};
|
||||
} catch {
|
||||
storageB = {};
|
||||
}
|
||||
|
||||
const mismatchedPaths = this.findMismatchedLeafPaths(storageA, storageB);
|
||||
return {
|
||||
hasMismatch: mismatchedPaths.length > 0,
|
||||
mismatchedPaths,
|
||||
storageA,
|
||||
storageB,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 객체의 리프 경로를 비교해 값이 다른 경로 목록 반환 (private helper)
|
||||
*
|
||||
* `getDualStorageMismatch` 전용. 배열은 리프로 취급하되 참조 비교가 아니라
|
||||
* JSON 문자열화 비교로 값 동등성 판정 (deep equal 수준 빠른 근사).
|
||||
*
|
||||
* @param a 비교 대상 A
|
||||
* @param b 비교 대상 B
|
||||
* @param prefix 재귀 prefix
|
||||
* @returns 불일치 경로 배열
|
||||
*/
|
||||
private findMismatchedLeafPaths(
|
||||
a: Record<string, any>,
|
||||
b: Record<string, any>,
|
||||
prefix = '',
|
||||
): string[] {
|
||||
const paths: string[] = [];
|
||||
const keys = new Set([
|
||||
...Object.keys(a || {}),
|
||||
...Object.keys(b || {}),
|
||||
]);
|
||||
|
||||
for (const key of keys) {
|
||||
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||
const va = a?.[key];
|
||||
const vb = b?.[key];
|
||||
const aIsObj = va && typeof va === 'object' && !Array.isArray(va);
|
||||
const bIsObj = vb && typeof vb === 'object' && !Array.isArray(vb);
|
||||
|
||||
if (aIsObj && bIsObj) {
|
||||
paths.push(...this.findMismatchedLeafPaths(va, vb, fullKey));
|
||||
} else {
|
||||
const sameA = va === undefined ? '__undefined__' : JSON.stringify(va);
|
||||
const sameB = vb === undefined ? '__undefined__' : JSON.stringify(vb);
|
||||
if (sameA !== sameB) {
|
||||
paths.push(fullKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태 감시 등록
|
||||
*/
|
||||
|
||||
@@ -5,6 +5,20 @@
|
||||
>
|
||||
> 형식: [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)
|
||||
|
||||
## [engine-v1.43.0] - 2026-04-22
|
||||
|
||||
### Fixed
|
||||
|
||||
- Form 자동바인딩 값이 `globalState._local`에 동기화되지 않아 CKEditor5 등 `setLocal({render:false})` 플러그인과 공존하는 폼에서 자동바인딩 값이 누락되던 문제 수정. `performStateUpdate`가 기존 React `localDynamicState` 쓰기와 함께 `G7Core.state.setLocal(..., {render:false})`로 globalState._local에 동기화 기록. 이중 저장소 구조는 성능상 의도적으로 유지하며, 자세한 배경은 DynamicRenderer.tsx `performStateUpdate` 상단 주석 참조 (DynamicRenderer)
|
||||
|
||||
### Added
|
||||
|
||||
- 자동바인딩 경로 레지스트리 `__g7AutoBindingPaths` — Input 마운트 시 `fullPath`를 reference count 기반으로 등록/해제. iteration 내 중복·React Strict Mode 이중 마운트 대응 (DynamicRenderer)
|
||||
- `G7Core.state.setLocal(..., {render:false})` 호출이 자동바인딩 경로와 겹치면 엔진이 자동으로 render:true로 승격 — 미래 플러그인이 자동바인딩 대상 필드를 render:false로 쓰더라도 저장소 A↔B 정합성 구조적 보장 (G7CoreGlobals)
|
||||
- `G7Core.state.setLocal` 옵션 `selfManaged: true` 신설 — 플러그인이 자체 DOM 관리를 의도적으로 선언하는 opt-out 마커. 명시 시 자동 승격 제외하여 render:false 유지(성능 보존). CKEditor5처럼 React 밖에서 DOM을 관리하는 플러그인 전용. 기본값 undefined(=false)는 safe-by-default (G7CoreGlobals)
|
||||
- SPA 네비게이션 시 `__g7AutoBindingPaths`를 빈 `Map`으로 재초기화 — 이전 페이지 컴포넌트 언마운트와 라우트 전환 경쟁으로 인한 stale 경로 잔존 방지 (TemplateApp)
|
||||
- `G7DevToolsCore.getDualStorageMismatch()` 진단 메서드 — 저장소 A(localDynamicState) / B(globalState._local) 불일치 leaf 경로 감지. Phase 1 이후 유지보수 중 쓰기 경로 누락 조기 발견을 위한 보조 안전망 (G7DevToolsCore)
|
||||
|
||||
## [engine-v1.42.0] - 2026-04-16
|
||||
|
||||
### Added
|
||||
|
||||
@@ -3222,6 +3222,45 @@ const DynamicRenderer: React.FC<DynamicRendererProps> = memo(
|
||||
// debounce 중 부모 리렌더링 시 stale state value 대신 pending value를 사용
|
||||
const autoBindingPendingValueRef = useRef<any>(undefined);
|
||||
|
||||
/**
|
||||
* 자동바인딩 경로 레지스트리 등록/해제 (engine-v1.43.0+)
|
||||
*
|
||||
* 이 Input/Textarea 등이 마운트되는 동안 fullPath(=`${dataKey}.${name}`)를
|
||||
* `window.__g7AutoBindingPaths` Map에 reference count로 기록한다.
|
||||
*
|
||||
* 용도: G7CoreGlobals.setLocal이 `render:false` 호출 시 업데이트 경로가
|
||||
* 레지스트리와 겹치면 render:true로 자동 승격하여 저장소 A(localDynamicState)
|
||||
* 동기화를 구조적으로 보장한다 (`options.selfManaged === true`만 예외).
|
||||
*
|
||||
* Reference count가 필요한 이유:
|
||||
* - iteration 내 동일 fullPath 중복 마운트 (여러 Input이 같은 name)
|
||||
* - React Strict Mode의 이중 마운트 (mount→cleanup→mount)
|
||||
* Set만 쓰면 첫 cleanup이 경로 제거 → 두 번째 마운트 후 stale 위험.
|
||||
*
|
||||
* 조건: 실제 자동바인딩이 활성화된 경로만 등록 (propsWithAutoBinding의 조건과 동일).
|
||||
* - name prop 존재
|
||||
* - 부모 FormContext의 dataKey/setState 존재
|
||||
* - props.autoBinding !== false
|
||||
*/
|
||||
useEffect(() => {
|
||||
const name = stableResolvedProps?.name;
|
||||
if (!name || !parentFormContext.dataKey || !parentFormContext.setState) return;
|
||||
if (stableResolvedProps?.autoBinding === false) return;
|
||||
|
||||
const fullPath = `${parentFormContext.dataKey}.${name}`;
|
||||
const registry: Map<string, number> =
|
||||
((window as any).__g7AutoBindingPaths as Map<string, number> | undefined)
|
||||
?? new Map<string, number>();
|
||||
(window as any).__g7AutoBindingPaths = registry;
|
||||
registry.set(fullPath, (registry.get(fullPath) ?? 0) + 1);
|
||||
|
||||
return () => {
|
||||
const count = registry.get(fullPath) ?? 0;
|
||||
if (count <= 1) registry.delete(fullPath);
|
||||
else registry.set(fullPath, count - 1);
|
||||
};
|
||||
}, [stableResolvedProps?.name, stableResolvedProps?.autoBinding, parentFormContext.dataKey, parentFormContext.setState]);
|
||||
|
||||
/**
|
||||
* 폼 자동 바인딩이 적용된 props
|
||||
*
|
||||
@@ -3277,9 +3316,51 @@ const DynamicRenderer: React.FC<DynamicRendererProps> = memo(
|
||||
// debounce 설정 가져오기
|
||||
const debounceMs = parentFormContext.debounce;
|
||||
|
||||
// 상태 업데이트 함수 (debounce 적용 가능)
|
||||
// parentFormContext.state (= extendedDataContext._local)를 기반으로 업데이트해야
|
||||
// dataContext._local의 기존 데이터(API 초기값)와 localDynamicState를 모두 포함함
|
||||
/**
|
||||
* Form 자동바인딩 상태 업데이트 — "이중 저장소 동기화" 구조 (engine-v1.43.0+)
|
||||
*
|
||||
* ⚠️ 엔진 유지보수자 필독: 아래 구조는 단순 구현이 아니라 "단일화 시도 실패" 이력의 결과다.
|
||||
*
|
||||
* [배경]
|
||||
* 엔진은 폼 데이터를 두 저장소에 나눠 관리한다:
|
||||
* - 저장소 A: React localDynamicState (useState)
|
||||
* — Form 자동바인딩 쓰기, Input 컴포넌트 부분 리렌더로 빠른 반영
|
||||
* - 저장소 B: globalState._local (TemplateApp)
|
||||
* — G7Core.state.setLocal/getLocal, apiCall body 바인딩, 플러그인 동기화
|
||||
*
|
||||
* engine-v1.43.0 이전에는 자동바인딩이 A에만 썼다. CKEditor5 같은 플러그인이
|
||||
* setLocal({render:false})로 B에 쓸 때 mergedPending = deepMerge(globalLocal, ...)의
|
||||
* globalLocal base에 자동바인딩 값이 없어, 자동바인딩 값이 빈 초기값으로 덮이는
|
||||
* 구조적 결함이 있었다.
|
||||
*
|
||||
* [왜 저장소 A를 없애고 B로 단일화하지 않는가]
|
||||
* 1) B 단일화 = 매 키입력마다 TemplateApp.setGlobalState → root.render() 전체 리렌더.
|
||||
* 대형 폼(필드 수십 개 + 바인딩 수천 개)에서 타이핑 지연 발생 위험.
|
||||
* 2) "필요한 부분만 리렌더" 최적화 경로(경로 기반 구독 시스템)는 과거에 도입 후
|
||||
* 필터 체크박스 클릭 수백 ms 지연 이슈로 전체 롤백된 실패 경로.
|
||||
* 3) 즉 "구조 단일화 + 부분 리렌더"는 복구 경로 없는 설계. 이중 저장소를 전제로 운영한다.
|
||||
*
|
||||
* [동기화 규칙]
|
||||
* - 자동바인딩 쓰기는 A + B 양쪽에 동시에 쓴다. B 쓰기는 render:false로 리렌더 억제.
|
||||
* - A의 React useState가 해당 Input 자식 트리 리렌더를 담당 (성능 보존).
|
||||
* - B는 플러그인/핸들러가 읽는 정본(source of truth). A는 B의 캐시가 아니라 "쓰는 시점에 강제로 일치시키는" 미러.
|
||||
* - G7Core.state.setLocal({...}, {render:false}) 호출로 __g7PendingLocalState,
|
||||
* __g7ForcedLocalFields, __g7SetLocalOverrideKeys, __g7LastSetLocalSnapshot,
|
||||
* __g7SequenceLocalSync 보조 캐시도 모두 B와 함께 자동 갱신된다.
|
||||
*
|
||||
* [엔진 수정 시 주의]
|
||||
* - 이 동기화 규칙을 지키지 않는 새 쓰기 경로를 추가하면 자동바인딩 정합성이 깨진다.
|
||||
* - 엔진 레벨 재발 방지 장치 (같은 engine-v1.43.0 도입):
|
||||
* · __g7AutoBindingPaths 레지스트리: 자동바인딩 마운트 중인 fullPath 추적 (아래 useEffect)
|
||||
* · G7CoreGlobals.setLocal: render:false 호출이 레지스트리와 겹치면 render:true 자동 승격
|
||||
* · 예외: options.selfManaged === true 인 경우 승격 제외. CKEditor5 등 자체 DOM 관리
|
||||
* 플러그인이 의도적으로 render:false를 유지하려 할 때 명시. 누락 시 엔진이 자동 승격하여
|
||||
* 정합성 보장 (safe-by-default).
|
||||
* · 즉 플러그인이 render:false setLocal을 쓰든 자동바인딩과 충돌 시 엔진이 구조적으로 차단,
|
||||
* selfManaged 명시한 플러그인만 예외적으로 render:false 유지.
|
||||
* - 구독 기반 선택적 리렌더는 과거에 도입 후 롤백된 경로이므로 재시도 전에
|
||||
* 반드시 관련 설계 검토 후 논의할 것.
|
||||
*/
|
||||
const performStateUpdate = (newValue: any) => {
|
||||
// 중요: prev (localDynamicState) 대신 parentFormContext.state (extendedDataContext._local)를 기반으로 사용
|
||||
// 그래야 init_actions로 설정된 API 데이터가 손실되지 않음
|
||||
@@ -3291,12 +3372,22 @@ const DynamicRenderer: React.FC<DynamicRendererProps> = memo(
|
||||
update.hasChanges = true;
|
||||
}
|
||||
|
||||
// React setState가 비동기이므로, 액션 핸들러에서 즉시 최신 값을 읽을 수 있도록
|
||||
// 전역 캐시(__g7PendingLocalState)에 업데이트된 상태를 저장합니다.
|
||||
// G7Core.state.getLocal()이 이 캐시를 우선적으로 사용합니다.
|
||||
// 저장소 A: React setState가 비동기이므로, 액션 핸들러에서 즉시 최신 값을 읽을 수 있도록
|
||||
// 전역 캐시(__g7PendingLocalState)에 업데이트된 상태를 저장. G7Core.state.getLocal()이 이 캐시를 우선 사용.
|
||||
(window as any).__g7PendingLocalState = update;
|
||||
|
||||
// 저장소 A: React localDynamicState — 해당 Input 트리 리렌더 (기존 경로, 성능 보존)
|
||||
parentFormContext.setState!(update);
|
||||
|
||||
// _global.xxx dataKey는 _globalSetState가 이미 globalState에 직접 쓰므로 B 동기화 불필요
|
||||
if ((parentFormContext as any)._isGlobal) return;
|
||||
|
||||
// 저장소 B: globalState._local — 플러그인/apiCall이 읽는 정본. render:false로 리렌더 억제
|
||||
// 이중 저장소를 늘 일치시키는 핵심 1줄. 이 경로가 빠지면 자동바인딩 정합성이 깨진다.
|
||||
const G7Core = (window as any).G7Core;
|
||||
const patch: Record<string, any> = { [fullPath]: newValue };
|
||||
if (parentFormContext.trackChanges) patch.hasChanges = true;
|
||||
G7Core?.state?.setLocal?.(patch, { render: false });
|
||||
};
|
||||
|
||||
// debounce가 설정되면 debounced 업데이트, 아니면 즉시 업데이트
|
||||
|
||||
@@ -1314,6 +1314,41 @@ function convertDotNotationToObject(updates: Record<string, any>): Record<string
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 중첩 객체에서 리프(leaf) 경로 전부를 dot notation 문자열 배열로 추출합니다.
|
||||
*
|
||||
* engine-v1.43.0+ 자동바인딩 경로 자동 승격 감지에 사용.
|
||||
* `__g7AutoBindingPaths` 레지스트리에 fullPath 형식(`form.title`)으로 키가 등록되므로,
|
||||
* setLocal의 `converted` 중첩 객체에서도 동일한 형식의 리프 경로를 추출해 교집합을 검사한다.
|
||||
*
|
||||
* 배열은 리프로 취급 (자동바인딩은 배열 자체 값에 매핑되므로).
|
||||
*
|
||||
* @example
|
||||
* flattenLeafPaths({ form: { title: "X", content: "Y" } })
|
||||
* // Returns: ["form.title", "form.content"]
|
||||
*
|
||||
* flattenLeafPaths({ hasChanges: true, form: { tags: ["a", "b"] } })
|
||||
* // Returns: ["hasChanges", "form.tags"]
|
||||
*
|
||||
* @param obj 중첩 객체
|
||||
* @param prefix 재귀용 prefix (내부)
|
||||
* @returns 리프 경로 문자열 배열
|
||||
*/
|
||||
function flattenLeafPaths(obj: Record<string, any>, prefix = ''): string[] {
|
||||
const paths: string[] = [];
|
||||
if (!obj || typeof obj !== 'object') return paths;
|
||||
for (const key of Object.keys(obj)) {
|
||||
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||
const value = obj[key];
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
paths.push(...flattenLeafPaths(value, fullKey));
|
||||
} else {
|
||||
paths.push(fullKey);
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* 객체의 모든 키가 숫자(배열 인덱스)인지 확인합니다.
|
||||
*
|
||||
@@ -1524,9 +1559,35 @@ function initStateAPI(G7Core: any): void {
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
setLocal: (updates: Record<string, any>, options?: { scope?: 'current' | 'parent' | 'root'; merge?: 'replace' | 'shallow' | 'deep'; debounce?: number; debounceKey?: string; render?: boolean }) => {
|
||||
setLocal: (updates: Record<string, any>, options?: { scope?: 'current' | 'parent' | 'root'; merge?: 'replace' | 'shallow' | 'deep'; debounce?: number; debounceKey?: string; render?: boolean; selfManaged?: boolean }) => {
|
||||
const templateApp = (window as any).__templateApp;
|
||||
|
||||
// [engine-v1.43.0+] 이중 저장소 동기화 보호 — 자동바인딩 경로 자동 승격
|
||||
//
|
||||
// 배경: 엔진은 폼 데이터를 React localDynamicState(저장소 A)와 globalState._local(저장소 B)에
|
||||
// 이중 저장한다. render:false 호출이 저장소 A에 자동바인딩된 경로를 건드리면 A가 갱신되지 않아
|
||||
// Input이 stale 값을 표시한다. DynamicRenderer의 propsWithAutoBinding 주변 useEffect가 활성
|
||||
// 자동바인딩 경로를 __g7AutoBindingPaths: Map<string, number>에 ref count로 등록한다.
|
||||
//
|
||||
// 규칙: setLocal({render:false})가 레지스트리에 등록된 경로를 업데이트하면 render:true로
|
||||
// 강제 승격하여 A↔B 정합성 보장. 예외는 selfManaged:true를 명시한 호출 — CKEditor5 등
|
||||
// 자체 DOM 관리 플러그인이 의도적으로 render:false를 유지하려 할 때 사용. 누락 시 엔진이
|
||||
// 자동 승격하여 안전 확보 (safe-by-default).
|
||||
//
|
||||
// 자세한 설계 배경: DynamicRenderer.tsx의 performStateUpdate 상단 주석 참조.
|
||||
if (options?.render === false && !options?.selfManaged) {
|
||||
const registry = (window as any).__g7AutoBindingPaths as Map<string, number> | undefined;
|
||||
if (registry && registry.size > 0) {
|
||||
const leafPaths = flattenLeafPaths(convertDotNotationToObject(updates));
|
||||
if (leafPaths.some((path) => registry.has(path))) {
|
||||
logger.log(
|
||||
'[setLocal] render:false + 자동바인딩 경로 겹침 감지 → render:true 자동 승격 (engine-v1.43.0)'
|
||||
);
|
||||
options = { ...options, render: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// engine-v1.41.0: debounce 옵션 처리
|
||||
// ActionDispatcher의 debouncedCall을 사용하여 기존 타이머 인프라 활용
|
||||
// 컴포넌트 언마운트 시 자동 정리 + flushPendingDebounceTimers 연동
|
||||
|
||||
@@ -4221,3 +4221,274 @@ describe('[사례 12] _localInit + 자식 useEffect setState가 API 데이터를
|
||||
expect((window as any).__g7PendingLocalState.form.options).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* [사례 32] 이슈 #282 - 이중 저장소 동기화 + 자동바인딩 경로 자동 승격 + selfManaged opt-out (engine-v1.43.0)
|
||||
*
|
||||
* 게시판 WYSIWYG 글쓰기 저장 시 "제목은 필수입니다" 422 문제의 구조적 해결.
|
||||
*
|
||||
* 수정 구성 요소:
|
||||
* - A-0: performStateUpdate가 `G7Core.state.setLocal({render:false})` 추가 호출 (A+B 동시 쓰기)
|
||||
* - A-1: DynamicRenderer propsWithAutoBinding 근처 useEffect로 `__g7AutoBindingPaths: Map<string, number>` 관리
|
||||
* - A-2: G7CoreGlobals.setLocal이 render:false 호출과 레지스트리 겹침 시 render:true 자동 승격 (selfManaged:true 예외)
|
||||
* - A-3: TemplateApp.handleRouteChange에서 레지스트리 빈 Map으로 재초기화
|
||||
* - A-4: CKEditor5 syncToForm에 selfManaged:true 명시로 자동 승격 제외 (성능 보존)
|
||||
*
|
||||
* 본 describe 블록은 엔진 내부 신규 구조의 contract test.
|
||||
* 이슈 #282 실제 재현(게시판 WYSIWYG 저장 422)은 PO 브라우저 검증으로 증명.
|
||||
*/
|
||||
describe('[사례 32] 이슈 #282 이중 저장소 동기화 + 자동 승격 (engine-v1.43.0)', () => {
|
||||
/**
|
||||
* flattenLeafPaths 헬퍼 contract
|
||||
*
|
||||
* G7CoreGlobals.ts의 private 헬퍼로, 자동 승격 판정 시 setLocal updates 객체의 리프 경로를
|
||||
* 추출하여 레지스트리 Map.has(path) 체크에 사용한다. 엔진과 동일한 로직을 테스트에서 재구현하여
|
||||
* 레지스트리 겹침 판정 알고리즘의 정확성을 검증한다.
|
||||
*/
|
||||
function flattenLeafPaths(obj: Record<string, any>, prefix = ''): string[] {
|
||||
const paths: string[] = [];
|
||||
if (!obj || typeof obj !== 'object') return paths;
|
||||
for (const key of Object.keys(obj)) {
|
||||
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||
const value = obj[key];
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
paths.push(...flattenLeafPaths(value, fullKey));
|
||||
} else {
|
||||
paths.push(fullKey);
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
describe('flattenLeafPaths — 자동 승격 판정용 리프 경로 추출', () => {
|
||||
it('중첩 객체의 리프 경로를 dot notation으로 반환', () => {
|
||||
expect(flattenLeafPaths({ form: { title: 'X', content: 'Y' } })).toEqual([
|
||||
'form.title',
|
||||
'form.content',
|
||||
]);
|
||||
});
|
||||
|
||||
it('배열은 리프로 취급 (자동바인딩은 배열 전체 값에 매핑)', () => {
|
||||
expect(flattenLeafPaths({ form: { tags: ['a', 'b'] } })).toEqual(['form.tags']);
|
||||
});
|
||||
|
||||
it('최상위 scalar (hasChanges 등)와 중첩 객체 혼합', () => {
|
||||
expect(flattenLeafPaths({ hasChanges: true, form: { title: 'X' } })).toEqual([
|
||||
'hasChanges',
|
||||
'form.title',
|
||||
]);
|
||||
});
|
||||
|
||||
it('빈 객체/null/undefined 입력 시 빈 배열 반환', () => {
|
||||
expect(flattenLeafPaths({})).toEqual([]);
|
||||
expect(flattenLeafPaths(null as any)).toEqual([]);
|
||||
expect(flattenLeafPaths(undefined as any)).toEqual([]);
|
||||
});
|
||||
|
||||
it('중첩 배열 경로 (iteration 시나리오)', () => {
|
||||
expect(flattenLeafPaths({ form: { items: [1, 2, 3] } })).toEqual(['form.items']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('__g7AutoBindingPaths 레지스트리 ref count (A-1)', () => {
|
||||
beforeEach(() => {
|
||||
(window as any).__g7AutoBindingPaths = new Map<string, number>();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as any).__g7AutoBindingPaths;
|
||||
});
|
||||
|
||||
// DynamicRenderer의 useEffect가 수행하는 등록/해제 로직 재현
|
||||
function simulateMount(fullPath: string): () => void {
|
||||
const registry: Map<string, number> =
|
||||
((window as any).__g7AutoBindingPaths as Map<string, number> | undefined) ??
|
||||
new Map<string, number>();
|
||||
(window as any).__g7AutoBindingPaths = registry;
|
||||
registry.set(fullPath, (registry.get(fullPath) ?? 0) + 1);
|
||||
return () => {
|
||||
const count = registry.get(fullPath) ?? 0;
|
||||
if (count <= 1) registry.delete(fullPath);
|
||||
else registry.set(fullPath, count - 1);
|
||||
};
|
||||
}
|
||||
|
||||
it('동일 fullPath iteration 3회 마운트 → count=3, 순차 언마운트 시 정확히 감소', () => {
|
||||
const registry = (window as any).__g7AutoBindingPaths as Map<string, number>;
|
||||
const fullPath = 'form.items';
|
||||
|
||||
const unmount1 = simulateMount(fullPath);
|
||||
const unmount2 = simulateMount(fullPath);
|
||||
const unmount3 = simulateMount(fullPath);
|
||||
expect(registry.get(fullPath)).toBe(3);
|
||||
|
||||
unmount1();
|
||||
expect(registry.get(fullPath)).toBe(2);
|
||||
unmount2();
|
||||
expect(registry.get(fullPath)).toBe(1);
|
||||
unmount3();
|
||||
expect(registry.has(fullPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('React Strict Mode 이중 마운트 (mount→cleanup→mount) 후 count=1 정확 복원', () => {
|
||||
const registry = (window as any).__g7AutoBindingPaths as Map<string, number>;
|
||||
const fullPath = 'form.title';
|
||||
|
||||
const cleanup1 = simulateMount(fullPath);
|
||||
cleanup1();
|
||||
const cleanup2 = simulateMount(fullPath);
|
||||
|
||||
expect(registry.get(fullPath)).toBe(1);
|
||||
|
||||
cleanup2();
|
||||
expect(registry.has(fullPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('서로 다른 fullPath 여러 개 독립 관리', () => {
|
||||
const registry = (window as any).__g7AutoBindingPaths as Map<string, number>;
|
||||
|
||||
const cleanupTitle = simulateMount('form.title');
|
||||
const cleanupContent = simulateMount('form.content');
|
||||
const cleanupCategory = simulateMount('form.category');
|
||||
|
||||
expect(registry.size).toBe(3);
|
||||
expect(registry.get('form.title')).toBe(1);
|
||||
expect(registry.get('form.content')).toBe(1);
|
||||
expect(registry.get('form.category')).toBe(1);
|
||||
|
||||
cleanupTitle();
|
||||
expect(registry.has('form.title')).toBe(false);
|
||||
expect(registry.size).toBe(2);
|
||||
|
||||
cleanupContent();
|
||||
cleanupCategory();
|
||||
expect(registry.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('자동 승격 판정 로직 (A-2)', () => {
|
||||
beforeEach(() => {
|
||||
(window as any).__g7AutoBindingPaths = new Map<string, number>();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as any).__g7AutoBindingPaths;
|
||||
});
|
||||
|
||||
// G7CoreGlobals.setLocal 상단의 자동 승격 분기 로직 재현
|
||||
function shouldPromoteRender(
|
||||
updates: Record<string, any>,
|
||||
options?: { render?: boolean; selfManaged?: boolean },
|
||||
): boolean {
|
||||
if (options?.render !== false) return false;
|
||||
if (options?.selfManaged) return false;
|
||||
const registry = (window as any).__g7AutoBindingPaths as Map<string, number> | undefined;
|
||||
if (!registry || registry.size === 0) return false;
|
||||
const leafPaths = flattenLeafPaths(updates);
|
||||
return leafPaths.some((p) => registry.has(p));
|
||||
}
|
||||
|
||||
it('render:false + 레지스트리 미등록 경로 → 승격 안 함 (CKEditor form.content 시나리오: 레지스트리 비어있을 때)', () => {
|
||||
const registry = (window as any).__g7AutoBindingPaths as Map<string, number>;
|
||||
registry.set('form.title', 1);
|
||||
|
||||
// CKEditor가 form.content만 건드리고 form.content는 레지스트리 없음
|
||||
expect(
|
||||
shouldPromoteRender({ form: { content: 'X' } }, { render: false }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('render:false + 레지스트리 등록 경로 → 승격 (미래 플러그인 실수 방어)', () => {
|
||||
const registry = (window as any).__g7AutoBindingPaths as Map<string, number>;
|
||||
registry.set('form.title', 1);
|
||||
|
||||
// 플러그인이 자동바인딩 대상인 form.title에 render:false로 씀
|
||||
expect(
|
||||
shouldPromoteRender({ form: { title: 'X' } }, { render: false }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('selfManaged:true → 레지스트리 겹쳐도 승격 안 함 (CKEditor 성능 보존)', () => {
|
||||
const registry = (window as any).__g7AutoBindingPaths as Map<string, number>;
|
||||
registry.set('form.content', 1); // HtmlEditor 내부 Textarea가 자동바인딩 등록
|
||||
|
||||
// CKEditor syncToForm: form.content를 render:false + selfManaged:true로 씀
|
||||
expect(
|
||||
shouldPromoteRender(
|
||||
{ form: { content: '<p>내용</p>' } },
|
||||
{ render: false, selfManaged: true },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('render 옵션 생략 (기본 render:true) → 분기 진입 자체 안 함', () => {
|
||||
const registry = (window as any).__g7AutoBindingPaths as Map<string, number>;
|
||||
registry.set('form.title', 1);
|
||||
|
||||
expect(shouldPromoteRender({ form: { title: 'X' } }, {})).toBe(false);
|
||||
expect(shouldPromoteRender({ form: { title: 'X' } }, undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('빈 레지스트리 → 승격 안 함 (SPA 네비게이션 직후 등)', () => {
|
||||
// 레지스트리가 빈 Map인 상태 (handleRouteChange 재초기화 후)
|
||||
expect(
|
||||
shouldPromoteRender({ form: { title: 'X' } }, { render: false }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('dot notation 키 + 중첩 객체 혼합 입력도 올바르게 판정', () => {
|
||||
const registry = (window as any).__g7AutoBindingPaths as Map<string, number>;
|
||||
registry.set('form.content', 1);
|
||||
|
||||
// setLocal의 updates가 { "form.content": "X" } 형태로 올 수도 있음
|
||||
// 이 경우 convertDotNotationToObject 후 { form: { content: "X" } }로 변환 → 판정
|
||||
const converted = { form: { content: 'X' } }; // convertDotNotationToObject 결과 시뮬레이션
|
||||
expect(shouldPromoteRender(converted, { render: false })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SPA 네비게이션 시 레지스트리 재초기화 (A-3)', () => {
|
||||
afterEach(() => {
|
||||
delete (window as any).__g7AutoBindingPaths;
|
||||
});
|
||||
|
||||
it('handleRouteChange 시뮬레이션: 기존 경로 비우고 빈 Map으로 재초기화', () => {
|
||||
// 이전 페이지 상태
|
||||
const oldRegistry = new Map<string, number>();
|
||||
oldRegistry.set('form.title', 2);
|
||||
oldRegistry.set('form.content', 1);
|
||||
(window as any).__g7AutoBindingPaths = oldRegistry;
|
||||
|
||||
// handleRouteChange 시뮬레이션
|
||||
(window as any).__g7AutoBindingPaths = new Map<string, number>();
|
||||
|
||||
const newRegistry = (window as any).__g7AutoBindingPaths as Map<string, number>;
|
||||
expect(newRegistry).toBeInstanceOf(Map);
|
||||
expect(newRegistry.size).toBe(0);
|
||||
expect(newRegistry.has('form.title')).toBe(false);
|
||||
});
|
||||
|
||||
it('재초기화 후 이전 페이지 cleanup의 registry.delete 호출이 에러 없이 noop', () => {
|
||||
const oldRegistry = new Map<string, number>();
|
||||
oldRegistry.set('form.title', 1);
|
||||
(window as any).__g7AutoBindingPaths = oldRegistry;
|
||||
|
||||
// 이전 페이지 useEffect cleanup 클로저가 oldRegistry를 참조
|
||||
const oldCleanup = () => {
|
||||
const count = oldRegistry.get('form.title') ?? 0;
|
||||
if (count <= 1) oldRegistry.delete('form.title');
|
||||
else oldRegistry.set('form.title', count - 1);
|
||||
};
|
||||
|
||||
// handleRouteChange로 새 Map 할당
|
||||
(window as any).__g7AutoBindingPaths = new Map<string, number>();
|
||||
|
||||
// 이전 페이지 cleanup 실행 — oldRegistry만 변경, 새 Map은 영향 없음
|
||||
expect(() => oldCleanup()).not.toThrow();
|
||||
|
||||
const newRegistry = (window as any).__g7AutoBindingPaths as Map<string, number>;
|
||||
expect(newRegistry.size).toBe(0); // 새 Map은 비영향
|
||||
expect(oldRegistry.size).toBe(0); // 이전 Map만 비워짐
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Console\Commands;
|
||||
|
||||
use App\Exceptions\UpgradeHandoffException;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* `core:execute-upgrade-steps` 커맨드의 핸드오프 신호 출력 계약 테스트
|
||||
*
|
||||
* 업그레이드 스텝이 `UpgradeHandoffException` 을 던지면 본 커맨드는:
|
||||
* 1. stdout 에 `[HANDOFF] <json>` 라인 출력 (부모 spawnUpgradeStepsProcess 가 파싱)
|
||||
* 2. exit code `UpgradeHandoffException::EXIT_CODE` (=75) 반환
|
||||
*
|
||||
* 본 계약은 spawn 부모가 자식의 handoff 신호를 감지하는 유일한 경로이므로
|
||||
* stdout 포맷과 exit code 쌍이 동시에 고정되어야 한다.
|
||||
*
|
||||
* 각 테스트는 고유 버전(0.0.1 / 0.0.2 / ...) 및 고유 클래스명을 사용하여
|
||||
* PHP 클래스 캐싱으로 인한 테스트 간 간섭을 차단한다 (`require_once` 의존).
|
||||
*/
|
||||
class ExecuteUpgradeStepsCommandHandoffTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* 이번 테스트 메서드가 생성한 임시 Upgrade 파일 경로 추적.
|
||||
*/
|
||||
private array $createdPaths = [];
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
foreach ($this->createdPaths as $path) {
|
||||
if (File::exists($path)) {
|
||||
File::delete($path);
|
||||
}
|
||||
}
|
||||
$this->createdPaths = [];
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_handoff_exception_produces_handoff_stdout_and_exit_75(): void
|
||||
{
|
||||
$version = '0.1.1';
|
||||
$this->writeHandoffStep(
|
||||
version: $version,
|
||||
suffix: 'case1',
|
||||
afterVersion: '0.1.0',
|
||||
reason: '테스트 핸드오프 사유',
|
||||
resumeCommand: null,
|
||||
);
|
||||
|
||||
ob_start();
|
||||
$exitCode = Artisan::call('core:execute-upgrade-steps', [
|
||||
'--from' => '0.1.0',
|
||||
'--to' => $version,
|
||||
'--force' => true,
|
||||
]);
|
||||
ob_end_clean();
|
||||
|
||||
$output = Artisan::output();
|
||||
|
||||
$this->assertSame(UpgradeHandoffException::EXIT_CODE, $exitCode);
|
||||
$this->assertSame(75, UpgradeHandoffException::EXIT_CODE);
|
||||
$this->assertStringContainsString('[HANDOFF] ', $output);
|
||||
}
|
||||
|
||||
public function test_handoff_stdout_contains_valid_json_payload(): void
|
||||
{
|
||||
$version = '0.1.2';
|
||||
$this->writeHandoffStep(
|
||||
version: $version,
|
||||
suffix: 'case2',
|
||||
afterVersion: '0.1.0',
|
||||
reason: '테스트 핸드오프 사유',
|
||||
resumeCommand: 'php artisan custom:resume',
|
||||
);
|
||||
|
||||
ob_start();
|
||||
Artisan::call('core:execute-upgrade-steps', [
|
||||
'--from' => '0.1.0',
|
||||
'--to' => $version,
|
||||
'--force' => true,
|
||||
]);
|
||||
ob_end_clean();
|
||||
|
||||
$output = Artisan::output();
|
||||
|
||||
$this->assertMatchesRegularExpression('/^\[HANDOFF\] (\{.*\})\r?$/m', $output);
|
||||
|
||||
preg_match('/^\[HANDOFF\] (\{.*\})\r?$/m', $output, $m);
|
||||
$payload = json_decode($m[1], true);
|
||||
|
||||
$this->assertIsArray($payload);
|
||||
$this->assertSame('0.1.0', $payload['afterVersion']);
|
||||
$this->assertSame('테스트 핸드오프 사유', $payload['reason']);
|
||||
$this->assertSame('php artisan custom:resume', $payload['resumeCommand']);
|
||||
}
|
||||
|
||||
public function test_handoff_payload_preserves_null_resume_command(): void
|
||||
{
|
||||
$version = '0.1.3';
|
||||
$this->writeHandoffStep(
|
||||
version: $version,
|
||||
suffix: 'case3',
|
||||
afterVersion: '0.1.0',
|
||||
reason: '자동 생성 안내',
|
||||
resumeCommand: null,
|
||||
);
|
||||
|
||||
ob_start();
|
||||
Artisan::call('core:execute-upgrade-steps', [
|
||||
'--from' => '0.1.0',
|
||||
'--to' => $version,
|
||||
'--force' => true,
|
||||
]);
|
||||
ob_end_clean();
|
||||
|
||||
$output = Artisan::output();
|
||||
$this->assertMatchesRegularExpression('/^\[HANDOFF\] (\{.*\})\r?$/m', $output);
|
||||
|
||||
preg_match('/^\[HANDOFF\] (\{.*\})\r?$/m', $output, $m);
|
||||
$payload = json_decode($m[1], true);
|
||||
|
||||
$this->assertArrayHasKey('resumeCommand', $payload);
|
||||
$this->assertNull($payload['resumeCommand']);
|
||||
}
|
||||
|
||||
public function test_generic_exception_does_not_produce_handoff_signal(): void
|
||||
{
|
||||
$version = '0.1.4';
|
||||
$path = base_path("upgrades/Upgrade_0_1_4_test_cmd_case4.php");
|
||||
|
||||
$code = <<<'PHP'
|
||||
<?php
|
||||
|
||||
namespace App\Upgrades;
|
||||
|
||||
use App\Contracts\Extension\UpgradeStepInterface;
|
||||
use App\Extension\UpgradeContext;
|
||||
|
||||
class Upgrade_0_1_4_test_cmd_case4 implements UpgradeStepInterface
|
||||
{
|
||||
public function run(UpgradeContext $context): void
|
||||
{
|
||||
throw new \RuntimeException('일반 실패');
|
||||
}
|
||||
}
|
||||
PHP;
|
||||
|
||||
File::put($path, $code);
|
||||
$this->createdPaths[] = $path;
|
||||
|
||||
ob_start();
|
||||
$exitCode = Artisan::call('core:execute-upgrade-steps', [
|
||||
'--from' => '0.1.0',
|
||||
'--to' => $version,
|
||||
'--force' => true,
|
||||
]);
|
||||
ob_end_clean();
|
||||
|
||||
$output = Artisan::output();
|
||||
|
||||
$this->assertNotSame(UpgradeHandoffException::EXIT_CODE, $exitCode);
|
||||
$this->assertStringNotContainsString('[HANDOFF] ', $output);
|
||||
}
|
||||
|
||||
/**
|
||||
* 테스트 전용 Upgrade 파일을 upgrades/ 에 작성. 각 테스트가 고유 버전+suffix 를
|
||||
* 사용하여 클래스명 충돌·캐싱 문제를 회피.
|
||||
*/
|
||||
private function writeHandoffStep(
|
||||
string $version,
|
||||
string $suffix,
|
||||
string $afterVersion,
|
||||
string $reason,
|
||||
?string $resumeCommand
|
||||
): void {
|
||||
$versionSnake = str_replace('.', '_', $version);
|
||||
$className = "Upgrade_{$versionSnake}_test_cmd_{$suffix}";
|
||||
$fileName = "Upgrade_{$versionSnake}_test_cmd_{$suffix}.php";
|
||||
$path = base_path('upgrades/'.$fileName);
|
||||
|
||||
$resumeArg = $resumeCommand === null ? 'null' : var_export($resumeCommand, true);
|
||||
$afterArg = var_export($afterVersion, true);
|
||||
$reasonArg = var_export($reason, true);
|
||||
|
||||
$code = <<<PHP
|
||||
<?php
|
||||
|
||||
namespace App\\Upgrades;
|
||||
|
||||
use App\\Contracts\\Extension\\UpgradeStepInterface;
|
||||
use App\\Exceptions\\UpgradeHandoffException;
|
||||
use App\\Extension\\UpgradeContext;
|
||||
|
||||
class {$className} implements UpgradeStepInterface
|
||||
{
|
||||
public function run(UpgradeContext \$context): void
|
||||
{
|
||||
throw new UpgradeHandoffException(
|
||||
afterVersion: {$afterArg},
|
||||
reason: {$reasonArg},
|
||||
resumeCommand: {$resumeArg},
|
||||
);
|
||||
}
|
||||
}
|
||||
PHP;
|
||||
|
||||
File::put($path, $code);
|
||||
$this->createdPaths[] = $path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Console;
|
||||
|
||||
use App\Console\Commands\Core\CoreUpdateCommand;
|
||||
use App\Exceptions\UpgradeHandoffException;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* CoreUpdateCommand 의 핸드오프 통합 계약 테스트 (spawn 파싱 경로)
|
||||
*
|
||||
* `handle()` 전체 체인(11 Step) 을 통과시키는 통합 테스트는 GitHub 연동·migration·
|
||||
* vendor 설치 등 사이드이펙트가 크고 mock 면적이 방대하다. 따라서 본 테스트는
|
||||
* handle() 의 **catch (UpgradeHandoffException) 분기로 들어가는 입구**에 해당하는
|
||||
* `spawnUpgradeStepsProcess` 의 파싱 계약을 검증한다. 이 입구가 보장되면:
|
||||
*
|
||||
* - spawn 자식의 [HANDOFF] + exit 75 → 부모가 UpgradeHandoffException 재구성 → throw
|
||||
* - 재구성된 예외는 handle() 의 try 전체를 덮는 catch 블록이 포착하여 cleanup 분기로 진입
|
||||
*
|
||||
* handle() catch 블록 내부 cleanup 시퀀스 (updateVersionInEnv(toVersion),
|
||||
* clearAllCaches, restoreOwnership, cleanupPending, disableMaintenanceMode,
|
||||
* 사용자 안내) 는 본 테스트 범위에서 제외되며, PO Linux 서버 수동 검증으로 보완한다.
|
||||
*
|
||||
* 본 테스트는 실제 proc_open 으로 자식 PHP 프로세스를 띄우므로 proc_open 미지원
|
||||
* 환경에서는 skip.
|
||||
*/
|
||||
class CoreUpdateCommandHandoffTest extends TestCase
|
||||
{
|
||||
private string $handoffStepPath;
|
||||
|
||||
private string $failingStepPath;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->handoffStepPath = base_path('upgrades/Upgrade_0_0_1_test_integration_handoff.php');
|
||||
$this->failingStepPath = base_path('upgrades/Upgrade_0_0_1_test_integration_fail.php');
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
foreach ([$this->handoffStepPath, $this->failingStepPath] as $p) {
|
||||
if (File::exists($p)) {
|
||||
File::delete($p);
|
||||
}
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* 자식 프로세스가 UpgradeHandoffException 을 던지면 부모의
|
||||
* spawnUpgradeStepsProcess 가 [HANDOFF] stdout + exit 75 를 파싱해
|
||||
* UpgradeHandoffException 을 재구성하여 상위로 throw 한다.
|
||||
*/
|
||||
public function test_spawn_rethrows_handoff_exception_from_child(): void
|
||||
{
|
||||
if (! function_exists('proc_open')) {
|
||||
$this->markTestSkipped('proc_open 미지원 환경');
|
||||
}
|
||||
|
||||
File::put($this->handoffStepPath, <<<'PHP'
|
||||
<?php
|
||||
|
||||
namespace App\Upgrades;
|
||||
|
||||
use App\Contracts\Extension\UpgradeStepInterface;
|
||||
use App\Exceptions\UpgradeHandoffException;
|
||||
use App\Extension\UpgradeContext;
|
||||
|
||||
class Upgrade_0_0_1_test_integration_handoff implements UpgradeStepInterface
|
||||
{
|
||||
public function run(UpgradeContext $context): void
|
||||
{
|
||||
throw new UpgradeHandoffException(
|
||||
afterVersion: '0.0.0',
|
||||
reason: '자식 프로세스 핸드오프',
|
||||
resumeCommand: 'php artisan custom:resume',
|
||||
);
|
||||
}
|
||||
}
|
||||
PHP);
|
||||
|
||||
$command = $this->makeCommandWithDummyIo();
|
||||
$method = new \ReflectionMethod(CoreUpdateCommand::class, 'spawnUpgradeStepsProcess');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$logs = [];
|
||||
$logCollector = function (string $message) use (&$logs) {
|
||||
$logs[] = $message;
|
||||
};
|
||||
|
||||
try {
|
||||
$method->invoke($command, '0.0.0', '0.0.1', true, $logCollector);
|
||||
$this->fail('UpgradeHandoffException 이 상위로 전파되어야 한다');
|
||||
} catch (UpgradeHandoffException $e) {
|
||||
$this->assertSame('0.0.0', $e->afterVersion);
|
||||
$this->assertSame('자식 프로세스 핸드오프', $e->reason);
|
||||
$this->assertSame('php artisan custom:resume', $e->resumeCommand);
|
||||
}
|
||||
|
||||
// 부모 로그에 핸드오프 수신 흔적이 있어야 한다
|
||||
$handoffLog = implode("\n", $logs);
|
||||
$this->assertStringContainsString('핸드오프', $handoffLog);
|
||||
}
|
||||
|
||||
/**
|
||||
* 자식 프로세스가 정상 완료(exit 0)되면 spawn 은 true 를 반환한다.
|
||||
* Handoff 파싱 로직이 정상 경로를 손상시키지 않는지 회귀 가드.
|
||||
*/
|
||||
public function test_spawn_returns_true_on_normal_exit(): void
|
||||
{
|
||||
if (! function_exists('proc_open')) {
|
||||
$this->markTestSkipped('proc_open 미지원 환경');
|
||||
}
|
||||
|
||||
// upgrade 파일 없이 실행 → runUpgradeSteps 가 조용히 no-op → exit 0
|
||||
$command = $this->makeCommandWithDummyIo();
|
||||
$method = new \ReflectionMethod(CoreUpdateCommand::class, 'spawnUpgradeStepsProcess');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$result = $method->invoke($command, '9.9.8', '9.9.9', true, fn () => null);
|
||||
|
||||
$this->assertTrue($result, '정상 종료 시 true 반환해야 한다');
|
||||
}
|
||||
|
||||
/**
|
||||
* 자식 프로세스가 일반 실패(exit != 0, != 75)면 spawn 은 false 를 반환하여
|
||||
* 상위 호출자가 in-process fallback 경로로 전환할 수 있게 한다.
|
||||
*/
|
||||
public function test_spawn_returns_false_on_generic_failure(): void
|
||||
{
|
||||
if (! function_exists('proc_open')) {
|
||||
$this->markTestSkipped('proc_open 미지원 환경');
|
||||
}
|
||||
|
||||
File::put($this->failingStepPath, <<<'PHP'
|
||||
<?php
|
||||
|
||||
namespace App\Upgrades;
|
||||
|
||||
use App\Contracts\Extension\UpgradeStepInterface;
|
||||
use App\Extension\UpgradeContext;
|
||||
|
||||
class Upgrade_0_0_1_test_integration_fail implements UpgradeStepInterface
|
||||
{
|
||||
public function run(UpgradeContext $context): void
|
||||
{
|
||||
throw new \RuntimeException('자식 일반 실패');
|
||||
}
|
||||
}
|
||||
PHP);
|
||||
|
||||
$command = $this->makeCommandWithDummyIo();
|
||||
$method = new \ReflectionMethod(CoreUpdateCommand::class, 'spawnUpgradeStepsProcess');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$result = $method->invoke($command, '0.0.0', '0.0.1', true, fn () => null);
|
||||
|
||||
$this->assertFalse($result, '일반 실패 시 false 반환 (in-process fallback 유도)');
|
||||
}
|
||||
|
||||
/**
|
||||
* resumeCommand 자동 생성 계약:
|
||||
* 자식이 resumeCommand=null 로 핸드오프 신호를 보낸 경우, 재구성된
|
||||
* UpgradeHandoffException::resumeCommand 는 null 이어야 한다 (부모 CoreUpdateCommand
|
||||
* 의 catch 블록이 sprintf 로 자동 생성하는 기본값 트리거 조건).
|
||||
*/
|
||||
public function test_spawn_preserves_null_resume_command_for_auto_generation(): void
|
||||
{
|
||||
if (! function_exists('proc_open')) {
|
||||
$this->markTestSkipped('proc_open 미지원 환경');
|
||||
}
|
||||
|
||||
File::put($this->handoffStepPath, <<<'PHP'
|
||||
<?php
|
||||
|
||||
namespace App\Upgrades;
|
||||
|
||||
use App\Contracts\Extension\UpgradeStepInterface;
|
||||
use App\Exceptions\UpgradeHandoffException;
|
||||
use App\Extension\UpgradeContext;
|
||||
|
||||
class Upgrade_0_0_1_test_integration_handoff implements UpgradeStepInterface
|
||||
{
|
||||
public function run(UpgradeContext $context): void
|
||||
{
|
||||
throw new UpgradeHandoffException(
|
||||
afterVersion: '0.0.0',
|
||||
reason: '자동 생성 필요',
|
||||
);
|
||||
}
|
||||
}
|
||||
PHP);
|
||||
|
||||
$command = $this->makeCommandWithDummyIo();
|
||||
$method = new \ReflectionMethod(CoreUpdateCommand::class, 'spawnUpgradeStepsProcess');
|
||||
$method->setAccessible(true);
|
||||
|
||||
try {
|
||||
$method->invoke($command, '0.0.0', '0.0.1', true, fn () => null);
|
||||
$this->fail('UpgradeHandoffException 이 전파되어야 한다');
|
||||
} catch (UpgradeHandoffException $e) {
|
||||
$this->assertNull(
|
||||
$e->resumeCommand,
|
||||
'resumeCommand 는 null 로 유지되어야 CoreUpdateCommand 가 자동 생성한다'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CoreUpdateCommand 를 OutputStyle 주입 없이 리플렉션 호출 가능한 형태로 준비.
|
||||
* spawnUpgradeStepsProcess 내부에서 $this->line / $this->info 를 호출하므로
|
||||
* OutputStyle 이 필요. Artisan 의 기본 Buffered Output 로 대체.
|
||||
*/
|
||||
private function makeCommandWithDummyIo(): CoreUpdateCommand
|
||||
{
|
||||
$command = app(CoreUpdateCommand::class);
|
||||
|
||||
$input = new \Symfony\Component\Console\Input\ArrayInput([]);
|
||||
$output = new \Symfony\Component\Console\Output\BufferedOutput;
|
||||
$style = new \Illuminate\Console\OutputStyle($input, $output);
|
||||
|
||||
$reflection = new \ReflectionClass($command);
|
||||
$property = $reflection->getProperty('output');
|
||||
$property->setAccessible(true);
|
||||
$property->setValue($command, $style);
|
||||
|
||||
// laravel-zero 등 컨테이너 의존 호출 대비 input 도 설정
|
||||
if ($reflection->hasProperty('input')) {
|
||||
$inputProp = $reflection->getProperty('input');
|
||||
$inputProp->setAccessible(true);
|
||||
$inputProp->setValue($command, $input);
|
||||
}
|
||||
|
||||
return $command;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Exceptions;
|
||||
|
||||
use App\Exceptions\UpgradeHandoffException;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* UpgradeHandoffException 단위 테스트
|
||||
*
|
||||
* 업그레이드 스텝이 새 PHP 프로세스 재진입을 요청할 때 던지는 sentinel exception.
|
||||
* 본 예외는 7.0.0-beta.3 에서 신설되었으며, 상위 계층(CoreUpdateCommand,
|
||||
* ExecuteUpgradeStepsCommand, spawnUpgradeStepsProcess) 이 이 예외의 필드를
|
||||
* 계약대로 읽어 동작하므로 필드/상수/메시지 계약을 고정한다.
|
||||
*/
|
||||
class UpgradeHandoffExceptionTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* EXIT_CODE 는 spawn 체인의 핸드오프 판정에 쓰이는 고정 값(sysexits.h EX_TEMPFAIL=75).
|
||||
* 이 값이 변경되면 spawnUpgradeStepsProcess 의 판정 로직과 어긋나므로 고정.
|
||||
*/
|
||||
public function test_exit_code_is_75(): void
|
||||
{
|
||||
$this->assertSame(75, UpgradeHandoffException::EXIT_CODE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 필수 필드(afterVersion, reason) 는 readonly 로 전달받아 그대로 노출.
|
||||
*/
|
||||
public function test_stores_required_fields_as_readonly(): void
|
||||
{
|
||||
$exception = new UpgradeHandoffException(
|
||||
afterVersion: '7.0.0-beta.2',
|
||||
reason: '신설 메서드 미로드',
|
||||
);
|
||||
|
||||
$this->assertSame('7.0.0-beta.2', $exception->afterVersion);
|
||||
$this->assertSame('신설 메서드 미로드', $exception->reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* resumeCommand 는 nullable — 생략 시 null.
|
||||
* CoreUpdateCommand catch 시점에 from/to 버전 기반으로 자동 생성된다.
|
||||
*/
|
||||
public function test_resume_command_defaults_to_null(): void
|
||||
{
|
||||
$exception = new UpgradeHandoffException(
|
||||
afterVersion: '7.0.0-beta.2',
|
||||
reason: '테스트',
|
||||
);
|
||||
|
||||
$this->assertNull($exception->resumeCommand);
|
||||
}
|
||||
|
||||
/**
|
||||
* resumeCommand 를 명시 전달하면 그대로 저장.
|
||||
*/
|
||||
public function test_resume_command_can_be_overridden(): void
|
||||
{
|
||||
$custom = 'php artisan custom:command --flag';
|
||||
$exception = new UpgradeHandoffException(
|
||||
afterVersion: '7.0.0-beta.2',
|
||||
reason: '테스트',
|
||||
resumeCommand: $custom,
|
||||
);
|
||||
|
||||
$this->assertSame($custom, $exception->resumeCommand);
|
||||
}
|
||||
|
||||
/**
|
||||
* 예외 메시지는 다국어(`exceptions.core_update.handoff`) 로부터 빌드되며
|
||||
* 필드 값이 치환된다. 현재 locale 에 맞는 언어로 빌드된다.
|
||||
*/
|
||||
public function test_message_contains_field_values(): void
|
||||
{
|
||||
$exception = new UpgradeHandoffException(
|
||||
afterVersion: '7.0.0-beta.2',
|
||||
reason: '고유한_사유_마커',
|
||||
resumeCommand: 'php artisan some:cmd',
|
||||
);
|
||||
|
||||
$message = $exception->getMessage();
|
||||
|
||||
// 필드 값 3가지가 메시지에 모두 포함되어야 한다 (한/영 메시지 공통)
|
||||
$this->assertStringContainsString('7.0.0-beta.2', $message);
|
||||
$this->assertStringContainsString('고유한_사유_마커', $message);
|
||||
$this->assertStringContainsString('php artisan some:cmd', $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* resumeCommand 가 null 인 경우 메시지에는 placeholder 문자열이 치환된다.
|
||||
* 예외 메시지는 주로 로그/디버깅용이며, 사용자에게 노출되는 실제 재실행 명령은
|
||||
* CoreUpdateCommand 가 별도로 생성해 출력한다.
|
||||
*/
|
||||
public function test_message_uses_placeholder_when_resume_command_is_null(): void
|
||||
{
|
||||
$exception = new UpgradeHandoffException(
|
||||
afterVersion: '7.0.0-beta.2',
|
||||
reason: '테스트',
|
||||
);
|
||||
|
||||
$message = $exception->getMessage();
|
||||
$this->assertStringContainsString('(auto)', $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* RuntimeException 상속 — catch 계층이 \Throwable / \RuntimeException 양쪽에서
|
||||
* 포착 가능해야 한다. 본 계약이 깨지면 상위 fallback catch 경로가 누락된다.
|
||||
*/
|
||||
public function test_extends_runtime_exception(): void
|
||||
{
|
||||
$exception = new UpgradeHandoffException(
|
||||
afterVersion: '7.0.0-beta.2',
|
||||
reason: '테스트',
|
||||
);
|
||||
|
||||
$this->assertInstanceOf(\RuntimeException::class, $exception);
|
||||
$this->assertInstanceOf(\Throwable::class, $exception);
|
||||
}
|
||||
}
|
||||
@@ -237,4 +237,134 @@ class FilePermissionHelperTest extends TestCase
|
||||
// 퍼미션 복원 시도 확인 (Windows에서는 값이 같을 수 있음)
|
||||
$this->assertEquals($originalPerms, fileperms($destFile));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// syncGroupWritability — sudo 업데이트 시 발생한 그룹 쓰기 권한 비대칭 정상화
|
||||
// (코어 7.0.0-beta.3 도입)
|
||||
//
|
||||
// 배경: sudo root 로 실행된 코어 업데이트가 storage/framework/cache 하위에
|
||||
// umask 022 로 신규 디렉토리(0755 drwxr-xr-x) 를 생성한 뒤 chownRecursive 가
|
||||
// 소유자만 jjh:www-data 로 복원하면, www-data 그룹에 쓰기 권한이 없어
|
||||
// php-fpm 이 cache 파일 생성 실패 (Permission denied).
|
||||
//
|
||||
// 정책: 루트가 g+w 면 하위 항목 중 g-w 인 디렉토리·파일을 g+w 로 승격.
|
||||
// 다른 비트 무변경. 루트가 g-w 면 no-op (운영자 정책 보존).
|
||||
//
|
||||
// 검증 대상은 chmod / fileperms 가 의미를 갖는 POSIX 환경 전용. Windows
|
||||
// 로컬은 자동 스킵하며, 실제 Linux CI / 운영 서버에서 의미 있는 검증 수행.
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Linux/macOS 환경 감지. Windows / chmod 미지원 환경은 스킵.
|
||||
*/
|
||||
private function assertPosixOrSkip(): void
|
||||
{
|
||||
if (DIRECTORY_SEPARATOR !== '/' || ! function_exists('posix_getuid')) {
|
||||
$this->markTestSkipped('chmod 검증은 POSIX 환경 전용 (Windows 로컬 자동 스킵)');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 루트 0775 + 자식·손자 0755 + 파일 0644 → 호출 후 자식·손자·파일 모두 g+w 승격.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function test_sync_group_writability_recovers_child_dirs_and_files(): void
|
||||
{
|
||||
$this->assertPosixOrSkip();
|
||||
|
||||
$root = $this->createTempDir();
|
||||
chmod($root, 0775);
|
||||
|
||||
$child = $root.DIRECTORY_SEPARATOR.'cache_hash_2c';
|
||||
mkdir($child, 0755);
|
||||
|
||||
$grandchild = $child.DIRECTORY_SEPARATOR.'ab';
|
||||
mkdir($grandchild, 0755);
|
||||
|
||||
$file = $grandchild.DIRECTORY_SEPARATOR.'cachekey';
|
||||
file_put_contents($file, 'data');
|
||||
chmod($file, 0644);
|
||||
|
||||
$changed = FilePermissionHelper::syncGroupWritability($root);
|
||||
|
||||
// 루트 정책 (0775) 그대로 + 하위 모두 g+w 승격
|
||||
$this->assertSame(0775, fileperms($root) & 0777, '루트는 변경되지 않음');
|
||||
$this->assertSame(0775, fileperms($child) & 0777, '자식 디렉토리 g+w 승격');
|
||||
$this->assertSame(0775, fileperms($grandchild) & 0777, '손자 디렉토리 g+w 승격');
|
||||
$this->assertSame(0664, fileperms($file) & 0777, '파일 g+w 승격 (0644 → 0664)');
|
||||
$this->assertSame(3, $changed, 'changed 카운트 = 자식 + 손자 + 파일');
|
||||
}
|
||||
|
||||
/**
|
||||
* 루트가 g-w (0755) 인 경우 — no-op. 운영자 정책 보존.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function test_sync_group_writability_respects_root_policy_without_group_write(): void
|
||||
{
|
||||
$this->assertPosixOrSkip();
|
||||
|
||||
$root = $this->createTempDir();
|
||||
chmod($root, 0755);
|
||||
|
||||
$child = $root.DIRECTORY_SEPARATOR.'sub';
|
||||
mkdir($child, 0755);
|
||||
|
||||
$changed = FilePermissionHelper::syncGroupWritability($root);
|
||||
|
||||
$this->assertSame(0755, fileperms($root) & 0777);
|
||||
$this->assertSame(0755, fileperms($child) & 0777, '루트가 g-w 면 자식 변경 없음');
|
||||
$this->assertSame(0, $changed);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미 정상 (자식·손자 모두 g+w) → no-op (멱등).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function test_sync_group_writability_is_idempotent(): void
|
||||
{
|
||||
$this->assertPosixOrSkip();
|
||||
|
||||
$root = $this->createTempDir();
|
||||
chmod($root, 0775);
|
||||
|
||||
$child = $root.DIRECTORY_SEPARATOR.'ok';
|
||||
mkdir($child, 0775);
|
||||
|
||||
$file = $root.DIRECTORY_SEPARATOR.'fine.txt';
|
||||
file_put_contents($file, 'x');
|
||||
chmod($file, 0664);
|
||||
|
||||
$changed = FilePermissionHelper::syncGroupWritability($root);
|
||||
|
||||
$this->assertSame(0775, fileperms($child) & 0777);
|
||||
$this->assertSame(0664, fileperms($file) & 0777);
|
||||
$this->assertSame(0, $changed, '이미 정상 → changed=0');
|
||||
}
|
||||
|
||||
/**
|
||||
* 다른 비트(other, owner, sticky 등) 무변경 — g+w 만 OR.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function test_sync_group_writability_only_adds_group_write_bit(): void
|
||||
{
|
||||
$this->assertPosixOrSkip();
|
||||
|
||||
$root = $this->createTempDir();
|
||||
chmod($root, 0775);
|
||||
|
||||
// 0700 = owner rwx만, group/other 무권한. 그룹에 w만 추가되어 0720이 되어야 함
|
||||
$file = $root.DIRECTORY_SEPARATOR.'restricted.bin';
|
||||
file_put_contents($file, '');
|
||||
chmod($file, 0700);
|
||||
|
||||
$changed = FilePermissionHelper::syncGroupWritability($root);
|
||||
|
||||
// 0700 → 0720 (g+w 만 OR, owner/other 비트 무변경)
|
||||
$this->assertSame(0720, fileperms($file) & 0777, 'g+w 만 추가, 다른 비트 무변경');
|
||||
$this->assertSame(1, $changed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Services;
|
||||
|
||||
use App\Exceptions\UpgradeHandoffException;
|
||||
use App\Services\CoreUpdateService;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* CoreUpdateService::runUpgradeSteps 의 예외 전파 계약 테스트
|
||||
*
|
||||
* 본 메서드는 어떤 예외도 catch 하지 않고 상위로 그대로 전파한다. 특히:
|
||||
* - 일반 예외(\Throwable): 상위 CoreUpdateCommand 의 catch(\Throwable) 경로에서
|
||||
* 백업 복원 + 실패 리포트 로직이 트리거된다.
|
||||
* - UpgradeHandoffException: 상위 CoreUpdateCommand 의 catch(UpgradeHandoffException)
|
||||
* 경로에서 maintenance 해제 + .env 고정 + 사용자 안내 로직이 트리거된다.
|
||||
*
|
||||
* 만약 runUpgradeSteps 가 실수로 예외를 삼키면 핸드오프 인프라 전체가 무력화되므로
|
||||
* 본 계약을 회귀 테스트로 고정.
|
||||
*/
|
||||
class CoreUpdateServiceHandoffPropagationTest extends TestCase
|
||||
{
|
||||
private string $upgradesPath;
|
||||
|
||||
private string $handoffStepPath;
|
||||
|
||||
private string $failingStepPath;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->upgradesPath = base_path('upgrades');
|
||||
$this->handoffStepPath = $this->upgradesPath.'/Upgrade_0_0_1_test_handoff.php';
|
||||
$this->failingStepPath = $this->upgradesPath.'/Upgrade_0_0_1_test_failing.php';
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
foreach ([$this->handoffStepPath, $this->failingStepPath] as $path) {
|
||||
if (File::exists($path)) {
|
||||
File::delete($path);
|
||||
}
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* UpgradeHandoffException 은 runUpgradeSteps 를 뚫고 상위로 그대로 전파되어야 한다.
|
||||
*/
|
||||
public function test_handoff_exception_propagates_up(): void
|
||||
{
|
||||
$code = <<<'PHP'
|
||||
<?php
|
||||
|
||||
namespace App\Upgrades;
|
||||
|
||||
use App\Contracts\Extension\UpgradeStepInterface;
|
||||
use App\Exceptions\UpgradeHandoffException;
|
||||
use App\Extension\UpgradeContext;
|
||||
|
||||
class Upgrade_0_0_1_test_handoff implements UpgradeStepInterface
|
||||
{
|
||||
public function run(UpgradeContext $context): void
|
||||
{
|
||||
throw new UpgradeHandoffException(
|
||||
afterVersion: '0.0.0',
|
||||
reason: '테스트 핸드오프',
|
||||
);
|
||||
}
|
||||
}
|
||||
PHP;
|
||||
|
||||
File::put($this->handoffStepPath, $code);
|
||||
|
||||
$service = new CoreUpdateService;
|
||||
|
||||
try {
|
||||
$service->runUpgradeSteps('0.0.0', '0.0.1', null, true);
|
||||
$this->fail('UpgradeHandoffException 이 상위로 전파되어야 한다');
|
||||
} catch (UpgradeHandoffException $e) {
|
||||
$this->assertSame('0.0.0', $e->afterVersion);
|
||||
$this->assertSame('테스트 핸드오프', $e->reason);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 일반 RuntimeException 도 상위로 그대로 전파되어야 한다 (catch 안 함).
|
||||
*/
|
||||
public function test_generic_exception_propagates_up(): void
|
||||
{
|
||||
$code = <<<'PHP'
|
||||
<?php
|
||||
|
||||
namespace App\Upgrades;
|
||||
|
||||
use App\Contracts\Extension\UpgradeStepInterface;
|
||||
use App\Extension\UpgradeContext;
|
||||
|
||||
class Upgrade_0_0_1_test_failing implements UpgradeStepInterface
|
||||
{
|
||||
public function run(UpgradeContext $context): void
|
||||
{
|
||||
throw new \RuntimeException('테스트 일반 실패');
|
||||
}
|
||||
}
|
||||
PHP;
|
||||
|
||||
File::put($this->failingStepPath, $code);
|
||||
|
||||
$service = new CoreUpdateService;
|
||||
|
||||
try {
|
||||
$service->runUpgradeSteps('0.0.0', '0.0.1', null, true);
|
||||
$this->fail('RuntimeException 이 상위로 전파되어야 한다');
|
||||
} catch (RuntimeException $e) {
|
||||
// UpgradeHandoffException 도 RuntimeException 상속이므로 구체 타입 확인
|
||||
$this->assertNotInstanceOf(UpgradeHandoffException::class, $e);
|
||||
$this->assertSame('테스트 일반 실패', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* upgrades 디렉토리에 매칭 스텝이 없으면 조용히 반환 (예외 없음).
|
||||
* 경계 조건 검증.
|
||||
*/
|
||||
public function test_no_matching_steps_returns_silently(): void
|
||||
{
|
||||
$service = new CoreUpdateService;
|
||||
|
||||
// 매우 작은 from~to 범위 — 어떤 실제 Upgrade_*.php 도 매칭되지 않음
|
||||
$service->runUpgradeSteps('0.0.0', '0.0.0', null, false);
|
||||
|
||||
$this->assertTrue(true, '예외 없이 종료되어야 한다');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Support;
|
||||
|
||||
use App\Support\UmaskHelper;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* UmaskHelper::configureForGroupSharing 단위 테스트
|
||||
*
|
||||
* 운영자 의도 존중 umask 조정 헬퍼. 본 테스트는 POSIX 의미의 `fileperms` 와
|
||||
* `umask` 동작을 검증하므로 Linux 환경에서만 의미 있는 결과를 낸다. Windows
|
||||
* 에서는 자동 스킵.
|
||||
*/
|
||||
class UmaskHelperTest extends TestCase
|
||||
{
|
||||
private string $tmpRoot;
|
||||
|
||||
private int $originalUmask;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->originalUmask = umask();
|
||||
$this->tmpRoot = storage_path('app/test-umask-helper-'.uniqid());
|
||||
File::ensureDirectoryExists($this->tmpRoot);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
// 헬퍼 호출로 변경된 umask 를 원래대로 복원 (다른 테스트 격리)
|
||||
umask($this->originalUmask);
|
||||
|
||||
if (File::isDirectory($this->tmpRoot)) {
|
||||
File::deleteDirectory($this->tmpRoot);
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function assertPosixOrSkip(): void
|
||||
{
|
||||
if (DIRECTORY_SEPARATOR !== '/' || ! function_exists('posix_getuid')) {
|
||||
$this->markTestSkipped('POSIX 전용 — Windows 에서는 fileperms/umask 의미 제한');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* storage 디렉토리에 g+w 가 설정되어 있으면 umask 를 0002 로 조정하고
|
||||
* 이전 umask 를 반환한다.
|
||||
*/
|
||||
public function test_sets_umask_when_storage_has_group_write(): void
|
||||
{
|
||||
$this->assertPosixOrSkip();
|
||||
|
||||
chmod($this->tmpRoot, 0775);
|
||||
|
||||
// 초기 umask 를 0022 로 고정해 비교 가능하게
|
||||
umask(0022);
|
||||
|
||||
$previous = UmaskHelper::configureForGroupSharing($this->tmpRoot);
|
||||
|
||||
$this->assertSame(0022, $previous, '이전 umask 를 반환해야 한다');
|
||||
$this->assertSame(0002, umask(), '현재 umask 는 0002 로 조정되어야 한다');
|
||||
}
|
||||
|
||||
/**
|
||||
* storage 디렉토리가 g-w 이면 운영자 의도 존중 — null 반환 + umask 변경 없음.
|
||||
*/
|
||||
public function test_skips_when_storage_has_no_group_write(): void
|
||||
{
|
||||
$this->assertPosixOrSkip();
|
||||
|
||||
chmod($this->tmpRoot, 0755);
|
||||
|
||||
umask(0022);
|
||||
|
||||
$result = UmaskHelper::configureForGroupSharing($this->tmpRoot);
|
||||
|
||||
$this->assertNull($result, 'g-w 환경에서는 null 반환 (no-op)');
|
||||
$this->assertSame(0022, umask(), 'umask 는 변경되지 않아야 한다');
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정한 경로가 디렉토리가 아니면 null.
|
||||
*/
|
||||
public function test_returns_null_when_path_is_not_a_directory(): void
|
||||
{
|
||||
$this->assertPosixOrSkip();
|
||||
|
||||
$nonExistent = $this->tmpRoot.'/does-not-exist';
|
||||
|
||||
umask(0022);
|
||||
|
||||
$result = UmaskHelper::configureForGroupSharing($nonExistent);
|
||||
|
||||
$this->assertNull($result);
|
||||
$this->assertSame(0022, umask(), 'umask 는 변경되지 않아야 한다');
|
||||
}
|
||||
|
||||
/**
|
||||
* 조정 후 런타임이 만드는 새 디렉토리가 실제로 g+w 를 포함하는지 검증.
|
||||
* umask 조정의 최종 효과를 확인.
|
||||
*/
|
||||
public function test_new_directory_inherits_group_write_after_adjustment(): void
|
||||
{
|
||||
$this->assertPosixOrSkip();
|
||||
|
||||
chmod($this->tmpRoot, 0775);
|
||||
|
||||
umask(0022);
|
||||
UmaskHelper::configureForGroupSharing($this->tmpRoot);
|
||||
|
||||
$newDir = $this->tmpRoot.'/new-child';
|
||||
mkdir($newDir, 0777);
|
||||
|
||||
$perms = fileperms($newDir) & 0777;
|
||||
$this->assertSame(0775, $perms, '조정된 umask(0002) 기준 mkdir 결과는 0775 여야 한다');
|
||||
}
|
||||
|
||||
/**
|
||||
* 원상 복원 — 헬퍼가 이전 umask 를 반환하므로 호출자가 그 값으로 되돌릴 수 있다.
|
||||
*/
|
||||
public function test_caller_can_restore_previous_umask(): void
|
||||
{
|
||||
$this->assertPosixOrSkip();
|
||||
|
||||
chmod($this->tmpRoot, 0775);
|
||||
|
||||
umask(0022);
|
||||
$previous = UmaskHelper::configureForGroupSharing($this->tmpRoot);
|
||||
|
||||
$this->assertSame(0022, $previous);
|
||||
$this->assertSame(0002, umask());
|
||||
|
||||
// 호출자가 반환값으로 원상 복원
|
||||
umask($previous);
|
||||
$this->assertSame(0022, umask());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Upgrades;
|
||||
|
||||
use App\Extension\UpgradeContext;
|
||||
use App\Upgrades\Upgrade_7_0_0_beta_3;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Upgrade_7_0_0_beta_3 단위 테스트
|
||||
*
|
||||
* 본 스텝은 경로 C (beta.2 부모 CoreUpdateCommand in-process) 로 실행되도록 설계됨.
|
||||
* spawn 자식 경로에서는 의도적 throw → beta.2 의 in-process fallback 재실행을 유도.
|
||||
* in-process 경로에서는 부모 umask(0002) 주입 + 로컬 재귀 chmod 로 기존 g-w 디렉토리를
|
||||
* g+w 로 승격.
|
||||
*
|
||||
* POSIX 전용 테스트는 Windows 에서 `assertPosixOrSkip()` 으로 자동 스킵.
|
||||
*/
|
||||
class Upgrade_7_0_0_beta_3Test extends TestCase
|
||||
{
|
||||
/**
|
||||
* upgrades/ 디렉토리 파일은 composer autoload 에 없으므로 테스트 진입 전 수동 로드.
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
if (! class_exists(Upgrade_7_0_0_beta_3::class, false)) {
|
||||
require_once base_path('upgrades/Upgrade_7_0_0_beta_3.php');
|
||||
}
|
||||
}
|
||||
|
||||
private function assertPosixOrSkip(): void
|
||||
{
|
||||
if (DIRECTORY_SEPARATOR !== '/' || ! function_exists('posix_getuid')) {
|
||||
$this->markTestSkipped('POSIX 전용 — chmod/fileperms/umask 의미 제한');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TARGETS 상수는 인스톨러 REQUIRED_DIRECTORIES (public/install/includes/config.php)
|
||||
* 및 config/app.php 의 restore_ownership_group_writable 기본값과 1:1 정렬되어야 한다.
|
||||
*/
|
||||
public function test_targets_constant_matches_config_default(): void
|
||||
{
|
||||
$reflection = new \ReflectionClassConstant(Upgrade_7_0_0_beta_3::class, 'TARGETS');
|
||||
$targets = $reflection->getValue();
|
||||
|
||||
$expected = [
|
||||
'storage',
|
||||
'bootstrap/cache',
|
||||
'vendor',
|
||||
'modules',
|
||||
'modules/_pending',
|
||||
'plugins',
|
||||
'plugins/_pending',
|
||||
'templates',
|
||||
'templates/_pending',
|
||||
'storage/app/core_pending',
|
||||
];
|
||||
|
||||
$this->assertSame($expected, $targets, 'TARGETS 목록은 인스톨러 SSoT 와 동기화되어야 한다');
|
||||
}
|
||||
|
||||
/**
|
||||
* config/app.php 의 restore_ownership_group_writable 기본값과도 동일해야 한다.
|
||||
*/
|
||||
public function test_targets_matches_config_app_restore_ownership_group_writable(): void
|
||||
{
|
||||
$configDefault = config('app.update.restore_ownership_group_writable');
|
||||
|
||||
$reflection = new \ReflectionClassConstant(Upgrade_7_0_0_beta_3::class, 'TARGETS');
|
||||
$targets = $reflection->getValue();
|
||||
|
||||
$this->assertSame(
|
||||
$targets,
|
||||
array_values($configDefault),
|
||||
'TARGETS 상수와 config 기본값이 1:1 동일해야 한다'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* spawn 자식 탐지 — argv 에 `core:execute-upgrade-steps` 가 있으면 의도적으로 throw.
|
||||
* beta.2 의 spawnUpgradeStepsProcess 가 exit != 0 감지 → in-process fallback 유도.
|
||||
*/
|
||||
public function test_throws_when_running_inside_spawned_child(): void
|
||||
{
|
||||
$originalArgv = $_SERVER['argv'] ?? null;
|
||||
$_SERVER['argv'] = ['php', 'artisan', 'core:execute-upgrade-steps', '--from=7.0.0-beta.2', '--to=7.0.0-beta.3'];
|
||||
|
||||
try {
|
||||
$step = new Upgrade_7_0_0_beta_3;
|
||||
$context = new UpgradeContext(
|
||||
fromVersion: '7.0.0-beta.2',
|
||||
toVersion: '7.0.0-beta.3',
|
||||
currentStep: '7.0.0-beta.3',
|
||||
);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessageMatches('/in-process/');
|
||||
|
||||
$step->run($context);
|
||||
} finally {
|
||||
if ($originalArgv === null) {
|
||||
unset($_SERVER['argv']);
|
||||
} else {
|
||||
$_SERVER['argv'] = $originalArgv;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* in-process 경로 (argv 에 core:update 등) 에서는 예외 없이 완료.
|
||||
* 현재 코드베이스의 storage 가 g+w 이든 g-w 이든 예외는 발생하지 않아야 한다
|
||||
* (운영자 의도 존중 분기 또는 정상 실행 어느 쪽이든 정상 종료).
|
||||
*/
|
||||
public function test_run_completes_without_exception_in_process(): void
|
||||
{
|
||||
$originalArgv = $_SERVER['argv'] ?? null;
|
||||
$_SERVER['argv'] = ['php', 'artisan', 'core:update'];
|
||||
|
||||
try {
|
||||
$step = new Upgrade_7_0_0_beta_3;
|
||||
$context = new UpgradeContext(
|
||||
fromVersion: '7.0.0-beta.2',
|
||||
toVersion: '7.0.0-beta.3',
|
||||
currentStep: '7.0.0-beta.3',
|
||||
);
|
||||
|
||||
$step->run($context);
|
||||
|
||||
$this->assertTrue(true, 'run() 이 예외 없이 완료되었다');
|
||||
} finally {
|
||||
if ($originalArgv === null) {
|
||||
unset($_SERVER['argv']);
|
||||
} else {
|
||||
$_SERVER['argv'] = $originalArgv;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 경로 C 규율 준수 검증: 파일에서 `FilePermissionHelper::syncGroupWritability` 호출이
|
||||
* 제거되었는지 확인. 경로 C 는 신설 메서드 호출 금지.
|
||||
*/
|
||||
public function test_does_not_call_new_file_permission_helper_method(): void
|
||||
{
|
||||
$source = file_get_contents(base_path('upgrades/Upgrade_7_0_0_beta_3.php'));
|
||||
|
||||
// 호출 패턴(`::메서드명(`) 만 잡는다. docblock 설명 텍스트는 허용.
|
||||
$this->assertStringNotContainsString(
|
||||
'FilePermissionHelper::syncGroupWritability(',
|
||||
$source,
|
||||
'경로 C 규율: beta.3 신설 메서드 호출 금지. 로컬 private 메서드로 인라인되어야 한다'
|
||||
);
|
||||
|
||||
// use 문도 제거되어야 한다 (의존성 최소화)
|
||||
$this->assertStringNotContainsString(
|
||||
'use App\Extension\Helpers\FilePermissionHelper;',
|
||||
$source,
|
||||
'FilePermissionHelper import 제거 — 경로 C 는 인라인 로직만 사용'
|
||||
);
|
||||
|
||||
$this->assertStringContainsString(
|
||||
'@upgrade-path C',
|
||||
$source,
|
||||
'docblock 에 @upgrade-path C 선언이 있어야 한다'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* in-process 실행 시 storage g+w 환경에서는 umask 가 0002 로 전환되어야 한다.
|
||||
*/
|
||||
public function test_sets_umask_to_0002_when_storage_has_group_write(): void
|
||||
{
|
||||
$this->assertPosixOrSkip();
|
||||
|
||||
// storage 가 g+w 인 경우에만 의미. 테스트 환경 storage 퍼미션 확인.
|
||||
$storagePath = base_path('storage');
|
||||
if (! is_dir($storagePath)) {
|
||||
$this->markTestSkipped('storage/ 디렉토리 미존재');
|
||||
}
|
||||
$perms = fileperms($storagePath);
|
||||
if (($perms & 0020) === 0) {
|
||||
$this->markTestSkipped('테스트 환경 storage 가 g-w — 운영자 의도 존중 스킵 분기라 검증 불가');
|
||||
}
|
||||
|
||||
$originalUmask = umask(0022);
|
||||
$originalArgv = $_SERVER['argv'] ?? null;
|
||||
$_SERVER['argv'] = ['php', 'artisan', 'core:update'];
|
||||
|
||||
try {
|
||||
$step = new Upgrade_7_0_0_beta_3;
|
||||
$context = new UpgradeContext(
|
||||
fromVersion: '7.0.0-beta.2',
|
||||
toVersion: '7.0.0-beta.3',
|
||||
currentStep: '7.0.0-beta.3',
|
||||
);
|
||||
|
||||
$step->run($context);
|
||||
|
||||
$this->assertSame(0002, umask(), 'in-process 실행 후 umask 는 0002 로 전환되어야 한다');
|
||||
} finally {
|
||||
umask($originalUmask);
|
||||
if ($originalArgv === null) {
|
||||
unset($_SERVER['argv']);
|
||||
} else {
|
||||
$_SERVER['argv'] = $originalArgv;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,49 @@ if (! file_exists(__DIR__.'/../.env.testing')) {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 프로덕션 DB 오염 방지 가드 (테스트 vs 프로덕션 DB 이름 충돌 차단)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| phpunit.xml 의 `DB_*_DATABASE` 하드코딩을 제거하고 `.env.testing` 을 SSoT 로
|
||||
| 삼았으므로, 실수로 `.env.testing` 의 DB 이름을 `.env` (프로덕션) 과 동일하게
|
||||
| 설정할 경우 테스트가 프로덕션 DB 를 파괴할 수 있다.
|
||||
|
|
||||
| 여기서 양쪽의 DB_WRITE_DATABASE 를 비교하여 동일하면 즉시 중단한다.
|
||||
|
|
||||
*/
|
||||
$parseDbName = static function (string $envFile): ?string {
|
||||
if (! file_exists($envFile)) {
|
||||
return null;
|
||||
}
|
||||
foreach (file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '' || str_starts_with($line, '#')) {
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/^DB_WRITE_DATABASE\s*=\s*(.+)$/', $line, $m)) {
|
||||
// 따옴표 제거
|
||||
return trim($m[1], "\"' \t");
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
$prodDbName = $parseDbName(__DIR__.'/../.env');
|
||||
$testDbName = $parseDbName(__DIR__.'/../.env.testing');
|
||||
|
||||
if ($prodDbName !== null && $testDbName !== null && $prodDbName === $testDbName) {
|
||||
fwrite(STDERR, "\n".str_repeat('=', 60)."\n");
|
||||
fwrite(STDERR, " ERROR: 프로덕션 DB 오염 위험 — 테스트 중단.\n\n");
|
||||
fwrite(STDERR, " .env 와 .env.testing 의 DB_WRITE_DATABASE 가 동일합니다: {$prodDbName}\n\n");
|
||||
fwrite(STDERR, " 테스트용 별도 DB 를 사용하도록 .env.testing 을 수정하세요.\n");
|
||||
fwrite(STDERR, " (예: DB_WRITE_DATABASE={$prodDbName}_testing)\n");
|
||||
fwrite(STDERR, str_repeat('=', 60)."\n\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Config 캐시 삭제 (테스트 환경 보장)
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
<?php
|
||||
|
||||
namespace App\Upgrades;
|
||||
|
||||
use App\Contracts\Extension\UpgradeStepInterface;
|
||||
use App\Extension\UpgradeContext;
|
||||
use FilesystemIterator;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* 코어 7.0.0-beta.3 업그레이드 스텝
|
||||
*
|
||||
* sudo root 로 실행된 beta.2 → beta.3 업데이트가 `storage/framework/cache` 등 하위
|
||||
* 디렉토리를 umask 022 환경에서 `0755` (drwxr-xr-x) 로 생성한 뒤, 이후 첫 웹 요청에서
|
||||
* php-fpm(www-data 그룹) 이 그 안에 파일을 쓰지 못해 "Permission denied" 500 에러가
|
||||
* 발생하던 문제를 1회성으로 구조적으로 정정한다.
|
||||
*
|
||||
* 실패한 1차 접근 (spawn 자식, 경로 B):
|
||||
*
|
||||
* 초기에 경로 B 로 설계했으나 근본 원인을 해결하지 못함이 밝혀졌다.
|
||||
*
|
||||
* - 문제 생성 시점: beta.2 CoreUpdateCommand (부모) 의 Step 11 cleanup 이후 일괄
|
||||
* 확장 업데이트 프롬프트 내에서 Laravel 재부팅 (CoreServiceProvider::boot →
|
||||
* ModuleManager::loadModules → CachesModuleStatus → FileStore::put) 이
|
||||
* `storage/framework/cache/data/<hash>` 디렉토리를 신규 생성하는 순간.
|
||||
* - 이 cache 쓰기는 beta.2 **부모 프로세스 내 Artisan::call** 경로를 타므로 부모의
|
||||
* umask 022 로 `drwxr-xr-x` 생성.
|
||||
* - spawn 자식에서 아무리 `umask(0002)` 를 호출하거나 `syncGroupWritability` 를
|
||||
* 돌려도 그것은 자식에만 영향. 자식 종료 후 부모가 재생성하는 디렉토리에 무효.
|
||||
*
|
||||
* 채택 방식 (경로 C, in-process):
|
||||
*
|
||||
* 본 스텝은 **beta.2 CoreUpdateCommand 의 in-process fallback 경로로만 실행되도록**
|
||||
* 설계한다. 그 경로에서 `umask(0002)` 을 호출하면 beta.2 부모 프로세스 자체의 umask
|
||||
* 가 바뀌어 Step 11 이후 모든 cache/session/view/log 파일 생성이 `g+w` 를 유지한다.
|
||||
*
|
||||
* - spawn 자식 (argv 에 `core:execute-upgrade-steps` 포함) 에서 실행되면 **의도적으로
|
||||
* 예외를 던져 exit 1 유도**. beta.2 의 `spawnUpgradeStepsProcess` 가 false 반환
|
||||
* → in-process fallback 경로 진입 → 본 스텝이 부모 프로세스 내에서 재실행.
|
||||
* - in-process 실행에서 `umask(0002)` 주입 + 인라인 재귀 chmod 로 기존 g-w 디렉토리도
|
||||
* g+w 로 승격.
|
||||
*
|
||||
* @upgrade-path C
|
||||
*
|
||||
* 경로 C 규율 준수:
|
||||
* - 이전 버전(beta.2) 의 메모리에서 실행되므로 beta.2 에 없는 **신설 메서드/클래스 호출
|
||||
* 금지**. 과거 구현에 있던 `FilePermissionHelper` 의 `syncGroupWritability` 정적
|
||||
* 호출을 제거하고 인라인 private 메서드로 재작성.
|
||||
* - Laravel Facade (`File::isDirectory` 등) 사용은 beta.2 에도 존재하므로 허용이지만
|
||||
* 의존성 최소화를 위해 순수 PHP 함수만 사용.
|
||||
*
|
||||
* 운영자 의도 존중:
|
||||
* - `storage/` 가 g-w 로 설정된 공유 호스팅 등 특수 환경에서는 umask 변경·chmod 순회
|
||||
* 모두 스킵. 업그레이드 후 권한 이슈가 없는 구성이므로 정상.
|
||||
*
|
||||
* 상세: docs/extension/upgrade-step-guide.md 섹션 9, 10.
|
||||
*/
|
||||
class Upgrade_7_0_0_beta_3 implements UpgradeStepInterface
|
||||
{
|
||||
/**
|
||||
* 그룹 쓰기 권한 정상화 대상 — 인스톨러 SSoT 와 1:1 정렬.
|
||||
*
|
||||
* 출처: `public/install/includes/config.php` 의 `REQUIRED_DIRECTORIES` 키 목록.
|
||||
* config/app.php 의 `restore_ownership_group_writable` 기본값과 동일.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
private const TARGETS = [
|
||||
'storage',
|
||||
'bootstrap/cache',
|
||||
'vendor',
|
||||
'modules',
|
||||
'modules/_pending',
|
||||
'plugins',
|
||||
'plugins/_pending',
|
||||
'templates',
|
||||
'templates/_pending',
|
||||
'storage/app/core_pending',
|
||||
];
|
||||
|
||||
/**
|
||||
* 업그레이드 스텝을 실행합니다.
|
||||
*
|
||||
* spawn 자식에서 호출된 경우에는 의도적으로 실패(throw)하여 beta.2 부모의 in-process
|
||||
* fallback 경로로 재실행 유도. in-process 에서는 부모 프로세스 umask 를 0002 로
|
||||
* 주입하고 기존 g-w 디렉토리를 g+w 로 승격한다.
|
||||
*
|
||||
* @param UpgradeContext $context 업그레이드 컨텍스트
|
||||
*
|
||||
* @throws RuntimeException spawn 자식에서 호출된 경우 (의도적 실패)
|
||||
*/
|
||||
public function run(UpgradeContext $context): void
|
||||
{
|
||||
// spawn 자식 탐지 — beta.2 의 spawnUpgradeStepsProcess 가 proc_open 으로 띄운
|
||||
// `php artisan core:execute-upgrade-steps ...` 프로세스에서 실행 중이면
|
||||
// argv 에 해당 커맨드명이 포함된다.
|
||||
if ($this->isSpawnedChild()) {
|
||||
$context->logger->warning(
|
||||
'[beta.3] spawn 자식에서 호출됨 — 부모 프로세스 umask 주입을 위해 의도적 실패 '
|
||||
.'(beta.2 CoreUpdateCommand 가 in-process fallback 으로 자동 재실행)'
|
||||
);
|
||||
|
||||
throw new RuntimeException(
|
||||
'Upgrade_7_0_0_beta_3 은 부모 프로세스 in-process 실행이 필요합니다. '
|
||||
.'beta.2 CoreUpdateCommand 의 spawn exit != 0 감지 후 fallback 으로 재진입합니다.'
|
||||
);
|
||||
}
|
||||
|
||||
$context->logger->info('[beta.3] 그룹 쓰기 권한 정상화 시작 (부모 프로세스 in-process)');
|
||||
|
||||
if (! function_exists('chmod')) {
|
||||
$context->logger->info('[beta.3] chmod 미지원 환경 — 스킵');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 운영자 의도 존중 — storage 가 g-w 면 전체 스킵
|
||||
$storagePath = base_path('storage');
|
||||
if (! is_dir($storagePath)) {
|
||||
$context->logger->info('[beta.3] storage/ 디렉토리 없음 — 스킵');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$storagePerms = @fileperms($storagePath);
|
||||
if ($storagePerms === false || ($storagePerms & 0020) === 0) {
|
||||
$context->logger->info('[beta.3] storage/ 에 그룹 쓰기 비활성 — 운영자 의도 존중, 스킵');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 1) 부모 프로세스 umask 를 0002 로 전환. Step 11 이후 beta.2 가 생성할 모든
|
||||
// cache/session/view/log 파일이 `g+w` 를 유지하도록 한다.
|
||||
if (function_exists('umask')) {
|
||||
$previousUmask = umask(0002);
|
||||
$context->logger->info(sprintf(
|
||||
'[beta.3] 부모 프로세스 umask 전환: 0%03o → 0002',
|
||||
$previousUmask & 0777
|
||||
));
|
||||
}
|
||||
|
||||
// 2) 기존에 이미 g-w 로 생성된 디렉토리/파일을 g+w 로 승격 (재귀 순회).
|
||||
$totalChanged = 0;
|
||||
$touched = [];
|
||||
|
||||
foreach (self::TARGETS as $rel) {
|
||||
$path = base_path($rel);
|
||||
if (! is_dir($path)) {
|
||||
$context->logger->info("[beta.3] 경로 없음 — 스킵: {$rel}");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$changed = $this->syncGroupWritableLocally($path);
|
||||
$totalChanged += $changed;
|
||||
|
||||
if ($changed > 0) {
|
||||
$touched[$rel] = $changed;
|
||||
$context->logger->info("[beta.3] 권한 복구: {$rel} (변경 {$changed}건)");
|
||||
} else {
|
||||
$context->logger->info("[beta.3] 정상 상태 — 변경 불필요: {$rel}");
|
||||
}
|
||||
}
|
||||
|
||||
$context->logger->info('[beta.3] 그룹 쓰기 권한 정상화 완료', [
|
||||
'total_changed' => $totalChanged,
|
||||
'touched' => $touched,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 프로세스가 `core:execute-upgrade-steps` spawn 자식인지 판정.
|
||||
*
|
||||
* beta.2 의 spawnUpgradeStepsProcess 가 `php artisan core:execute-upgrade-steps
|
||||
* --from=... --to=...` 형태로 띄우므로 argv 에 해당 커맨드명이 포함된다.
|
||||
*/
|
||||
private function isSpawnedChild(): bool
|
||||
{
|
||||
$argv = $_SERVER['argv'] ?? null;
|
||||
if (! is_array($argv)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($argv as $arg) {
|
||||
if ($arg === 'core:execute-upgrade-steps') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 로컬 재귀 chmod — `FilePermissionHelper::syncGroupWritability` 와 동등한 로직을
|
||||
* 경로 C 규율에 맞춰 스텝 파일 내부에 인라인 복제.
|
||||
*
|
||||
* 정책:
|
||||
* - 루트가 g-w 면 no-op (정책 보존)
|
||||
* - 하위 재귀 순회하며 g-w 항목을 g+w 로 승격
|
||||
* - 그 외 비트 무변경 (g+w 만 OR)
|
||||
* - symbolic link 는 링크 자체만 처리 (대상 미추적)
|
||||
* - silent fail — 권한 부족 / chmod 실패 시 카운트만 누락
|
||||
*
|
||||
* @return int 실제 chmod 한 항목 수
|
||||
*/
|
||||
private function syncGroupWritableLocally(string $root): int
|
||||
{
|
||||
$rootPerms = @fileperms($root);
|
||||
if ($rootPerms === false || ($rootPerms & 0020) === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$changed = 0;
|
||||
$this->walkAndElevate($root, $changed, isRoot: true);
|
||||
|
||||
return $changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 재귀 순회 내부 구현. $changed 를 참조로 누적.
|
||||
*/
|
||||
private function walkAndElevate(string $path, int &$changed, bool $isRoot): void
|
||||
{
|
||||
if (is_link($path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $isRoot) {
|
||||
$perms = @fileperms($path);
|
||||
if ($perms !== false && ($perms & 0020) === 0) {
|
||||
if (@chmod($path, $perms | 0020)) {
|
||||
$changed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! is_dir($path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$items = new FilesystemIterator($path, FilesystemIterator::SKIP_DOTS);
|
||||
foreach ($items as $item) {
|
||||
$this->walkAndElevate($item->getPathname(), $changed, isRoot: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user