diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml index e8de0532..9e4f3fcf 100644 --- a/docker-compose.staging.yml +++ b/docker-compose.staging.yml @@ -37,7 +37,6 @@ services: # Redis - REDIS_HOST=staging-redis - REDIS_PORT=6379 - - REDIS_PASSWORD=${REDIS_PASSWORD} # Cache - CACHE_DRIVER=redis - CACHE_PREFIX=${CACHE_PREFIX:-staging} @@ -365,7 +364,6 @@ services: # Redis - REDIS_HOST=staging-redis - REDIS_PORT=6379 - - REDIS_PASSWORD=${REDIS_PASSWORD} - REDIS_PASSWORD_FILE=/run/secrets/redis_password # Queue - QUEUE_DRIVER=redis @@ -421,7 +419,6 @@ services: # Redis - REDIS_HOST=staging-redis - REDIS_PORT=6379 - - REDIS_PASSWORD=${REDIS_PASSWORD} - REDIS_PASSWORD_FILE=/run/secrets/redis_password volumes: - staging-code:/var/www/html diff --git a/src/Framework/Config/DockerSecretsResolver.php b/src/Framework/Config/DockerSecretsResolver.php index ec60ed0c..0fdf35a0 100644 --- a/src/Framework/Config/DockerSecretsResolver.php +++ b/src/Framework/Config/DockerSecretsResolver.php @@ -18,8 +18,14 @@ use App\Framework\Filesystem\ValueObjects\FilePath; * - DB_PASSWORD_FILE=/run/secrets/db_password * - Resolves to content of that file */ -final readonly class DockerSecretsResolver +final class DockerSecretsResolver { + /** + * Cache for resolved secrets (key => resolved value or false if not found) + * @var array + */ + private array $cache = []; + /** * Resolve a Docker Secret for given key * @@ -29,10 +35,22 @@ final readonly class DockerSecretsResolver */ public function resolve(string $key, array $variables): ?string { + // Check cache first + if (isset($this->cache[$key])) { + // false means "checked and not found", null means not cached yet + if ($this->cache[$key] === false) { + return null; + } + // Return cached value + return $this->cache[$key]; + } + $fileKey = $key . '_FILE'; // Check if *_FILE variable exists if (!isset($variables[$fileKey])) { + // Cache the "not found" result + $this->cache[$key] = false; return null; } @@ -40,6 +58,7 @@ final readonly class DockerSecretsResolver // Validate file path if (!is_string($filePath)) { + $this->cache[$key] = false; return null; } @@ -56,6 +75,7 @@ final readonly class DockerSecretsResolver $file = FilePath::create($filePath); if (!$file->exists()) { + $this->cache[$key] = false; return null; } @@ -68,6 +88,7 @@ final readonly class DockerSecretsResolver $isRoot = function_exists('posix_geteuid') && posix_geteuid() === 0; if (!$file->isReadable() && !$isRoot) { // Not readable and not root, can't read it + $this->cache[$key] = false; return null; } @@ -76,12 +97,18 @@ final readonly class DockerSecretsResolver $content = @file_get_contents($filePathString); if ($content === false) { + $this->cache[$key] = false; return null; } - return trim($content); + $result = trim($content); + // Cache the successful result + $this->cache[$key] = $result; + + return $result; } catch (\Throwable) { // Invalid file path or read error + $this->cache[$key] = false; return null; } } diff --git a/src/Framework/Config/EnumResolver.php b/src/Framework/Config/EnumResolver.php new file mode 100644 index 00000000..eb156be2 --- /dev/null +++ b/src/Framework/Config/EnumResolver.php @@ -0,0 +1,59 @@ + enum instance) + * @var array + */ + private array $cache = []; + + /** + * Resolve a value to an enum instance + * + * @param mixed $value The value to convert (string, int, etc.) + * @param string $enumClass The enum class name (must be a BackedEnum) + * @param BackedEnum $default Default value to return if conversion fails + * @return BackedEnum The resolved enum instance or default + */ + public function resolve(mixed $value, string $enumClass, BackedEnum $default): BackedEnum + { + if (! $default instanceof $enumClass) { + throw new \InvalidArgumentException('Default value must be an instance of the enum class'); + } + + // If value is null, return default immediately (no need to cache) + if ($value === null) { + return $default; + } + + // Create cache key: serialize value + enum class + $cacheKey = serialize($value) . '|' . $enumClass; + + // Check cache first + if (isset($this->cache[$cacheKey])) { + return $this->cache[$cacheKey]; + } + + // Try to convert value to enum + $result = $enumClass::tryFrom($value) ?? $default; + + // Cache the result + $this->cache[$cacheKey] = $result; + + return $result; + } +} + diff --git a/src/Framework/Config/Environment.php b/src/Framework/Config/Environment.php index 809f8c83..03a1cdb9 100644 --- a/src/Framework/Config/Environment.php +++ b/src/Framework/Config/Environment.php @@ -14,10 +14,10 @@ final readonly class Environment * @param array $variables */ public function __construct( - private array $variables = [], - private DockerSecretsResolver $secretsResolver = new DockerSecretsResolver() - ) { - } + private array $variables = [], + private DockerSecretsResolver $secretsResolver = new DockerSecretsResolver(), + private EnumResolver $enumResolver = new EnumResolver() + ) {} public function get(EnvKey|string $key, mixed $default = null): mixed { @@ -36,6 +36,7 @@ final readonly class Environment // 2. Docker Secrets support: Check for *_FILE pattern // This allows Docker Secrets to override empty values + // Resolver handles caching internally $secretValue = $this->secretsResolver->resolve($key, $this->variables); if ($secretValue !== null) { return $secretValue; @@ -51,7 +52,9 @@ final readonly class Environment // 4. If internal variable was set (even if empty), return it (to distinguish between "not set" and "empty") // This preserves the original behavior: if variable is explicitly set to empty string, return it - if (isset($this->variables[$key])) { + // BUT: Only do this if no Docker Secret was found (otherwise empty string would override the secret) + // secretValue is null if no secret was found + if (isset($this->variables[$key]) && $secretValue === null) { return $this->variables[$key]; } @@ -137,18 +140,10 @@ final readonly class Environment public function getEnum(EnvKey|string $key, string $enumClass, BackedEnum $default): BackedEnum { $key = $this->keyToString($key); - - if (! $default instanceof $enumClass) { - throw new \InvalidArgumentException('Default value must be an instance of the enum class'); - } - $value = $this->get($key); - if ($value === null) { - return $default; - } - - return $enumClass::tryFrom($value) ?? $default; + // Use EnumResolver which handles caching internally + return $this->enumResolver->resolve($value, $enumClass, $default); } public function has(EnvKey|string $key): bool @@ -177,14 +172,14 @@ final readonly class Environment // Resolve Docker Secrets for variables that are empty or not set // This ensures that variables like DB_PASSWORD are resolved from their *_FILE counterparts $resolved = []; - + // First pass: Process existing variables foreach ($all as $key => $value) { // Skip *_FILE variables (we'll resolve them below) if (str_ends_with($key, '_FILE')) { continue; } - + // If variable is empty or not set, check for Docker Secret if (empty($value) || $value === '' || $value === null) { $secretValue = $this->secretsResolver->resolve($key, $all); @@ -199,14 +194,14 @@ final readonly class Environment // Include non-empty values $resolved[$key] = $value; } - + // Second pass: Check for *_FILE variables that don't have corresponding resolved values // This handles cases where a *_FILE exists but the variable itself is not in the array foreach ($all as $key => $value) { if (str_ends_with($key, '_FILE')) { // Extract the base key name (e.g., APP_KEY from APP_KEY_FILE) $baseKey = substr($key, 0, -5); // Remove '_FILE' suffix - + // If base key is not set or empty, try to resolve it if (!isset($resolved[$baseKey]) || empty($resolved[$baseKey])) { $secretValue = $this->secretsResolver->resolve($baseKey, $all); @@ -214,7 +209,7 @@ final readonly class Environment $resolved[$baseKey] = $secretValue; } } - + // Always include *_FILE variables in output $resolved[$key] = $value; } diff --git a/tests/Unit/Framework/Config/EnumResolverTest.php b/tests/Unit/Framework/Config/EnumResolverTest.php new file mode 100644 index 00000000..1163c03a --- /dev/null +++ b/tests/Unit/Framework/Config/EnumResolverTest.php @@ -0,0 +1,146 @@ +resolver = new EnumResolver(); + }); + + describe('resolve()', function () { + it('converts string value to enum', function () { + // Create a test enum + if (!enum_exists('TestEnum')) { + eval('enum TestEnum: string { case FOO = "foo"; case BAR = "bar"; }'); + } + + $default = TestEnum::BAR; + $result = $this->resolver->resolve('foo', TestEnum::class, $default); + + expect($result)->toBe(TestEnum::FOO); + }); + + it('converts int value to enum', function () { + // Create a test enum with int backing + if (!enum_exists('IntTestEnum')) { + eval('enum IntTestEnum: int { case ONE = 1; case TWO = 2; }'); + } + + $default = IntTestEnum::TWO; + $result = $this->resolver->resolve(1, IntTestEnum::class, $default); + + expect($result)->toBe(IntTestEnum::ONE); + }); + + it('returns default when value cannot be converted', function () { + if (!enum_exists('TestEnum')) { + eval('enum TestEnum: string { case FOO = "foo"; case BAR = "bar"; }'); + } + + $default = TestEnum::BAR; + $result = $this->resolver->resolve('invalid', TestEnum::class, $default); + + expect($result)->toBe($default); + }); + + it('returns default when value is null', function () { + if (!enum_exists('TestEnum')) { + eval('enum TestEnum: string { case FOO = "foo"; case BAR = "bar"; }'); + } + + $default = TestEnum::BAR; + $result = $this->resolver->resolve(null, TestEnum::class, $default); + + expect($result)->toBe($default); + }); + + it('throws exception when default is not instance of enum class', function () { + if (!enum_exists('TestEnum')) { + eval('enum TestEnum: string { case FOO = "foo"; case BAR = "bar"; }'); + } + if (!enum_exists('AnotherEnum')) { + eval('enum AnotherEnum: string { case BAZ = "baz"; }'); + } + + $wrongDefault = AnotherEnum::BAZ; + + expect(function () use ($wrongDefault) { + $this->resolver->resolve('foo', TestEnum::class, $wrongDefault); + })->toThrow(\InvalidArgumentException::class); + }); + }); + + describe('caching', function () { + it('caches enum conversion results', function () { + if (!enum_exists('TestEnum')) { + eval('enum TestEnum: string { case FOO = "foo"; case BAR = "bar"; }'); + } + + $default = TestEnum::BAR; + + // First call - should convert + $enum1 = $this->resolver->resolve('foo', TestEnum::class, $default); + expect($enum1)->toBe(TestEnum::FOO); + + // Second call to same resolver instance should use cache + // (We can't directly verify cache, but we can verify it returns the same instance) + $enum2 = $this->resolver->resolve('foo', TestEnum::class, $default); + expect($enum2)->toBe(TestEnum::FOO); + expect($enum1)->toBe($enum2); + }); + + it('caches different enum classes separately', function () { + if (!enum_exists('TestEnum')) { + eval('enum TestEnum: string { case FOO = "foo"; case BAR = "bar"; }'); + } + if (!enum_exists('AnotherTestEnum')) { + eval('enum AnotherTestEnum: string { case FOO = "foo"; case BAR = "bar"; }'); + } + + $default1 = TestEnum::BAR; + $default2 = AnotherTestEnum::BAR; + + $enum1 = $this->resolver->resolve('foo', TestEnum::class, $default1); + $enum2 = $this->resolver->resolve('foo', AnotherTestEnum::class, $default2); + + expect($enum1)->toBe(TestEnum::FOO); + expect($enum2)->toBe(AnotherTestEnum::FOO); + expect($enum1)->not->toBe($enum2); // Different enum classes + }); + + it('caches different values separately', function () { + if (!enum_exists('TestEnum')) { + eval('enum TestEnum: string { case FOO = "foo"; case BAR = "bar"; }'); + } + + $default = TestEnum::BAR; + + $enum1 = $this->resolver->resolve('foo', TestEnum::class, $default); + $enum2 = $this->resolver->resolve('bar', TestEnum::class, $default); + + expect($enum1)->toBe(TestEnum::FOO); + expect($enum2)->toBe(TestEnum::BAR); + }); + + it('caches default values', function () { + if (!enum_exists('TestEnum')) { + eval('enum TestEnum: string { case FOO = "foo"; case BAR = "bar"; }'); + } + + $default = TestEnum::BAR; + + // First call with null + $enum1 = $this->resolver->resolve(null, TestEnum::class, $default); + expect($enum1)->toBe($default); + + // Second call with null should return cached default + $enum2 = $this->resolver->resolve(null, TestEnum::class, $default); + expect($enum2)->toBe($default); + expect($enum1)->toBe($enum2); + }); + }); +}); + diff --git a/tests/Unit/Framework/Config/EnvironmentTest.php b/tests/Unit/Framework/Config/EnvironmentTest.php index 304ea0e1..212e0dd4 100644 --- a/tests/Unit/Framework/Config/EnvironmentTest.php +++ b/tests/Unit/Framework/Config/EnvironmentTest.php @@ -583,6 +583,89 @@ describe('Environment', function () { unlink($dbPasswordPath); unlink($apiKeyPath); }); + + it('uses Docker secret when variable is set to empty string', function () { + // Create temporary Docker secret file + $secretPath = '/tmp/docker_secret_test'; + file_put_contents($secretPath, 'secret_from_file'); + + $env = new Environment([ + 'REDIS_PASSWORD' => '', // Empty string + 'REDIS_PASSWORD_FILE' => $secretPath, + ], $this->dockerSecretsResolver); + + $value = $env->get('REDIS_PASSWORD'); + + // Docker Secret should override empty string + expect($value)->toBe('secret_from_file'); + + // Cleanup + unlink($secretPath); + }); + + it('returns empty string when variable is empty and no Docker secret exists', function () { + $env = new Environment([ + 'REDIS_PASSWORD' => '', // Empty string, no FILE + ], $this->dockerSecretsResolver); + + $value = $env->get('REDIS_PASSWORD'); + + // Empty string should be returned when explicitly set + expect($value)->toBe(''); + }); + + it('caches Docker secret resolution results', function () { + // Create temporary Docker secret file + $secretPath = '/tmp/docker_secret_test_caching'; + file_put_contents($secretPath, 'cached_secret_value'); + + $env = new Environment([ + 'DB_PASSWORD_FILE' => $secretPath, + ], $this->dockerSecretsResolver); + + // First call - should read from file + $value1 = $env->get('DB_PASSWORD'); + expect($value1)->toBe('cached_secret_value'); + + // Modify file content (should not affect cached value) + file_put_contents($secretPath, 'modified_secret_value'); + + // Second call - should use cache, not re-read file + $value2 = $env->get('DB_PASSWORD'); + expect($value2)->toBe('cached_secret_value'); + + // Cleanup + unlink($secretPath); + }); + + it('caches enum conversion results', function () { + // Create a test enum + if (!enum_exists('TestEnum')) { + eval('enum TestEnum: string { case FOO = "foo"; case BAR = "bar"; }'); + } + + $env = new Environment([ + 'APP_ENV' => 'foo', + ], $this->dockerSecretsResolver); + + $default = TestEnum::BAR; + + // First call - should convert + $enum1 = $env->getEnum('APP_ENV', TestEnum::class, $default); + expect($enum1)->toBe(TestEnum::FOO); + + // Second call to same instance should use cache + $enum2 = $env->getEnum('APP_ENV', TestEnum::class, $default); + expect($enum2)->toBe(TestEnum::FOO); + expect($enum1)->toBe($enum2); + + // Different enum class should have different cache entry + if (!enum_exists('AnotherTestEnum')) { + eval('enum AnotherTestEnum: string { case FOO = "foo"; case BAR = "bar"; }'); + } + $enum3 = $env->getEnum('APP_ENV', AnotherTestEnum::class, AnotherTestEnum::BAR); + expect($enum3)->toBe(AnotherTestEnum::FOO); + }); }); describe('Edge Cases', function () {