Lines 0.00% 0 / 58
Functions and Methods 0.00% 0 / 9
Classes and Traits 0.00% 0 / 1
Name Lines Functions and Methods CRAP Classes and Traits
RunCommand 0.00% 0 / 58 0.00% 0 / 9 600 0.00% 0 / 1
 __construct 0.00% 0 / 1 0.00% 0 / 1 2
 configure 0.00% 0 / 5 0.00% 0 / 1 2
 execute 0.00% 0 / 10 0.00% 0 / 1 30
 prepareDaemon 0.00% 0 / 14 0.00% 0 / 1 12
 noteMissingConfigFile 0.00% 0 / 2 0.00% 0 / 1 6
 warnAboutDiscovery 0.00% 0 / 19 0.00% 0 / 1 30
 configOverridesFromOptions 0.00% 0 / 2 0.00% 0 / 1 6
 selectAppsFromOptions 0.00% 0 / 4 0.00% 0 / 1 12
 logFormatFromOptions 0.00% 0 / 1 0.00% 0 / 1 6
1<?php
2
3declare(strict_types=1);
4
5namespace Stewart\Runtime\Console;
6
7use Psr\Log\LoggerInterface;
8use Stewart\Contracts\App\AppId;
9use Stewart\Contracts\App\Collection\AppIdCollection;
10use Stewart\Contracts\Exception\IdentifierException;
11use Stewart\Contracts\Exception\StewartException;
12use Stewart\Runtime\App\AppCatalog;
13use Stewart\Runtime\App\AppCatalogResolver;
14use Stewart\Runtime\App\AppDiscovery;
15use Stewart\Runtime\App\AppSelection;
16use Stewart\Runtime\App\DiscoveryResult;
17use Stewart\Runtime\Config\ConfigLoader;
18use Stewart\Runtime\Kernel\BrokerKernel;
19use Stewart\Runtime\Lifecycle\BrokerStopOutcome;
20use Stewart\Runtime\Logging\LogFormat;
21use Stewart\Runtime\Logging\LoggerFactory;
22use Symfony\Component\Console\Attribute\AsCommand;
23use Symfony\Component\Console\Command\Command;
24use Symfony\Component\Console\Input\InputInterface;
25use Symfony\Component\Console\Input\InputOption;
26use Symfony\Component\Console\Output\OutputInterface;
27use Throwable;
28
29#[AsCommand(name: self::NAME, description: 'Run the Stewart daemon')]
30final class RunCommand extends StewartCommand
31{
32    public const string NAME = 'run';
33
34    // sysexits EX_TEMPFAIL: the container entrypoint starts the daemon again on the staged release.
35    public const int RESTART_EXIT_CODE = 75;
36
37    public function __construct(
38        private readonly ConfigLoader $config,
39        private readonly AppDiscovery $discovery,
40        private readonly AppCatalogResolver $catalogs,
41        private readonly BrokerKernel $brokers,
42        private readonly LoggerFactory $loggers,
43    ) {
44        parent::__construct();
45    }
46
47    protected function configure(): void
48    {
49        parent::configure();
50        $this
51            ->addOption('log-level', 'l', InputOption::VALUE_REQUIRED, 'debug|info|notice|warning|error')
52            ->addOption('json-logs', null, InputOption::VALUE_NONE, 'Emit logs as JSON')
53            ->addOption('only', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Run only this automation, even if disabled; repeat for more');
54    }
55
56    protected function execute(InputInterface $input, OutputInterface $output): int
57    {
58        try {
59            $daemon = $this->prepareDaemon($input);
60        } catch (StewartException $e) {
61            $output->writeln('<error>' . $e->getMessage() . '</error>');
62
63            return Command::FAILURE;
64        }
65
66        try {
67            $outcome = $daemon->broker->run();
68        } catch (Throwable $e) {
69            $daemon->logger->error('Stewart stopped', ['exception' => $e]);
70
71            return Command::FAILURE;
72        }
73
74        return match ($outcome) {
75            BrokerStopOutcome::Stopped => Command::SUCCESS,
76            BrokerStopOutcome::RestartRequested => self::RESTART_EXIT_CODE,
77        };
78    }
79
80    /** @throws StewartException */
81    private function prepareDaemon(InputInterface $input): PreparedDaemon
82    {
83        $configOption = $this->findConfigOption($input);
84        $config = $this->config->loadConfig($configOption, $this->configOverridesFromOptions($input));
85        // Validated before the logger exists, so a missing home_assistant section fails as a config error.
86        $config->requireHomeAssistant();
87        $discovered = $this->discovery->discover();
88        $selection = $this->selectAppsFromOptions($input);
89        $apps = $this->catalogs->resolveCatalog($discovered, $config->apps, $config->workers, $selection);
90        $logger = $this->loggers->createStdoutLogger($config->logLevel, $this->logFormatFromOptions($input, $config->logFormat));
91
92        $this->noteMissingConfigFile($this->config->findMissingDefaultFile($configOption), $logger);
93        $this->warnAboutDiscovery($discovered, $apps, $this->config->locateConfigFile($configOption), $logger);
94
95        if ($config->serviceCalls->dryRun) {
96            $logger->warning('service_calls.dry_run is on: service calls are logged, not sent to Home Assistant.');
97        }
98
99        if ($selection->onlyIds !== null) {
100            $logger->notice('Running only the automations named by --only.', ['apps' => $selection->onlyIds->toStrings()]);
101        }
102
103        return new PreparedDaemon($this->brokers->createBroker($config, $apps, $logger, $configOption), $logger);
104    }
105
106    private function noteMissingConfigFile(?string $missingConfigFile, LoggerInterface $logger): void
107    {
108        if ($missingConfigFile !== null) {
109            $logger->info('No config file found; using the environment only.', ['config' => $missingConfigFile]);
110        }
111    }
112
113    private function warnAboutDiscovery(DiscoveryResult $discovered, AppCatalog $apps, string $configPath, LoggerInterface $logger): void
114    {
115        foreach ($discovered->unmarked as $unmarked) {
116            $logger->warning('A class in apps/ implements App but is not marked #[Automation], so it will not run.', [
117                'class' => $unmarked->class,
118            ]);
119        }
120
121        foreach ($discovered->unloadable as $file) {
122            $logger->error('A file in apps/ failed to load, so the automation it declares will not run.', [
123                'file' => $file->path,
124                'exception' => $file->cause,
125            ]);
126        }
127
128        if (!$apps->unmatchedOverrideIds->isEmpty()) {
129            $logger->warning('stewart.yaml configures automations that were not found, possibly because their file failed to load.', [
130                'apps' => $apps->unmatchedOverrideIds->toStrings(),
131                'files' => $discovered->unloadable->listPaths(),
132            ]);
133        }
134
135        if ($apps->enabled->isEmpty()) {
136            $logger->warning('No automations to run; Stewart will connect and do nothing.', [
137                'config' => $configPath,
138                'found' => $discovered->apps->count(),
139            ]);
140        }
141    }
142
143    /** @return array<string, mixed> */
144    private function configOverridesFromOptions(InputInterface $input): array
145    {
146        $level = $this->nonEmptyStringOption($input, 'log-level');
147
148        return $level === null ? [] : ['log_level' => $level];
149    }
150
151    /** @throws IdentifierException */
152    private function selectAppsFromOptions(InputInterface $input): AppSelection
153    {
154        $only = $input->getOption('only');
155
156        if (!\is_array($only) || $only === []) {
157            return new AppSelection();
158        }
159
160        return new AppSelection(AppIdCollection::fromList(array_map(static fn(string $id): AppId => new AppId($id), array_values(array_filter($only, \is_string(...))))));
161    }
162
163    private function logFormatFromOptions(InputInterface $input, LogFormat $configured): LogFormat
164    {
165        return $input->getOption('json-logs') === true ? LogFormat::Json : $configured;
166    }
167}