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:
@@ -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