fix(core): sudo 업데이트의 root 캐시 산출물 소유권 정상화 + 캐시 쓰기 fail-soft
클린 7.0.9→7.0.10 실서버 업그레이드에서 전면 500 재발. 치명점을 캐시 파일
내용 역산으로 확정 — sha1('g7:_idx:g7:core') = 모든 remember 가 갱신하는
G7 캐시 키 인덱스 파일이 업데이트 마지막 root 쓰기(cache:clear 직후 버전
bump·상태/훅 캐시 재생성)로 root 소유 생성되면, 웹 프로세스의 모든 캐시
쓰기가 인덱스 갱신에서 Permission denied 로 죽어 부팅 경로가 500 이 된다
(로거 도달 전이라 laravel.log 공백). 직전 커밋의 자식측 게시 게이트만으로
부족했던 이유: 마지막 root 쓰기의 주체가 구코드 부모라 그 이후에 신코드가
개입할 지점이 없다.
- 원인 수정: CoreUpdateService::normalizeRuntimeOwnershipAfterRootRun —
root 실행 시 storage/framework/cache·bootstrap/cache·storage/app/ext-bundles
를 디렉토리 소유자 기준 재귀 정상화 + 그룹 쓰기 동기(업그레이드 로그
정상화 선례 확장). 부모·자식 커맨드의 모든 흐름 종료부에 짝으로 배선 —
이후 업데이트는 root 산출물을 남기지 않고 과거 잔재도 재귀 자가 수복
- 안전망: AbstractCacheDriver 쓰기 fail-soft — put/forget/putMany 는 false,
remember 는 콜백 1회 실행 보장 후 저장 실패를 삼키고 결과 반환(무캐시
동작), 경고 로그는 조합당 프로세스 1회. 구코드가 남긴 오염처럼 신코드가
선제 개입 못 하는 상황에서도 화면이 죽지 않는다 — 캐시는 최적화다
- red→green: CacheDriverWriteFailSoftTest 4케이스, CoreUpdateRuntimeOwnershipTest
(비-root no-op + 종료부 짝 호출 소스 훑기)
This commit is contained in:
@@ -18,6 +18,7 @@
|
|||||||
|
|
||||||
- 사이트 첫 접속 시 확장 캐시 버전이 어긋나 있으면 라우트·다국어 데이터를 두 번 내려받던 문제를 수정했습니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
|
- 사이트 첫 접속 시 확장 캐시 버전이 어긋나 있으면 라우트·다국어 데이터를 두 번 내려받던 문제를 수정했습니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
|
||||||
- 레이아웃 props 의 `$switch` 조건 분기 값이 검색엔진(봇) 화면에서는 해석되지 않아 해당 속성이 표시되지 않던 문제를 수정했습니다. 이제 일반 화면과 봇 화면이 동일하게 분기 값을 렌더링합니다.
|
- 레이아웃 props 의 `$switch` 조건 분기 값이 검색엔진(봇) 화면에서는 해석되지 않아 해당 속성이 표시되지 않던 문제를 수정했습니다. 이제 일반 화면과 봇 화면이 동일하게 분기 값을 렌더링합니다.
|
||||||
|
- sudo(root) 로 코어를 업데이트하면 업데이트 과정이 만든 캐시 파일이 root 소유로 남아, 이후 웹 화면 전체가 서버 오류(500)가 되거나 캐시가 동작하지 않을 수 있던 문제를 수정했습니다. 업데이트 종료 시 캐시·번들 디렉토리 소유권을 자동 정상화하고, 캐시 쓰기 실패는 화면을 중단시키지 않고 경고 로그와 함께 무캐시로 계속 동작합니다.
|
||||||
|
|
||||||
## [7.0.9] - 2026-08-24
|
## [7.0.9] - 2026-08-24
|
||||||
|
|
||||||
|
|||||||
@@ -593,6 +593,10 @@ class CoreUpdateCommand extends Command
|
|||||||
// fallback(spawn 실패)로 부모가 upgrade step 을 직접 실행한 경우, 부모가 만든
|
// fallback(spawn 실패)로 부모가 upgrade step 을 직접 실행한 경우, 부모가 만든
|
||||||
// upgrade 로그가 root 로 남는다 — 모든 로그 쓰기가 끝난 이 시점에 정합한다.
|
// upgrade 로그가 root 로 남는다 — 모든 로그 쓰기가 끝난 이 시점에 정합한다.
|
||||||
$this->restoreUpgradeLogOwnership();
|
$this->restoreUpgradeLogOwnership();
|
||||||
|
// root 업데이트가 종료 시점까지 만든 캐시/번들 산출물 소유권 정상화 —
|
||||||
|
// restoreOwnership(흐름 중간) 이후의 root 쓰기가 웹 캐시 쓰기를 죽이는
|
||||||
|
// 전면 500 차단 (7.0.9→7.0.10 실사례)
|
||||||
|
app(CoreUpdateService::class)->normalizeRuntimeOwnershipAfterRootRun();
|
||||||
|
|
||||||
return Command::SUCCESS;
|
return Command::SUCCESS;
|
||||||
|
|
||||||
@@ -677,6 +681,10 @@ class CoreUpdateCommand extends Command
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->restoreUpgradeLogOwnership();
|
$this->restoreUpgradeLogOwnership();
|
||||||
|
// root 업데이트가 종료 시점까지 만든 캐시/번들 산출물 소유권 정상화 —
|
||||||
|
// restoreOwnership(흐름 중간) 이후의 root 쓰기가 웹 캐시 쓰기를 죽이는
|
||||||
|
// 전면 500 차단 (7.0.9→7.0.10 실사례)
|
||||||
|
app(CoreUpdateService::class)->normalizeRuntimeOwnershipAfterRootRun();
|
||||||
|
|
||||||
return Command::SUCCESS;
|
return Command::SUCCESS;
|
||||||
|
|
||||||
@@ -772,6 +780,10 @@ class CoreUpdateCommand extends Command
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->restoreUpgradeLogOwnership();
|
$this->restoreUpgradeLogOwnership();
|
||||||
|
// root 업데이트가 종료 시점까지 만든 캐시/번들 산출물 소유권 정상화 —
|
||||||
|
// restoreOwnership(흐름 중간) 이후의 root 쓰기가 웹 캐시 쓰기를 죽이는
|
||||||
|
// 전면 500 차단 (7.0.9→7.0.10 실사례)
|
||||||
|
app(CoreUpdateService::class)->normalizeRuntimeOwnershipAfterRootRun();
|
||||||
|
|
||||||
return Command::FAILURE;
|
return Command::FAILURE;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -206,6 +206,7 @@ class ExecuteUpgradeStepsCommand extends Command
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$this->restoreUpgradeLogOwnership();
|
$this->restoreUpgradeLogOwnership();
|
||||||
|
$service->normalizeRuntimeOwnershipAfterRootRun();
|
||||||
|
|
||||||
return UpgradeHandoffException::EXIT_CODE;
|
return UpgradeHandoffException::EXIT_CODE;
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
@@ -217,6 +218,7 @@ class ExecuteUpgradeStepsCommand extends Command
|
|||||||
$this->error($e->getMessage());
|
$this->error($e->getMessage());
|
||||||
|
|
||||||
$this->restoreUpgradeLogOwnership();
|
$this->restoreUpgradeLogOwnership();
|
||||||
|
$service->normalizeRuntimeOwnershipAfterRootRun();
|
||||||
|
|
||||||
return self::FAILURE;
|
return self::FAILURE;
|
||||||
}
|
}
|
||||||
@@ -278,6 +280,10 @@ class ExecuteUpgradeStepsCommand extends Command
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->restoreUpgradeLogOwnership();
|
$this->restoreUpgradeLogOwnership();
|
||||||
|
// 단독 실행(sudo core:execute-upgrade-steps)이 만든 캐시/번들 root 산출물
|
||||||
|
// 소유권 정상화 — spawn 자식 모드에서도 무해(멱등)하며, 부모(CoreUpdateCommand)
|
||||||
|
// 종료부의 동일 호출이 부모 측 후속 쓰기를 담당한다
|
||||||
|
$service->normalizeRuntimeOwnershipAfterRootRun();
|
||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Extension\Cache;
|
namespace App\Extension\Cache;
|
||||||
|
|
||||||
use App\Contracts\Extension\CacheInterface;
|
use App\Contracts\Extension\CacheInterface;
|
||||||
|
use Illuminate\Cache\Repository;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
@@ -48,15 +49,15 @@ abstract class AbstractCacheDriver implements CacheInterface
|
|||||||
*/
|
*/
|
||||||
public function resolveKey(string $key): string
|
public function resolveKey(string $key): string
|
||||||
{
|
{
|
||||||
return $this->getPrefix() . ':' . $key;
|
return $this->getPrefix().':'.$key;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Laravel Cache 스토어 인스턴스를 반환합니다.
|
* Laravel Cache 스토어 인스턴스를 반환합니다.
|
||||||
*
|
*
|
||||||
* @return \Illuminate\Cache\Repository 캐시 스토어 인스턴스
|
* @return Repository 캐시 스토어 인스턴스
|
||||||
*/
|
*/
|
||||||
protected function store(): \Illuminate\Cache\Repository
|
protected function store(): Repository
|
||||||
{
|
{
|
||||||
return Cache::store($this->store);
|
return Cache::store($this->store);
|
||||||
}
|
}
|
||||||
@@ -122,7 +123,13 @@ abstract class AbstractCacheDriver implements CacheInterface
|
|||||||
$resolvedKey = $this->resolveKey($key);
|
$resolvedKey = $this->resolveKey($key);
|
||||||
$ttl = $ttl ?? $this->getDefaultTtl();
|
$ttl = $ttl ?? $this->getDefaultTtl();
|
||||||
|
|
||||||
return $this->store()->put($resolvedKey, $value, $ttl);
|
try {
|
||||||
|
return $this->store()->put($resolvedKey, $value, $ttl);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->warnWriteFailure('put', $resolvedKey, $e);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -144,7 +151,13 @@ abstract class AbstractCacheDriver implements CacheInterface
|
|||||||
*/
|
*/
|
||||||
public function forget(string $key): bool
|
public function forget(string $key): bool
|
||||||
{
|
{
|
||||||
return $this->store()->forget($this->resolveKey($key));
|
try {
|
||||||
|
return $this->store()->forget($this->resolveKey($key));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->warnWriteFailure('forget', $this->resolveKey($key), $e);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Remember 패턴 ===
|
// === Remember 패턴 ===
|
||||||
@@ -170,8 +183,25 @@ abstract class AbstractCacheDriver implements CacheInterface
|
|||||||
|
|
||||||
// 항상 일반 remember + 키 인덱스에 태그 매핑 기록
|
// 항상 일반 remember + 키 인덱스에 태그 매핑 기록
|
||||||
// Laravel 네이티브 태그 저장은 사용하지 않음 (get/has와의 일관성 보장)
|
// Laravel 네이티브 태그 저장은 사용하지 않음 (get/has와의 일관성 보장)
|
||||||
$result = $this->store()->remember($resolvedKey, $ttl, $callback);
|
//
|
||||||
$this->recordKeyTags($resolvedKey, $allTags);
|
// 저장 실패는 fail-soft — 캐시는 최적화이므로 콜백 결과를 그대로 반환한다.
|
||||||
|
// Repository::remember 를 쓰지 않고 get→callback→put 을 직접 수행하는 이유:
|
||||||
|
// put 예외를 잡아도 콜백을 재실행하지 않기 위해서다 (부수효과 이중 실행 금지).
|
||||||
|
// 실사례: sudo 업데이트가 키 인덱스 파일을 root 소유로 남기면 웹의 모든
|
||||||
|
// remember 가 put 예외로 죽어 부팅 전면 500 이 됐다 (7.0.9→7.0.10).
|
||||||
|
$cached = $this->store()->get($resolvedKey);
|
||||||
|
if ($cached !== null) {
|
||||||
|
return $cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $callback();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->store()->put($resolvedKey, $result, $ttl);
|
||||||
|
$this->recordKeyTags($resolvedKey, $allTags);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->warnWriteFailure('remember', $resolvedKey, $e);
|
||||||
|
}
|
||||||
|
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
@@ -187,7 +217,7 @@ abstract class AbstractCacheDriver implements CacheInterface
|
|||||||
*/
|
*/
|
||||||
public function rememberQuery(string $queryHash, callable $callback, ?int $ttl = null, array $tags = []): mixed
|
public function rememberQuery(string $queryHash, callable $callback, ?int $ttl = null, array $tags = []): mixed
|
||||||
{
|
{
|
||||||
return $this->remember('query:' . $queryHash, $callback, $ttl, $tags);
|
return $this->remember('query:'.$queryHash, $callback, $ttl, $tags);
|
||||||
}
|
}
|
||||||
|
|
||||||
// === 벌크 연산 ===
|
// === 벌크 연산 ===
|
||||||
@@ -237,7 +267,13 @@ abstract class AbstractCacheDriver implements CacheInterface
|
|||||||
$resolved[$this->resolveKey($key)] = $value;
|
$resolved[$this->resolveKey($key)] = $value;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->store()->putMany($resolved, $ttl);
|
try {
|
||||||
|
return $this->store()->putMany($resolved, $ttl);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->warnWriteFailure('putMany', implode(',', array_keys($resolved)), $e);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// === 무효화 ===
|
// === 무효화 ===
|
||||||
@@ -291,6 +327,42 @@ abstract class AbstractCacheDriver implements CacheInterface
|
|||||||
|
|
||||||
// === 키 인덱스 (태그 미지원 드라이버용) ===
|
// === 키 인덱스 (태그 미지원 드라이버용) ===
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐시 쓰기 실패를 경고 로그로 강등합니다 (fail-soft).
|
||||||
|
*
|
||||||
|
* 캐시는 최적화다 — 쓰기 실패(권한/디스크)가 페이지를 죽이면 안 된다. 실사례:
|
||||||
|
* sudo 코어 업데이트가 키 인덱스 파일을 root 소유로 남겨 웹 프로세스의 모든
|
||||||
|
* 캐시 쓰기가 Permission denied 로 죽고 부팅 경로가 전면 500 이 됐다
|
||||||
|
* (예외가 로거 도달 전에 발생해 laravel.log 도 비어 있었다).
|
||||||
|
*
|
||||||
|
* 로그 폭주 방지: 같은 (연산, 예외 메시지) 조합은 프로세스당 1회만 기록한다.
|
||||||
|
*
|
||||||
|
* @param string $operation 실패한 연산 (put/forget/putMany/remember/index)
|
||||||
|
* @param string $key 대상 키 (진단용)
|
||||||
|
* @param \Throwable $e 원인 예외
|
||||||
|
*/
|
||||||
|
protected function warnWriteFailure(string $operation, string $key, \Throwable $e): void
|
||||||
|
{
|
||||||
|
static $warned = [];
|
||||||
|
|
||||||
|
$signature = $operation.'|'.$e->getMessage();
|
||||||
|
if (isset($warned[$signature])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$warned[$signature] = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
Log::warning('캐시 쓰기 실패 — 무캐시로 계속 동작합니다 (스토리지 권한/디스크 확인 필요)', [
|
||||||
|
'operation' => $operation,
|
||||||
|
'key' => $key,
|
||||||
|
'store' => $this->store,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// 로그 기록조차 불가한 환경(로그 디렉토리 권한 등) — 조용히 계속
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 키-태그 매핑을 인덱스에 기록합니다.
|
* 키-태그 매핑을 인덱스에 기록합니다.
|
||||||
*
|
*
|
||||||
@@ -318,16 +390,22 @@ abstract class AbstractCacheDriver implements CacheInterface
|
|||||||
*/
|
*/
|
||||||
private function flushByIndex(): bool
|
private function flushByIndex(): bool
|
||||||
{
|
{
|
||||||
$indexKey = $this->getIndexKey();
|
try {
|
||||||
$index = $this->store()->get($indexKey, []);
|
$indexKey = $this->getIndexKey();
|
||||||
|
$index = $this->store()->get($indexKey, []);
|
||||||
|
|
||||||
foreach (array_keys($index) as $key) {
|
foreach (array_keys($index) as $key) {
|
||||||
$this->store()->forget($key);
|
$this->store()->forget($key);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->store()->forget($indexKey);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->warnWriteFailure('flush', $this->getIndexKey(), $e);
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->store()->forget($indexKey);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -338,20 +416,26 @@ abstract class AbstractCacheDriver implements CacheInterface
|
|||||||
*/
|
*/
|
||||||
private function flushTagsByIndex(array $tags): bool
|
private function flushTagsByIndex(array $tags): bool
|
||||||
{
|
{
|
||||||
$indexKey = $this->getIndexKey();
|
try {
|
||||||
$index = $this->store()->get($indexKey, []);
|
$indexKey = $this->getIndexKey();
|
||||||
$tagsSet = array_flip($tags);
|
$index = $this->store()->get($indexKey, []);
|
||||||
|
$tagsSet = array_flip($tags);
|
||||||
|
|
||||||
foreach ($index as $key => $keyTags) {
|
foreach ($index as $key => $keyTags) {
|
||||||
if (array_intersect_key(array_flip($keyTags), $tagsSet)) {
|
if (array_intersect_key(array_flip($keyTags), $tagsSet)) {
|
||||||
$this->store()->forget($key);
|
$this->store()->forget($key);
|
||||||
unset($index[$key]);
|
unset($index[$key]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->store()->put($indexKey, $index, 86400 * 30);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->warnWriteFailure('flushTags', implode(',', $tags), $e);
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->store()->put($indexKey, $index, 86400 * 30);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -361,6 +445,6 @@ abstract class AbstractCacheDriver implements CacheInterface
|
|||||||
*/
|
*/
|
||||||
private function getIndexKey(): string
|
private function getIndexKey(): string
|
||||||
{
|
{
|
||||||
return 'g7:_idx:' . $this->getPrefix();
|
return 'g7:_idx:'.$this->getPrefix();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2188,6 +2188,56 @@ class CoreUpdateService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* root 로 실행된 업데이트가 종료된 뒤, 런타임 쓰기 디렉토리의 소유권을 정상화합니다.
|
||||||
|
*
|
||||||
|
* `restoreOwnership()` 은 흐름 **중간**의 한 단계라, 그 이후에 일어나는 캐시
|
||||||
|
* 쓰기(버전 bump·상태/훅 캐시 재생성·키 인덱스 갱신·번들 빌드)가 root 소유
|
||||||
|
* 파일을 새로 만든다. 그 파일들이 남으면 웹 프로세스의 캐시 쓰기가 Permission
|
||||||
|
* denied 로 죽어 전면 500 이 된다 (실사례: 7.0.9→7.0.10 sudo 업데이트 —
|
||||||
|
* 치명점은 모든 remember 가 갱신하는 캐시 키 인덱스 파일).
|
||||||
|
*
|
||||||
|
* 따라서 이 메서드는 **흐름의 마지막**(restoreUpgradeLogOwnership 과 같은
|
||||||
|
* 지점)에서 호출되어, 대상 디렉토리 자신의 소유자(웹 쓰기 소유)를 기준으로
|
||||||
|
* 내용물을 재귀 정상화하고 그룹 쓰기를 동기화한다. 과거 업데이트가 남긴
|
||||||
|
* root 잔재도 함께 정리된다 (재귀 전체 대상).
|
||||||
|
*
|
||||||
|
* 비-root 프로세스는 chown 자체가 불가능하고 필요도 없으므로 즉시 no-op.
|
||||||
|
* 기준 디렉토리 자체가 root 소유(비정상 배포)면 상속 근거가 없어 스킵한다.
|
||||||
|
*/
|
||||||
|
public function normalizeRuntimeOwnershipAfterRootRun(): void
|
||||||
|
{
|
||||||
|
if (! function_exists('chown') || ! function_exists('posix_geteuid') || posix_geteuid() !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$targets = [
|
||||||
|
storage_path('framework/cache'),
|
||||||
|
base_path('bootstrap/cache'),
|
||||||
|
storage_path('app/ext-bundles'),
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($targets as $dir) {
|
||||||
|
if (! is_dir($dir)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$owner = @fileowner($dir);
|
||||||
|
$group = @filegroup($dir);
|
||||||
|
|
||||||
|
if ($owner === false || $owner === 0) {
|
||||||
|
Log::channel('upgrade')->warning('런타임 소유권 정상화 스킵 — 기준 디렉토리가 root/판독불가 소유', [
|
||||||
|
'dir' => $dir,
|
||||||
|
]);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
FilePermissionHelper::chownRecursive($dir, $owner, $group);
|
||||||
|
FilePermissionHelper::syncGroupWritability($dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 업데이트 경로의 소유권을 스냅샷 기준으로 복원합니다.
|
* 업데이트 경로의 소유권을 스냅샷 기준으로 복원합니다.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
3. 접두사로 캐시 키 격리: g7:core:{key}, g7:module.{id}:{key}, g7:plugin.{id}:{key}
|
3. 접두사로 캐시 키 격리: g7:core:{key}, g7:module.{id}:{key}, g7:plugin.{id}:{key}
|
||||||
4. TTL은 g7_core_settings('cache.*')로 중앙 관리 (하드코딩 금지)
|
4. TTL은 g7_core_settings('cache.*')로 중앙 관리 (하드코딩 금지)
|
||||||
5. Service 생성자에서 CacheInterface 타입힌트하면 자동 주입
|
5. Service 생성자에서 CacheInterface 타입힌트하면 자동 주입
|
||||||
|
6. 쓰기 실패는 fail-soft — put/forget/putMany 는 false, remember 는 콜백 결과 반환 + 경고 로그 (캐시는 최적화라 페이지를 죽이지 않는다)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Cache;
|
||||||
|
|
||||||
|
use App\Extension\Cache\CoreCacheDriver;
|
||||||
|
use Illuminate\Contracts\Cache\Store;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐시 쓰기 실패는 페이지를 죽이면 안 된다 (fail-soft).
|
||||||
|
*
|
||||||
|
* 실사례 (7.0.9→7.0.10 sudo 업데이트): root 로 실행된 업데이트가 캐시 키 인덱스
|
||||||
|
* 파일(`g7:_idx:g7:core` — 모든 remember 가 갱신)을 root 소유로 남기면, 이후 웹
|
||||||
|
* 프로세스의 **모든** 캐시 쓰기가 fopen Permission denied 예외로 죽어 부팅 경로
|
||||||
|
* 전면 500 이 된다 (로거 도달 전이라 laravel.log 무기록). 캐시는 최적화일 뿐이므로
|
||||||
|
* 쓰기 실패는 경고 로그 + 무캐시 동작으로 강등되어야 한다.
|
||||||
|
*/
|
||||||
|
class CacheDriverWriteFailSoftTest extends TestCase
|
||||||
|
{
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
config(['cache.stores.g7_failing_store' => ['driver' => 'g7_failing_store']]);
|
||||||
|
|
||||||
|
Cache::extend('g7_failing_store', function () {
|
||||||
|
return Cache::repository(new class implements Store
|
||||||
|
{
|
||||||
|
public array $data = [];
|
||||||
|
|
||||||
|
public function get($key)
|
||||||
|
{
|
||||||
|
return $this->data[$key] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function many(array $keys)
|
||||||
|
{
|
||||||
|
return array_map(fn ($k) => $this->get($k), array_combine($keys, $keys));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function put($key, $value, $seconds)
|
||||||
|
{
|
||||||
|
throw new \RuntimeException("fopen({$key}): Failed to open stream: Permission denied");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function putMany(array $values, $seconds)
|
||||||
|
{
|
||||||
|
throw new \RuntimeException('putMany: Permission denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function increment($key, $value = 1)
|
||||||
|
{
|
||||||
|
throw new \RuntimeException('increment: Permission denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function decrement($key, $value = 1)
|
||||||
|
{
|
||||||
|
throw new \RuntimeException('decrement: Permission denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forever($key, $value)
|
||||||
|
{
|
||||||
|
throw new \RuntimeException('forever: Permission denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forget($key)
|
||||||
|
{
|
||||||
|
throw new \RuntimeException('forget: Permission denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function flush()
|
||||||
|
{
|
||||||
|
throw new \RuntimeException('flush: Permission denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPrefix()
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function driver(): CoreCacheDriver
|
||||||
|
{
|
||||||
|
return new CoreCacheDriver('g7_failing_store');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* put 실패는 예외를 전파하지 않고 false 를 반환한다.
|
||||||
|
*/
|
||||||
|
public function test_put_write_failure_returns_false_without_throwing(): void
|
||||||
|
{
|
||||||
|
$this->assertFalse($this->driver()->put('some.key', 'value'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* remember 는 저장(put/인덱스 기록)이 실패해도 콜백 결과를 반환한다 —
|
||||||
|
* 부팅 경로의 remember 하나가 죽으면 화면 전체가 500 이 되기 때문.
|
||||||
|
* 콜백은 정확히 1회만 실행되어야 한다 (재실행 부수효과 금지).
|
||||||
|
*/
|
||||||
|
public function test_remember_returns_callback_value_when_write_fails(): void
|
||||||
|
{
|
||||||
|
$calls = 0;
|
||||||
|
|
||||||
|
$value = $this->driver()->remember('boot.key', function () use (&$calls) {
|
||||||
|
$calls++;
|
||||||
|
|
||||||
|
return ['ok' => true];
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->assertSame(['ok' => true], $value);
|
||||||
|
$this->assertSame(1, $calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* forget 실패도 예외를 전파하지 않는다.
|
||||||
|
*/
|
||||||
|
public function test_forget_write_failure_returns_false_without_throwing(): void
|
||||||
|
{
|
||||||
|
$this->assertFalse($this->driver()->forget('some.key'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* putMany 실패도 예외를 전파하지 않는다.
|
||||||
|
*/
|
||||||
|
public function test_put_many_write_failure_returns_false_without_throwing(): void
|
||||||
|
{
|
||||||
|
$this->assertFalse($this->driver()->putMany(['a' => 1, 'b' => 2]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Services;
|
||||||
|
|
||||||
|
use App\Services\CoreUpdateService;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 업데이트 종료 시 root 산출물 소유권 정상화 (실사례: 7.0.9→7.0.10 전면 500).
|
||||||
|
*
|
||||||
|
* sudo 업데이트가 캐시 키 인덱스·상태 캐시·병합 번들을 root 소유로 남기면
|
||||||
|
* 웹 프로세스의 캐시 쓰기가 Permission denied 로 죽는다. 업데이트 흐름의
|
||||||
|
* 모든 쓰기가 끝난 종료 지점에서 런타임 디렉토리를 기준 소유자(디렉토리
|
||||||
|
* 자신의 소유자)로 재귀 정상화해 root 산출물이 남지 않게 한다.
|
||||||
|
*/
|
||||||
|
class CoreUpdateRuntimeOwnershipTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 비-root 프로세스에서는 no-op 이다 (chown 자체가 불가능하고 불필요).
|
||||||
|
*/
|
||||||
|
public function test_non_root_process_is_noop(): void
|
||||||
|
{
|
||||||
|
$service = app(CoreUpdateService::class);
|
||||||
|
|
||||||
|
// 예외 없이 종료해야 한다 (Windows/비-root: posix 가드로 즉시 반환)
|
||||||
|
$service->normalizeRuntimeOwnershipAfterRootRun();
|
||||||
|
|
||||||
|
$this->assertTrue(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 부모(CoreUpdateCommand)·자식(ExecuteUpgradeStepsCommand) 흐름 종료부가
|
||||||
|
* 정상화를 호출한다 — 로그 소유권 정상화(restoreUpgradeLogOwnership)와
|
||||||
|
* 항상 짝으로 (#519 소스 훑기 선례).
|
||||||
|
*/
|
||||||
|
public function test_update_flows_invoke_runtime_ownership_normalization(): void
|
||||||
|
{
|
||||||
|
foreach ([
|
||||||
|
app_path('Console/Commands/Core/CoreUpdateCommand.php'),
|
||||||
|
app_path('Console/Commands/Core/ExecuteUpgradeStepsCommand.php'),
|
||||||
|
] as $file) {
|
||||||
|
$source = (string) file_get_contents($file);
|
||||||
|
|
||||||
|
$logCalls = substr_count($source, '$this->restoreUpgradeLogOwnership();');
|
||||||
|
$normalizeCalls = substr_count($source, 'normalizeRuntimeOwnershipAfterRootRun');
|
||||||
|
|
||||||
|
$this->assertGreaterThan(0, $logCalls, basename($file).': 로그 소유권 정상화 호출이 사라졌습니다');
|
||||||
|
$this->assertGreaterThanOrEqual(
|
||||||
|
$logCalls,
|
||||||
|
$normalizeCalls,
|
||||||
|
basename($file).': 흐름 종료부(restoreUpgradeLogOwnership 호출 지점)마다 런타임 소유권 정상화가 짝으로 호출되어야 합니다'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user