feat(Production): Complete production deployment infrastructure

- Add comprehensive health check system with multiple endpoints
- Add Prometheus metrics endpoint
- Add production logging configurations (5 strategies)
- Add complete deployment documentation suite:
  * QUICKSTART.md - 30-minute deployment guide
  * DEPLOYMENT_CHECKLIST.md - Printable verification checklist
  * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle
  * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference
  * production-logging.md - Logging configuration guide
  * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation
  * README.md - Navigation hub
  * DEPLOYMENT_SUMMARY.md - Executive summary
- Add deployment scripts and automation
- Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment
- Update README with production-ready features

All production infrastructure is now complete and ready for deployment.
This commit is contained in:
2025-10-25 19:18:37 +02:00
parent caa85db796
commit fc3d7e6357
83016 changed files with 378904 additions and 20919 deletions

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Framework\Sse\ValueObjects;
/**
* SSE Channel Scope Enum
*
* Defines the different types of SSE channels.
*/
enum ChannelScope: string
{
/**
* Global channel - broadcast to all connected clients
*/
case GLOBAL = 'global';
/**
* System channel - system-wide announcements
*/
case SYSTEM = 'system';
/**
* Component channel - updates for specific component instances
*/
case COMPONENT = 'component';
/**
* User channel - user-specific private events
*/
case USER = 'user';
/**
* Presence channel - online presence in rooms
*/
case PRESENCE = 'presence';
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Framework\Sse\ValueObjects;
/**
* SSE Connection ID Value Object
*
* Unique identifier for SSE connections.
*/
final readonly class ConnectionId
{
public function __construct(
public string $value
) {
if (empty($value)) {
throw new \InvalidArgumentException('Connection ID cannot be empty');
}
if (strlen($value) < 16) {
throw new \InvalidArgumentException('Connection ID must be at least 16 characters');
}
}
/**
* Generate a new unique connection ID
*/
public static function generate(): self
{
return new self(uniqid('sse_', true));
}
/**
* Create from string
*/
public static function fromString(string $value): self
{
return new self($value);
}
/**
* Convert to string
*/
public function toString(): string
{
return $this->value;
}
/**
* Check equality
*/
public function equals(self $other): bool
{
return $this->value === $other->value;
}
}

View File

@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace App\Framework\Sse\ValueObjects;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
/**
* SSE Channel Value Object
*
* Represents a typed channel for Server-Sent Events routing.
*
* Channel Format Examples:
* - global
* - component:counter:demo
* - user:123
* - presence:chat-room-1
*/
final readonly class SseChannel
{
public function __construct(
public ChannelScope $scope,
public ?string $identifier = null
) {
}
/**
* Global channel - broadcast to all connected clients
*/
public static function global(): self
{
return new self(ChannelScope::GLOBAL);
}
/**
* Component channel - updates for specific component instance
*/
public static function forComponent(ComponentId $componentId): self
{
return new self(ChannelScope::COMPONENT, $componentId->toString());
}
/**
* User channel - user-specific events (requires auth)
*/
public static function forUser(string $userId): self
{
if (empty($userId)) {
throw new \InvalidArgumentException('User ID cannot be empty');
}
return new self(ChannelScope::USER, $userId);
}
/**
* Presence channel - who is online in room
*/
public static function presence(string $room = 'global'): self
{
if (empty($room)) {
throw new \InvalidArgumentException('Presence room cannot be empty');
}
return new self(ChannelScope::PRESENCE, $room);
}
/**
* System channel - system-wide announcements
*/
public static function system(): self
{
return new self(ChannelScope::SYSTEM);
}
/**
* Create from string representation
*
* Examples:
* - "global" → SseChannel::global()
* - "component:counter:demo" → SseChannel::forComponent(...)
* - "user:123" → SseChannel::forUser("123")
*/
public static function fromString(string $channel): self
{
if ($channel === 'global') {
return self::global();
}
if ($channel === 'system') {
return self::system();
}
$parts = explode(':', $channel, 2);
if (count($parts) < 2) {
throw new \InvalidArgumentException("Invalid channel format: {$channel}");
}
[$scope, $identifier] = $parts;
return match ($scope) {
'component' => self::forComponent(ComponentId::fromString($identifier)),
'user' => self::forUser($identifier),
'presence' => self::presence($identifier),
default => throw new \InvalidArgumentException("Unknown channel scope: {$scope}")
};
}
/**
* Convert to string representation
*/
public function toString(): string
{
if ($this->identifier === null) {
return $this->scope->value;
}
return "{$this->scope->value}:{$this->identifier}";
}
/**
* Check if this channel matches another
*/
public function matches(self $other): bool
{
return $this->toString() === $other->toString();
}
/**
* Check if this is a private channel (requires auth)
*/
public function isPrivate(): bool
{
return $this->scope === ChannelScope::USER;
}
/**
* Check if this is a public channel
*/
public function isPublic(): bool
{
return ! $this->isPrivate();
}
/**
* Get scope
*/
public function getScope(): ChannelScope
{
return $this->scope;
}
/**
* Get identifier
*/
public function getIdentifier(): ?string
{
return $this->identifier;
}
}

View File

@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Framework\Sse\ValueObjects;
use App\Framework\Http\SseStreamInterface;
/**
* SSE Connection Value Object
*
* Represents a single SSE connection with its metadata.
*/
final readonly class SseConnection
{
/**
* @param ConnectionId $connectionId Unique connection identifier
* @param SseStreamInterface $stream The SSE stream for this connection
* @param array<SseChannel> $channels Subscribed channels
* @param ?string $userId User ID if authenticated
* @param \DateTimeImmutable $connectedAt Connection timestamp
* @param \DateTimeImmutable $lastHeartbeat Last heartbeat timestamp
*/
public function __construct(
public ConnectionId $connectionId,
public SseStreamInterface $stream,
public array $channels,
public ?string $userId = null,
public \DateTimeImmutable $connectedAt = new \DateTimeImmutable(),
public \DateTimeImmutable $lastHeartbeat = new \DateTimeImmutable()
) {
// Validate channels array contains only SseChannel instances
foreach ($channels as $channel) {
if (! $channel instanceof SseChannel) {
throw new \InvalidArgumentException('Channels must be instances of SseChannel');
}
}
}
/**
* Create a new connection with updated heartbeat
*/
public function withHeartbeat(\DateTimeImmutable $timestamp = new \DateTimeImmutable()): self
{
return new self(
connectionId: $this->connectionId,
stream: $this->stream,
channels: $this->channels,
userId: $this->userId,
connectedAt: $this->connectedAt,
lastHeartbeat: $timestamp
);
}
/**
* Check if connection is still alive
*/
public function isAlive(): bool
{
return $this->stream->isConnectionActive() && $this->stream->isActive();
}
/**
* Check if connection needs a heartbeat
*/
public function needsHeartbeat(int $heartbeatIntervalSeconds = 30): bool
{
$secondsSinceHeartbeat = (new \DateTimeImmutable())->getTimestamp() - $this->lastHeartbeat->getTimestamp();
return $secondsSinceHeartbeat >= $heartbeatIntervalSeconds;
}
/**
* Check if connection is subscribed to a channel
*/
public function isSubscribedTo(SseChannel $channel): bool
{
foreach ($this->channels as $subscribedChannel) {
if ($subscribedChannel->matches($channel)) {
return true;
}
}
return false;
}
/**
* Get connection age in seconds
*/
public function getAge(): int
{
return (new \DateTimeImmutable())->getTimestamp() - $this->connectedAt->getTimestamp();
}
/**
* Check if connection is authenticated
*/
public function isAuthenticated(): bool
{
return $this->userId !== null;
}
/**
* Get all subscribed channels as strings
*
* @return array<string>
*/
public function getChannelNames(): array
{
return array_map(
fn (SseChannel $channel) => $channel->toString(),
$this->channels
);
}
/**
* Get connection info as array
*/
public function toArray(): array
{
return [
'connection_id' => $this->connectionId->toString(),
'user_id' => $this->userId,
'channels' => $this->getChannelNames(),
'connected_at' => $this->connectedAt->format('Y-m-d H:i:s'),
'last_heartbeat' => $this->lastHeartbeat->format('Y-m-d H:i:s'),
'age_seconds' => $this->getAge(),
'is_alive' => $this->isAlive(),
'is_authenticated' => $this->isAuthenticated(),
];
}
}