Lines
100.00%
145 / 145
Functions and Methods
100.00%
17 / 17
Classes and Traits
100.00%
1 / 1
| Name | Lines | Functions and Methods | CRAP | Classes and Traits | ||||||
|---|---|---|---|---|---|---|---|---|---|---|
| AppContainerBuilder | 100.00% | 145 / 145 | 100.00% | 17 / 17 | 39 | 100.00% | 1 / 1 | |||
| __construct | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| buildAppServiceId | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| buildPeerStoreServiceId | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| buildAppContainer | 100.00% | 12 / 12 | 100.00% | 1 / 1 | 5 | |||||
| isolateUnbuildableApps | 100.00% | 11 / 11 | 100.00% | 1 / 1 | 4 | |||||
| compileOrFailWorker | 100.00% | 3 / 3 | 100.00% | 1 / 1 | 2 | |||||
| compileContainerFor | 100.00% | 20 / 20 | 100.00% | 1 / 1 | 3 | |||||
| explainAppFailure | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 2 | |||||
| explainWorkerFailure | 100.00% | 5 / 5 | 100.00% | 1 / 1 | 2 | |||||
| loadUserServices | 100.00% | 4 / 4 | 100.00% | 1 / 1 | 2 | |||||
| registerFrameworkServices | 100.00% | 18 / 18 | 100.00% | 1 / 1 | 2 | |||||
| compileWithSynthetics | 100.00% | 5 / 5 | 100.00% | 1 / 1 | 2 | |||||
| rejectAppDefinitions | 100.00% | 4 / 4 | 100.00% | 1 / 1 | 3 | |||||
| registerSharedGeneratedRoots | 100.00% | 6 / 6 | 100.00% | 1 / 1 | 3 | |||||
| registerApp | 100.00% | 9 / 9 | 100.00% | 1 / 1 | 1 | |||||
| registerAppScopedServices | 100.00% | 29 / 29 | 100.00% | 1 / 1 | 3 | |||||
| buildAppBindings | 100.00% | 15 / 15 | 100.00% | 1 / 1 | 2 | |||||
| 1 | <?php | |
| 2 | ||
| 3 | declare(strict_types=1); | |
| 4 | ||
| 5 | namespace Stewart\Runtime\Container; | |
| 6 | ||
| 7 | use Psr\Log\LoggerInterface; | |
| 8 | use Stewart\Contracts\App; | |
| 9 | use Stewart\Contracts\App\AppId; | |
| 10 | use Stewart\Contracts\Exception\ExceptionReason; | |
| 11 | use Stewart\Contracts\Exception\StewartException; | |
| 12 | use Stewart\Contracts\Exposure\EntityExposure; | |
| 13 | use Stewart\Contracts\HaContext; | |
| 14 | use Stewart\Contracts\Mqtt\Mqtt; | |
| 15 | use Stewart\Contracts\Registry\RegistryEditor; | |
| 16 | use Stewart\Contracts\Schedule\Scheduler; | |
| 17 | use Stewart\Contracts\Store\ReadableStore; | |
| 18 | use Stewart\Contracts\Store\Store; | |
| 19 | use Stewart\Contracts\Sun\SunCalendar; | |
| 20 | use Stewart\Contracts\Time\Clock; | |
| 21 | use Stewart\Contracts\Time\Timers; | |
| 22 | use Stewart\Runtime\App\GeneratedRoots; | |
| 23 | use Stewart\Runtime\Container\Collection\AppBuildFailureCollection; | |
| 24 | use Stewart\Runtime\Exception\ContainerError; | |
| 25 | use Stewart\Runtime\Exception\ContainerException; | |
| 26 | use Stewart\Runtime\Ipc\Collection\WorkerAppCollection; | |
| 27 | use Stewart\Runtime\Model\WorkerId; | |
| 28 | use Stewart\Runtime\Schedule\WorkerScheduler; | |
| 29 | use Stewart\Runtime\Schedule\WorkerTimers; | |
| 30 | use Stewart\Runtime\Worker\WorkerEntityExposure; | |
| 31 | use Stewart\Runtime\Worker\WorkerHaContext; | |
| 32 | use Stewart\Runtime\Worker\WorkerLogger; | |
| 33 | use Stewart\Runtime\Worker\WorkerMqtt; | |
| 34 | use Stewart\Runtime\Worker\WorkerRegistryEditor; | |
| 35 | use Stewart\Store\ReadOnlyStore; | |
| 36 | use Stewart\Store\ScopedStore; | |
| 37 | use Symfony\Component\Config\FileLocator; | |
| 38 | use Symfony\Component\DependencyInjection\Argument\BoundArgument; | |
| 39 | use Symfony\Component\DependencyInjection\Compiler\PassConfig; | |
| 40 | use Symfony\Component\DependencyInjection\ContainerBuilder; | |
| 41 | use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; | |
| 42 | use Symfony\Component\DependencyInjection\Reference; | |
| 43 | use Throwable; | |
| 44 | ||
| 45 | final readonly class AppContainerBuilder | |
| 46 | { | |
| 47 | // A leading dot hides an id from autowiring, so user wiring sees only the contract types. | |
| 48 | public const string LOGGER = '.stewart.logger'; | |
| 49 | ||
| 50 | public const string CONTEXT = '.stewart.ha'; | |
| 51 | ||
| 52 | public const string SCHEDULER = '.stewart.scheduler'; | |
| 53 | ||
| 54 | public const string STORES = '.stewart.stores'; | |
| 55 | ||
| 56 | public const string MQTT = '.stewart.mqtt'; | |
| 57 | ||
| 58 | public const string EXPOSURE = '.stewart.exposure'; | |
| 59 | ||
| 60 | public const string REGISTRY_EDITOR = '.stewart.registry_editor'; | |
| 61 | ||
| 62 | public const string GLOBAL_STORE = '.stewart.store.global'; | |
| 63 | ||
| 64 | public const string READ_ONLY_GLOBAL_STORE = '.stewart.store.global.read_only'; | |
| 65 | ||
| 66 | private const string TIMERS = '.stewart.timers'; | |
| 67 | ||
| 68 | private const string LOOP_TIMERS = '.stewart.loop_timers'; | |
| 69 | ||
| 70 | private const string LOGGER_SUFFIX = '.logger'; | |
| 71 | ||
| 72 | private const string CONTEXT_SUFFIX = '.context'; | |
| 73 | ||
| 74 | private const string SCHEDULER_SUFFIX = '.scheduler'; | |
| 75 | ||
| 76 | private const string TIMERS_SUFFIX = '.timers'; | |
| 77 | ||
| 78 | private const string STORE_SUFFIX = '.store'; | |
| 79 | ||
| 80 | private const string READ_ONLY_STORE_SUFFIX = '.store.read_only'; | |
| 81 | ||
| 82 | private const string MQTT_SUFFIX = '.mqtt'; | |
| 83 | ||
| 84 | private const string EXPOSURE_SUFFIX = '.exposure'; | |
| 85 | ||
| 86 | private const string REGISTRY_EDITOR_SUFFIX = '.registry_editor'; | |
| 87 | ||
| 88 | private const string ENTITIES_SUFFIX = '.entities'; | |
| 89 | ||
| 90 | private const string SERVICES_SUFFIX = '.services'; | |
| 91 | ||
| 92 | public function __construct(private AppOptionResolver $optionResolver) {} | |
| 93 | ||
| 94 | public static function buildAppServiceId(AppId $appId): string | |
| 95 | { | |
| 96 | return '.stewart.app.' . $appId->value; | |
| 97 | } | |
| 98 | ||
| 99 | public static function buildPeerStoreServiceId(AppId $appId): string | |
| 100 | { | |
| 101 | return '.stewart.store.peer.' . $appId->value; | |
| 102 | } | |
| 103 | ||
| 104 | /** @throws StewartException */ | |
| 105 | public function buildAppContainer(WorkerAppCollection $apps, AppRuntimeServices $runtime, ?string $servicesFile, WorkerId $workerId): AppContainerBuild | |
| 106 | { | |
| 107 | $registrations = []; | |
| 108 | $failures = []; | |
| 109 | ||
| 110 | foreach ($apps as $app) { | |
| 111 | try { | |
| 112 | $registrations[] = new AppRegistration($app, $this->optionResolver->resolveOptionArguments($app)); | |
| 113 | } catch (Throwable $e) { | |
| 114 | $failures[] = new AppBuildFailure($app->id, $this->explainAppFailure($app->id, $e)); | |
| 115 | } | |
| 116 | } | |
| 117 | ||
| 118 | try { | |
| 119 | $container = $this->compileContainerFor($registrations, $runtime, $servicesFile, $workerId); | |
| 120 | } catch (Throwable $e) { | |
| 121 | if ($registrations === []) { | |
| 122 | throw $this->explainWorkerFailure($workerId, $e); | |
| 123 | } | |
| 124 | ||
| 125 | return $this->isolateUnbuildableApps($registrations, $failures, $runtime, $servicesFile, $workerId); | |
| 126 | } | |
| 127 | ||
| 128 | return new AppContainerBuild($container, AppBuildFailureCollection::keyedByAppId($failures)); | |
| 129 | } | |
| 130 | ||
| 131 | /** | |
| 132 | * @param list<AppRegistration> $registrations | |
| 133 | * @param list<AppBuildFailure> $failures | |
| 134 | * @throws StewartException | |
| 135 | */ | |
| 136 | private function isolateUnbuildableApps( | |
| 137 | array $registrations, | |
| 138 | array $failures, | |
| 139 | AppRuntimeServices $runtime, | |
| 140 | ?string $servicesFile, | |
| 141 | WorkerId $workerId, | |
| 142 | ): AppContainerBuild { | |
| 143 | $this->compileOrFailWorker([], $runtime, $servicesFile, $workerId); | |
| 144 | $isolated = []; | |
| 145 | ||
| 146 | foreach ($registrations as $registration) { | |
| 147 | try { | |
| 148 | $this->compileContainerFor([$registration], $runtime, $servicesFile, $workerId); | |
| 149 | } catch (Throwable $e) { | |
| 150 | $failures[] = new AppBuildFailure($registration->app->id, $this->explainAppFailure($registration->app->id, $e)); | |
| 151 | $isolated[] = $registration->app->id->value; | |
| 152 | } | |
| 153 | } | |
| 154 | ||
| 155 | if ($isolated !== []) { | |
| 156 | $runtime->logger->warning('The app container failed to compile; apps that cannot be built were left out', ['apps' => $isolated]); | |
| 157 | } | |
| 158 | ||
| 159 | $survivors = array_values(array_filter($registrations, static fn(AppRegistration $registration): bool => !\in_array($registration->app->id->value, $isolated, true))); | |
| 160 | ||
| 161 | return new AppContainerBuild($this->compileOrFailWorker($survivors, $runtime, $servicesFile, $workerId), AppBuildFailureCollection::keyedByAppId($failures)); | |
| 162 | } | |
| 163 | ||
| 164 | /** | |
| 165 | * @param list<AppRegistration> $registrations | |
| 166 | * @throws StewartException | |
| 167 | */ | |
| 168 | private function compileOrFailWorker(array $registrations, AppRuntimeServices $runtime, ?string $servicesFile, WorkerId $workerId): TypedContainer | |
| 169 | { | |
| 170 | try { | |
| 171 | return $this->compileContainerFor($registrations, $runtime, $servicesFile, $workerId); | |
| 172 | } catch (Throwable $e) { | |
| 173 | throw $this->explainWorkerFailure($workerId, $e); | |
| 174 | } | |
| 175 | } | |
| 176 | ||
| 177 | /** | |
| 178 | * @param list<AppRegistration> $registrations | |
| 179 | * @throws Throwable | |
| 180 | */ | |
| 181 | private function compileContainerFor(array $registrations, AppRuntimeServices $runtime, ?string $servicesFile, WorkerId $workerId): TypedContainer | |
| 182 | { | |
| 183 | $container = new ContainerBuilder(); | |
| 184 | $container->setParameter('stewart.worker_id', $workerId->value); | |
| 185 | ||
| 186 | if ($servicesFile !== null) { | |
| 187 | $this->loadUserServices($container, $servicesFile); | |
| 188 | } | |
| 189 | ||
| 190 | $synthetics = [ | |
| 191 | Clock::class => $runtime->clock, | |
| 192 | SunCalendar::class => $runtime->sunCalendar, | |
| 193 | self::LOGGER => $runtime->logger, | |
| 194 | self::CONTEXT => $runtime->context, | |
| 195 | self::SCHEDULER => $runtime->scheduler, | |
| 196 | self::LOOP_TIMERS => $runtime->timers, | |
| 197 | self::STORES => $runtime->stores, | |
| 198 | self::MQTT => $runtime->mqtt, | |
| 199 | self::EXPOSURE => $runtime->exposure, | |
| 200 | self::REGISTRY_EDITOR => $runtime->registryEditor, | |
| 201 | ]; | |
| 202 | ||
| 203 | $this->registerFrameworkServices($container, array_keys($synthetics), $runtime->generated); | |
| 204 | ||
| 205 | foreach ($registrations as $registration) { | |
| 206 | $this->registerApp($container, $registration, $runtime->generated); | |
| 207 | } | |
| 208 | ||
| 209 | return $this->compileWithSynthetics($container, $synthetics); | |
| 210 | } | |
| 211 | ||
| 212 | /** @return StewartException<ExceptionReason> */ | |
| 213 | private function explainAppFailure(AppId $appId, Throwable $error): StewartException | |
| 214 | { | |
| 215 | return $error instanceof StewartException ? $error : ContainerException::createForReason(ContainerError::AppBuildFailed, $error, appId: $appId->value); | |
| 216 | } | |
| 217 | ||
| 218 | /** @return StewartException<ExceptionReason> */ | |
| 219 | private function explainWorkerFailure(WorkerId $workerId, Throwable $error): StewartException | |
| 220 | { | |
| 221 | return $error instanceof StewartException ? $error : ContainerException::createForReason( | |
| 222 | ContainerError::BuildFailed, | |
| 223 | $error, | |
| 224 | workerId: $workerId->value, | |
| 225 | ); | |
| 226 | } | |
| 227 | ||
| 228 | /** @throws ContainerException */ | |
| 229 | private function loadUserServices(ContainerBuilder $container, string $servicesFile): void | |
| 230 | { | |
| 231 | if (!is_file($servicesFile)) { | |
| 232 | throw ContainerException::createForReason(ContainerError::ServicesFileMissing, path: $servicesFile); | |
| 233 | } | |
| 234 | ||
| 235 | new PhpFileLoader($container, new FileLocator(\dirname($servicesFile)))->load(basename($servicesFile)); | |
| 236 | $this->rejectAppDefinitions($container); | |
| 237 | } | |
| 238 | ||
| 239 | /** @param list<string> $syntheticIds */ | |
| 240 | private function registerFrameworkServices(ContainerBuilder $container, array $syntheticIds, ?GeneratedRoots $generated): void | |
| 241 | { | |
| 242 | foreach ($syntheticIds as $id) { | |
| 243 | // Public so unused definitions survive compilation and can still be set. | |
| 244 | $container->register($id)->setSynthetic(true)->setPublic(true); | |
| 245 | } | |
| 246 | ||
| 247 | // A user service belongs to no automation, so its Store is the global scope. | |
| 248 | $container->register(self::GLOBAL_STORE, ScopedStore::class) | |
| 249 | ->setFactory([new Reference(self::STORES), 'openGlobalStore']); | |
| 250 | $container->register(self::READ_ONLY_GLOBAL_STORE, ReadOnlyStore::class) | |
| 251 | ->setArguments([new Reference(self::GLOBAL_STORE)]); | |
| 252 | $container->register(self::TIMERS, WorkerTimers::class) | |
| 253 | ->setArguments([new Reference(self::SCHEDULER), new Reference(self::LOOP_TIMERS)]); | |
| 254 | ||
| 255 | $container->setAlias(LoggerInterface::class, self::LOGGER)->setPublic(true); | |
| 256 | $container->setAlias(HaContext::class, self::CONTEXT)->setPublic(true); | |
| 257 | $container->setAlias(Scheduler::class, self::SCHEDULER)->setPublic(true); | |
| 258 | $container->setAlias(Timers::class, self::TIMERS)->setPublic(true); | |
| 259 | $container->setAlias(Store::class, self::GLOBAL_STORE)->setPublic(true); | |
| 260 | $container->setAlias(ReadableStore::class, self::READ_ONLY_GLOBAL_STORE)->setPublic(true); | |
| 261 | $container->setAlias(Mqtt::class, self::MQTT)->setPublic(true); | |
| 262 | $container->setAlias(EntityExposure::class, self::EXPOSURE)->setPublic(true); | |
| 263 | $container->setAlias(RegistryEditor::class, self::REGISTRY_EDITOR)->setPublic(true); | |
| 264 | ||
| 265 | $this->registerSharedGeneratedRoots($container, $generated); | |
| 266 | } | |
| 267 | ||
| 268 | /** @param array<string, object> $synthetics */ | |
| 269 | private function compileWithSynthetics(ContainerBuilder $container, array $synthetics): TypedContainer | |
| 270 | { | |
| 271 | $container->addCompilerPass(new StoreAttributesPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION); | |
| 272 | $container->compile(); | |
| 273 | ||
| 274 | foreach ($synthetics as $id => $service) { | |
| 275 | $container->set($id, $service); | |
| 276 | } | |
| 277 | ||
| 278 | return new TypedContainer($container); | |
| 279 | } | |
| 280 | ||
| 281 | /** @throws ContainerException */ | |
| 282 | private function rejectAppDefinitions(ContainerBuilder $container): void | |
| 283 | { | |
| 284 | foreach ($container->getDefinitions() as $id => $definition) { | |
| 285 | $class = $definition->getClass() ?? $id; | |
| 286 | ||
| 287 | if (is_a($class, App::class, true)) { | |
| 288 | throw ContainerException::createForReason(ContainerError::AppInServicesFile, serviceId: $id, class: $class); | |
| 289 | } | |
| 290 | } | |
| 291 | } | |
| 292 | ||
| 293 | private function registerSharedGeneratedRoots(ContainerBuilder $container, ?GeneratedRoots $generated): void | |
| 294 | { | |
| 295 | if ($generated === null) { | |
| 296 | return; | |
| 297 | } | |
| 298 | ||
| 299 | foreach ([$generated->entitiesClass, $generated->servicesClass] as $class) { | |
| 300 | $container->register($class, $class) | |
| 301 | ->setArguments([new Reference(self::CONTEXT)]) | |
| 302 | ->setPublic(true); | |
| 303 | } | |
| 304 | } | |
| 305 | ||
| 306 | private function registerApp(ContainerBuilder $container, AppRegistration $registration, ?GeneratedRoots $generated): void | |
| 307 | { | |
| 308 | $appId = $registration->app->id; | |
| 309 | $serviceId = self::buildAppServiceId($appId); | |
| 310 | $this->registerAppScopedServices($container, $appId, $serviceId, $generated); | |
| 311 | ||
| 312 | $container->register($serviceId, $registration->app->class) | |
| 313 | ->setPublic(true) | |
| 314 | ->setAutowired(true) | |
| 315 | ->setAutoconfigured(true) | |
| 316 | ->setBindings($this->buildAppBindings($serviceId, $generated)) | |
| 317 | ->setArguments($registration->optionArguments); | |
| 318 | } | |
| 319 | ||
| 320 | private function registerAppScopedServices(ContainerBuilder $container, AppId $appId, string $serviceId, ?GeneratedRoots $generated): void | |
| 321 | { | |
| 322 | $container->register($serviceId . self::LOGGER_SUFFIX, WorkerLogger::class) | |
| 323 | ->setFactory([new Reference(self::LOGGER), 'forApp']) | |
| 324 | ->addArgument($appId); | |
| 325 | ||
| 326 | $container->register($serviceId . self::CONTEXT_SUFFIX, WorkerHaContext::class) | |
| 327 | ->setFactory([new Reference(self::CONTEXT), 'forApp']) | |
| 328 | ->addArgument($appId); | |
| 329 | ||
| 330 | $container->register($serviceId . self::SCHEDULER_SUFFIX, WorkerScheduler::class) | |
| 331 | ->setFactory([new Reference(self::SCHEDULER), 'forApp']) | |
| 332 | ->setArguments([$appId, new Reference($serviceId . self::LOGGER_SUFFIX)]); | |
| 333 | ||
| 334 | $container->register($serviceId . self::TIMERS_SUFFIX, WorkerTimers::class) | |
| 335 | ->setArguments([new Reference($serviceId . self::SCHEDULER_SUFFIX), new Reference(self::LOOP_TIMERS)]); | |
| 336 | ||
| 337 | $container->register($serviceId . self::STORE_SUFFIX, ScopedStore::class) | |
| 338 | ->setFactory([new Reference(self::STORES), 'openAppStore']) | |
| 339 | ->addArgument($appId); | |
| 340 | ||
| 341 | $container->register($serviceId . self::READ_ONLY_STORE_SUFFIX, ReadOnlyStore::class) | |
| 342 | ->addArgument(new Reference($serviceId . self::STORE_SUFFIX)); | |
| 343 | ||
| 344 | $container->register($serviceId . self::MQTT_SUFFIX, WorkerMqtt::class) | |
| 345 | ->setFactory([new Reference(self::MQTT), 'forApp']) | |
| 346 | ->addArgument($appId); | |
| 347 | ||
| 348 | $container->register($serviceId . self::EXPOSURE_SUFFIX, WorkerEntityExposure::class) | |
| 349 | ->setFactory([new Reference(self::EXPOSURE), 'forApp']) | |
| 350 | ->addArgument($appId); | |
| 351 | ||
| 352 | $container->register($serviceId . self::REGISTRY_EDITOR_SUFFIX, WorkerRegistryEditor::class) | |
| 353 | ->setFactory([new Reference(self::REGISTRY_EDITOR), 'forApp']) | |
| 354 | ->addArgument($appId); | |
| 355 | ||
| 356 | if ($generated === null) { | |
| 357 | return; | |
| 358 | } | |
| 359 | ||
| 360 | foreach ([self::ENTITIES_SUFFIX => $generated->entitiesClass, self::SERVICES_SUFFIX => $generated->servicesClass] as $suffix => $class) { | |
| 361 | $container->register($serviceId . $suffix, $class)->setArguments([new Reference($serviceId . self::CONTEXT_SUFFIX)]); | |
| 362 | } | |
| 363 | } | |
| 364 | ||
| 365 | // Untracked, like the framework bindings, so an app that uses none of them still compiles. | |
| 366 | /** @return array<string, BoundArgument> */ | |
| 367 | private function buildAppBindings(string $serviceId, ?GeneratedRoots $generated): array | |
| 368 | { | |
| 369 | $serviceIdsByType = [ | |
| 370 | HaContext::class => $serviceId . self::CONTEXT_SUFFIX, | |
| 371 | LoggerInterface::class => $serviceId . self::LOGGER_SUFFIX, | |
| 372 | Scheduler::class => $serviceId . self::SCHEDULER_SUFFIX, | |
| 373 | Timers::class => $serviceId . self::TIMERS_SUFFIX, | |
| 374 | Store::class => $serviceId . self::STORE_SUFFIX, | |
| 375 | ReadableStore::class => $serviceId . self::READ_ONLY_STORE_SUFFIX, | |
| 376 | Mqtt::class => $serviceId . self::MQTT_SUFFIX, | |
| 377 | EntityExposure::class => $serviceId . self::EXPOSURE_SUFFIX, | |
| 378 | RegistryEditor::class => $serviceId . self::REGISTRY_EDITOR_SUFFIX, | |
| 379 | ]; | |
| 380 | ||
| 381 | if ($generated !== null) { | |
| 382 | $serviceIdsByType[$generated->entitiesClass] = $serviceId . self::ENTITIES_SUFFIX; | |
| 383 | $serviceIdsByType[$generated->servicesClass] = $serviceId . self::SERVICES_SUFFIX; | |
| 384 | } | |
| 385 | ||
| 386 | return array_map(static fn(string $id): BoundArgument => new BoundArgument(new Reference($id), false), $serviceIdsByType); | |
| 387 | } | |
| 388 | } |