Lines 99.41% 342 / 344
Functions and Methods 91.66% 22 / 24
Classes and Traits 0.00% 0 / 1
Name Lines Functions and Methods CRAP Classes and Traits
StewartConfigSchema 99.41% 342 / 344 91.66% 22 / 24 27 0.00% 0 / 1
 isOff 0.00% 0 / 1 0.00% 0 / 1 2
 getConfigTreeBuilder 100.00% 16 / 16 100.00% 1 / 1 1
 buildConfigTree 0.00% 0 / 1 0.00% 0 / 1 2
 addTopLevelSettings 100.00% 34 / 34 100.00% 1 / 1 1
 requireKeysMatching 100.00% 9 / 9 100.00% 1 / 1 1
 createDurationNode 100.00% 3 / 3 100.00% 1 / 1 1
 appendBackoffNodes 100.00% 3 / 3 100.00% 1 / 1 1
 createScalarNode 100.00% 3 / 3 100.00% 1 / 1 1
 addHomeAssistantSection 100.00% 24 / 24 100.00% 1 / 1 1
 addReconnectSection 100.00% 6 / 6 100.00% 1 / 1 1
 addSupervisionSection 100.00% 22 / 22 100.00% 1 / 1 1
 addPersistenceSection 100.00% 13 / 13 100.00% 1 / 1 1
 addMqttSection 100.00% 38 / 38 100.00% 1 / 1 1
 addControlSection 100.00% 14 / 14 100.00% 1 / 1 1
 addHttpSection 100.00% 21 / 21 100.00% 1 / 1 1
 addCodegenSection 100.00% 14 / 14 100.00% 1 / 1 1
 addDeploySection 100.00% 16 / 16 100.00% 1 / 1 1
 addExposeSection 100.00% 15 / 15 100.00% 1 / 1 1
 createWebsocketUrlNode 100.00% 10 / 10 100.00% 1 / 1 1
 createSizeLimitNode 100.00% 3 / 3 100.00% 1 / 1 1
 createPatternListNode 100.00% 7 / 7 100.00% 1 / 1 1
 createDomainAttributesNode 100.00% 11 / 11 100.00% 1 / 1 1
 addServiceCallsSection 100.00% 23 / 23 100.00% 1 / 1 1
 addAppsSection 100.00% 37 / 37 100.00% 1 / 1 4
1<?php
2
3declare(strict_types=1);
4
5namespace Stewart\Runtime\Config;
6
7use Closure;
8use InvalidArgumentException;
9use Stewart\Client\Connection\HomeAssistantUrl;
10use Stewart\Contracts\App\AppId;
11use Stewart\Runtime\Logging\LogFormat;
12use Stewart\Runtime\Model\LogLevel;
13use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition;
14use Symfony\Component\Config\Definition\Builder\IntegerNodeDefinition;
15use Symfony\Component\Config\Definition\Builder\NodeBuilder;
16use Symfony\Component\Config\Definition\Builder\ScalarNodeDefinition;
17use Symfony\Component\Config\Definition\Builder\TreeBuilder;
18use Symfony\Component\Config\Definition\ConfigurationInterface;
19use Symfony\Component\Config\Definition\NodeInterface;
20
21final class StewartConfigSchema implements ConfigurationInterface
22{
23    public const string ROOT = 'stewart';
24
25    public const string OFF = 'off';
26
27
28    private const int DEFAULT_SIZE_LIMIT_BYTES = 64 * 1024 * 1024;
29
30    private const string DOMAIN_KEY = '/\A[a-z0-9_]+\z/';
31
32    public static function isOff(string $value): bool
33    {
34        return strcasecmp(trim($value), self::OFF) === 0;
35    }
36
37    public function getConfigTreeBuilder(): TreeBuilder
38    {
39        $treeBuilder = new TreeBuilder(self::ROOT);
40        $root = $treeBuilder->getRootNode();
41
42        $this->addTopLevelSettings($root);
43        $this->addHomeAssistantSection($root);
44        $this->addReconnectSection($root);
45        $this->addSupervisionSection($root);
46        $this->addServiceCallsSection($root);
47        $this->addPersistenceSection($root);
48        $this->addMqttSection($root);
49        $this->addControlSection($root);
50        $this->addHttpSection($root);
51        $this->addCodegenSection($root);
52        $this->addDeploySection($root);
53        $this->addExposeSection($root);
54        $this->addAppsSection($root);
55
56        return $treeBuilder;
57    }
58
59    public function buildConfigTree(): NodeInterface
60    {
61        return $this->getConfigTreeBuilder()->buildTree();
62    }
63
64    private function addTopLevelSettings(ArrayNodeDefinition $root): void
65    {
66        $root
67            ->children()
68                ->integerNode('workers')
69                    ->info('Worker processes to run. 0 means min(4, cpu count).')
70                    ->min(0)
71                    ->defaultValue(0)
72                ->end()
73                ->enumNode('log_level')
74                    ->info('How much the daemon says.')
75                    ->values(array_map(static fn(LogLevel $level): string => $level->value, LogLevel::configurable()))
76                    ->defaultValue(LogLevel::Info->value)
77                ->end()
78                ->enumNode('log_format')
79                    ->info('How log lines are written: line for a terminal, json for a log collector. --json-logs overrides it.')
80                    ->values(array_map(static fn(LogFormat $format): string => $format->value, LogFormat::cases()))
81                    ->defaultValue(LogFormat::Line->value)
82                ->end()
83                ->append($this->createDurationNode('shutdown_grace', 'How long a worker gets to dispose its apps before it is killed.', '5s'))
84                ->integerNode('worker_event_buffer')
85                    ->info('Topic messages and Home Assistant events buffered per worker, together up to this many, before the oldest are dropped.')
86                    ->min(1)
87                    ->defaultValue(1000)
88                ->end()
89                ->integerNode('worker_state_batch')
90                    ->info('State changes sent to a worker in one message at most.')
91                    ->min(1)
92                    ->defaultValue(256)
93                ->end()
94                ->integerNode('subscription_buffer')
95                    ->info('Events queued per subscription while its handler is busy, before queued events are dropped, superseded state changes first. A burst of state changes from Home Assistant is always queued whole.')
96                    ->min(1)
97                    ->defaultValue(100)
98                ->end()
99            ->end();
100    }
101
102    /** @param Closure(string): bool $accepts */
103    private function requireKeysMatching(ArrayNodeDefinition $node, Closure $accepts, string $expected): ArrayNodeDefinition
104    {
105        $node
106            ->validate()
107                ->ifTrue(static fn(array $map): bool => array_any(array_keys($map), static fn(int|string $key): bool => !$accepts((string) $key)))
108                ->then(static function (array $map) use ($accepts, $expected): never {
109                    $bad = array_find(array_keys($map), static fn(int|string $key): bool => !$accepts((string) $key));
110
111                    throw new InvalidArgumentException(\sprintf('"%s" is not %s.', $bad, $expected));
112                })
113            ->end();
114
115        return $node;
116    }
117
118    private function createDurationNode(string $name, string $info, string $default): ScalarNodeDefinition
119    {
120        $node = new ScalarNodeDefinition($name);
121        $node->info($info)->defaultValue($default);
122
123        return $node;
124    }
125
126    private function appendBackoffNodes(NodeBuilder $children, string $keyPrefix, string $firstAttempt, string $maxDelay): NodeBuilder
127    {
128        return $children
129            ->append($this->createDurationNode($keyPrefix . 'initial_delay', \sprintf('Wait before the first %s. Doubles per attempt.', $firstAttempt), '1s'))
130            ->append($this->createDurationNode($keyPrefix . 'max_delay', 'Ceiling for the doubling.', $maxDelay));
131    }
132
133    private function createScalarNode(string $name, string $info, ?string $default): ScalarNodeDefinition
134    {
135        $node = new ScalarNodeDefinition($name);
136        $node->info($info)->defaultValue($default);
137
138        return $node;
139    }
140
141    private function addHomeAssistantSection(ArrayNodeDefinition $root): void
142    {
143        $root
144            ->children()
145                ->arrayNode('home_assistant')
146                    ->info('The instance to connect to. `run` and `generate` need it. The token is a secret: set it from the environment.')
147                    ->children()
148                        ->append($this->createWebsocketUrlNode())
149                        ->scalarNode('token')
150                            ->info('A long-lived access token: Home Assistant, Profile -> Security.')
151                            ->isRequired()
152                            ->cannotBeEmpty()
153                        ->end()
154                        ->append($this->createDurationNode('connect_timeout', 'How long opening the websocket and authenticating may take.', '10s'))
155                        ->append($this->createDurationNode('command_timeout', 'How long Home Assistant may take to answer one command.', '30s'))
156                        ->append($this->createSizeLimitNode('message_size_limit', 'A full get_states for a large instance is several megabytes in one message.'))
157                        ->append($this->createSizeLimitNode('frame_size_limit', 'The frame limit bites before the message limit, and looks like a disconnect.'))
158                        ->append($this->createDurationNode('heartbeat_interval', 'Time between websocket pings that detect a silently dead connection. Rounded to whole seconds.', '10s'))
159                        ->integerNode('heartbeat_missed_limit')
160                            ->info('Unanswered pings before the connection is treated as lost.')
161                            ->min(1)
162                            ->defaultValue(3)
163                        ->end()
164                    ->end()
165                ->end()
166            ->end();
167    }
168
169    private function addReconnectSection(ArrayNodeDefinition $root): void
170    {
171        $reconnect = $root
172            ->children()
173                ->arrayNode('reconnect')
174                    ->info('How long the broker waits between attempts to reach Home Assistant again. It never stops trying.')
175                    ->addDefaultsIfNotSet();
176
177        $this->appendBackoffNodes($reconnect->children(), '', 'retry', '60s');
178    }
179
180    private function addSupervisionSection(ArrayNodeDefinition $root): void
181    {
182        $supervision = $root
183            ->children()
184                ->arrayNode('supervision')
185                    ->info('What the broker does about workers that die or stop responding.')
186                    ->addDefaultsIfNotSet();
187
188        $children = $supervision->children()
189            ->integerNode('restart_attempts')
190                ->info('Restarts allowed inside the window before a worker is quarantined and its apps stay down.')
191                ->min(0)
192                ->defaultValue(5)
193            ->end()
194            ->append($this->createDurationNode('restart_window', 'Span the restart count is measured over.', '60s'));
195
196        $this->appendBackoffNodes($children, 'restart_', 'restart of a worker', '30s')
197            ->append($this->createDurationNode('ping_interval', 'Time between liveness probes. "off" disables the watchdog.', '10s'))
198            ->append($this->createDurationNode('lag_threshold', 'Round-trip lag before a worker is reported as falling behind.', '500ms'))
199            ->integerNode('unresponsive_after')
200                ->info('Missed liveness probes before a worker is killed and restarted. 0 only logs.')
201                ->min(0)
202                ->defaultValue(3)
203            ->end()
204            ->append($this->createDurationNode('initialize_timeout', 'Time an app gets in initialize() before it is dropped; apps initialize concurrently. "off" waits forever.', '60s'))
205            ->append($this->createDurationNode('ready_timeout', 'Time a worker gets to report its apps ready before it is restarted; keep it above initialize_timeout. "off" disables.', '5m'));
206    }
207
208    private function addPersistenceSection(ArrayNodeDefinition $root): void
209    {
210        $root
211            ->children()
212                ->arrayNode('persistence')
213                    ->info('Where apps keep what must survive a restart. Without a url, an app that asks for a store is not started.')
214                    ->addDefaultsIfNotSet()
215                    ->children()
216                        ->append($this->createScalarNode('url', 'Storage URL, for example redis://valkey:6379/0. Credentials and the database index belong in it.', null))
217                        ->append($this->createScalarNode('prefix', 'What every key is filed under, so two installations can share one server.', 'stewart'))
218                        ->append($this->createDurationNode('timeout', 'How long one storage operation may take.', '2s'))
219                        ->append($this->createDurationNode('recovery_interval', 'After the store times out or is unreachable, how long a worker fails store calls at once before it tries again.', '5s'))
220                    ->end()
221                ->end()
222            ->end();
223    }
224
225    private function addMqttSection(ArrayNodeDefinition $root): void
226    {
227        $root
228            ->children()
229                ->arrayNode('mqtt')
230                    ->info('The MQTT server apps exchange messages with. Without a url, an app that asks for Mqtt is not started. Needs stewart-php/mqtt.')
231                    ->addDefaultsIfNotSet()
232                    ->children()
233                        ->append($this->createScalarNode('url', 'Server URL, mqtt://host:1883 or mqtts://host:8883 for TLS. Credentials belong in it: mqtt://user:password@host.', null))
234                        ->append($this->createScalarNode('client_id', 'Client identifier the server knows the daemon by. Defaults to stewart-<hostname>.', null))
235                        ->append($this->createDurationNode('keepalive', 'Longest silence before the connection is probed, and dropped when the probe goes unanswered.', '30s'))
236                        ->append($this->createDurationNode('connect_timeout', 'How long connecting and the CONNECT handshake may take.', '10s'))
237                        ->append($this->createDurationNode('reconnect_initial_delay', 'Wait before the first reconnect to the MQTT server. Doubles per attempt.', '1s'))
238                        ->append($this->createDurationNode('reconnect_max_delay', 'Ceiling for the doubling.', '60s'))
239                        ->integerNode('outbound_buffer')
240                            ->info('QoS 1 messages kept while the server is unreachable, before the oldest are dropped. QoS 0 messages are dropped at once.')
241                            ->min(0)
242                            ->defaultValue(100)
243                        ->end()
244                        ->arrayNode('will')
245                            ->info('Message the server publishes when the daemon disappears without disconnecting.')
246                            ->addDefaultsIfNotSet()
247                            ->children()
248                                ->append($this->createScalarNode('topic', 'Topic of the last will. Unset sends none.', null))
249                                ->append($this->createScalarNode('payload', 'Payload of the last will.', ''))
250                                ->integerNode('qos')
251                                    ->info('Delivery guarantee: 0 at most once, 1 at least once.')
252                                    ->min(0)
253                                    ->max(1)
254                                    ->defaultValue(0)
255                                ->end()
256                                ->booleanNode('retain')
257                                    ->info('Whether the server keeps the last will for later subscribers.')
258                                    ->defaultFalse()
259                                ->end()
260                            ->end()
261                        ->end()
262                    ->end()
263                ->end()
264            ->end();
265    }
266
267    private function addControlSection(ArrayNodeDefinition $root): void
268    {
269        $root
270            ->children()
271                ->arrayNode('control')
272                    ->info('The socket `stewart status`, `app:pause` and `worker:restart` connect to. The token is a secret: set it from the environment.')
273                    ->addDefaultsIfNotSet()
274                    ->children()
275                        ->append($this->createScalarNode('listen', 'unix://path, relative to the project root, or tcp://host:port. "off" disables the socket; an empty environment variable means unset, so use the word.', 'unix://' . ControlAddress::DEFAULT_SOCKET))
276                        ->scalarNode('token')
277                            ->info('Shared secret every client presents. Required while listen is set: the daemon refuses to start without one.')
278                            ->defaultNull()
279                        ->end()
280                    ->end()
281                ->end()
282            ->end();
283    }
284
285    private function addHttpSection(ArrayNodeDefinition $root): void
286    {
287        $root
288            ->children()
289                ->arrayNode('http')
290                    ->info('The read-only HTTP port that answers /healthz and /readyz, for Kubernetes probes.')
291                    ->addDefaultsIfNotSet()
292                    ->children()
293                        ->append($this->createScalarNode('listen', 'tcp://ip:port, such as tcp://0.0.0.0:8080 or tcp://[::]:8080. "off" disables the port.', self::OFF))
294                        ->arrayNode('admin')
295                            ->info('The HTTP admin API that lists, pauses and resumes apps. The token is a secret: set it from the environment.')
296                            ->addDefaultsIfNotSet()
297                            ->children()
298                                ->append($this->createScalarNode('listen', 'tcp://ip:port, such as tcp://127.0.0.1:8081. "off" disables the API.', self::OFF))
299                                ->scalarNode('token')
300                                    ->info('Bearer token every request presents. Required while listen is set: the daemon refuses to start without one.')
301                                    ->defaultNull()
302                                ->end()
303                            ->end()
304                        ->end()
305                    ->end()
306                ->end()
307            ->end();
308    }
309
310    private function addCodegenSection(ArrayNodeDefinition $root): void
311    {
312        $root
313            ->children()
314                ->arrayNode('codegen')
315                    ->info('What `stewart generate` writes: typed classes over the entities and services of your own instance.')
316                    ->addDefaultsIfNotSet()
317                    ->children()
318                        ->append($this->createScalarNode('namespace', 'Namespace of the generated classes. Composer must map it to output_dir.', 'App\\Generated'))
319                        ->append($this->createScalarNode('output_dir', 'Directory the classes are written to, relative to the project root.', 'generated'))
320                        ->append($this->createPatternListNode('include', 'Entity selectors that get classes. Exact IDs or globs (* and ?).', ['*']))
321                        ->append($this->createPatternListNode('exclude', 'Entity selectors that do not, whatever include says.', []))
322                        ->append($this->createDomainAttributesNode())
323                    ->end()
324                ->end()
325            ->end();
326    }
327
328    private function addDeploySection(ArrayNodeDefinition $root): void
329    {
330        $root
331            ->children()
332                ->arrayNode('deploy')
333                    ->info('Deploying new commits while running; needs the runtime image cloning the project (STEWART_BOOT_GIT_URL).')
334                    ->addDefaultsIfNotSet()
335                    ->children()
336                        ->arrayNode('git')
337                            ->addDefaultsIfNotSet()
338                            ->children()
339                                ->append($this->createDurationNode('poll', 'How often to fetch the tracked ref and deploy a new commit that passes `stewart check`. "off" disables it.', self::OFF))
340                                ->append($this->createDurationNode('prepare_timeout', 'Longest a fetch, dependency install and check of one commit may take.', '10m'))
341                            ->end()
342                        ->end()
343                    ->end()
344                ->end()
345            ->end();
346    }
347
348    private function addExposeSection(ArrayNodeDefinition $root): void
349    {
350        $root
351            ->children()
352                ->arrayNode('expose')
353                    ->info('Entities apps create in Home Assistant through the stewart integration.')
354                    ->addDefaultsIfNotSet()
355                    ->children()
356                        ->append($this->createScalarNode('instance', 'Name of this Stewart in Home Assistant, part of every exposed unique_id; daemons sharing one Home Assistant need different names.', 'default'))
357                        ->append($this->createDurationNode('command_timeout', 'How long Home Assistant waits for an app to answer a command on an exposed entity; at most 300s.', '10s'))
358                        ->booleanNode('prune')
359                            ->info('Remove this daemon\'s entities that no running app exposed shortly after startup; entities of failed apps stay.')
360                            ->defaultTrue()
361                        ->end()
362                    ->end()
363                ->end()
364            ->end();
365    }
366
367    private function createWebsocketUrlNode(): ScalarNodeDefinition
368    {
369        $node = new ScalarNodeDefinition('url');
370        $node
371            ->info('http:// and https:// are accepted and rewritten; /api/websocket is appended unless the path already ends in /websocket.')
372            ->isRequired()
373            ->cannotBeEmpty()
374            ->beforeNormalization()
375                ->ifString()
376                ->then(static fn(string $url): string => HomeAssistantUrl::tryParse($url)?->reveal() ?? $url)
377            ->end();
378
379        return $node;
380    }
381
382    private function createSizeLimitNode(string $name, string $info): IntegerNodeDefinition
383    {
384        $node = new IntegerNodeDefinition($name);
385        $node->info($info)->min(1)->defaultValue(self::DEFAULT_SIZE_LIMIT_BYTES);
386
387        return $node;
388    }
389
390    /** @param list<string> $default */
391    private function createPatternListNode(string $name, string $info, array $default): ArrayNodeDefinition
392    {
393        $node = new ArrayNodeDefinition($name);
394        $node
395            ->info($info)
396            ->scalarPrototype()->cannotBeEmpty()->end()
397            ->performNoDeepMerging()
398            ->defaultValue($default);
399
400        return $node;
401    }
402
403    private function createDomainAttributesNode(): ArrayNodeDefinition
404    {
405        $node = $this->requireKeysMatching(new ArrayNodeDefinition('attributes'), static fn(string $domain): bool => preg_match(self::DOMAIN_KEY, $domain) === 1, 'a domain such as "sensor"');
406        $node
407            ->info('Per-domain attribute keys for the state classes, keyed by domain. Known Home Assistant attributes get typed accessors; everything else stays reachable through attributes().')
408            ->normalizeKeys(false)
409            ->arrayPrototype()
410                ->children()
411                    ->append($this->createPatternListNode('include', 'Keys or globs that get an accessor when seen, in addition to the known ones.', []))
412                    ->append($this->createPatternListNode('exclude', 'Keys or globs that never get one, known or included.', []))
413                ->end()
414            ->end();
415
416        return $node;
417    }
418
419    private function addServiceCallsSection(ArrayNodeDefinition $root): void
420    {
421        $root
422            ->children()
423                ->arrayNode('service_calls')
424                    ->info('Bounds on the service calls workers may have in flight through the shared connection.')
425                    ->addDefaultsIfNotSet()
426                    ->children()
427                        ->integerNode('max_in_flight_per_worker')
428                            ->info('Calls one worker may have outstanding before further ones are refused. 0 is unlimited.')
429                            ->min(0)
430                            ->defaultValue(64)
431                        ->end()
432                        ->integerNode('max_in_flight')
433                            ->info('Calls the whole broker may have outstanding. 0 is unlimited.')
434                            ->min(0)
435                            ->defaultValue(256)
436                        ->end()
437                        ->booleanNode('dry_run')
438                            ->info('Log service calls and answer them as succeeded instead of sending them, to develop against a live Home Assistant.')
439                            ->defaultFalse()
440                        ->end()
441                    ->end()
442                ->end()
443            ->end();
444    }
445
446    private function addAppsSection(ArrayNodeDefinition $root): void
447    {
448        $root
449            ->children()
450                ->append($this->requireKeysMatching(new ArrayNodeDefinition('apps'), static fn(string $id): bool => AppId::tryFromString($id) !== null, 'an automation ID. IDs start with a letter and hold only lowercase letters, digits, "-" and "_"')
451                    ->info('Per-app overrides, keyed by the ID in the automation\'s #[Automation] attribute. Automations are found by scanning, not listed here.')
452                    ->normalizeKeys(false)
453                    ->arrayPrototype()
454                        // integerNode() rejects an explicit null, so `worker: ~` is removed before validation.
455                        ->beforeNormalization()
456                            ->ifTrue(static fn(mixed $app): bool => \is_array($app)
457                                && \array_key_exists('worker', $app)
458                                && $app['worker'] === null)
459                            ->then(static function (mixed $app): mixed {
460                                if (\is_array($app)) {
461                                    unset($app['worker']);
462                                }
463
464                                return $app;
465                            })
466                        ->end()
467                        ->children()
468                            ->booleanNode('enabled')->defaultTrue()->end()
469                            ->booleanNode('paused')
470                                ->info('Start loaded but paused: no events or schedule runs reach it until resumed.')
471                                ->defaultFalse()
472                            ->end()
473                            ->integerNode('worker')
474                                ->info('Pin to a worker index. Omit to have it distributed round-robin.')
475                                ->min(0)
476                                ->defaultNull()
477                            ->end()
478                            ->arrayNode('options')
479                                ->info('Passed to the app constructor as named arguments.')
480                                ->normalizeKeys(false)
481                                ->defaultValue([])
482                                ->variablePrototype()->end()
483                            ->end()
484                        ->end()
485                    ->end())
486            ->end();
487    }
488}