Lines
100.00%
81 / 81
Functions and Methods
100.00%
13 / 13
Classes and Traits
100.00%
1 / 1
| Name | Lines | Functions and Methods | CRAP | Classes and Traits | ||||||
|---|---|---|---|---|---|---|---|---|---|---|
| EnvironmentOverlay | 100.00% | 81 / 81 | 100.00% | 13 / 13 | 39 | 100.00% | 1 / 1 | |||
| __construct | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| collectEnvironmentOverrides | 100.00% | 24 / 24 | 100.00% | 1 / 1 | 7 | |||||
| isReadElsewhere | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 2 | |||||
| resolveOverrideTarget | 100.00% | 21 / 21 | 100.00% | 1 / 1 | 9 | |||||
| withValueAtPath | 100.00% | 7 / 7 | 100.00% | 1 / 1 | 3 | |||||
| resolveChildKey | 100.00% | 9 / 9 | 100.00% | 1 / 1 | 3 | |||||
| findSecretFileKey | 100.00% | 6 / 6 | 100.00% | 1 / 1 | 5 | |||||
| findPrototypedSecretFileKey | 100.00% | 5 / 5 | 100.00% | 1 / 1 | 4 | |||||
| findMatchingFileKey | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| findExistingFileKey | 100.00% | 3 / 3 | 100.00% | 1 / 1 | 1 | |||||
| findFileKeyBy | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| normalizeForKeyComparison | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| foldForKeyComparison | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| 1 | <?php | |
| 2 | ||
| 3 | declare(strict_types=1); | |
| 4 | ||
| 5 | namespace Stewart\Runtime\Config; | |
| 6 | ||
| 7 | use Closure; | |
| 8 | use Stewart\Runtime\Config\Environment\EnvironmentValueParser; | |
| 9 | use Stewart\Runtime\Exception\ConfigurationError; | |
| 10 | use Stewart\Runtime\Exception\ConfigurationException; | |
| 11 | use Stewart\Support\Text\ClosestNameFinder; | |
| 12 | use Symfony\Component\Config\Definition\ArrayNode; | |
| 13 | use Symfony\Component\Config\Definition\NodeInterface; | |
| 14 | use Symfony\Component\Config\Definition\PrototypedArrayNode; | |
| 15 | ||
| 16 | final readonly class EnvironmentOverlay | |
| 17 | { | |
| 18 | public const string PREFIX = 'STEWART_'; | |
| 19 | ||
| 20 | public const string ENTRYPOINT_PREFIX = self::PREFIX . 'BOOT_'; | |
| 21 | ||
| 22 | private const string SEPARATOR = '__'; | |
| 23 | ||
| 24 | private const string SECRET_FILE_SUFFIX = '_file'; | |
| 25 | ||
| 26 | // Kubernetes names these after every Service in the namespace, ours and others like stewart-valkey alike. | |
| 27 | private const string KUBERNETES_SERVICE_LINK = '/\A' . self::PREFIX . '(?:[A-Z0-9]+_)*(?:SERVICE_HOST|SERVICE_PORT(?:_[A-Z0-9_]+)?|PORT(?:_\d+_(?:TCP|UDP|SCTP)(?:_(?:PROTO|PORT|ADDR))?)?)\z/'; | |
| 28 | ||
| 29 | public function __construct( | |
| 30 | private EnvironmentVariables $variables, | |
| 31 | private EnvironmentValueParser $environmentValueParser, | |
| 32 | private ClosestNameFinder $closestNameFinder, | |
| 33 | private SecretFileReader $secretFiles, | |
| 34 | ) {} | |
| 35 | ||
| 36 | /** | |
| 37 | * @param array<array-key, mixed> $file | |
| 38 | * @return array<array-key, mixed> | |
| 39 | * @throws ConfigurationException | |
| 40 | */ | |
| 41 | public function collectEnvironmentOverrides(NodeInterface $tree, array $file): array | |
| 42 | { | |
| 43 | $overrides = []; | |
| 44 | $variableByPath = []; | |
| 45 | ||
| 46 | foreach ($this->variables->filterByPrefix(self::PREFIX) as $name => $raw) { | |
| 47 | if ($this->isReadElsewhere($name)) { | |
| 48 | continue; | |
| 49 | } | |
| 50 | ||
| 51 | $segments = array_values(array_filter( | |
| 52 | explode(self::SEPARATOR, substr($name, \strlen(self::PREFIX))), | |
| 53 | static fn(string $segment): bool => $segment !== '', | |
| 54 | )); | |
| 55 | ||
| 56 | if ($segments === []) { | |
| 57 | continue; | |
| 58 | } | |
| 59 | ||
| 60 | $target = $this->resolveOverrideTarget($tree, $file, $segments, $name); | |
| 61 | ||
| 62 | if (!$this->environmentValueParser->supportsNode($target->node)) { | |
| 63 | throw ConfigurationException::createForReason(ConfigurationError::EnvironmentVariableNamesSection, variable: $name, separator: self::SEPARATOR); | |
| 64 | } | |
| 65 | ||
| 66 | $valuePath = implode('.', $target->path); | |
| 67 | ||
| 68 | if (isset($variableByPath[$valuePath])) { | |
| 69 | throw ConfigurationException::createForReason( | |
| 70 | ConfigurationError::EnvironmentVariableConflict, | |
| 71 | variable: $variableByPath[$valuePath], | |
| 72 | otherVariable: $name, | |
| 73 | valuePath: $valuePath, | |
| 74 | ); | |
| 75 | } | |
| 76 | ||
| 77 | $variableByPath[$valuePath] = $name; | |
| 78 | $value = $target->readsSecretFile ? $this->secretFiles->readSecretFile($name, $raw) : $raw; | |
| 79 | $overrides = $this->withValueAtPath($overrides, $target->path, $this->environmentValueParser->parseForNode($target->node, $name, $value)); | |
| 80 | } | |
| 81 | ||
| 82 | return $overrides; | |
| 83 | } | |
| 84 | ||
| 85 | private function isReadElsewhere(string $name): bool | |
| 86 | { | |
| 87 | return str_starts_with($name, self::ENTRYPOINT_PREFIX) || preg_match(self::KUBERNETES_SERVICE_LINK, $name) === 1; | |
| 88 | } | |
| 89 | ||
| 90 | /** | |
| 91 | * @param array<array-key, mixed> $file | |
| 92 | * @param non-empty-list<string> $segments | |
| 93 | * @throws ConfigurationException | |
| 94 | */ | |
| 95 | private function resolveOverrideTarget(NodeInterface $node, array $file, array $segments, string $name): EnvironmentOverrideTarget | |
| 96 | { | |
| 97 | $path = []; | |
| 98 | $lastSegment = array_key_last($segments); | |
| 99 | ||
| 100 | foreach ($segments as $index => $segment) { | |
| 101 | if (!$node instanceof ArrayNode) { | |
| 102 | throw ConfigurationException::createForReason(ConfigurationError::EnvironmentVariableTooDeep, variable: $name, valuePath: implode('.', $path)); | |
| 103 | } | |
| 104 | ||
| 105 | if ($node instanceof PrototypedArrayNode) { | |
| 106 | $prototype = $node->getPrototype(); | |
| 107 | $secretKey = $index === $lastSegment ? $this->findPrototypedSecretFileKey($prototype, $segment, $file) : null; | |
| 108 | ||
| 109 | if ($secretKey !== null) { | |
| 110 | return new EnvironmentOverrideTarget([...$path, $secretKey], $prototype, readsSecretFile: true); | |
| 111 | } | |
| 112 | ||
| 113 | $key = $this->findMatchingFileKey($segment, $file); | |
| 114 | $node = $prototype; | |
| 115 | } else { | |
| 116 | $secretKey = $index === $lastSegment ? $this->findSecretFileKey($node, $segment) : null; | |
| 117 | ||
| 118 | if ($secretKey !== null) { | |
| 119 | return new EnvironmentOverrideTarget([...$path, $secretKey], $node->getChildren()[$secretKey], readsSecretFile: true); | |
| 120 | } | |
| 121 | ||
| 122 | $key = $this->resolveChildKey($node, $segment, $path, $name); | |
| 123 | $node = $node->getChildren()[$key]; | |
| 124 | } | |
| 125 | ||
| 126 | $nested = $file[$key] ?? []; | |
| 127 | $file = \is_array($nested) ? $nested : []; | |
| 128 | $path[] = $key; | |
| 129 | } | |
| 130 | ||
| 131 | return new EnvironmentOverrideTarget($path, $node); | |
| 132 | } | |
| 133 | ||
| 134 | /** | |
| 135 | * @param array<array-key, mixed> $values | |
| 136 | * @param non-empty-list<string> $path | |
| 137 | * @return array<array-key, mixed> | |
| 138 | */ | |
| 139 | private function withValueAtPath(array $values, array $path, mixed $value): array | |
| 140 | { | |
| 141 | $key = array_shift($path); | |
| 142 | ||
| 143 | if ($path === []) { | |
| 144 | $values[$key] = $value; | |
| 145 | ||
| 146 | return $values; | |
| 147 | } | |
| 148 | ||
| 149 | $nested = $values[$key] ?? null; | |
| 150 | $values[$key] = $this->withValueAtPath(\is_array($nested) ? $nested : [], $path, $value); | |
| 151 | ||
| 152 | return $values; | |
| 153 | } | |
| 154 | ||
| 155 | /** | |
| 156 | * @param list<string> $path | |
| 157 | * @throws ConfigurationException | |
| 158 | */ | |
| 159 | private function resolveChildKey(ArrayNode $node, string $segment, array $path, string $name): string | |
| 160 | { | |
| 161 | $key = strtolower($segment); | |
| 162 | $children = array_keys($node->getChildren()); | |
| 163 | ||
| 164 | if (\in_array($key, $children, true)) { | |
| 165 | return $key; | |
| 166 | } | |
| 167 | ||
| 168 | $closestChildKey = $this->closestNameFinder->findClosestName($key, $children); | |
| 169 | ||
| 170 | throw ConfigurationException::environmentVariableUnknown( | |
| 171 | $name, | |
| 172 | $closestChildKey === null ? null : self::PREFIX . strtoupper(implode(self::SEPARATOR, [...$path, $closestChildKey])), | |
| 173 | ); | |
| 174 | } | |
| 175 | ||
| 176 | private function findSecretFileKey(ArrayNode $node, string $segment): ?string | |
| 177 | { | |
| 178 | $key = strtolower($segment); | |
| 179 | $children = $node->getChildren(); | |
| 180 | ||
| 181 | if (isset($children[$key]) || !str_ends_with($key, self::SECRET_FILE_SUFFIX)) { | |
| 182 | return null; | |
| 183 | } | |
| 184 | ||
| 185 | $secretKey = substr($key, 0, -\strlen(self::SECRET_FILE_SUFFIX)); | |
| 186 | ||
| 187 | return isset($children[$secretKey]) && $this->environmentValueParser->supportsNode($children[$secretKey]) ? $secretKey : null; | |
| 188 | } | |
| 189 | ||
| 190 | // A free-form key the file already names, like an option called cert_file, keeps its literal meaning. | |
| 191 | /** @param array<array-key, mixed> $file */ | |
| 192 | private function findPrototypedSecretFileKey(NodeInterface $prototype, string $segment, array $file): ?string | |
| 193 | { | |
| 194 | if (!str_ends_with(strtolower($segment), self::SECRET_FILE_SUFFIX) || !$this->environmentValueParser->supportsNode($prototype)) { | |
| 195 | return null; | |
| 196 | } | |
| 197 | ||
| 198 | return $this->findExistingFileKey($segment, $file) === null | |
| 199 | ? $this->findMatchingFileKey(substr($segment, 0, -\strlen(self::SECRET_FILE_SUFFIX)), $file) | |
| 200 | : null; | |
| 201 | } | |
| 202 | ||
| 203 | /** @param array<array-key, mixed> $file */ | |
| 204 | private function findMatchingFileKey(string $segment, array $file): string | |
| 205 | { | |
| 206 | return $this->findExistingFileKey($segment, $file) ?? strtolower($segment); | |
| 207 | } | |
| 208 | ||
| 209 | /** @param array<array-key, mixed> $file */ | |
| 210 | private function findExistingFileKey(string $segment, array $file): ?string | |
| 211 | { | |
| 212 | $fileKeys = array_map(strval(...), array_keys($file)); | |
| 213 | ||
| 214 | return $this->findFileKeyBy($fileKeys, $segment, $this->normalizeForKeyComparison(...)) | |
| 215 | ?? $this->findFileKeyBy($fileKeys, $segment, $this->foldForKeyComparison(...)); | |
| 216 | } | |
| 217 | ||
| 218 | /** | |
| 219 | * @param list<string> $fileKeys | |
| 220 | * @param Closure(string): string $normalize | |
| 221 | */ | |
| 222 | private function findFileKeyBy(array $fileKeys, string $segment, Closure $normalize): ?string | |
| 223 | { | |
| 224 | return array_find($fileKeys, static fn(string $existing): bool => $normalize($existing) === $normalize($segment)); | |
| 225 | } | |
| 226 | ||
| 227 | private function normalizeForKeyComparison(string $key): string | |
| 228 | { | |
| 229 | // Env segments are upper case and cannot express "-", so both sides map it to "_". | |
| 230 | return strtolower(strtr($key, '-', '_')); | |
| 231 | } | |
| 232 | ||
| 233 | // MOTION_SENSOR and MOTIONSENSOR both stand for a camelCase key such as motionSensor. | |
| 234 | private function foldForKeyComparison(string $key): string | |
| 235 | { | |
| 236 | return strtolower(str_replace(['_', '-'], '', $key)); | |
| 237 | } | |
| 238 | } |