Lines 100.00% 81 / 81
Methods 100.00% 13 / 13
Classes 100.00% 1 / 1
Name Lines Methods CRAP
 __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
16final 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}