v7.0.0-beta.7 release
This commit is contained in:
1 parent
c186d9a9c2
commit
0e7fa04b03
36 files changed
+2625
-83
No files matched your search
@@ -40,6 +40,45 @@ class InstallerRuntimeServiceProvider extends ServiceProvider
|
||||
$runtimePath = base_path(self::RUNTIME_PATH_RELATIVE);
|
||||
|
||||
if (! is_file($runtimePath)) {
|
||||
// beta.7 한시 보정 — 이미 finalize 완료된 환경의 stale process ENV 회귀 차단.
|
||||
//
|
||||
// 배경 (이슈 #371): beta.4~6 손상 환경에서 beta.7 코어 업그레이드의 spawn 자식 1
|
||||
// (core:execute-upgrade-steps) 이 .env 머지 + runtime.php 삭제를 마쳐도, 부모
|
||||
// 프로세스의 process ENV (getenv / $_ENV / $_SERVER) 는 부팅 시점의 stale 값
|
||||
// (.env.example fallback: DB_WRITE_USERNAME=root) 그대로 유지된다. 그 직후 부모가
|
||||
// 띄우는 spawn 자식 2 (core:execute-bundled-updates) 에 stale ENV 가 그대로
|
||||
// 상속되어 Laravel Dotenv::createImmutable() 이 디스크의 정합 .env 값을 보지 못해
|
||||
// env('DB_WRITE_USERNAME') 이 stale 'root' 반환 → CoreServiceProvider::boot() 의
|
||||
// isDatabaseConnectionValid() 가드가 트리거되어 확장 로딩 skip + ERROR 출력 →
|
||||
// 번들 일괄 업데이트 전체 실패.
|
||||
//
|
||||
// 본 보정은 자식 2 가 부팅할 때 InstallerRuntimeServiceProvider::register() (가장 먼저
|
||||
// 실행되는 ServiceProvider) 안에서 다음 4개 조건을 모두 만족하면 디스크 .env 를
|
||||
// 직접 파싱하여 process ENV 를 갱신한다:
|
||||
// (1) runtime.php 부재 (이미 finalize 완료된 상태 — 본 분기에 도달)
|
||||
// (2) .env 존재
|
||||
// (3) .env 의 INSTALLER_COMPLETED 가 truthy (finalize 성공 신호)
|
||||
// (4) .env 의 DB_WRITE_USERNAME 이 정합 (root 아님 + 빈 값 아님)
|
||||
// AND process ENV 의 DB_WRITE_USERNAME 이 stale (root 또는 빈 값)
|
||||
//
|
||||
// 정상 환경에서는 (4) 의 process ENV stale 시그니처가 false 이므로 보정 미발동.
|
||||
$this->recoverStaleProcessEnvFromDiskEnv();
|
||||
|
||||
// 본 PR 부수효과 정리 — beta.7 업그레이드 흐름의 모든 root 권한 자식 spawn
|
||||
// 부팅마다 자동으로 storage/framework/cache/data/ 하위 root 소유 디렉토리를
|
||||
// 즉시 chown. 자식 1 (FinalizeOrphanedInstallerRuntime) 종료 후에 자식 2~N
|
||||
// (번들 update / 후속 자식들) 이 root 권한으로 캐시 쓰기를 트리거하더라도
|
||||
// 그 자식 자체의 ServiceProvider 부팅 시점에 본 보정이 발화하여 정리.
|
||||
//
|
||||
// 가드:
|
||||
// (1) runtime.php 부재 (이미 finalize 완료 — 본 분기에 도달)
|
||||
// (2) 디스크 config/app.php 의 version === '7.0.0-beta.7' (recover 와 동일 가드)
|
||||
// (3) posix_geteuid() === 0 (root 권한 자식만)
|
||||
//
|
||||
// 정상 환경(beta.3 / beta.8+) 또는 PHP-FPM 워커(www-data 권한) 는 (2) 또는 (3)
|
||||
// 가드로 자동 비활성화.
|
||||
$this->normalizeRootOwnedCacheDirs();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -53,6 +92,328 @@ class InstallerRuntimeServiceProvider extends ServiceProvider
|
||||
$this->applyAppKey($runtime);
|
||||
}
|
||||
|
||||
/**
|
||||
* beta.7 한시 보정 — 디스크 .env 가 정합이고 process ENV 가 stale 일 때만 갱신.
|
||||
*
|
||||
* 트리거 조건이 매우 보수적이라 정상 환경 부팅 비용은 .env 의 parseEnvFile 1회 + 키 4~5개
|
||||
* 비교. 손상 환경에서만 putenv/$_ENV/$_SERVER 갱신이 일어남.
|
||||
*/
|
||||
private function recoverStaleProcessEnvFromDiskEnv(): void
|
||||
{
|
||||
// beta.7 한시 가드 — 본 보정은 beta.4~6 finalize 결함 환경의 회귀 차단 전용.
|
||||
// 디스크 config/app.php 의 'version' 값을 라인 매칭으로 추출 (메모리/env 무관)
|
||||
// 하여 정확히 beta.7 로 업그레이드 중인 경우에만 발화. 메모리 config('app.version')
|
||||
// 은 부모 stale ENV 의 APP_VERSION 을 따라가므로 신뢰할 수 없음.
|
||||
$coreVersion = $this->readCoreVersionFromDisk();
|
||||
if ($coreVersion !== '7.0.0-beta.7') {
|
||||
return;
|
||||
}
|
||||
|
||||
$envPath = base_path('.env');
|
||||
|
||||
if (! is_file($envPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// parse_ini_file 은 .env 가 PHP INI 와 100% 호환되지 않는 케이스 (예약어 값,
|
||||
// 일부 특수문자) 에서 실패 가능. 라인 단위 직접 파싱으로 호환성 함정 회피.
|
||||
$parsed = $this->parseEnvFile($envPath);
|
||||
|
||||
if (! is_array($parsed) || $parsed === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 조건 (3): INSTALLER_COMPLETED 가 truthy 여야 함 (finalize 완료 신호)
|
||||
$completedFlagRaw = (string) ($parsed['INSTALLER_COMPLETED'] ?? '');
|
||||
$completedFlag = strtolower(trim($completedFlagRaw, " \t\"'"));
|
||||
if (! in_array($completedFlag, ['true', '1', 'yes'], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 조건 (4-a): .env 의 DB_WRITE_USERNAME 이 정합 (root 아님 + 비어있지 않음)
|
||||
$diskUsernameRaw = (string) ($parsed['DB_WRITE_USERNAME'] ?? '');
|
||||
$diskUsername = $this->unwrapEnvValue($diskUsernameRaw);
|
||||
if ($diskUsername === '' || $diskUsername === 'root') {
|
||||
return;
|
||||
}
|
||||
|
||||
// 조건 (4-b): process ENV 의 DB_WRITE_USERNAME 이 stale (root 또는 빈 값).
|
||||
//
|
||||
// bootstrap/app.php 의 Env::disablePutenv() 로 인해 getenv 가 빈 값일 수 있으므로,
|
||||
// getenv / $_ENV / $_SERVER 셋 중 어느 하나라도 정합 (root 아님 + 빈 값 아님) 이면
|
||||
// 정상 환경으로 판정하여 보정 skip. 셋 다 stale (root 또는 빈) 일 때만 진입.
|
||||
$envUsername = (string) getenv('DB_WRITE_USERNAME');
|
||||
$envUsernameSE = (string) ($_ENV['DB_WRITE_USERNAME'] ?? '');
|
||||
$serverUsernameSE = (string) ($_SERVER['DB_WRITE_USERNAME'] ?? '');
|
||||
foreach ([$envUsername, $envUsernameSE, $serverUsernameSE] as $candidate) {
|
||||
if ($candidate !== '' && $candidate !== 'root') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 4개 조건 모두 만족 — process ENV 갱신
|
||||
$refreshableKeys = [
|
||||
'DB_WRITE_HOST', 'DB_WRITE_PORT', 'DB_WRITE_DATABASE', 'DB_WRITE_USERNAME', 'DB_WRITE_PASSWORD',
|
||||
'DB_READ_HOST', 'DB_READ_PORT', 'DB_READ_DATABASE', 'DB_READ_USERNAME', 'DB_READ_PASSWORD',
|
||||
'DB_PREFIX', 'APP_KEY',
|
||||
];
|
||||
|
||||
foreach ($refreshableKeys as $key) {
|
||||
if (! array_key_exists($key, $parsed)) {
|
||||
continue;
|
||||
}
|
||||
$value = $this->unwrapEnvValue((string) $parsed[$key]);
|
||||
putenv($key.'='.$value);
|
||||
$_ENV[$key] = $value;
|
||||
$_SERVER[$key] = $value;
|
||||
}
|
||||
|
||||
// Laravel Config 도 동기 갱신 — register() 시점에 LoadConfiguration 이 이미 실행되어
|
||||
// process ENV 만 갱신해도 config('database.connections.mysql.*') 가 stale 값
|
||||
// (root) 으로 캐시된 상태이기 때문에 직접 덮어써야 한다.
|
||||
$this->applyConfigFromParsedEnv($parsed);
|
||||
}
|
||||
|
||||
/**
|
||||
* recover 분기 — 파싱된 .env 의 DB_* / APP_KEY / DB_PREFIX 를 Laravel Config 에 적용.
|
||||
*
|
||||
* 기존 `applyDatabaseConfig` 와 동등한 키 경로 / 구조 (`database.connections.mysql.{read,write}.*`).
|
||||
*
|
||||
* @param array<string, string> $parsed
|
||||
*/
|
||||
private function applyConfigFromParsedEnv(array $parsed): void
|
||||
{
|
||||
$writeHost = $parsed['DB_WRITE_HOST'] ?? null;
|
||||
if ($writeHost !== null && $writeHost !== '') {
|
||||
Config::set('database.connections.mysql.write.host', [$writeHost]);
|
||||
Config::set('database.connections.mysql.write.port', $parsed['DB_WRITE_PORT'] ?? '3306');
|
||||
Config::set('database.connections.mysql.write.database', $parsed['DB_WRITE_DATABASE'] ?? '');
|
||||
Config::set('database.connections.mysql.write.username', $parsed['DB_WRITE_USERNAME'] ?? '');
|
||||
Config::set('database.connections.mysql.write.password', $parsed['DB_WRITE_PASSWORD'] ?? '');
|
||||
}
|
||||
|
||||
$readHost = $parsed['DB_READ_HOST'] ?? null;
|
||||
if ($readHost !== null && $readHost !== '') {
|
||||
Config::set('database.connections.mysql.read.host', [$readHost]);
|
||||
Config::set('database.connections.mysql.read.port', $parsed['DB_READ_PORT'] ?? '3306');
|
||||
Config::set('database.connections.mysql.read.database', $parsed['DB_READ_DATABASE'] ?? '');
|
||||
Config::set('database.connections.mysql.read.username', $parsed['DB_READ_USERNAME'] ?? '');
|
||||
Config::set('database.connections.mysql.read.password', $parsed['DB_READ_PASSWORD'] ?? '');
|
||||
} elseif ($writeHost !== null && $writeHost !== '') {
|
||||
// read 가 별도 지정되지 않은 경우 write 값으로 동기화 (단일 DB 시나리오)
|
||||
Config::set('database.connections.mysql.read.host', [$writeHost]);
|
||||
Config::set('database.connections.mysql.read.port', $parsed['DB_WRITE_PORT'] ?? '3306');
|
||||
Config::set('database.connections.mysql.read.database', $parsed['DB_WRITE_DATABASE'] ?? '');
|
||||
Config::set('database.connections.mysql.read.username', $parsed['DB_WRITE_USERNAME'] ?? '');
|
||||
Config::set('database.connections.mysql.read.password', $parsed['DB_WRITE_PASSWORD'] ?? '');
|
||||
}
|
||||
|
||||
if (isset($parsed['DB_PREFIX'])) {
|
||||
Config::set('database.connections.mysql.prefix', $parsed['DB_PREFIX']);
|
||||
}
|
||||
|
||||
$appKey = $parsed['APP_KEY'] ?? null;
|
||||
if (is_string($appKey) && $appKey !== '') {
|
||||
Config::set('app.key', $appKey);
|
||||
}
|
||||
|
||||
// 기존 connection 인스턴스의 stale credential 캐시 폐기 — 다음 DB::connection() 호출 시
|
||||
// 새 Config 로 재인스턴스화되도록 강제. config.write.username 이 stale root 로
|
||||
// 남아 있던 회귀의 부속 원인 차단 (DB Manager 의 connection 캐시).
|
||||
try {
|
||||
\Illuminate\Support\Facades\DB::purge('mysql');
|
||||
} catch (\Throwable $e) {
|
||||
// non-fatal — Config 만 갱신된 상태로도 다음 connection 시 정합 값 사용
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* beta.7 업그레이드 자식 spawn 부팅마다 자동 발동 — storage/framework/cache/data/
|
||||
* 하위 root 소유 디렉토리/파일을 정상 owner/group 으로 chown.
|
||||
*
|
||||
* 본 PR 의 recover 가 발화하면서 부수적으로 만들 수 있는 root 권한 캐시 디렉토리를
|
||||
* 자식 자체의 부팅 시점에 자동 청소한다. 안전망 역할 — recover 가 정상 발화하여
|
||||
* loadModules 가 정상 권한 캐시를 생성하면 본 메서드는 found=0 으로 no-op.
|
||||
*
|
||||
* 가드:
|
||||
* - 디스크 config/app.php version === '7.0.0-beta.7' (recover 와 동일)
|
||||
* - posix_geteuid() === 0 (root 권한 자식만 — 운영자 의도 권한 보존)
|
||||
* - cache/data 자신이 정합 owner/group (chown 기준값 추정)
|
||||
*/
|
||||
private function normalizeRootOwnedCacheDirs(): void
|
||||
{
|
||||
// beta.7 한정 — recover 와 동일 가드
|
||||
$coreVersion = $this->readCoreVersionFromDisk();
|
||||
if ($coreVersion !== '7.0.0-beta.7') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! function_exists('posix_geteuid') || ! function_exists('chown') || ! function_exists('chgrp')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// root 권한 프로세스만 (PHP-FPM 워커는 www-data → euid != 0 → skip)
|
||||
if (posix_geteuid() !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$cacheRoot = base_path('storage/framework/cache/data');
|
||||
if (! is_dir($cacheRoot)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 정상 owner/group 추정 (cache/data 자신이 SSoT)
|
||||
$targetUid = @fileowner($cacheRoot);
|
||||
$targetGid = @filegroup($cacheRoot);
|
||||
if (! is_int($targetUid) || ! is_int($targetGid) || $targetUid === 0) {
|
||||
// cache/data 자신이 root 소유면 추정 실패 — bootstrap/cache fallback
|
||||
$bootstrapCache = base_path('bootstrap/cache');
|
||||
if (is_dir($bootstrapCache)) {
|
||||
$targetUid = @fileowner($bootstrapCache);
|
||||
$targetGid = @filegroup($bootstrapCache);
|
||||
}
|
||||
if (! is_int($targetUid) || ! is_int($targetGid) || $targetUid === 0) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 1depth 만 빠르게 점검 — root 소유 디렉토리 발견 시 재귀 chown
|
||||
$handle = @opendir($cacheRoot);
|
||||
if ($handle === false) {
|
||||
return;
|
||||
}
|
||||
while (($name = readdir($handle)) !== false) {
|
||||
if ($name === '.' || $name === '..') {
|
||||
continue;
|
||||
}
|
||||
$path = $cacheRoot.DIRECTORY_SEPARATOR.$name;
|
||||
$uid = @fileowner($path);
|
||||
$gid = @filegroup($path);
|
||||
if ($uid !== 0 && $gid !== 0) {
|
||||
continue;
|
||||
}
|
||||
// root 소유 항목만 보정 — 사용자가 의도해서 만든 다른 소유자는 보존
|
||||
$this->chownRecursive($path, $targetUid, $targetGid);
|
||||
}
|
||||
closedir($handle);
|
||||
}
|
||||
|
||||
/**
|
||||
* 디렉토리/파일을 재귀적으로 chown + chgrp.
|
||||
*
|
||||
* @return bool 모든 항목 성공 시 true
|
||||
*/
|
||||
private function chownRecursive(string $path, int $uid, int $gid): bool
|
||||
{
|
||||
$ok = @chown($path, $uid) && @chgrp($path, $gid);
|
||||
if (! is_dir($path) || is_link($path)) {
|
||||
return $ok;
|
||||
}
|
||||
$iter = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::CHILD_FIRST,
|
||||
);
|
||||
foreach ($iter as $entry) {
|
||||
/** @var \SplFileInfo $entry */
|
||||
$childPath = $entry->getPathname();
|
||||
$ok = (@chown($childPath, $uid) && @chgrp($childPath, $gid)) && $ok;
|
||||
}
|
||||
|
||||
return $ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* 디스크 `config/app.php` 의 `'version' => ...` 라인을 정규식 매칭으로 추출.
|
||||
*
|
||||
* 메모리 config('app.version') 은 부모 stale ENV 의 APP_VERSION 을 따르므로
|
||||
* 본 fix 의 자기 활성화 가드로 사용 불가. 디스크 SSoT (config/app.php) 직접 읽기로
|
||||
* 코어 업그레이드 시점의 실제 적용 버전 확보.
|
||||
*
|
||||
* @return string|null 추출 실패 시 null
|
||||
*/
|
||||
private function readCoreVersionFromDisk(): ?string
|
||||
{
|
||||
$configPath = base_path('config/app.php');
|
||||
if (! is_file($configPath)) {
|
||||
return null;
|
||||
}
|
||||
$content = @file_get_contents($configPath);
|
||||
if ($content === false) {
|
||||
return null;
|
||||
}
|
||||
// 'version' => env('APP_VERSION', '7.0.0-beta.7'),
|
||||
if (preg_match("/'version'\s*=>\s*env\(\s*'APP_VERSION'\s*,\s*'([^']+)'\s*\)/", $content, $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
// fallback: 'version' => '7.0.0-beta.7',
|
||||
if (preg_match("/'version'\s*=>\s*'([^']+)'/", $content, $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* .env 파일을 라인 단위로 직접 파싱하여 key=>value 배열 반환.
|
||||
*
|
||||
* parse_ini_file 의 PHP INI 호환성 함정을 회피하기 위해 자체 파싱:
|
||||
* - 빈 줄 / `#` 또는 `;` 로 시작하는 주석 줄 무시
|
||||
* - 첫 `=` 기준으로 key/value 분할 (값에 `=` 포함 가능: APP_KEY=base64:...= 등)
|
||||
* - 외곽 인용부호 (single/double) 제거 — unwrapEnvValue 사용
|
||||
* - 키 이름은 [A-Za-z_][A-Za-z0-9_]* 만 인정
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function parseEnvFile(string $envPath): array
|
||||
{
|
||||
$content = @file_get_contents($envPath);
|
||||
if ($content === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$lines = preg_split('/\r\n|\r|\n/', $content);
|
||||
if ($lines === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($lines as $line) {
|
||||
$trimmed = ltrim($line);
|
||||
if ($trimmed === '' || $trimmed[0] === '#' || $trimmed[0] === ';') {
|
||||
continue;
|
||||
}
|
||||
$eqPos = strpos($trimmed, '=');
|
||||
if ($eqPos === false || $eqPos === 0) {
|
||||
continue;
|
||||
}
|
||||
$key = rtrim(substr($trimmed, 0, $eqPos));
|
||||
if (! preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $key)) {
|
||||
continue;
|
||||
}
|
||||
$value = substr($trimmed, $eqPos + 1);
|
||||
// 줄 끝의 행간 공백 제거 (인용된 값은 인용부호 안의 내용 보존)
|
||||
$value = rtrim($value, " \t");
|
||||
$value = $this->unwrapEnvValue($value);
|
||||
$result[$key] = $value;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function unwrapEnvValue(string $value): string
|
||||
{
|
||||
$length = strlen($value);
|
||||
if ($length >= 2) {
|
||||
$first = $value[0];
|
||||
$last = $value[$length - 1];
|
||||
if (($first === '"' && $last === '"') || ($first === "'" && $last === "'")) {
|
||||
return substr($value, 1, $length - 2);
|
||||
}
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* runtime 배열의 DB 자격증명을 config('database.connections.mysql.*') 에 주입.
|
||||
*
|
||||
|
||||
Reference in new issue
Block a user