v7.0.0-beta.3 release

This commit is contained in:
HeuJung
2026-04-23 17:36:31 +09:00
parent d1e274165a
commit 05db17887d
45 changed files with 2822 additions and 58 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+15
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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>
+120 -3
View File
@@ -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 의 내부 재귀 구현. 실패 카운터를 참조 전달로 집계한다.
*
+39
View File
@@ -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,
+64
View File
@@ -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);
}
}
+18
View File
@@ -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
View File
@@ -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'
)))),
],
];
+99
View File
@@ -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) - 확장 시스템 전체 개요
+92
View File
@@ -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 이 수행하는 데이터 변경은 단순 "마이그레이션" 이 아니라 **완전 동기화** 를 지향한다. 즉:
+44
View File
@@ -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` 상태는 **레이아웃 이름 기준으로 선택적 초기화**됩니다:
+49
View File
@@ -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. 데이터베이스
+5
View File
@@ -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.',
+5
View File
@@ -42,6 +42,11 @@ return [
'save_failed' => '설정 저장에 실패했습니다: :category',
],
// 코어 업데이트 관련 예외
'core_update' => [
'handoff' => '업그레이드 핸드오프: :after_version 까지 완료 — :reason (재실행: :resume_command)',
],
// Vendor 번들/설치 관련 예외
'vendor' => [
'composer_not_available' => 'Composer를 실행할 수 없는 환경입니다. 번들 모드를 사용하세요.',
+4 -3
View File
@@ -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,
});
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -13,4 +13,4 @@
"src": "resources/js/app.js",
"isEntry": true
}
}
}
+4
View File
@@ -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, '예외 없이 종료되어야 한다');
}
}
+142
View File
@@ -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;
}
}
}
}
+43
View File
@@ -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 캐시 삭제 (테스트 환경 보장)
+246
View File
@@ -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);
}
}
}