feat(Deployment): Integrate Ansible deployment via PHP deployment pipeline

- Create AnsibleDeployStage using framework's Process module for secure command execution
- Integrate AnsibleDeployStage into DeploymentPipelineCommands for production deployments
- Add force_deploy flag support in Ansible playbook to override stale locks
- Use PHP deployment module as orchestrator (php console.php deploy:production)
- Fix ErrorAggregationInitializer to use Environment class instead of $_ENV superglobal

Architecture:
- BuildStage → AnsibleDeployStage → HealthCheckStage for production
- Process module provides timeout, error handling, and output capture
- Ansible playbook supports rollback via rollback-git-based.yml
- Zero-downtime deployments with health checks
This commit is contained in:
2025-10-26 14:08:07 +01:00
parent a90263d3be
commit 3b623e7afb
170 changed files with 19888 additions and 575 deletions

View File

@@ -0,0 +1,455 @@
<?php
declare(strict_types=1);
namespace App\Application\Api\MachineLearning;
use App\Framework\Attributes\Route;
use App\Framework\Core\ValueObjects\Version;
use App\Framework\Http\HttpRequest;
use App\Framework\Http\Method;
use App\Framework\Http\Status;
use App\Framework\MachineLearning\ModelManagement\ABTestingService;
use App\Framework\MachineLearning\ModelManagement\ModelRegistry;
use App\Framework\MachineLearning\ModelManagement\ValueObjects\ABTestConfig;
use App\Framework\OpenApi\Attributes\ApiEndpoint;
use App\Framework\OpenApi\Attributes\ApiParameter;
use App\Framework\OpenApi\Attributes\ApiRequestBody;
use App\Framework\OpenApi\Attributes\ApiResponse;
use App\Framework\OpenApi\Attributes\ApiSecurity;
use App\Framework\Router\Result\JsonResult;
/**
* ML A/B Testing API Controller
*
* RESTful API endpoints for A/B testing machine learning models:
* - Start A/B tests
* - Get test results
* - Generate rollout plans
* - Calculate sample sizes
*/
#[ApiSecurity('bearerAuth')]
final readonly class MLABTestingController
{
public function __construct(
private ABTestingService $abTesting,
private ModelRegistry $registry
) {}
#[Route(path: '/api/ml/ab-test', method: Method::POST)]
#[ApiEndpoint(
summary: 'Start A/B test',
description: 'Create and start an A/B test comparing two model versions',
tags: ['Machine Learning'],
)]
#[ApiRequestBody(
description: 'A/B test configuration',
required: true,
example: [
'model_name' => 'fraud-detector',
'version_a' => '1.0.0',
'version_b' => '2.0.0',
'traffic_split_a' => 0.5,
'primary_metric' => 'accuracy',
'minimum_improvement' => 0.05,
],
)]
#[ApiResponse(
statusCode: 201,
description: 'A/B test created successfully',
example: [
'test_id' => 'test_123',
'model_name' => 'fraud-detector',
'version_a' => '1.0.0',
'version_b' => '2.0.0',
'traffic_split' => [
'version_a' => 0.5,
'version_b' => 0.5,
],
'status' => 'running',
'created_at' => '2024-01-01T00:00:00Z',
],
)]
#[ApiResponse(
statusCode: 400,
description: 'Invalid test configuration',
)]
public function startTest(HttpRequest $request): JsonResult
{
try {
$data = $request->parsedBody->toArray();
// Validate required fields
if (!isset($data['model_name'], $data['version_a'], $data['version_b'])) {
return new JsonResult([
'error' => 'Missing required fields',
'required' => ['model_name', 'version_a', 'version_b'],
], Status::BAD_REQUEST);
}
// Parse versions
$versionA = Version::fromString($data['version_a']);
$versionB = Version::fromString($data['version_b']);
// Verify models exist
$metadataA = $this->registry->get($data['model_name'], $versionA);
$metadataB = $this->registry->get($data['model_name'], $versionB);
if ($metadataA === null) {
return new JsonResult([
'error' => 'Version A not found',
'model_name' => $data['model_name'],
'version' => $data['version_a'],
], Status::NOT_FOUND);
}
if ($metadataB === null) {
return new JsonResult([
'error' => 'Version B not found',
'model_name' => $data['model_name'],
'version' => $data['version_b'],
], Status::NOT_FOUND);
}
// Create A/B test config
$config = new ABTestConfig(
modelName: $data['model_name'],
versionA: $versionA,
versionB: $versionB,
trafficSplitA: (float) ($data['traffic_split_a'] ?? 0.5),
primaryMetric: $data['primary_metric'] ?? 'accuracy',
minimumImprovement: (float) ($data['minimum_improvement'] ?? 0.05),
significanceLevel: (float) ($data['significance_level'] ?? 0.05)
);
// Generate test ID (in production, store in database)
$testId = 'test_' . bin2hex(random_bytes(8));
return new JsonResult([
'test_id' => $testId,
'model_name' => $config->modelName,
'version_a' => $config->versionA->toString(),
'version_b' => $config->versionB->toString(),
'traffic_split' => [
'version_a' => $config->trafficSplitA,
'version_b' => 1.0 - $config->trafficSplitA,
],
'primary_metric' => $config->primaryMetric,
'minimum_improvement' => $config->minimumImprovement,
'status' => 'running',
'description' => $config->getDescription(),
'created_at' => date('c'),
], Status::CREATED);
} catch (\InvalidArgumentException $e) {
return new JsonResult([
'error' => 'Invalid test configuration',
'message' => $e->getMessage(),
], Status::BAD_REQUEST);
}
}
#[Route(path: '/api/ml/ab-test/compare', method: Method::POST)]
#[ApiEndpoint(
summary: 'Compare model versions',
description: 'Compare performance of two model versions and get winner recommendation',
tags: ['Machine Learning'],
)]
#[ApiRequestBody(
description: 'Model comparison configuration',
required: true,
example: [
'model_name' => 'fraud-detector',
'version_a' => '1.0.0',
'version_b' => '2.0.0',
'primary_metric' => 'f1_score',
'minimum_improvement' => 0.05,
],
)]
#[ApiResponse(
statusCode: 200,
description: 'Comparison completed successfully',
example: [
'winner' => 'B',
'statistically_significant' => true,
'metrics_difference' => [
'accuracy' => 0.07,
'f1_score' => 0.08,
],
'primary_metric_improvement' => 8.5,
'recommendation' => 'Version B wins with 8.5% improvement - deploy new version',
'summary' => 'Version B significantly outperforms Version A',
],
)]
#[ApiResponse(
statusCode: 404,
description: 'Model version not found',
)]
public function compareVersions(HttpRequest $request): JsonResult
{
try {
$data = $request->parsedBody->toArray();
if (!isset($data['model_name'], $data['version_a'], $data['version_b'])) {
return new JsonResult([
'error' => 'Missing required fields',
'required' => ['model_name', 'version_a', 'version_b'],
], Status::BAD_REQUEST);
}
$versionA = Version::fromString($data['version_a']);
$versionB = Version::fromString($data['version_b']);
$config = new ABTestConfig(
modelName: $data['model_name'],
versionA: $versionA,
versionB: $versionB,
trafficSplitA: 0.5,
primaryMetric: $data['primary_metric'] ?? 'accuracy',
minimumImprovement: (float) ($data['minimum_improvement'] ?? 0.05)
);
// Run comparison
$result = $this->abTesting->runTest($config);
return new JsonResult([
'winner' => $result->winner,
'statistically_significant' => $result->isStatisticallySignificant,
'metrics_difference' => $result->metricsDifference,
'primary_metric_improvement' => $result->getPrimaryMetricImprovementPercent(),
'recommendation' => $result->recommendation,
'summary' => $result->getSummary(),
'should_deploy_version_b' => $result->shouldDeployVersionB(),
'is_inconclusive' => $result->isInconclusive(),
]);
} catch (\InvalidArgumentException $e) {
return new JsonResult([
'error' => 'Invalid comparison parameters',
'message' => $e->getMessage(),
], Status::BAD_REQUEST);
}
}
#[Route(path: '/api/ml/ab-test/rollout-plan', method: Method::POST)]
#[ApiEndpoint(
summary: 'Generate rollout plan',
description: 'Generate a gradual rollout plan for deploying a new model version',
tags: ['Machine Learning'],
)]
#[ApiRequestBody(
description: 'Rollout configuration',
required: true,
example: [
'model_name' => 'fraud-detector',
'current_version' => '1.0.0',
'new_version' => '2.0.0',
'steps' => 5,
],
)]
#[ApiResponse(
statusCode: 200,
description: 'Rollout plan generated successfully',
example: [
'model_name' => 'fraud-detector',
'current_version' => '1.0.0',
'new_version' => '2.0.0',
'rollout_stages' => [
[
'stage' => 1,
'current_version_traffic' => 80,
'new_version_traffic' => 20,
],
[
'stage' => 2,
'current_version_traffic' => 60,
'new_version_traffic' => 40,
],
],
],
)]
public function generateRolloutPlan(HttpRequest $request): JsonResult
{
try {
$data = $request->parsedBody->toArray();
if (!isset($data['model_name'], $data['current_version'], $data['new_version'])) {
return new JsonResult([
'error' => 'Missing required fields',
'required' => ['model_name', 'current_version', 'new_version'],
], Status::BAD_REQUEST);
}
$steps = (int) ($data['steps'] ?? 5);
if ($steps < 2 || $steps > 10) {
return new JsonResult([
'error' => 'Steps must be between 2 and 10',
], Status::BAD_REQUEST);
}
// Generate rollout plan
$plan = $this->abTesting->generateRolloutPlan($steps);
// Format response
$stages = [];
foreach ($plan as $stage => $newVersionTraffic) {
$stages[] = [
'stage' => $stage,
'current_version_traffic' => (int) ((1.0 - $newVersionTraffic) * 100),
'new_version_traffic' => (int) ($newVersionTraffic * 100),
];
}
return new JsonResult([
'model_name' => $data['model_name'],
'current_version' => $data['current_version'],
'new_version' => $data['new_version'],
'total_stages' => $steps,
'rollout_stages' => $stages,
'recommendation' => 'Monitor performance at each stage before proceeding to next',
]);
} catch (\InvalidArgumentException $e) {
return new JsonResult([
'error' => 'Invalid rollout configuration',
'message' => $e->getMessage(),
], Status::BAD_REQUEST);
}
}
#[Route(path: '/api/ml/ab-test/sample-size', method: Method::GET)]
#[ApiEndpoint(
summary: 'Calculate required sample size',
description: 'Calculate the required sample size for statistically significant A/B test',
tags: ['Machine Learning'],
)]
#[ApiParameter(
name: 'confidence_level',
in: 'query',
description: 'Confidence level (0.90, 0.95, 0.99)',
required: false,
type: 'number',
example: 0.95,
)]
#[ApiParameter(
name: 'margin_of_error',
in: 'query',
description: 'Margin of error (typically 0.01-0.10)',
required: false,
type: 'number',
example: 0.05,
)]
#[ApiResponse(
statusCode: 200,
description: 'Sample size calculated successfully',
example: [
'required_samples_per_version' => 385,
'total_samples_needed' => 770,
'confidence_level' => 0.95,
'margin_of_error' => 0.05,
'recommendation' => 'Collect at least 385 predictions per version',
],
)]
public function calculateSampleSize(HttpRequest $request): JsonResult
{
$confidenceLevel = (float) ($request->queryParameters['confidence_level'] ?? 0.95);
$marginOfError = (float) ($request->queryParameters['margin_of_error'] ?? 0.05);
// Validate parameters
if ($confidenceLevel < 0.5 || $confidenceLevel > 0.99) {
return new JsonResult([
'error' => 'Confidence level must be between 0.5 and 0.99',
], Status::BAD_REQUEST);
}
if ($marginOfError < 0.01 || $marginOfError > 0.20) {
return new JsonResult([
'error' => 'Margin of error must be between 0.01 and 0.20',
], Status::BAD_REQUEST);
}
// Calculate sample size
$samplesPerVersion = $this->abTesting->calculateRequiredSampleSize(
$confidenceLevel,
$marginOfError
);
return new JsonResult([
'required_samples_per_version' => $samplesPerVersion,
'total_samples_needed' => $samplesPerVersion * 2,
'confidence_level' => $confidenceLevel,
'margin_of_error' => $marginOfError,
'confidence_level_percent' => ($confidenceLevel * 100) . '%',
'margin_of_error_percent' => ($marginOfError * 100) . '%',
'recommendation' => "Collect at least {$samplesPerVersion} predictions per version for statistically significant results",
]);
}
#[Route(path: '/api/ml/ab-test/select-version', method: Method::POST)]
#[ApiEndpoint(
summary: 'Select model version for traffic routing',
description: 'Randomly select a model version based on A/B test traffic split configuration',
tags: ['Machine Learning'],
)]
#[ApiRequestBody(
description: 'Traffic routing configuration',
required: true,
example: [
'model_name' => 'fraud-detector',
'version_a' => '1.0.0',
'version_b' => '2.0.0',
'traffic_split_a' => 0.8,
],
)]
#[ApiResponse(
statusCode: 200,
description: 'Version selected successfully',
example: [
'selected_version' => '2.0.0',
'model_name' => 'fraud-detector',
'traffic_split' => [
'version_a' => 0.8,
'version_b' => 0.2,
],
],
)]
public function selectVersion(HttpRequest $request): JsonResult
{
try {
$data = $request->parsedBody->toArray();
if (!isset($data['model_name'], $data['version_a'], $data['version_b'])) {
return new JsonResult([
'error' => 'Missing required fields',
'required' => ['model_name', 'version_a', 'version_b'],
], Status::BAD_REQUEST);
}
$versionA = Version::fromString($data['version_a']);
$versionB = Version::fromString($data['version_b']);
$config = new ABTestConfig(
modelName: $data['model_name'],
versionA: $versionA,
versionB: $versionB,
trafficSplitA: (float) ($data['traffic_split_a'] ?? 0.5),
primaryMetric: 'accuracy'
);
// Select version based on traffic split
$selectedVersion = $this->abTesting->selectVersion($config);
return new JsonResult([
'selected_version' => $selectedVersion->toString(),
'model_name' => $config->modelName,
'traffic_split' => [
'version_a' => $config->trafficSplitA,
'version_b' => 1.0 - $config->trafficSplitA,
],
]);
} catch (\InvalidArgumentException $e) {
return new JsonResult([
'error' => 'Invalid routing configuration',
'message' => $e->getMessage(),
], Status::BAD_REQUEST);
}
}
}