TenantAtlas/apps/platform/app/Services/Operations/OperationLifecyclePolicyValidator.php
ahmido ce0615a9c1 Spec 182: relocate Laravel platform to apps/platform (#213)
## Summary
- move the Laravel application into `apps/platform` and keep the repository root for orchestration, docs, and tooling
- update the local command model, Sail/Docker wiring, runtime paths, and ignore rules around the new platform location
- add relocation quickstart/contracts plus focused smoke coverage for bootstrap, command model, routes, and runtime behavior

## Validation
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PlatformRelocation`
- integrated browser smoke validated `/up`, `/`, `/admin`, `/admin/choose-workspace`, and tenant route semantics for `200`, `403`, and `404`

## Remaining Rollout Checks
- validate Dokploy build context and working-directory assumptions against the new `apps/platform` layout
- confirm web, queue, and scheduler processes all start from the expected working directory in staging/production
- verify no legacy volume mounts or asset-publish paths still point at the old root-level `public/` or `storage/` locations

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #213
2026-04-08 08:40:47 +00:00

140 lines
4.5 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Operations;
use App\Jobs\Concerns\BridgesFailedOperationRun;
use App\Support\Operations\OperationLifecyclePolicy;
use RuntimeException;
final class OperationLifecyclePolicyValidator
{
public function __construct(
private readonly OperationLifecyclePolicy $policy,
) {}
/**
* @return array{
* valid:bool,
* errors:array<int, string>,
* definitions:array<string, array<string, mixed>>
* }
*/
public function validate(): array
{
$errors = [];
$definitions = [];
foreach ($this->policy->coveredTypeNames() as $operationType) {
$definition = $this->policy->definition($operationType);
if ($definition === null) {
$errors[] = sprintf('Missing lifecycle policy definition for [%s].', $operationType);
continue;
}
$definitions[$operationType] = $definition;
$jobClass = $this->policy->jobClass($operationType);
if ($jobClass === null || ! class_exists($jobClass)) {
$errors[] = sprintf('Lifecycle policy [%s] points to a missing job class.', $operationType);
continue;
}
$timeout = $this->jobTimeoutSeconds($operationType);
if (! is_int($timeout) || $timeout <= 0) {
$errors[] = sprintf('Lifecycle policy [%s] requires an explicit positive job timeout.', $operationType);
}
if (! $this->jobFailsOnTimeout($operationType)) {
$errors[] = sprintf('Lifecycle policy [%s] requires failOnTimeout=true.', $operationType);
}
if ($this->policy->requiresDirectFailedBridge($operationType) && ! $this->jobUsesDirectFailedBridge($operationType)) {
$errors[] = sprintf('Lifecycle policy [%s] requires a direct failed-job bridge.', $operationType);
}
$retryAfter = $this->policy->queueRetryAfterSeconds($this->policy->queueConnection($operationType));
$safetyMargin = $this->policy->retryAfterSafetyMarginSeconds();
if (is_int($timeout) && is_int($retryAfter) && $timeout >= ($retryAfter - $safetyMargin)) {
$errors[] = sprintf(
'Lifecycle policy [%s] has timeout %d which is not safely below retry_after %d (margin %d).',
$operationType,
$timeout,
$retryAfter,
$safetyMargin,
);
}
$expectedMaxRuntime = $this->policy->expectedMaxRuntimeSeconds($operationType);
if (is_int($expectedMaxRuntime) && is_int($retryAfter) && $expectedMaxRuntime >= ($retryAfter - $safetyMargin)) {
$errors[] = sprintf(
'Lifecycle policy [%s] expected runtime %d is not safely below retry_after %d (margin %d).',
$operationType,
$expectedMaxRuntime,
$retryAfter,
$safetyMargin,
);
}
}
return [
'valid' => $errors === [],
'errors' => $errors,
'definitions' => $definitions,
];
}
public function assertValid(): void
{
$result = $this->validate();
if (($result['valid'] ?? false) === true) {
return;
}
throw new RuntimeException(implode(' ', $result['errors'] ?? ['Lifecycle policy validation failed.']));
}
public function jobTimeoutSeconds(string $operationType): ?int
{
$jobClass = $this->policy->jobClass($operationType);
if ($jobClass === null || ! class_exists($jobClass)) {
return null;
}
$timeout = get_class_vars($jobClass)['timeout'] ?? null;
return is_numeric($timeout) ? (int) $timeout : null;
}
public function jobFailsOnTimeout(string $operationType): bool
{
$jobClass = $this->policy->jobClass($operationType);
if ($jobClass === null || ! class_exists($jobClass)) {
return false;
}
return (bool) (get_class_vars($jobClass)['failOnTimeout'] ?? false);
}
public function jobUsesDirectFailedBridge(string $operationType): bool
{
$jobClass = $this->policy->jobClass($operationType);
if ($jobClass === null || ! class_exists($jobClass)) {
return false;
}
return in_array(BridgesFailedOperationRun::class, class_uses_recursive($jobClass), true);
}
}