From 621c6e5817d8234e4b8cb1abce2d99806e3a90c4 Mon Sep 17 00:00:00 2001 From: Seth Sharp <58869086+SethSharp@users.noreply.github.com> Date: Thu, 7 May 2026 11:22:25 +0930 Subject: [PATCH 01/15] feat: sync IVS recording configuration and recording state change EventBridge rule Co-Authored-By: Jarvis --- src/Aws.php | 12 ++ src/Commands/SyncLoggingCommand.php | 6 + src/Concerns/RegistersAws.php | 4 + src/Manifest.php | 7 + .../SyncIvsRecordingConfigurationStep.php | 82 +++++++++ .../SyncIvsRecordingEventBridgeRuleStep.php | 80 +++++++++ .../SyncIvsRecordingEventBridgeTargetStep.php | 156 ++++++++++++++++++ .../SyncIvsStorageConfigurationStep.php | 62 +++++++ 8 files changed, 409 insertions(+) create mode 100644 src/Steps/Logging/SyncIvsRecordingConfigurationStep.php create mode 100644 src/Steps/Logging/SyncIvsRecordingEventBridgeRuleStep.php create mode 100644 src/Steps/Logging/SyncIvsRecordingEventBridgeTargetStep.php create mode 100644 src/Steps/Logging/SyncIvsStorageConfigurationStep.php diff --git a/src/Aws.php b/src/Aws.php index 917aa60..be271c3 100644 --- a/src/Aws.php +++ b/src/Aws.php @@ -6,6 +6,7 @@ use Aws\Acm\AcmClient; use Aws\Ec2\Ec2Client; use Aws\Iam\IamClient; +use Aws\IVS\IVSClient; use Aws\Rds\RdsClient; use Aws\Sns\SnsClient; use Aws\Sqs\SqsClient; @@ -16,6 +17,7 @@ use Aws\CodeDeploy\CodeDeployClient; use Aws\AutoScaling\AutoScalingClient; use Aws\EventBridge\EventBridgeClient; +use Aws\IVSRealTime\IVSRealTimeClient; use Aws\CloudWatchLogs\CloudWatchLogsClient; use Aws\ElasticLoadBalancingV2\ElasticLoadBalancingV2Client; @@ -113,6 +115,16 @@ public static function iam(): IamClient return Helpers::app('iam'); } + public static function ivs(): IVSClient + { + return Helpers::app('ivs'); + } + + public static function ivsRealTime(): IVSRealTimeClient + { + return Helpers::app('ivsRealTime'); + } + public static function rds(): RdsClient { return Helpers::app('rds'); diff --git a/src/Commands/SyncLoggingCommand.php b/src/Commands/SyncLoggingCommand.php index 755c2df..24eae37 100644 --- a/src/Commands/SyncLoggingCommand.php +++ b/src/Commands/SyncLoggingCommand.php @@ -12,6 +12,12 @@ class SyncLoggingCommand extends SteppedCommand Steps\Logging\SyncIvsCloudWatchLogGroupStep::class, Steps\Logging\SyncIvsEventBridgeRuleStep::class, Steps\Logging\SyncIvsEventBridgeTargetStep::class, + + // ivs recording + Steps\Logging\SyncIvsRecordingConfigurationStep::class, + Steps\Logging\SyncIvsStorageConfigurationStep::class, + Steps\Logging\SyncIvsRecordingEventBridgeRuleStep::class, + Steps\Logging\SyncIvsRecordingEventBridgeTargetStep::class, ]; protected function configure(): void diff --git a/src/Concerns/RegistersAws.php b/src/Concerns/RegistersAws.php index a0409a2..2b3b7f5 100644 --- a/src/Concerns/RegistersAws.php +++ b/src/Concerns/RegistersAws.php @@ -6,6 +6,7 @@ use Aws\Acm\AcmClient; use Aws\Ec2\Ec2Client; use Aws\Iam\IamClient; +use Aws\IVS\IVSClient; use Aws\Rds\RdsClient; use Aws\Sns\SnsClient; use Aws\Sqs\SqsClient; @@ -20,6 +21,7 @@ use Aws\CodeDeploy\CodeDeployClient; use Aws\AutoScaling\AutoScalingClient; use Aws\EventBridge\EventBridgeClient; +use Aws\IVSRealTime\IVSRealTimeClient; use Codinglabs\Yolo\Enums\ServerGroup; use Aws\Credentials\CredentialProvider; use GuzzleHttp\Exception\ConnectException; @@ -48,6 +50,8 @@ protected function registerAwsServices(): void Helpers::app()->singleton('eventBridge', fn () => new EventBridgeClient($arguments)); Helpers::app()->singleton('elasticLoadBalancingV2', fn () => new ElasticLoadBalancingV2Client($arguments)); Helpers::app()->singleton('iam', fn () => new IamClient($arguments)); + Helpers::app()->singleton('ivs', fn () => new IVSClient($arguments)); + Helpers::app()->singleton('ivsRealTime', fn () => new IVSRealTimeClient($arguments)); Helpers::app()->singleton('rds', fn () => new RdsClient($arguments)); Helpers::app()->singleton('route53', fn () => new Route53Client($arguments)); Helpers::app()->singleton('s3', fn () => new S3Client($arguments)); diff --git a/src/Manifest.php b/src/Manifest.php index fd33d6f..f6e28b3 100644 --- a/src/Manifest.php +++ b/src/Manifest.php @@ -94,6 +94,13 @@ public static function isMultitenanted(): bool return ! empty(static::get('tenants')); } + /** + * Returns true when IVS is enabled for the current environment. + * + * Optional recording-related keys (both require `aws.ivs.logging: true`): + * - `aws.ivs.recording_bucket` — S3 bucket name for IVS recordings (standard + real-time) + * - `aws.ivs.recording_webhook_url` — HTTPS URL to receive IVS Recording State Change events + */ public static function ivsEnabled(): bool { return static::get('aws.ivs') === true diff --git a/src/Steps/Logging/SyncIvsRecordingConfigurationStep.php b/src/Steps/Logging/SyncIvsRecordingConfigurationStep.php new file mode 100644 index 0000000..4941e79 --- /dev/null +++ b/src/Steps/Logging/SyncIvsRecordingConfigurationStep.php @@ -0,0 +1,82 @@ +listRecordingConfigurations()['recordingConfigurations']) + ->first(fn ($config) => Arr::get($config, 'destinationConfiguration.s3.bucketName') === $bucket); + + if ($existing) { + note(sprintf('IVS RecordingConfiguration ARN: %s', $existing['arn'])); + note(sprintf('Set AWS_IVS_RECORDING_CONFIGURATION_ARN=%s', $existing['arn'])); + + return StepResult::SYNCED; + } + + if (! Arr::get($options, 'dry-run')) { + $result = Aws::ivs()->createRecordingConfiguration([ + 'name' => $name, + 'destinationConfiguration' => [ + 's3' => [ + 'bucketName' => $bucket, + ], + ], + 'tags' => [ + 'yolo:environment' => Helpers::app('environment'), + 'Name' => $name, + ], + ]); + + $arn = $result['recordingConfiguration']['arn']; + $state = $result['recordingConfiguration']['state'] ?? null; + + // Poll until ACTIVE — creation can take a few seconds + if ($state !== 'ACTIVE') { + $attempts = 0; + + while ($attempts < 30) { + sleep(2); + $polled = Aws::ivs()->getRecordingConfiguration(['arn' => $arn]); + $state = $polled['recordingConfiguration']['state'] ?? null; + + if ($state === 'ACTIVE') { + break; + } + + $attempts++; + } + } + + note(sprintf('IVS RecordingConfiguration ARN: %s', $arn)); + note(sprintf('Set AWS_IVS_RECORDING_CONFIGURATION_ARN=%s', $arn)); + + return StepResult::CREATED; + } + + return StepResult::WOULD_CREATE; + } +} diff --git a/src/Steps/Logging/SyncIvsRecordingEventBridgeRuleStep.php b/src/Steps/Logging/SyncIvsRecordingEventBridgeRuleStep.php new file mode 100644 index 0000000..84f1256 --- /dev/null +++ b/src/Steps/Logging/SyncIvsRecordingEventBridgeRuleStep.php @@ -0,0 +1,80 @@ +putRule([ + 'Name' => $name, + 'Description' => 'YOLO managed IVS recording state change events', + 'EventPattern' => json_encode(self::eventPattern()), + 'State' => 'ENABLED', + ...Aws::tags([ + 'Name' => $name, + ]), + ]); + + return StepResult::SYNCED; + } + + return StepResult::WOULD_SYNC; + } catch (ResourceDoesNotExistException $e) { + if (! Arr::get($options, 'dry-run')) { + Aws::eventBridge()->putRule([ + 'Name' => $name, + 'Description' => 'YOLO managed IVS recording state change events', + 'EventPattern' => json_encode(self::eventPattern()), + 'State' => 'ENABLED', + ...Aws::tags([ + 'Name' => $name, + ]), + ]); + + return StepResult::CREATED; + } + + return StepResult::WOULD_CREATE; + } + } + + public static function ruleName(): string + { + return Helpers::keyedResourceName('ivs-recording-state-change'); + } + + public static function eventPattern(): array + { + return [ + 'source' => ['aws.ivs'], + 'detail-type' => ['IVS Recording State Change'], + 'detail' => [ + 'recording_status' => ['Recording End'], + ], + ]; + } +} diff --git a/src/Steps/Logging/SyncIvsRecordingEventBridgeTargetStep.php b/src/Steps/Logging/SyncIvsRecordingEventBridgeTargetStep.php new file mode 100644 index 0000000..ea98b59 --- /dev/null +++ b/src/Steps/Logging/SyncIvsRecordingEventBridgeTargetStep.php @@ -0,0 +1,156 @@ +syncConnection($connectionName, $options); + + if (! $connectionArn) { + return StepResult::WOULD_CREATE; + } + + // Resolve or create the API Destination + $destinationArn = $this->syncApiDestination($destinationName, $connectionArn, $webhookUrl, $options); + + if (! $destinationArn) { + return StepResult::WOULD_CREATE; + } + + $existingTarget = null; + + try { + AwsResources::eventBridgeRule($ruleName); + + $existingTarget = collect(Aws::eventBridge()->listTargetsByRule([ + 'Rule' => $ruleName, + ])['Targets'])->first( + fn ($target) => $target['Id'] === 'ivs-recording-webhook' + ); + + if ($existingTarget && $existingTarget['Arn'] === $destinationArn) { + return StepResult::SYNCED; + } + } catch (ResourceDoesNotExistException) { + // Rule doesn't exist yet — target needs to be created + } + + if (! Arr::get($options, 'dry-run')) { + Aws::eventBridge()->putTargets([ + 'Rule' => $ruleName, + 'Targets' => [ + [ + 'Id' => 'ivs-recording-webhook', + 'Arn' => $destinationArn, + 'HttpParameters' => [ + 'HeaderParameters' => [], + 'QueryStringParameters' => [], + ], + ], + ], + ]); + + return $existingTarget + ? StepResult::SYNCED + : StepResult::CREATED; + } + + return $existingTarget + ? StepResult::WOULD_SYNC + : StepResult::WOULD_CREATE; + } + + private function syncConnection(string $name, array $options): ?string + { + try { + $connection = Aws::eventBridge()->describeConnection(['Name' => $name]); + + return $connection['ConnectionArn']; + } catch (EventBridgeException) { + // Does not exist — fall through to create + } + + if (Arr::get($options, 'dry-run')) { + return null; + } + + // EventBridge requires an auth type on connections; API_KEY with a placeholder + // is the lightest option for unauthenticated public webhook endpoints. + $result = Aws::eventBridge()->createConnection([ + 'Name' => $name, + 'Description' => 'YOLO managed connection for IVS recording webhook', + 'AuthorizationType' => 'API_KEY', + 'AuthParameters' => [ + 'ApiKeyAuthParameters' => [ + 'ApiKeyName' => 'X-Yolo-Managed', + 'ApiKeyValue' => 'placeholder', + ], + ], + ]); + + return $result['ConnectionArn']; + } + + private function syncApiDestination(string $name, string $connectionArn, string $webhookUrl, array $options): ?string + { + try { + $destination = Aws::eventBridge()->describeApiDestination(['Name' => $name]); + + // Sync the URL in case it has changed + if ($destination['InvocationEndpoint'] !== $webhookUrl && ! Arr::get($options, 'dry-run')) { + Aws::eventBridge()->updateApiDestination([ + 'Name' => $name, + 'ConnectionArn' => $connectionArn, + 'InvocationEndpoint' => $webhookUrl, + 'HttpMethod' => 'POST', + ]); + } + + return $destination['ApiDestinationArn']; + } catch (EventBridgeException) { + // Does not exist — fall through to create + } + + if (Arr::get($options, 'dry-run')) { + return null; + } + + $result = Aws::eventBridge()->createApiDestination([ + 'Name' => $name, + 'Description' => 'YOLO managed API destination for IVS recording webhook', + 'ConnectionArn' => $connectionArn, + 'InvocationEndpoint' => $webhookUrl, + 'HttpMethod' => 'POST', + ]); + + return $result['ApiDestinationArn']; + } +} diff --git a/src/Steps/Logging/SyncIvsStorageConfigurationStep.php b/src/Steps/Logging/SyncIvsStorageConfigurationStep.php new file mode 100644 index 0000000..4be5d12 --- /dev/null +++ b/src/Steps/Logging/SyncIvsStorageConfigurationStep.php @@ -0,0 +1,62 @@ +listStorageConfigurations()['storageConfigurations']) + ->first(fn ($config) => Arr::get($config, 's3.bucketName') === $bucket); + + if ($existing) { + note(sprintf('IVS StorageConfiguration ARN: %s', $existing['arn'])); + note(sprintf('Set AWS_IVS_STORAGE_CONFIGURATION_ARN=%s', $existing['arn'])); + + return StepResult::SYNCED; + } + + if (! Arr::get($options, 'dry-run')) { + $result = Aws::ivsRealTime()->createStorageConfiguration([ + 'name' => $name, + 's3' => [ + 'bucketName' => $bucket, + ], + 'tags' => [ + 'yolo:environment' => Helpers::app('environment'), + 'Name' => $name, + ], + ]); + + $arn = $result['storageConfiguration']['arn']; + + note(sprintf('IVS StorageConfiguration ARN: %s', $arn)); + note(sprintf('Set AWS_IVS_STORAGE_CONFIGURATION_ARN=%s', $arn)); + + return StepResult::CREATED; + } + + return StepResult::WOULD_CREATE; + } +} From f21d325fefd1f18a20defa2529e7f16ce6a7aedb Mon Sep 17 00:00:00 2001 From: Seth Sharp <58869086+SethSharp@users.noreply.github.com> Date: Thu, 7 May 2026 13:19:26 +0930 Subject: [PATCH 02/15] add authentication method to ivs recording event bridge target --- docs/reference/manifest.md | 21 +++++++++++ .../SyncIvsRecordingEventBridgeRuleStep.php | 4 ++ .../SyncIvsRecordingEventBridgeTargetStep.php | 37 ++++++++++++------- 3 files changed, 49 insertions(+), 13 deletions(-) diff --git a/docs/reference/manifest.md b/docs/reference/manifest.md index 9b5adfc..af855f5 100644 --- a/docs/reference/manifest.md +++ b/docs/reference/manifest.md @@ -117,6 +117,27 @@ aws: `logging` toggles the EventBridge → CloudWatch pipeline; `log-retention-days` overrides the log retention. +#### IVS recording + +Two optional keys enable S3 recording and webhook delivery for both standard IVS channels and IVS Real-Time stages: + +```yaml +aws: + ivs: + logging: true + recording_bucket: your-s3-bucket-name + recording_webhook_url: https://your-api.example.com/webhooks/ivs/recording + recording_webhook_secret: your-secret-here +``` + +| Key | Description | +|---|---| +| `recording_bucket` | S3 bucket name for IVS recordings. Provisions a `RecordingConfiguration` (standard channels) and a `StorageConfiguration` (Real-Time stages), each outputting its ARN for `AWS_IVS_RECORDING_CONFIGURATION_ARN` / `AWS_IVS_STORAGE_CONFIGURATION_ARN`. | +| `recording_webhook_url` | HTTPS endpoint to receive `IVS Recording State Change` / `Recording End` events via EventBridge. | +| `recording_webhook_secret` | Shared secret sent as the `X-Webhook-Secret` header on every delivery. Generate with `openssl rand -hex 32` and set the same value as `IVS_WEBHOOK_SECRET` in the app's environment. | + +All three keys are optional — omitting any of them skips the relevant steps without affecting existing resources. + ### `mysqldump` Enable scheduled MySQL backups via `mysqldump`. diff --git a/src/Steps/Logging/SyncIvsRecordingEventBridgeRuleStep.php b/src/Steps/Logging/SyncIvsRecordingEventBridgeRuleStep.php index 84f1256..4e1c242 100644 --- a/src/Steps/Logging/SyncIvsRecordingEventBridgeRuleStep.php +++ b/src/Steps/Logging/SyncIvsRecordingEventBridgeRuleStep.php @@ -23,6 +23,10 @@ public function __invoke(array $options): StepResult return StepResult::SKIPPED; } + if (! Manifest::get('aws.ivs.recording_webhook_secret')) { + return StepResult::SKIPPED; + } + $name = self::ruleName(); try { diff --git a/src/Steps/Logging/SyncIvsRecordingEventBridgeTargetStep.php b/src/Steps/Logging/SyncIvsRecordingEventBridgeTargetStep.php index ea98b59..a0f0e43 100644 --- a/src/Steps/Logging/SyncIvsRecordingEventBridgeTargetStep.php +++ b/src/Steps/Logging/SyncIvsRecordingEventBridgeTargetStep.php @@ -26,19 +26,22 @@ public function __invoke(array $options): StepResult return StepResult::SKIPPED; } + $webhookSecret = Manifest::get('aws.ivs.recording_webhook_secret'); + + if (! $webhookSecret) { + return StepResult::SKIPPED; + } + $ruleName = SyncIvsRecordingEventBridgeRuleStep::ruleName(); $connectionName = Helpers::keyedResourceName('ivs-recording-webhook-connection'); $destinationName = Helpers::keyedResourceName('ivs-recording-webhook-destination'); - // Resolve or create the EventBridge Connection (OAUTH_CLIENT_CREDENTIALS with NONE auth - // is not supported — use API_KEY with a dummy value for unauthenticated public endpoints) - $connectionArn = $this->syncConnection($connectionName, $options); + $connectionArn = $this->syncConnection($connectionName, $webhookSecret, $options); if (! $connectionArn) { return StepResult::WOULD_CREATE; } - // Resolve or create the API Destination $destinationArn = $this->syncApiDestination($destinationName, $connectionArn, $webhookUrl, $options); if (! $destinationArn) { @@ -88,11 +91,26 @@ public function __invoke(array $options): StepResult : StepResult::WOULD_CREATE; } - private function syncConnection(string $name, array $options): ?string + private function syncConnection(string $name, string $secret, array $options): ?string { + $authParameters = [ + 'ApiKeyAuthParameters' => [ + 'ApiKeyName' => 'X-Webhook-Secret', + 'ApiKeyValue' => $secret, + ], + ]; + try { $connection = Aws::eventBridge()->describeConnection(['Name' => $name]); + if (! Arr::get($options, 'dry-run')) { + Aws::eventBridge()->updateConnection([ + 'Name' => $name, + 'AuthorizationType' => 'API_KEY', + 'AuthParameters' => $authParameters, + ]); + } + return $connection['ConnectionArn']; } catch (EventBridgeException) { // Does not exist — fall through to create @@ -102,18 +120,11 @@ private function syncConnection(string $name, array $options): ?string return null; } - // EventBridge requires an auth type on connections; API_KEY with a placeholder - // is the lightest option for unauthenticated public webhook endpoints. $result = Aws::eventBridge()->createConnection([ 'Name' => $name, 'Description' => 'YOLO managed connection for IVS recording webhook', 'AuthorizationType' => 'API_KEY', - 'AuthParameters' => [ - 'ApiKeyAuthParameters' => [ - 'ApiKeyName' => 'X-Yolo-Managed', - 'ApiKeyValue' => 'placeholder', - ], - ], + 'AuthParameters' => $authParameters, ]); return $result['ConnectionArn']; From 840627320cd3f73026df0c7ed12c40fc67d85523 Mon Sep 17 00:00:00 2001 From: Seth Sharp <58869086+SethSharp@users.noreply.github.com> Date: Thu, 7 May 2026 14:58:24 +0930 Subject: [PATCH 03/15] cooking --- docs/reference/manifest.md | 10 ++- src/Commands/SyncLoggingCommand.php | 2 + ...vsRealtimeRecordingEventBridgeRuleStep.php | 84 +++++++++++++++++++ ...RealtimeRecordingEventBridgeTargetStep.php | 83 ++++++++++++++++++ .../SyncIvsStorageConfigurationStep.php | 2 +- 5 files changed, 176 insertions(+), 5 deletions(-) create mode 100644 src/Steps/Logging/SyncIvsRealtimeRecordingEventBridgeRuleStep.php create mode 100644 src/Steps/Logging/SyncIvsRealtimeRecordingEventBridgeTargetStep.php diff --git a/docs/reference/manifest.md b/docs/reference/manifest.md index af855f5..b5ccc38 100644 --- a/docs/reference/manifest.md +++ b/docs/reference/manifest.md @@ -125,18 +125,20 @@ Two optional keys enable S3 recording and webhook delivery for both standard IVS aws: ivs: logging: true - recording_bucket: your-s3-bucket-name + recording_bucket: your-ivs-channel-recordings-bucket + realtime_recording_bucket: your-ivs-realtime-recordings-bucket recording_webhook_url: https://your-api.example.com/webhooks/ivs/recording recording_webhook_secret: your-secret-here ``` | Key | Description | |---|---| -| `recording_bucket` | S3 bucket name for IVS recordings. Provisions a `RecordingConfiguration` (standard channels) and a `StorageConfiguration` (Real-Time stages), each outputting its ARN for `AWS_IVS_RECORDING_CONFIGURATION_ARN` / `AWS_IVS_STORAGE_CONFIGURATION_ARN`. | -| `recording_webhook_url` | HTTPS endpoint to receive `IVS Recording State Change` / `Recording End` events via EventBridge. | +| `recording_bucket` | S3 bucket for standard IVS channel recordings. Provisions a `RecordingConfiguration` and outputs its ARN for `AWS_IVS_RECORDING_CONFIGURATION_ARN`. | +| `realtime_recording_bucket` | S3 bucket for IVS Real-Time stage recordings. Provisions a `StorageConfiguration` and outputs its ARN for `AWS_IVS_STORAGE_CONFIGURATION_ARN`. Real-Time recordings are written to the bucket root rather than a structured prefix, so a dedicated bucket is recommended. | +| `recording_webhook_url` | HTTPS endpoint to receive `IVS Recording State Change` and `IVS Participant Recording State Change` events via EventBridge. | | `recording_webhook_secret` | Shared secret sent as the `X-Webhook-Secret` header on every delivery. Generate with `openssl rand -hex 32` and set the same value as `IVS_WEBHOOK_SECRET` in the app's environment. | -All three keys are optional — omitting any of them skips the relevant steps without affecting existing resources. +All keys are optional — omitting any of them skips the relevant steps without affecting existing resources. ### `mysqldump` diff --git a/src/Commands/SyncLoggingCommand.php b/src/Commands/SyncLoggingCommand.php index 24eae37..8d493d7 100644 --- a/src/Commands/SyncLoggingCommand.php +++ b/src/Commands/SyncLoggingCommand.php @@ -18,6 +18,8 @@ class SyncLoggingCommand extends SteppedCommand Steps\Logging\SyncIvsStorageConfigurationStep::class, Steps\Logging\SyncIvsRecordingEventBridgeRuleStep::class, Steps\Logging\SyncIvsRecordingEventBridgeTargetStep::class, + Steps\Logging\SyncIvsRealtimeRecordingEventBridgeRuleStep::class, + Steps\Logging\SyncIvsRealtimeRecordingEventBridgeTargetStep::class, ]; protected function configure(): void diff --git a/src/Steps/Logging/SyncIvsRealtimeRecordingEventBridgeRuleStep.php b/src/Steps/Logging/SyncIvsRealtimeRecordingEventBridgeRuleStep.php new file mode 100644 index 0000000..5d73431 --- /dev/null +++ b/src/Steps/Logging/SyncIvsRealtimeRecordingEventBridgeRuleStep.php @@ -0,0 +1,84 @@ +putRule([ + 'Name' => $name, + 'Description' => 'YOLO managed IVS Real-Time participant recording state change events', + 'EventPattern' => json_encode(self::eventPattern()), + 'State' => 'ENABLED', + ...Aws::tags([ + 'Name' => $name, + ]), + ]); + + return StepResult::SYNCED; + } + + return StepResult::WOULD_SYNC; + } catch (ResourceDoesNotExistException) { + if (! Arr::get($options, 'dry-run')) { + Aws::eventBridge()->putRule([ + 'Name' => $name, + 'Description' => 'YOLO managed IVS Real-Time participant recording state change events', + 'EventPattern' => json_encode(self::eventPattern()), + 'State' => 'ENABLED', + ...Aws::tags([ + 'Name' => $name, + ]), + ]); + + return StepResult::CREATED; + } + + return StepResult::WOULD_CREATE; + } + } + + public static function ruleName(): string + { + return Helpers::keyedResourceName('ivs-participant-recording-state-change'); + } + + public static function eventPattern(): array + { + return [ + 'source' => ['aws.ivs'], + 'detail-type' => ['IVS Participant Recording State Change'], + 'detail' => [ + 'event_name' => ['Recording End'], + ], + ]; + } +} diff --git a/src/Steps/Logging/SyncIvsRealtimeRecordingEventBridgeTargetStep.php b/src/Steps/Logging/SyncIvsRealtimeRecordingEventBridgeTargetStep.php new file mode 100644 index 0000000..13bc0b8 --- /dev/null +++ b/src/Steps/Logging/SyncIvsRealtimeRecordingEventBridgeTargetStep.php @@ -0,0 +1,83 @@ +describeApiDestination(['Name' => $destinationName]); + $destinationArn = $destination['ApiDestinationArn']; + } catch (EventBridgeException) { + return StepResult::WOULD_CREATE; + } + + $existingTarget = null; + + try { + AwsResources::eventBridgeRule($ruleName); + + $existingTarget = collect(Aws::eventBridge()->listTargetsByRule([ + 'Rule' => $ruleName, + ])['Targets'])->first( + fn ($target) => $target['Id'] === 'ivs-recording-webhook' + ); + + if ($existingTarget && $existingTarget['Arn'] === $destinationArn) { + return StepResult::SYNCED; + } + } catch (ResourceDoesNotExistException) { + // Rule doesn't exist yet — target needs to be created + } + + if (! Arr::get($options, 'dry-run')) { + Aws::eventBridge()->putTargets([ + 'Rule' => $ruleName, + 'Targets' => [ + [ + 'Id' => 'ivs-recording-webhook', + 'Arn' => $destinationArn, + 'HttpParameters' => [ + 'HeaderParameters' => [], + 'QueryStringParameters' => [], + ], + ], + ], + ]); + + return $existingTarget + ? StepResult::SYNCED + : StepResult::CREATED; + } + + return $existingTarget + ? StepResult::WOULD_SYNC + : StepResult::WOULD_CREATE; + } +} diff --git a/src/Steps/Logging/SyncIvsStorageConfigurationStep.php b/src/Steps/Logging/SyncIvsStorageConfigurationStep.php index 4be5d12..baceb84 100644 --- a/src/Steps/Logging/SyncIvsStorageConfigurationStep.php +++ b/src/Steps/Logging/SyncIvsStorageConfigurationStep.php @@ -19,7 +19,7 @@ public function __invoke(array $options): StepResult return StepResult::SKIPPED; } - $bucket = Manifest::get('aws.ivs.recording_bucket'); + $bucket = Manifest::get('aws.ivs.realtime_recording_bucket'); if (! $bucket) { return StepResult::SKIPPED; From 09a2e9487d05a7d0b55ba87ae16c1a7b3d276c96 Mon Sep 17 00:00:00 2001 From: Seth Sharp <58869086+SethSharp@users.noreply.github.com> Date: Fri, 8 May 2026 10:24:36 +0930 Subject: [PATCH 04/15] flatten config to just recording > webhook_url --- docs/reference/manifest.md | 18 +++---- src/Commands/SyncLoggingCommand.php | 2 + src/Manifest.php | 29 ++++++++--- .../SyncIvsRealtimeRecordingBucketStep.php | 48 +++++++++++++++++++ ...vsRealtimeRecordingEventBridgeRuleStep.php | 11 +---- ...RealtimeRecordingEventBridgeTargetStep.php | 8 +--- .../Logging/SyncIvsRecordingBucketStep.php | 48 +++++++++++++++++++ .../SyncIvsRecordingConfigurationStep.php | 9 +--- .../SyncIvsRecordingEventBridgeRuleStep.php | 11 +---- .../SyncIvsRecordingEventBridgeTargetStep.php | 8 +--- .../SyncIvsStorageConfigurationStep.php | 9 +--- 11 files changed, 141 insertions(+), 60 deletions(-) create mode 100644 src/Steps/Logging/SyncIvsRealtimeRecordingBucketStep.php create mode 100644 src/Steps/Logging/SyncIvsRecordingBucketStep.php diff --git a/docs/reference/manifest.md b/docs/reference/manifest.md index b5ccc38..a39f4d7 100644 --- a/docs/reference/manifest.md +++ b/docs/reference/manifest.md @@ -125,20 +125,20 @@ Two optional keys enable S3 recording and webhook delivery for both standard IVS aws: ivs: logging: true - recording_bucket: your-ivs-channel-recordings-bucket - realtime_recording_bucket: your-ivs-realtime-recordings-bucket - recording_webhook_url: https://your-api.example.com/webhooks/ivs/recording - recording_webhook_secret: your-secret-here + recording: + webhook_url: https://your-api.example.com/webhooks/ivs/recording ``` +Setting `recording` provisions S3 buckets, IVS `RecordingConfiguration` and `StorageConfiguration`, and EventBridge rules targeting the webhook URL for both standard channel and Real-Time stage recordings. + | Key | Description | |---|---| -| `recording_bucket` | S3 bucket for standard IVS channel recordings. Provisions a `RecordingConfiguration` and outputs its ARN for `AWS_IVS_RECORDING_CONFIGURATION_ARN`. | -| `realtime_recording_bucket` | S3 bucket for IVS Real-Time stage recordings. Provisions a `StorageConfiguration` and outputs its ARN for `AWS_IVS_STORAGE_CONFIGURATION_ARN`. Real-Time recordings are written to the bucket root rather than a structured prefix, so a dedicated bucket is recommended. | -| `recording_webhook_url` | HTTPS endpoint to receive `IVS Recording State Change` and `IVS Participant Recording State Change` events via EventBridge. | -| `recording_webhook_secret` | Shared secret sent as the `X-Webhook-Secret` header on every delivery. Generate with `openssl rand -hex 32` and set the same value as `IVS_WEBHOOK_SECRET` in the app's environment. | +| `recording` | Set to `true` or expand with sub-keys to enable IVS recording provisioning. Provisions auto-named S3 buckets (`yolo-{env}-{app}-ivs-recordings` and `yolo-{env}-{app}-ivs-realtime-recordings`) and their corresponding IVS configurations. ARNs are printed after creation for `AWS_IVS_RECORDING_CONFIGURATION_ARN` and `AWS_IVS_STORAGE_CONFIGURATION_ARN`. | +| `recording.webhook_url` | HTTPS endpoint to receive `IVS Recording State Change` and `IVS Participant Recording State Change` events via EventBridge. Required for the EventBridge steps. | + +The EventBridge steps also require `IVS_WEBHOOK_SECRET` to be present in the local `.env.{environment}` file. This secret is sent as the `X-Webhook-Secret` header on every delivery — generate it with `openssl rand -hex 32` and set the same value in the app's environment. Manage it alongside other app secrets via `yolo env:push` / `yolo env:pull`. -All keys are optional — omitting any of them skips the relevant steps without affecting existing resources. +Omitting `recording` entirely skips all recording steps without affecting existing resources. ### `mysqldump` diff --git a/src/Commands/SyncLoggingCommand.php b/src/Commands/SyncLoggingCommand.php index 8d493d7..b033629 100644 --- a/src/Commands/SyncLoggingCommand.php +++ b/src/Commands/SyncLoggingCommand.php @@ -14,6 +14,8 @@ class SyncLoggingCommand extends SteppedCommand Steps\Logging\SyncIvsEventBridgeTargetStep::class, // ivs recording + Steps\Logging\SyncIvsRecordingBucketStep::class, + Steps\Logging\SyncIvsRealtimeRecordingBucketStep::class, Steps\Logging\SyncIvsRecordingConfigurationStep::class, Steps\Logging\SyncIvsStorageConfigurationStep::class, Steps\Logging\SyncIvsRecordingEventBridgeRuleStep::class, diff --git a/src/Manifest.php b/src/Manifest.php index f6e28b3..accf983 100644 --- a/src/Manifest.php +++ b/src/Manifest.php @@ -2,6 +2,7 @@ namespace Codinglabs\Yolo; +use Dotenv\Dotenv; use Illuminate\Support\Arr; use Symfony\Component\Yaml\Yaml; use Codinglabs\Yolo\Exceptions\IntegrityCheckException; @@ -94,19 +95,33 @@ public static function isMultitenanted(): bool return ! empty(static::get('tenants')); } - /** - * Returns true when IVS is enabled for the current environment. - * - * Optional recording-related keys (both require `aws.ivs.logging: true`): - * - `aws.ivs.recording_bucket` — S3 bucket name for IVS recordings (standard + real-time) - * - `aws.ivs.recording_webhook_url` — HTTPS URL to receive IVS Recording State Change events - */ public static function ivsEnabled(): bool { return static::get('aws.ivs') === true || static::get('aws.ivs.logging') === true; } + public static function ivsRecordingEnabled(): bool + { + return ! empty(static::get('aws.ivs.recording')); + } + + public static function ivsRecordingWebhookUrl(): ?string + { + return static::get('aws.ivs.recording.webhook_url'); + } + + public static function ivsWebhookSecret(): ?string + { + $envFile = Paths::base('.env.' . Helpers::environment()); + + if (! file_exists($envFile)) { + return null; + } + + return Dotenv::parse(file_get_contents($envFile))['IVS_WEBHOOK_SECRET'] ?? null; + } + /** * @return arraycreateBucket(['Bucket' => $bucket]); + Aws::s3()->waitUntil('BucketExists', ['Bucket' => $bucket]); + Aws::s3()->putBucketTagging([ + 'Bucket' => $bucket, + 'Tagging' => [...Aws::tags(['Name' => $bucket], wrap: 'TagSet')], + ]); + + return StepResult::CREATED; + } + + return StepResult::WOULD_CREATE; + } + } + + public static function bucketName(): string + { + return Helpers::keyedResourceName('ivs-realtime-recordings'); + } +} diff --git a/src/Steps/Logging/SyncIvsRealtimeRecordingEventBridgeRuleStep.php b/src/Steps/Logging/SyncIvsRealtimeRecordingEventBridgeRuleStep.php index 5d73431..2814fa8 100644 --- a/src/Steps/Logging/SyncIvsRealtimeRecordingEventBridgeRuleStep.php +++ b/src/Steps/Logging/SyncIvsRealtimeRecordingEventBridgeRuleStep.php @@ -15,15 +15,11 @@ class SyncIvsRealtimeRecordingEventBridgeRuleStep implements Step { public function __invoke(array $options): StepResult { - if (! Manifest::ivsEnabled()) { + if (! Manifest::ivsRecordingWebhookUrl()) { return StepResult::SKIPPED; } - if (! Manifest::get('aws.ivs.recording_webhook_url')) { - return StepResult::SKIPPED; - } - - if (! Manifest::get('aws.ivs.recording_webhook_secret')) { + if (! Manifest::ivsWebhookSecret()) { return StepResult::SKIPPED; } @@ -76,9 +72,6 @@ public static function eventPattern(): array return [ 'source' => ['aws.ivs'], 'detail-type' => ['IVS Participant Recording State Change'], - 'detail' => [ - 'event_name' => ['Recording End'], - ], ]; } } diff --git a/src/Steps/Logging/SyncIvsRealtimeRecordingEventBridgeTargetStep.php b/src/Steps/Logging/SyncIvsRealtimeRecordingEventBridgeTargetStep.php index 13bc0b8..9db57e8 100644 --- a/src/Steps/Logging/SyncIvsRealtimeRecordingEventBridgeTargetStep.php +++ b/src/Steps/Logging/SyncIvsRealtimeRecordingEventBridgeTargetStep.php @@ -16,15 +16,11 @@ class SyncIvsRealtimeRecordingEventBridgeTargetStep implements Step { public function __invoke(array $options): StepResult { - if (! Manifest::ivsEnabled()) { + if (! Manifest::ivsRecordingWebhookUrl()) { return StepResult::SKIPPED; } - if (! Manifest::get('aws.ivs.recording_webhook_url')) { - return StepResult::SKIPPED; - } - - if (! Manifest::get('aws.ivs.recording_webhook_secret')) { + if (! Manifest::ivsWebhookSecret()) { return StepResult::SKIPPED; } diff --git a/src/Steps/Logging/SyncIvsRecordingBucketStep.php b/src/Steps/Logging/SyncIvsRecordingBucketStep.php new file mode 100644 index 0000000..d8559ed --- /dev/null +++ b/src/Steps/Logging/SyncIvsRecordingBucketStep.php @@ -0,0 +1,48 @@ +createBucket(['Bucket' => $bucket]); + Aws::s3()->waitUntil('BucketExists', ['Bucket' => $bucket]); + Aws::s3()->putBucketTagging([ + 'Bucket' => $bucket, + 'Tagging' => [...Aws::tags(['Name' => $bucket], wrap: 'TagSet')], + ]); + + return StepResult::CREATED; + } + + return StepResult::WOULD_CREATE; + } + } + + public static function bucketName(): string + { + return Helpers::keyedResourceName('ivs-recordings'); + } +} diff --git a/src/Steps/Logging/SyncIvsRecordingConfigurationStep.php b/src/Steps/Logging/SyncIvsRecordingConfigurationStep.php index 4941e79..e780b2a 100644 --- a/src/Steps/Logging/SyncIvsRecordingConfigurationStep.php +++ b/src/Steps/Logging/SyncIvsRecordingConfigurationStep.php @@ -15,16 +15,11 @@ class SyncIvsRecordingConfigurationStep implements Step { public function __invoke(array $options): StepResult { - if (! Manifest::ivsEnabled()) { - return StepResult::SKIPPED; - } - - $bucket = Manifest::get('aws.ivs.recording_bucket'); - - if (! $bucket) { + if (! Manifest::ivsRecordingEnabled()) { return StepResult::SKIPPED; } + $bucket = SyncIvsRecordingBucketStep::bucketName(); $name = Helpers::keyedResourceName('ivs-recording'); $existing = collect(Aws::ivs()->listRecordingConfigurations()['recordingConfigurations']) diff --git a/src/Steps/Logging/SyncIvsRecordingEventBridgeRuleStep.php b/src/Steps/Logging/SyncIvsRecordingEventBridgeRuleStep.php index 4e1c242..5115be4 100644 --- a/src/Steps/Logging/SyncIvsRecordingEventBridgeRuleStep.php +++ b/src/Steps/Logging/SyncIvsRecordingEventBridgeRuleStep.php @@ -15,15 +15,11 @@ class SyncIvsRecordingEventBridgeRuleStep implements Step { public function __invoke(array $options): StepResult { - if (! Manifest::ivsEnabled()) { + if (! Manifest::ivsRecordingWebhookUrl()) { return StepResult::SKIPPED; } - if (! Manifest::get('aws.ivs.recording_webhook_url')) { - return StepResult::SKIPPED; - } - - if (! Manifest::get('aws.ivs.recording_webhook_secret')) { + if (! Manifest::ivsWebhookSecret()) { return StepResult::SKIPPED; } @@ -76,9 +72,6 @@ public static function eventPattern(): array return [ 'source' => ['aws.ivs'], 'detail-type' => ['IVS Recording State Change'], - 'detail' => [ - 'recording_status' => ['Recording End'], - ], ]; } } diff --git a/src/Steps/Logging/SyncIvsRecordingEventBridgeTargetStep.php b/src/Steps/Logging/SyncIvsRecordingEventBridgeTargetStep.php index a0f0e43..3992d02 100644 --- a/src/Steps/Logging/SyncIvsRecordingEventBridgeTargetStep.php +++ b/src/Steps/Logging/SyncIvsRecordingEventBridgeTargetStep.php @@ -16,17 +16,13 @@ class SyncIvsRecordingEventBridgeTargetStep implements Step { public function __invoke(array $options): StepResult { - if (! Manifest::ivsEnabled()) { - return StepResult::SKIPPED; - } - - $webhookUrl = Manifest::get('aws.ivs.recording_webhook_url'); + $webhookUrl = Manifest::ivsRecordingWebhookUrl(); if (! $webhookUrl) { return StepResult::SKIPPED; } - $webhookSecret = Manifest::get('aws.ivs.recording_webhook_secret'); + $webhookSecret = Manifest::ivsWebhookSecret(); if (! $webhookSecret) { return StepResult::SKIPPED; diff --git a/src/Steps/Logging/SyncIvsStorageConfigurationStep.php b/src/Steps/Logging/SyncIvsStorageConfigurationStep.php index baceb84..88e9005 100644 --- a/src/Steps/Logging/SyncIvsStorageConfigurationStep.php +++ b/src/Steps/Logging/SyncIvsStorageConfigurationStep.php @@ -15,16 +15,11 @@ class SyncIvsStorageConfigurationStep implements Step { public function __invoke(array $options): StepResult { - if (! Manifest::ivsEnabled()) { - return StepResult::SKIPPED; - } - - $bucket = Manifest::get('aws.ivs.realtime_recording_bucket'); - - if (! $bucket) { + if (! Manifest::ivsRecordingEnabled()) { return StepResult::SKIPPED; } + $bucket = SyncIvsRealtimeRecordingBucketStep::bucketName(); $name = Helpers::keyedResourceName('ivs-storage'); $existing = collect(Aws::ivsRealTime()->listStorageConfigurations()['storageConfigurations']) From 05a21dc600b8268a8aecb87664b50d56f2fb0571 Mon Sep 17 00:00:00 2001 From: Seth Sharp <58869086+SethSharp@users.noreply.github.com> Date: Fri, 8 May 2026 11:43:23 +0930 Subject: [PATCH 05/15] iam role + sync log group --- README.md | 1 + src/Commands/SyncIamCommand.php | 2 + src/Commands/SyncLoggingCommand.php | 9 -- src/Commands/SyncRecordingCommand.php | 31 ++++++ src/Concerns/UsesIam.php | 30 ++++++ src/Enums/Iam.php | 1 + ...ventBridgeIvsRecordingRolePoliciesStep.php | 39 ++++++++ .../SyncEventBridgeIvsRecordingRoleStep.php | 63 ++++++++++++ ...vsRealtimeRecordingEventBridgeRuleStep.php | 2 +- ...RealtimeRecordingEventBridgeTargetStep.php | 22 ++++- ...SyncIvsRecordingCloudWatchLogGroupStep.php | 96 +++++++++++++++++++ .../SyncIvsRecordingEventBridgeTargetStep.php | 22 ++++- src/Yolo.php | 1 + 13 files changed, 299 insertions(+), 20 deletions(-) create mode 100644 src/Commands/SyncRecordingCommand.php create mode 100644 src/Steps/Iam/AttachEventBridgeIvsRecordingRolePoliciesStep.php create mode 100644 src/Steps/Iam/SyncEventBridgeIvsRecordingRoleStep.php create mode 100644 src/Steps/Logging/SyncIvsRecordingCloudWatchLogGroupStep.php diff --git a/README.md b/README.md index bb03e1a..6bf5b22 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,7 @@ The full list of available sync commands are: - `yolo sync:ci ` prepares the continuous integration pipeline - `yolo sync:iam ` prepares necessary permissions - `yolo sync:logging ` prepares observability infrastructure (e.g. IVS state-change events) +- `yolo sync:recording ` prepares IVS recording infrastructure (S3 buckets, RecordingConfiguration, StorageConfiguration, EventBridge rules) > [!TIP] > All sync commands support a `--dry-run` argument; this is a great starting point to see what resources will be created diff --git a/src/Commands/SyncIamCommand.php b/src/Commands/SyncIamCommand.php index fed2674..4d133cd 100644 --- a/src/Commands/SyncIamCommand.php +++ b/src/Commands/SyncIamCommand.php @@ -15,6 +15,8 @@ class SyncIamCommand extends SteppedCommand Steps\Iam\AttachEc2RoleToInstanceProfileStep::class, Steps\Iam\SyncMediaConvertRoleStep::class, Steps\Iam\AttachMediaConvertRolePoliciesStep::class, + Steps\Iam\SyncEventBridgeIvsRecordingRoleStep::class, + Steps\Iam\AttachEventBridgeIvsRecordingRolePoliciesStep::class, ]; protected function configure(): void diff --git a/src/Commands/SyncLoggingCommand.php b/src/Commands/SyncLoggingCommand.php index b033629..91e48fd 100644 --- a/src/Commands/SyncLoggingCommand.php +++ b/src/Commands/SyncLoggingCommand.php @@ -13,15 +13,6 @@ class SyncLoggingCommand extends SteppedCommand Steps\Logging\SyncIvsEventBridgeRuleStep::class, Steps\Logging\SyncIvsEventBridgeTargetStep::class, - // ivs recording - Steps\Logging\SyncIvsRecordingBucketStep::class, - Steps\Logging\SyncIvsRealtimeRecordingBucketStep::class, - Steps\Logging\SyncIvsRecordingConfigurationStep::class, - Steps\Logging\SyncIvsStorageConfigurationStep::class, - Steps\Logging\SyncIvsRecordingEventBridgeRuleStep::class, - Steps\Logging\SyncIvsRecordingEventBridgeTargetStep::class, - Steps\Logging\SyncIvsRealtimeRecordingEventBridgeRuleStep::class, - Steps\Logging\SyncIvsRealtimeRecordingEventBridgeTargetStep::class, ]; protected function configure(): void diff --git a/src/Commands/SyncRecordingCommand.php b/src/Commands/SyncRecordingCommand.php new file mode 100644 index 0000000..8bb07bf --- /dev/null +++ b/src/Commands/SyncRecordingCommand.php @@ -0,0 +1,31 @@ +setName('sync:recording') + ->addArgument('environment', InputArgument::REQUIRED, 'The environment name') + ->addOption('dry-run', null, null, 'Run the command without making changes') + ->addOption('no-progress', null, null, 'Hide the progress output') + ->setDescription('Sync the IVS recording resources for the given environment'); + } +} diff --git a/src/Concerns/UsesIam.php b/src/Concerns/UsesIam.php index d6723e6..f93714b 100644 --- a/src/Concerns/UsesIam.php +++ b/src/Concerns/UsesIam.php @@ -172,4 +172,34 @@ public static function mediaConvertPolicyDocument(): array ], ]; } + + public static function eventBridgeIvsRecordingRole(): array + { + $name = Helpers::keyedResourceName(Iam::EVENT_BRIDGE_IVS_RECORDING_ROLE); + $roles = Aws::iam()->listRoles(); + + foreach ($roles['Roles'] as $role) { + if ($role['RoleName'] === $name) { + return $role; + } + } + + throw new ResourceDoesNotExistException("Could not find IAM role with name $name"); + } + + public static function eventBridgeIvsRecordingPolicyDocument(): array + { + return [ + 'Version' => '2012-10-17', + 'Statement' => [ + [ + 'Effect' => 'Allow', + 'Principal' => [ + 'Service' => 'events.amazonaws.com', + ], + 'Action' => 'sts:AssumeRole', + ], + ], + ]; + } } diff --git a/src/Enums/Iam.php b/src/Enums/Iam.php index 45c652a..25e4d11 100644 --- a/src/Enums/Iam.php +++ b/src/Enums/Iam.php @@ -6,4 +6,5 @@ enum Iam: string { case INSTANCE_PROFILE = 'instance-profile'; case MEDIA_CONVERT_ROLE = 'mediaconvert-role'; + case EVENT_BRIDGE_IVS_RECORDING_ROLE = 'eventbridge-ivs-recording-role'; } diff --git a/src/Steps/Iam/AttachEventBridgeIvsRecordingRolePoliciesStep.php b/src/Steps/Iam/AttachEventBridgeIvsRecordingRolePoliciesStep.php new file mode 100644 index 0000000..9eebde2 --- /dev/null +++ b/src/Steps/Iam/AttachEventBridgeIvsRecordingRolePoliciesStep.php @@ -0,0 +1,39 @@ +managedPolicies as $policyArn) { + Aws::iam()->attachRolePolicy([ + 'RoleName' => $role['RoleName'], + 'PolicyArn' => $policyArn, + ]); + } + + return StepResult::SYNCED; + } + + return StepResult::WOULD_SYNC; + } +} diff --git a/src/Steps/Iam/SyncEventBridgeIvsRecordingRoleStep.php b/src/Steps/Iam/SyncEventBridgeIvsRecordingRoleStep.php new file mode 100644 index 0000000..c5d0a22 --- /dev/null +++ b/src/Steps/Iam/SyncEventBridgeIvsRecordingRoleStep.php @@ -0,0 +1,63 @@ +updateRole([ + 'RoleName' => $name, + 'Description' => 'YOLO managed EventBridge role for IVS recording webhook delivery', + ]); + + Aws::iam()->updateAssumeRolePolicy([ + 'RoleName' => $name, + 'PolicyDocument' => json_encode(AwsResources::eventBridgeIvsRecordingPolicyDocument()), + ]); + + Aws::iam()->tagRole([ + 'RoleName' => $name, + ...Aws::tags(), + ]); + + return StepResult::SYNCED; + } + + return StepResult::WOULD_SYNC; + } catch (ResourceDoesNotExistException) { + if (! Arr::get($options, 'dry-run')) { + Aws::iam()->createRole([ + 'RoleName' => Helpers::keyedResourceName(Iam::EVENT_BRIDGE_IVS_RECORDING_ROLE), + 'Description' => 'YOLO managed EventBridge role for IVS recording webhook delivery', + 'AssumeRolePolicyDocument' => json_encode(AwsResources::eventBridgeIvsRecordingPolicyDocument()), + ...Aws::tags(), + ]); + + return StepResult::CREATED; + } + + return StepResult::WOULD_CREATE; + } + } +} diff --git a/src/Steps/Logging/SyncIvsRealtimeRecordingEventBridgeRuleStep.php b/src/Steps/Logging/SyncIvsRealtimeRecordingEventBridgeRuleStep.php index 2814fa8..2debdff 100644 --- a/src/Steps/Logging/SyncIvsRealtimeRecordingEventBridgeRuleStep.php +++ b/src/Steps/Logging/SyncIvsRealtimeRecordingEventBridgeRuleStep.php @@ -64,7 +64,7 @@ public function __invoke(array $options): StepResult public static function ruleName(): string { - return Helpers::keyedResourceName('ivs-participant-recording-state-change'); + return Helpers::keyedResourceName('ivs-rt-recording'); } public static function eventPattern(): array diff --git a/src/Steps/Logging/SyncIvsRealtimeRecordingEventBridgeTargetStep.php b/src/Steps/Logging/SyncIvsRealtimeRecordingEventBridgeTargetStep.php index 9db57e8..5724302 100644 --- a/src/Steps/Logging/SyncIvsRealtimeRecordingEventBridgeTargetStep.php +++ b/src/Steps/Logging/SyncIvsRealtimeRecordingEventBridgeTargetStep.php @@ -39,13 +39,14 @@ public function __invoke(array $options): StepResult try { AwsResources::eventBridgeRule($ruleName); - $existingTarget = collect(Aws::eventBridge()->listTargetsByRule([ + $targets = collect(Aws::eventBridge()->listTargetsByRule([ 'Rule' => $ruleName, - ])['Targets'])->first( - fn ($target) => $target['Id'] === 'ivs-recording-webhook' - ); + ])['Targets']); - if ($existingTarget && $existingTarget['Arn'] === $destinationArn) { + $existingTarget = $targets->first(fn ($t) => $t['Id'] === 'ivs-recording-webhook'); + $existingLogTarget = $targets->first(fn ($t) => $t['Id'] === 'ivs-recording-logs'); + + if ($existingTarget && $existingTarget['Arn'] === $destinationArn && $existingLogTarget) { return StepResult::SYNCED; } } catch (ResourceDoesNotExistException) { @@ -53,17 +54,28 @@ public function __invoke(array $options): StepResult } if (! Arr::get($options, 'dry-run')) { + $roleArn = AwsResources::eventBridgeIvsRecordingRole()['Arn']; + $region = Manifest::get('aws.region'); + $accountId = Aws::accountId(); + $logGroupName = SyncIvsRecordingCloudWatchLogGroupStep::logGroupName(); + $logGroupArn = "arn:aws:logs:{$region}:{$accountId}:log-group:{$logGroupName}"; + Aws::eventBridge()->putTargets([ 'Rule' => $ruleName, 'Targets' => [ [ 'Id' => 'ivs-recording-webhook', 'Arn' => $destinationArn, + 'RoleArn' => $roleArn, 'HttpParameters' => [ 'HeaderParameters' => [], 'QueryStringParameters' => [], ], ], + [ + 'Id' => 'ivs-recording-logs', + 'Arn' => $logGroupArn, + ], ], ]); diff --git a/src/Steps/Logging/SyncIvsRecordingCloudWatchLogGroupStep.php b/src/Steps/Logging/SyncIvsRecordingCloudWatchLogGroupStep.php new file mode 100644 index 0000000..33b7316 --- /dev/null +++ b/src/Steps/Logging/SyncIvsRecordingCloudWatchLogGroupStep.php @@ -0,0 +1,96 @@ +putRetentionPolicy([ + 'logGroupName' => $name, + 'retentionInDays' => $retentionDays, + ]); + + self::putResourcePolicy($name, $logGroupArn); + + return StepResult::SYNCED; + } + + return StepResult::WOULD_SYNC; + } + + if (! Arr::get($options, 'dry-run')) { + self::putResourcePolicy($name, $logGroupArn); + } + + return StepResult::SYNCED; + } catch (ResourceDoesNotExistException) { + if (! Arr::get($options, 'dry-run')) { + Aws::cloudWatchLogs()->createLogGroup([ + 'logGroupName' => $name, + ...Aws::tags(['Name' => $name], wrap: 'tags', associative: true), + ]); + + Aws::cloudWatchLogs()->putRetentionPolicy([ + 'logGroupName' => $name, + 'retentionInDays' => $retentionDays, + ]); + + self::putResourcePolicy($name, $logGroupArn); + + return StepResult::CREATED; + } + + return StepResult::WOULD_CREATE; + } + } + + public static function logGroupName(): string + { + return '/aws/ivs/' . Helpers::keyedResourceName('recording'); + } + + private static function putResourcePolicy(string $logGroupName, string $logGroupArn): void + { + $region = Manifest::get('aws.region'); + $accountId = Aws::accountId(); + + Aws::cloudWatchLogs()->putResourcePolicy([ + 'policyName' => Helpers::keyedResourceName('ivs-recording-eventbridge-policy', exclusive: false), + 'policyDocument' => json_encode([ + 'Version' => '2012-10-17', + 'Statement' => [[ + 'Sid' => 'EventBridgeToCloudWatchLogs', + 'Effect' => 'Allow', + 'Principal' => ['Service' => 'events.amazonaws.com'], + 'Action' => ['logs:CreateLogStream', 'logs:PutLogEvents'], + 'Resource' => "arn:aws:logs:{$region}:{$accountId}:log-group:/aws/ivs/*", + ]], + ]), + ]); + } +} diff --git a/src/Steps/Logging/SyncIvsRecordingEventBridgeTargetStep.php b/src/Steps/Logging/SyncIvsRecordingEventBridgeTargetStep.php index 3992d02..2b6d9d1 100644 --- a/src/Steps/Logging/SyncIvsRecordingEventBridgeTargetStep.php +++ b/src/Steps/Logging/SyncIvsRecordingEventBridgeTargetStep.php @@ -49,13 +49,14 @@ public function __invoke(array $options): StepResult try { AwsResources::eventBridgeRule($ruleName); - $existingTarget = collect(Aws::eventBridge()->listTargetsByRule([ + $targets = collect(Aws::eventBridge()->listTargetsByRule([ 'Rule' => $ruleName, - ])['Targets'])->first( - fn ($target) => $target['Id'] === 'ivs-recording-webhook' - ); + ])['Targets']); - if ($existingTarget && $existingTarget['Arn'] === $destinationArn) { + $existingTarget = $targets->first(fn ($t) => $t['Id'] === 'ivs-recording-webhook'); + $existingLogTarget = $targets->first(fn ($t) => $t['Id'] === 'ivs-recording-logs'); + + if ($existingTarget && $existingTarget['Arn'] === $destinationArn && $existingLogTarget) { return StepResult::SYNCED; } } catch (ResourceDoesNotExistException) { @@ -63,17 +64,28 @@ public function __invoke(array $options): StepResult } if (! Arr::get($options, 'dry-run')) { + $roleArn = AwsResources::eventBridgeIvsRecordingRole()['Arn']; + $region = Manifest::get('aws.region'); + $accountId = Aws::accountId(); + $logGroupName = SyncIvsRecordingCloudWatchLogGroupStep::logGroupName(); + $logGroupArn = "arn:aws:logs:{$region}:{$accountId}:log-group:{$logGroupName}"; + Aws::eventBridge()->putTargets([ 'Rule' => $ruleName, 'Targets' => [ [ 'Id' => 'ivs-recording-webhook', 'Arn' => $destinationArn, + 'RoleArn' => $roleArn, 'HttpParameters' => [ 'HeaderParameters' => [], 'QueryStringParameters' => [], ], ], + [ + 'Id' => 'ivs-recording-logs', + 'Arn' => $logGroupArn, + ], ], ]); diff --git a/src/Yolo.php b/src/Yolo.php index 6773ebc..41fa5bc 100644 --- a/src/Yolo.php +++ b/src/Yolo.php @@ -46,6 +46,7 @@ class Yolo Commands\SyncCiCommand::class, Commands\SyncIamCommand::class, Commands\SyncLoggingCommand::class, + Commands\SyncRecordingCommand::class, ]; public function __construct() From eb83438f8b803c3e40f2c69e1ba7e6e57aa0b31f Mon Sep 17 00:00:00 2001 From: Seth Sharp <58869086+SethSharp@users.noreply.github.com> Date: Fri, 8 May 2026 12:20:03 +0930 Subject: [PATCH 06/15] move to recording directory --- src/Commands/SyncRecordingCommand.php | 18 +++++++++--------- .../SyncIvsRealtimeRecordingBucketStep.php | 2 +- ...IvsRealtimeRecordingEventBridgeRuleStep.php | 2 +- ...sRealtimeRecordingEventBridgeTargetStep.php | 2 +- .../SyncIvsRecordingBucketStep.php | 2 +- .../SyncIvsRecordingCloudWatchLogGroupStep.php | 2 +- .../SyncIvsRecordingConfigurationStep.php | 2 +- .../SyncIvsRecordingEventBridgeRuleStep.php | 2 +- .../SyncIvsRecordingEventBridgeTargetStep.php | 2 +- .../SyncIvsStorageConfigurationStep.php | 2 +- 10 files changed, 18 insertions(+), 18 deletions(-) rename src/Steps/{Logging => Recording}/SyncIvsRealtimeRecordingBucketStep.php (96%) rename src/Steps/{Logging => Recording}/SyncIvsRealtimeRecordingEventBridgeRuleStep.php (98%) rename src/Steps/{Logging => Recording}/SyncIvsRealtimeRecordingEventBridgeTargetStep.php (98%) rename src/Steps/{Logging => Recording}/SyncIvsRecordingBucketStep.php (96%) rename src/Steps/{Logging => Recording}/SyncIvsRecordingCloudWatchLogGroupStep.php (98%) rename src/Steps/{Logging => Recording}/SyncIvsRecordingConfigurationStep.php (98%) rename src/Steps/{Logging => Recording}/SyncIvsRecordingEventBridgeRuleStep.php (98%) rename src/Steps/{Logging => Recording}/SyncIvsRecordingEventBridgeTargetStep.php (99%) rename src/Steps/{Logging => Recording}/SyncIvsStorageConfigurationStep.php (97%) diff --git a/src/Commands/SyncRecordingCommand.php b/src/Commands/SyncRecordingCommand.php index 8bb07bf..94c792e 100644 --- a/src/Commands/SyncRecordingCommand.php +++ b/src/Commands/SyncRecordingCommand.php @@ -8,15 +8,15 @@ class SyncRecordingCommand extends SteppedCommand { protected array $steps = [ - Steps\Logging\SyncIvsRecordingCloudWatchLogGroupStep::class, - Steps\Logging\SyncIvsRecordingBucketStep::class, - Steps\Logging\SyncIvsRealtimeRecordingBucketStep::class, - Steps\Logging\SyncIvsRecordingConfigurationStep::class, - Steps\Logging\SyncIvsStorageConfigurationStep::class, - Steps\Logging\SyncIvsRecordingEventBridgeRuleStep::class, - Steps\Logging\SyncIvsRecordingEventBridgeTargetStep::class, - Steps\Logging\SyncIvsRealtimeRecordingEventBridgeRuleStep::class, - Steps\Logging\SyncIvsRealtimeRecordingEventBridgeTargetStep::class, + Steps\Recording\SyncIvsRecordingCloudWatchLogGroupStep::class, + Steps\Recording\SyncIvsRecordingBucketStep::class, + Steps\Recording\SyncIvsRealtimeRecordingBucketStep::class, + Steps\Recording\SyncIvsRecordingConfigurationStep::class, + Steps\Recording\SyncIvsStorageConfigurationStep::class, + Steps\Recording\SyncIvsRecordingEventBridgeRuleStep::class, + Steps\Recording\SyncIvsRecordingEventBridgeTargetStep::class, + Steps\Recording\SyncIvsRealtimeRecordingEventBridgeRuleStep::class, + Steps\Recording\SyncIvsRealtimeRecordingEventBridgeTargetStep::class, ]; protected function configure(): void diff --git a/src/Steps/Logging/SyncIvsRealtimeRecordingBucketStep.php b/src/Steps/Recording/SyncIvsRealtimeRecordingBucketStep.php similarity index 96% rename from src/Steps/Logging/SyncIvsRealtimeRecordingBucketStep.php rename to src/Steps/Recording/SyncIvsRealtimeRecordingBucketStep.php index 0aae8f8..f31040e 100644 --- a/src/Steps/Logging/SyncIvsRealtimeRecordingBucketStep.php +++ b/src/Steps/Recording/SyncIvsRealtimeRecordingBucketStep.php @@ -1,6 +1,6 @@ Date: Fri, 8 May 2026 12:38:26 +0930 Subject: [PATCH 07/15] Update SyncLoggingCommand.php --- src/Commands/SyncLoggingCommand.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Commands/SyncLoggingCommand.php b/src/Commands/SyncLoggingCommand.php index 91e48fd..755c2df 100644 --- a/src/Commands/SyncLoggingCommand.php +++ b/src/Commands/SyncLoggingCommand.php @@ -12,7 +12,6 @@ class SyncLoggingCommand extends SteppedCommand Steps\Logging\SyncIvsCloudWatchLogGroupStep::class, Steps\Logging\SyncIvsEventBridgeRuleStep::class, Steps\Logging\SyncIvsEventBridgeTargetStep::class, - ]; protected function configure(): void From 4f9d5fc2466dfe57a5ff0db52b1d2669525f0491 Mon Sep 17 00:00:00 2001 From: Seth Sharp <58869086+SethSharp@users.noreply.github.com> Date: Fri, 8 May 2026 16:35:03 +0930 Subject: [PATCH 08/15] feedback --- src/Concerns/UsesIam.php | 21 +++++-- ...ventBridgeIvsRecordingRolePoliciesStep.php | 22 ++++--- ...RealtimeRecordingEventBridgeTargetStep.php | 8 ++- .../SyncIvsRecordingConfigurationStep.php | 13 +++- .../SyncIvsStorageConfigurationStep.php | 9 ++- tests/Unit/ManifestTest.php | 62 +++++++++++++++++++ 6 files changed, 115 insertions(+), 20 deletions(-) diff --git a/src/Concerns/UsesIam.php b/src/Concerns/UsesIam.php index f93714b..b6e30b6 100644 --- a/src/Concerns/UsesIam.php +++ b/src/Concerns/UsesIam.php @@ -178,11 +178,19 @@ public static function eventBridgeIvsRecordingRole(): array $name = Helpers::keyedResourceName(Iam::EVENT_BRIDGE_IVS_RECORDING_ROLE); $roles = Aws::iam()->listRoles(); - foreach ($roles['Roles'] as $role) { - if ($role['RoleName'] === $name) { - return $role; + do { + foreach ($roles['Roles'] as $role) { + if ($role['RoleName'] === $name) { + return $role; + } } - } + + if (! $roles['IsTruncated']) { + break; + } + + $roles = Aws::iam()->listRoles(['Marker' => $roles['Marker']]); + } while (true); throw new ResourceDoesNotExistException("Could not find IAM role with name $name"); } @@ -198,6 +206,11 @@ public static function eventBridgeIvsRecordingPolicyDocument(): array 'Service' => 'events.amazonaws.com', ], 'Action' => 'sts:AssumeRole', + 'Condition' => [ + 'StringEquals' => [ + 'aws:SourceAccount' => Aws::accountId(), + ], + ], ], ], ]; diff --git a/src/Steps/Iam/AttachEventBridgeIvsRecordingRolePoliciesStep.php b/src/Steps/Iam/AttachEventBridgeIvsRecordingRolePoliciesStep.php index 9eebde2..4845181 100644 --- a/src/Steps/Iam/AttachEventBridgeIvsRecordingRolePoliciesStep.php +++ b/src/Steps/Iam/AttachEventBridgeIvsRecordingRolePoliciesStep.php @@ -11,10 +11,6 @@ class AttachEventBridgeIvsRecordingRolePoliciesStep implements Step { - protected array $managedPolicies = [ - 'arn:aws:iam::aws:policy/AmazonEventBridgeFullAccess', - ]; - public function __invoke(array $options): StepResult { if (! Manifest::ivsRecordingEnabled()) { @@ -24,12 +20,18 @@ public function __invoke(array $options): StepResult if (! Arr::get($options, 'dry-run')) { $role = AwsResources::eventBridgeIvsRecordingRole(); - foreach ($this->managedPolicies as $policyArn) { - Aws::iam()->attachRolePolicy([ - 'RoleName' => $role['RoleName'], - 'PolicyArn' => $policyArn, - ]); - } + Aws::iam()->putRolePolicy([ + 'RoleName' => $role['RoleName'], + 'PolicyName' => 'InvokeApiDestination', + 'PolicyDocument' => json_encode([ + 'Version' => '2012-10-17', + 'Statement' => [[ + 'Effect' => 'Allow', + 'Action' => ['events:InvokeApiDestination'], + 'Resource' => '*', + ]], + ]), + ]); return StepResult::SYNCED; } diff --git a/src/Steps/Recording/SyncIvsRealtimeRecordingEventBridgeTargetStep.php b/src/Steps/Recording/SyncIvsRealtimeRecordingEventBridgeTargetStep.php index ba7e85b..85e1bf2 100644 --- a/src/Steps/Recording/SyncIvsRealtimeRecordingEventBridgeTargetStep.php +++ b/src/Steps/Recording/SyncIvsRealtimeRecordingEventBridgeTargetStep.php @@ -30,8 +30,12 @@ public function __invoke(array $options): StepResult try { $destination = Aws::eventBridge()->describeApiDestination(['Name' => $destinationName]); $destinationArn = $destination['ApiDestinationArn']; - } catch (EventBridgeException) { - return StepResult::WOULD_CREATE; + } catch (EventBridgeException $e) { + if (Arr::get($options, 'dry-run')) { + return StepResult::WOULD_CREATE; + } + + throw $e; } $existingTarget = null; diff --git a/src/Steps/Recording/SyncIvsRecordingConfigurationStep.php b/src/Steps/Recording/SyncIvsRecordingConfigurationStep.php index f30cc81..d08667a 100644 --- a/src/Steps/Recording/SyncIvsRecordingConfigurationStep.php +++ b/src/Steps/Recording/SyncIvsRecordingConfigurationStep.php @@ -22,8 +22,13 @@ public function __invoke(array $options): StepResult $bucket = SyncIvsRecordingBucketStep::bucketName(); $name = Helpers::keyedResourceName('ivs-recording'); - $existing = collect(Aws::ivs()->listRecordingConfigurations()['recordingConfigurations']) - ->first(fn ($config) => Arr::get($config, 'destinationConfiguration.s3.bucketName') === $bucket); + $response = Aws::ivs()->listRecordingConfigurations(); + $all = $response['recordingConfigurations']; + while ($nextToken = $response['nextToken'] ?? null) { + $response = Aws::ivs()->listRecordingConfigurations(['nextToken' => $nextToken]); + $all = array_merge($all, $response['recordingConfigurations']); + } + $existing = collect($all)->first(fn ($config) => Arr::get($config, 'destinationConfiguration.s3.bucketName') === $bucket); if ($existing) { note(sprintf('IVS RecordingConfiguration ARN: %s', $existing['arn'])); @@ -66,6 +71,10 @@ public function __invoke(array $options): StepResult } } + if ($state !== 'ACTIVE') { + note(sprintf('Warning: IVS RecordingConfiguration did not reach ACTIVE state (current: %s) — verify in AWS Console before setting the ARN', $state ?? 'unknown')); + } + note(sprintf('IVS RecordingConfiguration ARN: %s', $arn)); note(sprintf('Set AWS_IVS_RECORDING_CONFIGURATION_ARN=%s', $arn)); diff --git a/src/Steps/Recording/SyncIvsStorageConfigurationStep.php b/src/Steps/Recording/SyncIvsStorageConfigurationStep.php index 91b8878..2f278e5 100644 --- a/src/Steps/Recording/SyncIvsStorageConfigurationStep.php +++ b/src/Steps/Recording/SyncIvsStorageConfigurationStep.php @@ -22,8 +22,13 @@ public function __invoke(array $options): StepResult $bucket = SyncIvsRealtimeRecordingBucketStep::bucketName(); $name = Helpers::keyedResourceName('ivs-storage'); - $existing = collect(Aws::ivsRealTime()->listStorageConfigurations()['storageConfigurations']) - ->first(fn ($config) => Arr::get($config, 's3.bucketName') === $bucket); + $response = Aws::ivsRealTime()->listStorageConfigurations(); + $all = $response['storageConfigurations']; + while ($nextToken = $response['nextToken'] ?? null) { + $response = Aws::ivsRealTime()->listStorageConfigurations(['nextToken' => $nextToken]); + $all = array_merge($all, $response['storageConfigurations']); + } + $existing = collect($all)->first(fn ($config) => Arr::get($config, 's3.bucketName') === $bucket); if ($existing) { note(sprintf('IVS StorageConfiguration ARN: %s', $existing['arn'])); diff --git a/tests/Unit/ManifestTest.php b/tests/Unit/ManifestTest.php index c77a325..2f42a4f 100644 --- a/tests/Unit/ManifestTest.php +++ b/tests/Unit/ManifestTest.php @@ -133,6 +133,68 @@ }); }); +describe('ivsRecordingEnabled', function () { + it('is false when aws.ivs.recording is absent', function () { + writeManifest([]); + + expect(Manifest::ivsRecordingEnabled())->toBeFalse(); + }); + + it('is true for the boolean shorthand', function () { + writeManifest(['aws' => ['ivs' => ['recording' => true]]]); + + expect(Manifest::ivsRecordingEnabled())->toBeTrue(); + }); + + it('is false when aws.ivs.recording is explicitly false', function () { + writeManifest(['aws' => ['ivs' => ['recording' => false]]]); + + expect(Manifest::ivsRecordingEnabled())->toBeFalse(); + }); + + it('is true for the expanded form with a webhook_url', function () { + writeManifest(['aws' => ['ivs' => ['recording' => ['webhook_url' => 'https://example.com/webhook']]]]); + + expect(Manifest::ivsRecordingEnabled())->toBeTrue(); + }); + + it('is false when aws.ivs.recording is null', function () { + writeManifest(['aws' => ['ivs' => ['recording' => null]]]); + + expect(Manifest::ivsRecordingEnabled())->toBeFalse(); + }); +}); + +describe('ivsWebhookSecret', function () { + beforeEach(function () { + if (file_exists(BASE_PATH . '/.env.testing')) { + unlink(BASE_PATH . '/.env.testing'); + } + }); + + afterEach(function () { + if (file_exists(BASE_PATH . '/.env.testing')) { + unlink(BASE_PATH . '/.env.testing'); + } + }); + + it('returns null when the env file does not exist', function () { + expect(Manifest::ivsWebhookSecret())->toBeNull(); + }); + + it('returns null when the env file exists but the key is absent', function () { + file_put_contents(BASE_PATH . '/.env.testing', "OTHER_KEY=somevalue\n"); + + expect(Manifest::ivsWebhookSecret())->toBeNull(); + }); + + it('returns the secret when the env file exists with the key set', function () { + file_put_contents(BASE_PATH . '/.env.testing', "IVS_WEBHOOK_SECRET=abc123secret\n"); + + expect(Manifest::ivsWebhookSecret())->toBe('abc123secret'); + }); +}); + describe('apex', function () { it('returns the apex domain', function () { writeManifest(['domain' => 'example.com']); From 4b2f9fdaadf9e03bbc575ccd93590ace5b856533 Mon Sep 17 00:00:00 2001 From: Seth Sharp <58869086+SethSharp@users.noreply.github.com> Date: Wed, 13 May 2026 08:51:11 +0930 Subject: [PATCH 09/15] lambda/ffmpeg setup --- resources/lambda/ivs_realtime_remux.py | 71 +++++++ src/Aws.php | 6 + src/Commands/SyncIamCommand.php | 2 + src/Commands/SyncRecordingCommand.php | 2 + src/Concerns/RegistersAws.php | 2 + src/Concerns/UsesIam.php | 36 ++++ src/Enums/Iam.php | 1 + src/Manifest.php | 21 ++ .../AttachLambdaIvsRemuxRolePoliciesStep.php | 72 +++++++ src/Steps/Iam/SyncLambdaIvsRemuxRoleStep.php | 71 +++++++ .../SyncIvsRealtimeRecordingBucketStep.php | 51 +++++ ...vsRealtimeRecordingEventBridgeRuleStep.php | 6 +- ...RealtimeRecordingEventBridgeTargetStep.php | 59 +++--- .../Recording/SyncIvsRemuxFfmpegLayerStep.php | 116 +++++++++++ .../Recording/SyncIvsRemuxLambdaStep.php | 195 ++++++++++++++++++ 15 files changed, 681 insertions(+), 30 deletions(-) create mode 100644 resources/lambda/ivs_realtime_remux.py create mode 100644 src/Steps/Iam/AttachLambdaIvsRemuxRolePoliciesStep.php create mode 100644 src/Steps/Iam/SyncLambdaIvsRemuxRoleStep.php create mode 100644 src/Steps/Recording/SyncIvsRemuxFfmpegLayerStep.php create mode 100644 src/Steps/Recording/SyncIvsRemuxLambdaStep.php diff --git a/resources/lambda/ivs_realtime_remux.py b/resources/lambda/ivs_realtime_remux.py new file mode 100644 index 0000000..b427ba3 --- /dev/null +++ b/resources/lambda/ivs_realtime_remux.py @@ -0,0 +1,71 @@ +import os +import re +import json +import boto3 +import subprocess +import urllib.request +from pathlib import Path + + +def handler(event, context): + detail = event.get('detail', {}) + + if detail.get('event_name') != 'Recording End': + return + + stage_arn = (event.get('resources') or [None])[0] + src_bucket = detail.get('recording_s3_bucket_name') + prefix = detail.get('recording_s3_key_prefix') + + if not stage_arn or not src_bucket or not prefix: + return + + ivs = boto3.client('ivs-realtime', region_name=os.environ['IVS_REGION']) + stage_name = ivs.get_stage(arn=stage_arn)['stage']['name'] + m = re.match(r'^(.+)-live-event-(\d+)$', stage_name) + if not m: + return + + tenant_id, live_event_id = m.group(1), int(m.group(2)) + + s3 = boto3.client('s3') + hls_prefix = f'{prefix}/media/hls/' + local_hls = f'/tmp/{live_event_id}/media/hls' + Path(local_hls).mkdir(parents=True, exist_ok=True) + + paginator = s3.get_paginator('list_objects_v2') + for page in paginator.paginate(Bucket=src_bucket, Prefix=hls_prefix): + for obj in page.get('Contents', []): + key = obj['Key'] + relative = key[len(hls_prefix):] + local_path = Path(local_hls) / relative + local_path.parent.mkdir(parents=True, exist_ok=True) + s3.download_file(src_bucket, key, str(local_path)) + + output_path = f'/tmp/recording_{live_event_id}.mp4' + subprocess.run( + ['/opt/bin/ffmpeg', '-i', f'{local_hls}/multivariant.m3u8', '-c:v', 'copy', '-c:a', 'aac', '-movflags', '+faststart', output_path], + check=True, + capture_output=True, + ) + + dest_bucket = os.environ['MAIN_S3_BUCKET'] + dest_key = f'tmp/realtime-mp4/{live_event_id}/recording.mp4' + s3.upload_file(output_path, dest_bucket, dest_key) + + payload = json.dumps({ + 'tenant_id': tenant_id, + 'live_event_id': live_event_id, + 'mp4_s3_url': f's3://{dest_bucket}/{dest_key}', + }).encode() + + req = urllib.request.Request( + os.environ['WEBHOOK_URL'], + data=payload, + headers={ + 'Content-Type': 'application/json', + 'X-Webhook-Secret': os.environ['WEBHOOK_SECRET'], + }, + method='POST', + ) + urllib.request.urlopen(req) diff --git a/src/Aws.php b/src/Aws.php index be271c3..bca3f7b 100644 --- a/src/Aws.php +++ b/src/Aws.php @@ -12,6 +12,7 @@ use Aws\Sqs\SqsClient; use Aws\Ssm\SsmClient; use Aws\Sts\StsClient; +use Aws\Lambda\LambdaClient; use Aws\Route53\Route53Client; use Aws\CloudWatch\CloudWatchClient; use Aws\CodeDeploy\CodeDeployClient; @@ -115,6 +116,11 @@ public static function iam(): IamClient return Helpers::app('iam'); } + public static function lambda(): LambdaClient + { + return Helpers::app('lambda'); + } + public static function ivs(): IVSClient { return Helpers::app('ivs'); diff --git a/src/Commands/SyncIamCommand.php b/src/Commands/SyncIamCommand.php index 4d133cd..53cb68c 100644 --- a/src/Commands/SyncIamCommand.php +++ b/src/Commands/SyncIamCommand.php @@ -17,6 +17,8 @@ class SyncIamCommand extends SteppedCommand Steps\Iam\AttachMediaConvertRolePoliciesStep::class, Steps\Iam\SyncEventBridgeIvsRecordingRoleStep::class, Steps\Iam\AttachEventBridgeIvsRecordingRolePoliciesStep::class, + Steps\Iam\SyncLambdaIvsRemuxRoleStep::class, + Steps\Iam\AttachLambdaIvsRemuxRolePoliciesStep::class, ]; protected function configure(): void diff --git a/src/Commands/SyncRecordingCommand.php b/src/Commands/SyncRecordingCommand.php index 94c792e..0a49ef0 100644 --- a/src/Commands/SyncRecordingCommand.php +++ b/src/Commands/SyncRecordingCommand.php @@ -15,6 +15,8 @@ class SyncRecordingCommand extends SteppedCommand Steps\Recording\SyncIvsStorageConfigurationStep::class, Steps\Recording\SyncIvsRecordingEventBridgeRuleStep::class, Steps\Recording\SyncIvsRecordingEventBridgeTargetStep::class, + Steps\Recording\SyncIvsRemuxFfmpegLayerStep::class, + Steps\Recording\SyncIvsRemuxLambdaStep::class, Steps\Recording\SyncIvsRealtimeRecordingEventBridgeRuleStep::class, Steps\Recording\SyncIvsRealtimeRecordingEventBridgeTargetStep::class, ]; diff --git a/src/Concerns/RegistersAws.php b/src/Concerns/RegistersAws.php index 2b3b7f5..3b178c4 100644 --- a/src/Concerns/RegistersAws.php +++ b/src/Concerns/RegistersAws.php @@ -14,6 +14,7 @@ use Aws\Sts\StsClient; use GuzzleHttp\Client; use Codinglabs\Yolo\Aws; +use Aws\Lambda\LambdaClient; use Codinglabs\Yolo\Helpers; use Codinglabs\Yolo\Manifest; use Aws\Route53\Route53Client; @@ -51,6 +52,7 @@ protected function registerAwsServices(): void Helpers::app()->singleton('elasticLoadBalancingV2', fn () => new ElasticLoadBalancingV2Client($arguments)); Helpers::app()->singleton('iam', fn () => new IamClient($arguments)); Helpers::app()->singleton('ivs', fn () => new IVSClient($arguments)); + Helpers::app()->singleton('lambda', fn () => new LambdaClient($arguments)); Helpers::app()->singleton('ivsRealTime', fn () => new IVSRealTimeClient($arguments)); Helpers::app()->singleton('rds', fn () => new RdsClient($arguments)); Helpers::app()->singleton('route53', fn () => new Route53Client($arguments)); diff --git a/src/Concerns/UsesIam.php b/src/Concerns/UsesIam.php index b6e30b6..da9d71e 100644 --- a/src/Concerns/UsesIam.php +++ b/src/Concerns/UsesIam.php @@ -195,6 +195,42 @@ public static function eventBridgeIvsRecordingRole(): array throw new ResourceDoesNotExistException("Could not find IAM role with name $name"); } + public static function lambdaIvsRemuxRole(): array + { + $name = Helpers::keyedResourceName(Iam::LAMBDA_IVS_REMUX_ROLE); + $roles = Aws::iam()->listRoles(); + + do { + foreach ($roles['Roles'] as $role) { + if ($role['RoleName'] === $name) { + return $role; + } + } + + if (! $roles['IsTruncated']) { + break; + } + + $roles = Aws::iam()->listRoles(['Marker' => $roles['Marker']]); + } while (true); + + throw new ResourceDoesNotExistException("Could not find IAM role with name $name"); + } + + public static function lambdaIvsRemuxPolicyDocument(): array + { + return [ + 'Version' => '2012-10-17', + 'Statement' => [ + [ + 'Effect' => 'Allow', + 'Principal' => ['Service' => 'lambda.amazonaws.com'], + 'Action' => 'sts:AssumeRole', + ], + ], + ]; + } + public static function eventBridgeIvsRecordingPolicyDocument(): array { return [ diff --git a/src/Enums/Iam.php b/src/Enums/Iam.php index 25e4d11..5fff705 100644 --- a/src/Enums/Iam.php +++ b/src/Enums/Iam.php @@ -7,4 +7,5 @@ enum Iam: string case INSTANCE_PROFILE = 'instance-profile'; case MEDIA_CONVERT_ROLE = 'mediaconvert-role'; case EVENT_BRIDGE_IVS_RECORDING_ROLE = 'eventbridge-ivs-recording-role'; + case LAMBDA_IVS_REMUX_ROLE = 'lambda-ivs-remux-role'; } diff --git a/src/Manifest.php b/src/Manifest.php index accf983..a1d671a 100644 --- a/src/Manifest.php +++ b/src/Manifest.php @@ -111,6 +111,27 @@ public static function ivsRecordingWebhookUrl(): ?string return static::get('aws.ivs.recording.webhook_url'); } + public static function ivsRealtimeRemuxWebhookUrl(): ?string + { + return static::get('aws.ivs.recording.realtime_webhook_url'); + } + + public static function ivsRemuxFfmpegLayerArn(): ?string + { + return static::get('aws.ivs.recording.ffmpeg_layer_arn'); + } + + public static function ivsRealtimeMainBucket(): ?string + { + $envFile = Paths::base('.env.' . Helpers::environment()); + + if (! file_exists($envFile)) { + return null; + } + + return Dotenv::parse(file_get_contents($envFile))['AWS_BUCKET'] ?? null; + } + public static function ivsWebhookSecret(): ?string { $envFile = Paths::base('.env.' . Helpers::environment()); diff --git a/src/Steps/Iam/AttachLambdaIvsRemuxRolePoliciesStep.php b/src/Steps/Iam/AttachLambdaIvsRemuxRolePoliciesStep.php new file mode 100644 index 0000000..5b7d313 --- /dev/null +++ b/src/Steps/Iam/AttachLambdaIvsRemuxRolePoliciesStep.php @@ -0,0 +1,72 @@ +putRolePolicy([ + 'RoleName' => $role['RoleName'], + 'PolicyName' => 'LambdaIvsRemuxPolicy', + 'PolicyDocument' => json_encode([ + 'Version' => '2012-10-17', + 'Statement' => [ + [ + 'Effect' => 'Allow', + 'Action' => ['logs:CreateLogGroup', 'logs:CreateLogStream', 'logs:PutLogEvents'], + 'Resource' => '*', + ], + [ + 'Effect' => 'Allow', + 'Action' => ['s3:GetObject', 's3:ListBucket'], + 'Resource' => [ + "arn:aws:s3:::{$realtimeBucket}", + "arn:aws:s3:::{$realtimeBucket}/*", + ], + ], + [ + 'Effect' => 'Allow', + 'Action' => ['s3:PutObject'], + 'Resource' => "arn:aws:s3:::{$mainBucket}/tmp/realtime-mp4/*", + ], + [ + 'Effect' => 'Allow', + 'Action' => ['ivs:GetStage'], + 'Resource' => '*', + ], + ], + ]), + ]); + + return StepResult::SYNCED; + } + + return StepResult::WOULD_SYNC; + } +} diff --git a/src/Steps/Iam/SyncLambdaIvsRemuxRoleStep.php b/src/Steps/Iam/SyncLambdaIvsRemuxRoleStep.php new file mode 100644 index 0000000..bd8aa01 --- /dev/null +++ b/src/Steps/Iam/SyncLambdaIvsRemuxRoleStep.php @@ -0,0 +1,71 @@ +updateRole([ + 'RoleName' => $name, + 'Description' => 'YOLO managed Lambda role for IVS Real-Time remux', + ]); + + Aws::iam()->updateAssumeRolePolicy([ + 'RoleName' => $name, + 'PolicyDocument' => json_encode(AwsResources::lambdaIvsRemuxPolicyDocument()), + ]); + + Aws::iam()->tagRole([ + 'RoleName' => $name, + ...Aws::tags(), + ]); + + return StepResult::SYNCED; + } + + return StepResult::WOULD_SYNC; + } catch (ResourceDoesNotExistException) { + if (! Arr::get($options, 'dry-run')) { + Aws::iam()->createRole([ + 'RoleName' => Helpers::keyedResourceName(Iam::LAMBDA_IVS_REMUX_ROLE), + 'Description' => 'YOLO managed Lambda role for IVS Real-Time remux', + 'AssumeRolePolicyDocument' => json_encode(AwsResources::lambdaIvsRemuxPolicyDocument()), + ...Aws::tags(), + ]); + + return StepResult::CREATED; + } + + return StepResult::WOULD_CREATE; + } + } +} diff --git a/src/Steps/Recording/SyncIvsRealtimeRecordingBucketStep.php b/src/Steps/Recording/SyncIvsRealtimeRecordingBucketStep.php index f31040e..b14795f 100644 --- a/src/Steps/Recording/SyncIvsRealtimeRecordingBucketStep.php +++ b/src/Steps/Recording/SyncIvsRealtimeRecordingBucketStep.php @@ -6,6 +6,7 @@ use Illuminate\Support\Arr; use Codinglabs\Yolo\Helpers; use Codinglabs\Yolo\Manifest; +use Codinglabs\Yolo\Enums\Iam; use Codinglabs\Yolo\AwsResources; use Codinglabs\Yolo\Contracts\Step; use Codinglabs\Yolo\Enums\StepResult; @@ -24,6 +25,10 @@ public function __invoke(array $options): StepResult try { AwsResources::bucket($bucket); + if (! Arr::get($options, 'dry-run')) { + $this->putBucketPolicy($bucket); + } + return StepResult::SYNCED; } catch (ResourceDoesNotExistException) { if (! Arr::get($options, 'dry-run')) { @@ -33,6 +38,7 @@ public function __invoke(array $options): StepResult 'Bucket' => $bucket, 'Tagging' => [...Aws::tags(['Name' => $bucket], wrap: 'TagSet')], ]); + $this->putBucketPolicy($bucket); return StepResult::CREATED; } @@ -41,6 +47,51 @@ public function __invoke(array $options): StepResult } } + protected function putBucketPolicy(string $bucket): void + { + $region = Manifest::get('aws.region'); + $accountId = Aws::accountId(); + $mediaConvertRoleName = Helpers::keyedResourceName(Iam::MEDIA_CONVERT_ROLE); + $mediaConvertRoleArn = "arn:aws:iam::{$accountId}:role/{$mediaConvertRoleName}"; + + // IVS Real-Time writes via ivs-composite.{region}.amazonaws.com, not ivs.amazonaws.com. + // It requires PutObjectAcl with bucket-owner-full-control so ownership transfers to the + // bucket owner, allowing same-account services (e.g. MediaConvert) to read the objects. + Aws::s3()->putBucketPolicy([ + 'Bucket' => $bucket, + 'Policy' => json_encode([ + 'Version' => '2012-10-17', + 'Statement' => [ + [ + 'Sid' => 'IVSRealtimeRecording', + 'Effect' => 'Allow', + 'Principal' => ['Service' => "ivs-composite.{$region}.amazonaws.com"], + 'Action' => ['s3:PutObject', 's3:PutObjectAcl'], + 'Resource' => "arn:aws:s3:::{$bucket}/*", + 'Condition' => [ + 'StringEquals' => ['s3:x-amz-acl' => 'bucket-owner-full-control'], + 'Bool' => ['aws:SecureTransport' => 'true'], + ], + ], + [ + 'Sid' => 'MediaConvertRead', + 'Effect' => 'Allow', + 'Principal' => ['AWS' => $mediaConvertRoleArn], + 'Action' => ['s3:GetObject', 's3:GetObjectVersion', 's3:ListBucket'], + 'Resource' => ["arn:aws:s3:::{$bucket}", "arn:aws:s3:::{$bucket}/*"], + ], + ], + ]), + ]); + + Aws::s3()->putBucketOwnershipControls([ + 'Bucket' => $bucket, + 'OwnershipControls' => [ + 'Rules' => [['ObjectOwnership' => 'BucketOwnerPreferred']], + ], + ]); + } + public static function bucketName(): string { return Helpers::keyedResourceName('ivs-realtime-recordings'); diff --git a/src/Steps/Recording/SyncIvsRealtimeRecordingEventBridgeRuleStep.php b/src/Steps/Recording/SyncIvsRealtimeRecordingEventBridgeRuleStep.php index bfd8fcd..a14997c 100644 --- a/src/Steps/Recording/SyncIvsRealtimeRecordingEventBridgeRuleStep.php +++ b/src/Steps/Recording/SyncIvsRealtimeRecordingEventBridgeRuleStep.php @@ -15,7 +15,7 @@ class SyncIvsRealtimeRecordingEventBridgeRuleStep implements Step { public function __invoke(array $options): StepResult { - if (! Manifest::ivsRecordingWebhookUrl()) { + if (! Manifest::ivsRealtimeRemuxWebhookUrl()) { return StepResult::SKIPPED; } @@ -23,6 +23,10 @@ public function __invoke(array $options): StepResult return StepResult::SKIPPED; } + if (! Manifest::ivsRealtimeMainBucket()) { + return StepResult::SKIPPED; + } + $name = self::ruleName(); try { diff --git a/src/Steps/Recording/SyncIvsRealtimeRecordingEventBridgeTargetStep.php b/src/Steps/Recording/SyncIvsRealtimeRecordingEventBridgeTargetStep.php index 85e1bf2..dc97352 100644 --- a/src/Steps/Recording/SyncIvsRealtimeRecordingEventBridgeTargetStep.php +++ b/src/Steps/Recording/SyncIvsRealtimeRecordingEventBridgeTargetStep.php @@ -4,19 +4,17 @@ use Codinglabs\Yolo\Aws; use Illuminate\Support\Arr; -use Codinglabs\Yolo\Helpers; use Codinglabs\Yolo\Manifest; use Codinglabs\Yolo\AwsResources; use Codinglabs\Yolo\Contracts\Step; use Codinglabs\Yolo\Enums\StepResult; -use Aws\EventBridge\Exception\EventBridgeException; use Codinglabs\Yolo\Exceptions\ResourceDoesNotExistException; class SyncIvsRealtimeRecordingEventBridgeTargetStep implements Step { public function __invoke(array $options): StepResult { - if (! Manifest::ivsRecordingWebhookUrl()) { + if (! Manifest::ivsRealtimeRemuxWebhookUrl()) { return StepResult::SKIPPED; } @@ -24,21 +22,20 @@ public function __invoke(array $options): StepResult return StepResult::SKIPPED; } - $ruleName = SyncIvsRealtimeRecordingEventBridgeRuleStep::ruleName(); - $destinationName = Helpers::keyedResourceName('ivs-recording-webhook-destination'); - - try { - $destination = Aws::eventBridge()->describeApiDestination(['Name' => $destinationName]); - $destinationArn = $destination['ApiDestinationArn']; - } catch (EventBridgeException $e) { - if (Arr::get($options, 'dry-run')) { - return StepResult::WOULD_CREATE; - } - - throw $e; + if (! Manifest::ivsRealtimeMainBucket()) { + return StepResult::SKIPPED; } + $ruleName = SyncIvsRealtimeRecordingEventBridgeRuleStep::ruleName(); + $region = Manifest::get('aws.region'); + $accountId = Aws::accountId(); + $functionName = SyncIvsRemuxLambdaStep::functionName(); + $lambdaArn = "arn:aws:lambda:{$region}:{$accountId}:function:{$functionName}"; + $logGroupName = SyncIvsRecordingCloudWatchLogGroupStep::logGroupName(); + $logGroupArn = "arn:aws:logs:{$region}:{$accountId}:log-group:{$logGroupName}"; + $existingTarget = null; + $hasOldWebhookTarget = false; try { AwsResources::eventBridgeRule($ruleName); @@ -47,10 +44,18 @@ public function __invoke(array $options): StepResult 'Rule' => $ruleName, ])['Targets']); - $existingTarget = $targets->first(fn ($t) => $t['Id'] === 'ivs-recording-webhook'); + $existingTarget = $targets->first(fn ($t) => $t['Id'] === 'ivs-realtime-remux'); $existingLogTarget = $targets->first(fn ($t) => $t['Id'] === 'ivs-recording-logs'); + $hasOldWebhookTarget = $targets->contains(fn ($t) => $t['Id'] === 'ivs-recording-webhook'); + + if ($existingTarget && $existingTarget['Arn'] === $lambdaArn && $existingLogTarget) { + if ($hasOldWebhookTarget && ! Arr::get($options, 'dry-run')) { + Aws::eventBridge()->removeTargets([ + 'Rule' => $ruleName, + 'Ids' => ['ivs-recording-webhook'], + ]); + } - if ($existingTarget && $existingTarget['Arn'] === $destinationArn && $existingLogTarget) { return StepResult::SYNCED; } } catch (ResourceDoesNotExistException) { @@ -58,23 +63,19 @@ public function __invoke(array $options): StepResult } if (! Arr::get($options, 'dry-run')) { - $roleArn = AwsResources::eventBridgeIvsRecordingRole()['Arn']; - $region = Manifest::get('aws.region'); - $accountId = Aws::accountId(); - $logGroupName = SyncIvsRecordingCloudWatchLogGroupStep::logGroupName(); - $logGroupArn = "arn:aws:logs:{$region}:{$accountId}:log-group:{$logGroupName}"; + if ($hasOldWebhookTarget) { + Aws::eventBridge()->removeTargets([ + 'Rule' => $ruleName, + 'Ids' => ['ivs-recording-webhook'], + ]); + } Aws::eventBridge()->putTargets([ 'Rule' => $ruleName, 'Targets' => [ [ - 'Id' => 'ivs-recording-webhook', - 'Arn' => $destinationArn, - 'RoleArn' => $roleArn, - 'HttpParameters' => [ - 'HeaderParameters' => [], - 'QueryStringParameters' => [], - ], + 'Id' => 'ivs-realtime-remux', + 'Arn' => $lambdaArn, ], [ 'Id' => 'ivs-recording-logs', diff --git a/src/Steps/Recording/SyncIvsRemuxFfmpegLayerStep.php b/src/Steps/Recording/SyncIvsRemuxFfmpegLayerStep.php new file mode 100644 index 0000000..7b4249f --- /dev/null +++ b/src/Steps/Recording/SyncIvsRemuxFfmpegLayerStep.php @@ -0,0 +1,116 @@ + 300]))->get(self::FFMPEG_URL, ['sink' => $tarPath]); + + @mkdir($extractDir, 0755, true); + exec('tar xf ' . escapeshellarg($tarPath) . ' -C ' . escapeshellarg($extractDir)); + + exec('find ' . escapeshellarg($extractDir) . ' -maxdepth 2 -name ffmpeg -type f', $found); + $ffmpegBin = $found[0] ?? null; + + if (! $ffmpegBin) { + throw new \RuntimeException('Could not find ffmpeg binary in extracted archive'); + } + + chmod($ffmpegBin, 0755); + + $zipContent = $this->buildLayerZip($ffmpegBin); + } finally { + @unlink($tarPath); + exec('rm -rf ' . escapeshellarg($extractDir)); + } + + // Lambda direct zip upload limit is 50 MB — route via S3 (the binary zip is ~70 MB) + $bucket = SyncIvsRealtimeRecordingBucketStep::bucketName(); + $s3Key = 'lambda-layers/ffmpeg.zip'; + + Aws::s3()->putObject(['Bucket' => $bucket, 'Key' => $s3Key, 'Body' => $zipContent]); + + try { + $layerName = Helpers::keyedResourceName('ffmpeg-layer'); + + $result = Aws::lambda()->publishLayerVersion([ + 'LayerName' => $layerName, + 'Description' => 'YOLO managed FFmpeg layer for IVS Real-Time remux', + 'Content' => ['S3Bucket' => $bucket, 'S3Key' => $s3Key], + 'CompatibleRuntimes' => ['python3.12'], + 'CompatibleArchitectures' => ['x86_64'], + ]); + } finally { + Aws::s3()->deleteObject(['Bucket' => $bucket, 'Key' => $s3Key]); + } + + $layerVersionArn = $result['LayerVersionArn']; + + Manifest::put('aws.ivs.recording.ffmpeg_layer_arn', $layerVersionArn); + + note(sprintf('FFmpeg layer ARN saved to yolo.yml: %s', $layerVersionArn)); + + return StepResult::CREATED; + } + + private function buildLayerZip(string $ffmpegBin): string + { + $zipPath = tempnam(sys_get_temp_dir(), 'yolo-ffmpeg-layer') . '.zip'; + $zip = new ZipArchive(); + $zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE); + $zip->addFile($ffmpegBin, 'bin/ffmpeg'); + $zip->setCompressionName('bin/ffmpeg', ZipArchive::CM_STORE); + $zip->close(); + + $content = file_get_contents($zipPath); + unlink($zipPath); + + return $content; + } +} diff --git a/src/Steps/Recording/SyncIvsRemuxLambdaStep.php b/src/Steps/Recording/SyncIvsRemuxLambdaStep.php new file mode 100644 index 0000000..a85263d --- /dev/null +++ b/src/Steps/Recording/SyncIvsRemuxLambdaStep.php @@ -0,0 +1,195 @@ +getFunction(['FunctionName' => $functionName]); + + if (! Arr::get($options, 'dry-run')) { + Aws::lambda()->updateFunctionCode([ + 'FunctionName' => $functionName, + 'ZipFile' => $this->buildZip(), + ]); + + $this->waitForUpdate($functionName); + + Aws::lambda()->updateFunctionConfiguration([ + 'FunctionName' => $functionName, + 'Runtime' => 'python3.12', + 'Handler' => 'lambda_function.handler', + 'Timeout' => 900, + 'MemorySize' => 1024, + 'EphemeralStorage' => ['Size' => 10240], + 'Environment' => ['Variables' => $this->envVars()], + 'Layers' => $this->layers(), + ]); + + $this->syncEventBridgePermission($functionName); + + return StepResult::SYNCED; + } + + return StepResult::WOULD_SYNC; + } catch (LambdaException $e) { + if ($e->getAwsErrorCode() !== 'ResourceNotFoundException') { + throw $e; + } + } + + if (! Arr::get($options, 'dry-run')) { + $role = AwsResources::lambdaIvsRemuxRole(); + + Aws::lambda()->createFunction([ + 'FunctionName' => $functionName, + 'Runtime' => 'python3.12', + 'Handler' => 'lambda_function.handler', + 'Role' => $role['Arn'], + 'Code' => ['ZipFile' => $this->buildZip()], + 'Timeout' => 900, + 'MemorySize' => 1024, + 'EphemeralStorage' => ['Size' => 10240], + 'Environment' => ['Variables' => $this->envVars()], + 'Layers' => $this->layers(), + ...Aws::tags(['Name' => $functionName], associative: true), + ]); + + $this->waitForActive($functionName); + $this->syncEventBridgePermission($functionName); + + return StepResult::CREATED; + } + + return StepResult::WOULD_CREATE; + } + + private function envVars(): array + { + return [ + 'MAIN_S3_BUCKET' => Manifest::ivsRealtimeMainBucket(), + 'WEBHOOK_URL' => Manifest::ivsRealtimeRemuxWebhookUrl(), + 'WEBHOOK_SECRET' => Manifest::ivsWebhookSecret(), + 'IVS_REGION' => Manifest::get('aws.region'), + ]; + } + + private function layers(): array + { + $layerArn = Manifest::ivsRemuxFfmpegLayerArn(); + + return $layerArn ? [$layerArn] : []; + } + + private function buildZip(): string + { + $zipPath = tempnam(sys_get_temp_dir(), 'yolo-lambda') . '.zip'; + $zip = new ZipArchive(); + $zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE); + $zip->addFile(dirname(__DIR__, 3) . '/resources/lambda/ivs_realtime_remux.py', 'lambda_function.py'); + $zip->close(); + + $content = file_get_contents($zipPath); + unlink($zipPath); + + return $content; + } + + private function syncEventBridgePermission(string $functionName): void + { + $region = Manifest::get('aws.region'); + $accountId = Aws::accountId(); + $ruleName = SyncIvsRealtimeRecordingEventBridgeRuleStep::ruleName(); + $ruleArn = "arn:aws:events:{$region}:{$accountId}:rule/{$ruleName}"; + + try { + Aws::lambda()->removePermission([ + 'FunctionName' => $functionName, + 'StatementId' => 'AllowEventBridgeInvoke', + ]); + } catch (\Exception) { + // Permission may not exist yet — that's fine + } + + Aws::lambda()->addPermission([ + 'FunctionName' => $functionName, + 'StatementId' => 'AllowEventBridgeInvoke', + 'Action' => 'lambda:InvokeFunction', + 'Principal' => 'events.amazonaws.com', + 'SourceArn' => $ruleArn, + ]); + } + + private function waitForActive(string $functionName): void + { + $attempts = 0; + + while ($attempts < 30) { + $fn = Aws::lambda()->getFunctionConfiguration(['FunctionName' => $functionName]); + + if ($fn['State'] === 'Active') { + return; + } + + sleep(2); + $attempts++; + } + } + + private function waitForUpdate(string $functionName): void + { + $attempts = 0; + + while ($attempts < 30) { + $fn = Aws::lambda()->getFunctionConfiguration(['FunctionName' => $functionName]); + + if (($fn['LastUpdateStatus'] ?? 'Successful') === 'Successful') { + return; + } + + sleep(2); + $attempts++; + } + } + + public static function functionName(): string + { + return Helpers::keyedResourceName('ivs-realtime-remux'); + } +} From 9f08dd91517dbd72fbaa5c4631432e7c52b76d15 Mon Sep 17 00:00:00 2001 From: Seth Sharp <58869086+SethSharp@users.noreply.github.com> Date: Wed, 13 May 2026 10:34:47 +0930 Subject: [PATCH 10/15] change structure of yolo yaml --- src/Manifest.php | 8 ++++---- .../Iam/AttachLambdaIvsRemuxRolePoliciesStep.php | 10 +--------- src/Steps/Iam/SyncLambdaIvsRemuxRoleStep.php | 10 +--------- .../SyncIvsRealtimeRecordingBucketStep.php | 2 +- ...ncIvsRealtimeRecordingEventBridgeRuleStep.php | 10 +--------- ...IvsRealtimeRecordingEventBridgeTargetStep.php | 10 +--------- .../Recording/SyncIvsRecordingBucketStep.php | 2 +- .../SyncIvsRecordingCloudWatchLogGroupStep.php | 2 +- .../SyncIvsRecordingConfigurationStep.php | 2 +- .../Recording/SyncIvsRemuxFfmpegLayerStep.php | 16 ++-------------- src/Steps/Recording/SyncIvsRemuxLambdaStep.php | 6 +++--- .../SyncIvsStorageConfigurationStep.php | 2 +- 12 files changed, 18 insertions(+), 62 deletions(-) diff --git a/src/Manifest.php b/src/Manifest.php index a1d671a..7285c3a 100644 --- a/src/Manifest.php +++ b/src/Manifest.php @@ -108,17 +108,17 @@ public static function ivsRecordingEnabled(): bool public static function ivsRecordingWebhookUrl(): ?string { - return static::get('aws.ivs.recording.webhook_url'); + return static::get('aws.ivs.recording.low_latency.webhook_url'); } - public static function ivsRealtimeRemuxWebhookUrl(): ?string + public static function ivsRealtimeWebhookUrl(): ?string { - return static::get('aws.ivs.recording.realtime_webhook_url'); + return static::get('aws.ivs.recording.real_time.webhook_url'); } public static function ivsRemuxFfmpegLayerArn(): ?string { - return static::get('aws.ivs.recording.ffmpeg_layer_arn'); + return static::get('aws.ivs.recording.real_time.ffmpeg_layer_arn'); } public static function ivsRealtimeMainBucket(): ?string diff --git a/src/Steps/Iam/AttachLambdaIvsRemuxRolePoliciesStep.php b/src/Steps/Iam/AttachLambdaIvsRemuxRolePoliciesStep.php index 5b7d313..2964000 100644 --- a/src/Steps/Iam/AttachLambdaIvsRemuxRolePoliciesStep.php +++ b/src/Steps/Iam/AttachLambdaIvsRemuxRolePoliciesStep.php @@ -14,15 +14,7 @@ class AttachLambdaIvsRemuxRolePoliciesStep implements Step { public function __invoke(array $options): StepResult { - if (! Manifest::ivsRecordingEnabled()) { - return StepResult::SKIPPED; - } - - if (! Manifest::ivsRealtimeRemuxWebhookUrl()) { - return StepResult::SKIPPED; - } - - if (! Manifest::ivsRealtimeMainBucket()) { + if (! Manifest::ivsRealtimeWebhookUrl()) { return StepResult::SKIPPED; } diff --git a/src/Steps/Iam/SyncLambdaIvsRemuxRoleStep.php b/src/Steps/Iam/SyncLambdaIvsRemuxRoleStep.php index bd8aa01..1dbc83d 100644 --- a/src/Steps/Iam/SyncLambdaIvsRemuxRoleStep.php +++ b/src/Steps/Iam/SyncLambdaIvsRemuxRoleStep.php @@ -16,15 +16,7 @@ class SyncLambdaIvsRemuxRoleStep implements Step { public function __invoke(array $options): StepResult { - if (! Manifest::ivsRecordingEnabled()) { - return StepResult::SKIPPED; - } - - if (! Manifest::ivsRealtimeRemuxWebhookUrl()) { - return StepResult::SKIPPED; - } - - if (! Manifest::ivsRealtimeMainBucket()) { + if (! Manifest::ivsRealtimeWebhookUrl()) { return StepResult::SKIPPED; } diff --git a/src/Steps/Recording/SyncIvsRealtimeRecordingBucketStep.php b/src/Steps/Recording/SyncIvsRealtimeRecordingBucketStep.php index b14795f..11a45d6 100644 --- a/src/Steps/Recording/SyncIvsRealtimeRecordingBucketStep.php +++ b/src/Steps/Recording/SyncIvsRealtimeRecordingBucketStep.php @@ -16,7 +16,7 @@ class SyncIvsRealtimeRecordingBucketStep implements Step { public function __invoke(array $options): StepResult { - if (! Manifest::ivsRecordingEnabled()) { + if (! Manifest::ivsRealtimeWebhookUrl()) { return StepResult::SKIPPED; } diff --git a/src/Steps/Recording/SyncIvsRealtimeRecordingEventBridgeRuleStep.php b/src/Steps/Recording/SyncIvsRealtimeRecordingEventBridgeRuleStep.php index a14997c..915aeca 100644 --- a/src/Steps/Recording/SyncIvsRealtimeRecordingEventBridgeRuleStep.php +++ b/src/Steps/Recording/SyncIvsRealtimeRecordingEventBridgeRuleStep.php @@ -15,15 +15,7 @@ class SyncIvsRealtimeRecordingEventBridgeRuleStep implements Step { public function __invoke(array $options): StepResult { - if (! Manifest::ivsRealtimeRemuxWebhookUrl()) { - return StepResult::SKIPPED; - } - - if (! Manifest::ivsWebhookSecret()) { - return StepResult::SKIPPED; - } - - if (! Manifest::ivsRealtimeMainBucket()) { + if (! Manifest::ivsRealtimeWebhookUrl()) { return StepResult::SKIPPED; } diff --git a/src/Steps/Recording/SyncIvsRealtimeRecordingEventBridgeTargetStep.php b/src/Steps/Recording/SyncIvsRealtimeRecordingEventBridgeTargetStep.php index dc97352..c74ebaa 100644 --- a/src/Steps/Recording/SyncIvsRealtimeRecordingEventBridgeTargetStep.php +++ b/src/Steps/Recording/SyncIvsRealtimeRecordingEventBridgeTargetStep.php @@ -14,15 +14,7 @@ class SyncIvsRealtimeRecordingEventBridgeTargetStep implements Step { public function __invoke(array $options): StepResult { - if (! Manifest::ivsRealtimeRemuxWebhookUrl()) { - return StepResult::SKIPPED; - } - - if (! Manifest::ivsWebhookSecret()) { - return StepResult::SKIPPED; - } - - if (! Manifest::ivsRealtimeMainBucket()) { + if (! Manifest::ivsRealtimeWebhookUrl()) { return StepResult::SKIPPED; } diff --git a/src/Steps/Recording/SyncIvsRecordingBucketStep.php b/src/Steps/Recording/SyncIvsRecordingBucketStep.php index 6753546..6dc6ed9 100644 --- a/src/Steps/Recording/SyncIvsRecordingBucketStep.php +++ b/src/Steps/Recording/SyncIvsRecordingBucketStep.php @@ -15,7 +15,7 @@ class SyncIvsRecordingBucketStep implements Step { public function __invoke(array $options): StepResult { - if (! Manifest::ivsRecordingEnabled()) { + if (! Manifest::ivsRecordingWebhookUrl()) { return StepResult::SKIPPED; } diff --git a/src/Steps/Recording/SyncIvsRecordingCloudWatchLogGroupStep.php b/src/Steps/Recording/SyncIvsRecordingCloudWatchLogGroupStep.php index 6802e19..d178c47 100644 --- a/src/Steps/Recording/SyncIvsRecordingCloudWatchLogGroupStep.php +++ b/src/Steps/Recording/SyncIvsRecordingCloudWatchLogGroupStep.php @@ -15,7 +15,7 @@ class SyncIvsRecordingCloudWatchLogGroupStep implements Step { public function __invoke(array $options): StepResult { - if (! Manifest::ivsRecordingEnabled()) { + if (! Manifest::ivsRecordingWebhookUrl()) { return StepResult::SKIPPED; } diff --git a/src/Steps/Recording/SyncIvsRecordingConfigurationStep.php b/src/Steps/Recording/SyncIvsRecordingConfigurationStep.php index d08667a..3e5acf5 100644 --- a/src/Steps/Recording/SyncIvsRecordingConfigurationStep.php +++ b/src/Steps/Recording/SyncIvsRecordingConfigurationStep.php @@ -15,7 +15,7 @@ class SyncIvsRecordingConfigurationStep implements Step { public function __invoke(array $options): StepResult { - if (! Manifest::ivsRecordingEnabled()) { + if (! Manifest::ivsRecordingWebhookUrl()) { return StepResult::SKIPPED; } diff --git a/src/Steps/Recording/SyncIvsRemuxFfmpegLayerStep.php b/src/Steps/Recording/SyncIvsRemuxFfmpegLayerStep.php index 7b4249f..8a2ae6d 100644 --- a/src/Steps/Recording/SyncIvsRemuxFfmpegLayerStep.php +++ b/src/Steps/Recording/SyncIvsRemuxFfmpegLayerStep.php @@ -20,19 +20,7 @@ class SyncIvsRemuxFfmpegLayerStep implements Step public function __invoke(array $options): StepResult { - if (! Manifest::ivsRecordingEnabled()) { - return StepResult::SKIPPED; - } - - if (! Manifest::ivsRealtimeRemuxWebhookUrl()) { - return StepResult::SKIPPED; - } - - if (! Manifest::ivsWebhookSecret()) { - return StepResult::SKIPPED; - } - - if (! Manifest::ivsRealtimeMainBucket()) { + if (! Manifest::ivsRealtimeWebhookUrl()) { return StepResult::SKIPPED; } @@ -92,7 +80,7 @@ public function __invoke(array $options): StepResult $layerVersionArn = $result['LayerVersionArn']; - Manifest::put('aws.ivs.recording.ffmpeg_layer_arn', $layerVersionArn); + Manifest::put('aws.ivs.recording.real_time.ffmpeg_layer_arn', $layerVersionArn); note(sprintf('FFmpeg layer ARN saved to yolo.yml: %s', $layerVersionArn)); diff --git a/src/Steps/Recording/SyncIvsRemuxLambdaStep.php b/src/Steps/Recording/SyncIvsRemuxLambdaStep.php index a85263d..17ea083 100644 --- a/src/Steps/Recording/SyncIvsRemuxLambdaStep.php +++ b/src/Steps/Recording/SyncIvsRemuxLambdaStep.php @@ -18,11 +18,11 @@ class SyncIvsRemuxLambdaStep implements Step { public function __invoke(array $options): StepResult { - if (! Manifest::ivsRecordingEnabled()) { + if (! Manifest::ivsRealtimeWebhookUrl()) { return StepResult::SKIPPED; } - if (! Manifest::ivsRealtimeRemuxWebhookUrl()) { + if (! Manifest::ivsRealtimeWebhookUrl()) { return StepResult::SKIPPED; } @@ -104,7 +104,7 @@ private function envVars(): array { return [ 'MAIN_S3_BUCKET' => Manifest::ivsRealtimeMainBucket(), - 'WEBHOOK_URL' => Manifest::ivsRealtimeRemuxWebhookUrl(), + 'WEBHOOK_URL' => Manifest::ivsRealtimeWebhookUrl(), 'WEBHOOK_SECRET' => Manifest::ivsWebhookSecret(), 'IVS_REGION' => Manifest::get('aws.region'), ]; diff --git a/src/Steps/Recording/SyncIvsStorageConfigurationStep.php b/src/Steps/Recording/SyncIvsStorageConfigurationStep.php index 2f278e5..e503e07 100644 --- a/src/Steps/Recording/SyncIvsStorageConfigurationStep.php +++ b/src/Steps/Recording/SyncIvsStorageConfigurationStep.php @@ -15,7 +15,7 @@ class SyncIvsStorageConfigurationStep implements Step { public function __invoke(array $options): StepResult { - if (! Manifest::ivsRecordingEnabled()) { + if (! Manifest::ivsRealtimeWebhookUrl()) { return StepResult::SKIPPED; } From c1cef180f6b46be2dc957d5645378cae26eea7f3 Mon Sep 17 00:00:00 2001 From: Seth Sharp <58869086+SethSharp@users.noreply.github.com> Date: Wed, 13 May 2026 10:43:45 +0930 Subject: [PATCH 11/15] dynamically fetch ffmpeg arn --- src/Manifest.php | 5 ---- .../Recording/SyncIvsRemuxFfmpegLayerStep.php | 24 +++++++++++++++---- .../Recording/SyncIvsRemuxLambdaStep.php | 10 +++----- 3 files changed, 22 insertions(+), 17 deletions(-) diff --git a/src/Manifest.php b/src/Manifest.php index 7285c3a..5018819 100644 --- a/src/Manifest.php +++ b/src/Manifest.php @@ -116,11 +116,6 @@ public static function ivsRealtimeWebhookUrl(): ?string return static::get('aws.ivs.recording.real_time.webhook_url'); } - public static function ivsRemuxFfmpegLayerArn(): ?string - { - return static::get('aws.ivs.recording.real_time.ffmpeg_layer_arn'); - } - public static function ivsRealtimeMainBucket(): ?string { $envFile = Paths::base('.env.' . Helpers::environment()); diff --git a/src/Steps/Recording/SyncIvsRemuxFfmpegLayerStep.php b/src/Steps/Recording/SyncIvsRemuxFfmpegLayerStep.php index 8a2ae6d..e3239ec 100644 --- a/src/Steps/Recording/SyncIvsRemuxFfmpegLayerStep.php +++ b/src/Steps/Recording/SyncIvsRemuxFfmpegLayerStep.php @@ -10,6 +10,7 @@ use Codinglabs\Yolo\Manifest; use Codinglabs\Yolo\Contracts\Step; use Codinglabs\Yolo\Enums\StepResult; +use Aws\Lambda\Exception\LambdaException; use function Laravel\Prompts\note; @@ -24,7 +25,7 @@ public function __invoke(array $options): StepResult return StepResult::SKIPPED; } - if (Manifest::ivsRemuxFfmpegLayerArn()) { + if (self::latestLayerArn()) { return StepResult::SYNCED; } @@ -78,13 +79,26 @@ public function __invoke(array $options): StepResult Aws::s3()->deleteObject(['Bucket' => $bucket, 'Key' => $s3Key]); } - $layerVersionArn = $result['LayerVersionArn']; + note(sprintf('FFmpeg layer published: %s', $result['LayerVersionArn'])); - Manifest::put('aws.ivs.recording.real_time.ffmpeg_layer_arn', $layerVersionArn); + return StepResult::CREATED; + } - note(sprintf('FFmpeg layer ARN saved to yolo.yml: %s', $layerVersionArn)); + public static function latestLayerArn(): ?string + { + $layerName = Helpers::keyedResourceName('ffmpeg-layer'); - return StepResult::CREATED; + try { + $versions = Aws::lambda()->listLayerVersions(['LayerName' => $layerName])['LayerVersions']; + } catch (LambdaException $e) { + if ($e->getAwsErrorCode() === 'ResourceNotFoundException') { + return null; + } + + throw $e; + } + + return $versions[0]['LayerVersionArn'] ?? null; } private function buildLayerZip(string $ffmpegBin): string diff --git a/src/Steps/Recording/SyncIvsRemuxLambdaStep.php b/src/Steps/Recording/SyncIvsRemuxLambdaStep.php index 17ea083..138f085 100644 --- a/src/Steps/Recording/SyncIvsRemuxLambdaStep.php +++ b/src/Steps/Recording/SyncIvsRemuxLambdaStep.php @@ -22,10 +22,6 @@ public function __invoke(array $options): StepResult return StepResult::SKIPPED; } - if (! Manifest::ivsRealtimeWebhookUrl()) { - return StepResult::SKIPPED; - } - if (! Manifest::ivsWebhookSecret()) { return StepResult::SKIPPED; } @@ -34,8 +30,8 @@ public function __invoke(array $options): StepResult return StepResult::SKIPPED; } - if (! Manifest::ivsRemuxFfmpegLayerArn()) { - note('Warning: aws.ivs.recording.ffmpeg_layer_arn is not set — Lambda will be deployed without the FFmpeg layer and will fail at runtime.'); + if (! SyncIvsRemuxFfmpegLayerStep::latestLayerArn()) { + note('Warning: FFmpeg layer not found — deploy sync:recording first. Lambda will be deployed without the FFmpeg layer and will fail at runtime.'); } $functionName = self::functionName(); @@ -112,7 +108,7 @@ private function envVars(): array private function layers(): array { - $layerArn = Manifest::ivsRemuxFfmpegLayerArn(); + $layerArn = SyncIvsRemuxFfmpegLayerStep::latestLayerArn(); return $layerArn ? [$layerArn] : []; } From 6de3de406522f21990ef691339f93ecf0e050d59 Mon Sep 17 00:00:00 2001 From: Seth Sharp <58869086+SethSharp@users.noreply.github.com> Date: Wed, 13 May 2026 14:27:39 +0930 Subject: [PATCH 12/15] drop ivs recording --- src/Commands/SyncIamCommand.php | 2 - src/Commands/SyncRecordingCommand.php | 5 - src/Concerns/UsesIam.php | 43 ----- src/Enums/Iam.php | 1 - src/Manifest.php | 10 - ...ventBridgeIvsRecordingRolePoliciesStep.php | 41 ---- .../SyncEventBridgeIvsRecordingRoleStep.php | 63 ------- ...RealtimeRecordingEventBridgeTargetStep.php | 17 -- .../Recording/SyncIvsRecordingBucketStep.php | 48 ----- ...SyncIvsRecordingCloudWatchLogGroupStep.php | 96 ---------- .../SyncIvsRecordingConfigurationStep.php | 86 --------- .../SyncIvsRecordingEventBridgeRuleStep.php | 77 -------- .../SyncIvsRecordingEventBridgeTargetStep.php | 175 ------------------ tests/Unit/ManifestTest.php | 32 ---- 14 files changed, 696 deletions(-) delete mode 100644 src/Steps/Iam/AttachEventBridgeIvsRecordingRolePoliciesStep.php delete mode 100644 src/Steps/Iam/SyncEventBridgeIvsRecordingRoleStep.php delete mode 100644 src/Steps/Recording/SyncIvsRecordingBucketStep.php delete mode 100644 src/Steps/Recording/SyncIvsRecordingCloudWatchLogGroupStep.php delete mode 100644 src/Steps/Recording/SyncIvsRecordingConfigurationStep.php delete mode 100644 src/Steps/Recording/SyncIvsRecordingEventBridgeRuleStep.php delete mode 100644 src/Steps/Recording/SyncIvsRecordingEventBridgeTargetStep.php diff --git a/src/Commands/SyncIamCommand.php b/src/Commands/SyncIamCommand.php index 53cb68c..a3e848c 100644 --- a/src/Commands/SyncIamCommand.php +++ b/src/Commands/SyncIamCommand.php @@ -15,8 +15,6 @@ class SyncIamCommand extends SteppedCommand Steps\Iam\AttachEc2RoleToInstanceProfileStep::class, Steps\Iam\SyncMediaConvertRoleStep::class, Steps\Iam\AttachMediaConvertRolePoliciesStep::class, - Steps\Iam\SyncEventBridgeIvsRecordingRoleStep::class, - Steps\Iam\AttachEventBridgeIvsRecordingRolePoliciesStep::class, Steps\Iam\SyncLambdaIvsRemuxRoleStep::class, Steps\Iam\AttachLambdaIvsRemuxRolePoliciesStep::class, ]; diff --git a/src/Commands/SyncRecordingCommand.php b/src/Commands/SyncRecordingCommand.php index 0a49ef0..4a5af27 100644 --- a/src/Commands/SyncRecordingCommand.php +++ b/src/Commands/SyncRecordingCommand.php @@ -8,13 +8,8 @@ class SyncRecordingCommand extends SteppedCommand { protected array $steps = [ - Steps\Recording\SyncIvsRecordingCloudWatchLogGroupStep::class, - Steps\Recording\SyncIvsRecordingBucketStep::class, Steps\Recording\SyncIvsRealtimeRecordingBucketStep::class, - Steps\Recording\SyncIvsRecordingConfigurationStep::class, Steps\Recording\SyncIvsStorageConfigurationStep::class, - Steps\Recording\SyncIvsRecordingEventBridgeRuleStep::class, - Steps\Recording\SyncIvsRecordingEventBridgeTargetStep::class, Steps\Recording\SyncIvsRemuxFfmpegLayerStep::class, Steps\Recording\SyncIvsRemuxLambdaStep::class, Steps\Recording\SyncIvsRealtimeRecordingEventBridgeRuleStep::class, diff --git a/src/Concerns/UsesIam.php b/src/Concerns/UsesIam.php index da9d71e..302800e 100644 --- a/src/Concerns/UsesIam.php +++ b/src/Concerns/UsesIam.php @@ -173,28 +173,6 @@ public static function mediaConvertPolicyDocument(): array ]; } - public static function eventBridgeIvsRecordingRole(): array - { - $name = Helpers::keyedResourceName(Iam::EVENT_BRIDGE_IVS_RECORDING_ROLE); - $roles = Aws::iam()->listRoles(); - - do { - foreach ($roles['Roles'] as $role) { - if ($role['RoleName'] === $name) { - return $role; - } - } - - if (! $roles['IsTruncated']) { - break; - } - - $roles = Aws::iam()->listRoles(['Marker' => $roles['Marker']]); - } while (true); - - throw new ResourceDoesNotExistException("Could not find IAM role with name $name"); - } - public static function lambdaIvsRemuxRole(): array { $name = Helpers::keyedResourceName(Iam::LAMBDA_IVS_REMUX_ROLE); @@ -230,25 +208,4 @@ public static function lambdaIvsRemuxPolicyDocument(): array ], ]; } - - public static function eventBridgeIvsRecordingPolicyDocument(): array - { - return [ - 'Version' => '2012-10-17', - 'Statement' => [ - [ - 'Effect' => 'Allow', - 'Principal' => [ - 'Service' => 'events.amazonaws.com', - ], - 'Action' => 'sts:AssumeRole', - 'Condition' => [ - 'StringEquals' => [ - 'aws:SourceAccount' => Aws::accountId(), - ], - ], - ], - ], - ]; - } } diff --git a/src/Enums/Iam.php b/src/Enums/Iam.php index 5fff705..69315c5 100644 --- a/src/Enums/Iam.php +++ b/src/Enums/Iam.php @@ -6,6 +6,5 @@ enum Iam: string { case INSTANCE_PROFILE = 'instance-profile'; case MEDIA_CONVERT_ROLE = 'mediaconvert-role'; - case EVENT_BRIDGE_IVS_RECORDING_ROLE = 'eventbridge-ivs-recording-role'; case LAMBDA_IVS_REMUX_ROLE = 'lambda-ivs-remux-role'; } diff --git a/src/Manifest.php b/src/Manifest.php index 5018819..c84a4df 100644 --- a/src/Manifest.php +++ b/src/Manifest.php @@ -101,16 +101,6 @@ public static function ivsEnabled(): bool || static::get('aws.ivs.logging') === true; } - public static function ivsRecordingEnabled(): bool - { - return ! empty(static::get('aws.ivs.recording')); - } - - public static function ivsRecordingWebhookUrl(): ?string - { - return static::get('aws.ivs.recording.low_latency.webhook_url'); - } - public static function ivsRealtimeWebhookUrl(): ?string { return static::get('aws.ivs.recording.real_time.webhook_url'); diff --git a/src/Steps/Iam/AttachEventBridgeIvsRecordingRolePoliciesStep.php b/src/Steps/Iam/AttachEventBridgeIvsRecordingRolePoliciesStep.php deleted file mode 100644 index 4845181..0000000 --- a/src/Steps/Iam/AttachEventBridgeIvsRecordingRolePoliciesStep.php +++ /dev/null @@ -1,41 +0,0 @@ -putRolePolicy([ - 'RoleName' => $role['RoleName'], - 'PolicyName' => 'InvokeApiDestination', - 'PolicyDocument' => json_encode([ - 'Version' => '2012-10-17', - 'Statement' => [[ - 'Effect' => 'Allow', - 'Action' => ['events:InvokeApiDestination'], - 'Resource' => '*', - ]], - ]), - ]); - - return StepResult::SYNCED; - } - - return StepResult::WOULD_SYNC; - } -} diff --git a/src/Steps/Iam/SyncEventBridgeIvsRecordingRoleStep.php b/src/Steps/Iam/SyncEventBridgeIvsRecordingRoleStep.php deleted file mode 100644 index c5d0a22..0000000 --- a/src/Steps/Iam/SyncEventBridgeIvsRecordingRoleStep.php +++ /dev/null @@ -1,63 +0,0 @@ -updateRole([ - 'RoleName' => $name, - 'Description' => 'YOLO managed EventBridge role for IVS recording webhook delivery', - ]); - - Aws::iam()->updateAssumeRolePolicy([ - 'RoleName' => $name, - 'PolicyDocument' => json_encode(AwsResources::eventBridgeIvsRecordingPolicyDocument()), - ]); - - Aws::iam()->tagRole([ - 'RoleName' => $name, - ...Aws::tags(), - ]); - - return StepResult::SYNCED; - } - - return StepResult::WOULD_SYNC; - } catch (ResourceDoesNotExistException) { - if (! Arr::get($options, 'dry-run')) { - Aws::iam()->createRole([ - 'RoleName' => Helpers::keyedResourceName(Iam::EVENT_BRIDGE_IVS_RECORDING_ROLE), - 'Description' => 'YOLO managed EventBridge role for IVS recording webhook delivery', - 'AssumeRolePolicyDocument' => json_encode(AwsResources::eventBridgeIvsRecordingPolicyDocument()), - ...Aws::tags(), - ]); - - return StepResult::CREATED; - } - - return StepResult::WOULD_CREATE; - } - } -} diff --git a/src/Steps/Recording/SyncIvsRealtimeRecordingEventBridgeTargetStep.php b/src/Steps/Recording/SyncIvsRealtimeRecordingEventBridgeTargetStep.php index c74ebaa..e175e7b 100644 --- a/src/Steps/Recording/SyncIvsRealtimeRecordingEventBridgeTargetStep.php +++ b/src/Steps/Recording/SyncIvsRealtimeRecordingEventBridgeTargetStep.php @@ -3,7 +3,6 @@ namespace Codinglabs\Yolo\Steps\Recording; use Codinglabs\Yolo\Aws; -use Illuminate\Support\Arr; use Codinglabs\Yolo\Manifest; use Codinglabs\Yolo\AwsResources; use Codinglabs\Yolo\Contracts\Step; @@ -27,7 +26,6 @@ public function __invoke(array $options): StepResult $logGroupArn = "arn:aws:logs:{$region}:{$accountId}:log-group:{$logGroupName}"; $existingTarget = null; - $hasOldWebhookTarget = false; try { AwsResources::eventBridgeRule($ruleName); @@ -38,16 +36,8 @@ public function __invoke(array $options): StepResult $existingTarget = $targets->first(fn ($t) => $t['Id'] === 'ivs-realtime-remux'); $existingLogTarget = $targets->first(fn ($t) => $t['Id'] === 'ivs-recording-logs'); - $hasOldWebhookTarget = $targets->contains(fn ($t) => $t['Id'] === 'ivs-recording-webhook'); if ($existingTarget && $existingTarget['Arn'] === $lambdaArn && $existingLogTarget) { - if ($hasOldWebhookTarget && ! Arr::get($options, 'dry-run')) { - Aws::eventBridge()->removeTargets([ - 'Rule' => $ruleName, - 'Ids' => ['ivs-recording-webhook'], - ]); - } - return StepResult::SYNCED; } } catch (ResourceDoesNotExistException) { @@ -55,13 +45,6 @@ public function __invoke(array $options): StepResult } if (! Arr::get($options, 'dry-run')) { - if ($hasOldWebhookTarget) { - Aws::eventBridge()->removeTargets([ - 'Rule' => $ruleName, - 'Ids' => ['ivs-recording-webhook'], - ]); - } - Aws::eventBridge()->putTargets([ 'Rule' => $ruleName, 'Targets' => [ diff --git a/src/Steps/Recording/SyncIvsRecordingBucketStep.php b/src/Steps/Recording/SyncIvsRecordingBucketStep.php deleted file mode 100644 index 6dc6ed9..0000000 --- a/src/Steps/Recording/SyncIvsRecordingBucketStep.php +++ /dev/null @@ -1,48 +0,0 @@ -createBucket(['Bucket' => $bucket]); - Aws::s3()->waitUntil('BucketExists', ['Bucket' => $bucket]); - Aws::s3()->putBucketTagging([ - 'Bucket' => $bucket, - 'Tagging' => [...Aws::tags(['Name' => $bucket], wrap: 'TagSet')], - ]); - - return StepResult::CREATED; - } - - return StepResult::WOULD_CREATE; - } - } - - public static function bucketName(): string - { - return Helpers::keyedResourceName('ivs-recordings'); - } -} diff --git a/src/Steps/Recording/SyncIvsRecordingCloudWatchLogGroupStep.php b/src/Steps/Recording/SyncIvsRecordingCloudWatchLogGroupStep.php deleted file mode 100644 index d178c47..0000000 --- a/src/Steps/Recording/SyncIvsRecordingCloudWatchLogGroupStep.php +++ /dev/null @@ -1,96 +0,0 @@ -putRetentionPolicy([ - 'logGroupName' => $name, - 'retentionInDays' => $retentionDays, - ]); - - self::putResourcePolicy($name, $logGroupArn); - - return StepResult::SYNCED; - } - - return StepResult::WOULD_SYNC; - } - - if (! Arr::get($options, 'dry-run')) { - self::putResourcePolicy($name, $logGroupArn); - } - - return StepResult::SYNCED; - } catch (ResourceDoesNotExistException) { - if (! Arr::get($options, 'dry-run')) { - Aws::cloudWatchLogs()->createLogGroup([ - 'logGroupName' => $name, - ...Aws::tags(['Name' => $name], wrap: 'tags', associative: true), - ]); - - Aws::cloudWatchLogs()->putRetentionPolicy([ - 'logGroupName' => $name, - 'retentionInDays' => $retentionDays, - ]); - - self::putResourcePolicy($name, $logGroupArn); - - return StepResult::CREATED; - } - - return StepResult::WOULD_CREATE; - } - } - - public static function logGroupName(): string - { - return '/aws/ivs/' . Helpers::keyedResourceName('recording'); - } - - private static function putResourcePolicy(string $logGroupName, string $logGroupArn): void - { - $region = Manifest::get('aws.region'); - $accountId = Aws::accountId(); - - Aws::cloudWatchLogs()->putResourcePolicy([ - 'policyName' => Helpers::keyedResourceName('ivs-recording-eventbridge-policy', exclusive: false), - 'policyDocument' => json_encode([ - 'Version' => '2012-10-17', - 'Statement' => [[ - 'Sid' => 'EventBridgeToCloudWatchLogs', - 'Effect' => 'Allow', - 'Principal' => ['Service' => 'events.amazonaws.com'], - 'Action' => ['logs:CreateLogStream', 'logs:PutLogEvents'], - 'Resource' => "arn:aws:logs:{$region}:{$accountId}:log-group:/aws/ivs/*", - ]], - ]), - ]); - } -} diff --git a/src/Steps/Recording/SyncIvsRecordingConfigurationStep.php b/src/Steps/Recording/SyncIvsRecordingConfigurationStep.php deleted file mode 100644 index 3e5acf5..0000000 --- a/src/Steps/Recording/SyncIvsRecordingConfigurationStep.php +++ /dev/null @@ -1,86 +0,0 @@ -listRecordingConfigurations(); - $all = $response['recordingConfigurations']; - while ($nextToken = $response['nextToken'] ?? null) { - $response = Aws::ivs()->listRecordingConfigurations(['nextToken' => $nextToken]); - $all = array_merge($all, $response['recordingConfigurations']); - } - $existing = collect($all)->first(fn ($config) => Arr::get($config, 'destinationConfiguration.s3.bucketName') === $bucket); - - if ($existing) { - note(sprintf('IVS RecordingConfiguration ARN: %s', $existing['arn'])); - note(sprintf('Set AWS_IVS_RECORDING_CONFIGURATION_ARN=%s', $existing['arn'])); - - return StepResult::SYNCED; - } - - if (! Arr::get($options, 'dry-run')) { - $result = Aws::ivs()->createRecordingConfiguration([ - 'name' => $name, - 'destinationConfiguration' => [ - 's3' => [ - 'bucketName' => $bucket, - ], - ], - 'tags' => [ - 'yolo:environment' => Helpers::app('environment'), - 'Name' => $name, - ], - ]); - - $arn = $result['recordingConfiguration']['arn']; - $state = $result['recordingConfiguration']['state'] ?? null; - - // Poll until ACTIVE — creation can take a few seconds - if ($state !== 'ACTIVE') { - $attempts = 0; - - while ($attempts < 30) { - sleep(2); - $polled = Aws::ivs()->getRecordingConfiguration(['arn' => $arn]); - $state = $polled['recordingConfiguration']['state'] ?? null; - - if ($state === 'ACTIVE') { - break; - } - - $attempts++; - } - } - - if ($state !== 'ACTIVE') { - note(sprintf('Warning: IVS RecordingConfiguration did not reach ACTIVE state (current: %s) — verify in AWS Console before setting the ARN', $state ?? 'unknown')); - } - - note(sprintf('IVS RecordingConfiguration ARN: %s', $arn)); - note(sprintf('Set AWS_IVS_RECORDING_CONFIGURATION_ARN=%s', $arn)); - - return StepResult::CREATED; - } - - return StepResult::WOULD_CREATE; - } -} diff --git a/src/Steps/Recording/SyncIvsRecordingEventBridgeRuleStep.php b/src/Steps/Recording/SyncIvsRecordingEventBridgeRuleStep.php deleted file mode 100644 index 37d2ac5..0000000 --- a/src/Steps/Recording/SyncIvsRecordingEventBridgeRuleStep.php +++ /dev/null @@ -1,77 +0,0 @@ -putRule([ - 'Name' => $name, - 'Description' => 'YOLO managed IVS recording state change events', - 'EventPattern' => json_encode(self::eventPattern()), - 'State' => 'ENABLED', - ...Aws::tags([ - 'Name' => $name, - ]), - ]); - - return StepResult::SYNCED; - } - - return StepResult::WOULD_SYNC; - } catch (ResourceDoesNotExistException $e) { - if (! Arr::get($options, 'dry-run')) { - Aws::eventBridge()->putRule([ - 'Name' => $name, - 'Description' => 'YOLO managed IVS recording state change events', - 'EventPattern' => json_encode(self::eventPattern()), - 'State' => 'ENABLED', - ...Aws::tags([ - 'Name' => $name, - ]), - ]); - - return StepResult::CREATED; - } - - return StepResult::WOULD_CREATE; - } - } - - public static function ruleName(): string - { - return Helpers::keyedResourceName('ivs-recording-state-change'); - } - - public static function eventPattern(): array - { - return [ - 'source' => ['aws.ivs'], - 'detail-type' => ['IVS Recording State Change'], - ]; - } -} diff --git a/src/Steps/Recording/SyncIvsRecordingEventBridgeTargetStep.php b/src/Steps/Recording/SyncIvsRecordingEventBridgeTargetStep.php deleted file mode 100644 index cd50c16..0000000 --- a/src/Steps/Recording/SyncIvsRecordingEventBridgeTargetStep.php +++ /dev/null @@ -1,175 +0,0 @@ -syncConnection($connectionName, $webhookSecret, $options); - - if (! $connectionArn) { - return StepResult::WOULD_CREATE; - } - - $destinationArn = $this->syncApiDestination($destinationName, $connectionArn, $webhookUrl, $options); - - if (! $destinationArn) { - return StepResult::WOULD_CREATE; - } - - $existingTarget = null; - - try { - AwsResources::eventBridgeRule($ruleName); - - $targets = collect(Aws::eventBridge()->listTargetsByRule([ - 'Rule' => $ruleName, - ])['Targets']); - - $existingTarget = $targets->first(fn ($t) => $t['Id'] === 'ivs-recording-webhook'); - $existingLogTarget = $targets->first(fn ($t) => $t['Id'] === 'ivs-recording-logs'); - - if ($existingTarget && $existingTarget['Arn'] === $destinationArn && $existingLogTarget) { - return StepResult::SYNCED; - } - } catch (ResourceDoesNotExistException) { - // Rule doesn't exist yet — target needs to be created - } - - if (! Arr::get($options, 'dry-run')) { - $roleArn = AwsResources::eventBridgeIvsRecordingRole()['Arn']; - $region = Manifest::get('aws.region'); - $accountId = Aws::accountId(); - $logGroupName = SyncIvsRecordingCloudWatchLogGroupStep::logGroupName(); - $logGroupArn = "arn:aws:logs:{$region}:{$accountId}:log-group:{$logGroupName}"; - - Aws::eventBridge()->putTargets([ - 'Rule' => $ruleName, - 'Targets' => [ - [ - 'Id' => 'ivs-recording-webhook', - 'Arn' => $destinationArn, - 'RoleArn' => $roleArn, - 'HttpParameters' => [ - 'HeaderParameters' => [], - 'QueryStringParameters' => [], - ], - ], - [ - 'Id' => 'ivs-recording-logs', - 'Arn' => $logGroupArn, - ], - ], - ]); - - return $existingTarget - ? StepResult::SYNCED - : StepResult::CREATED; - } - - return $existingTarget - ? StepResult::WOULD_SYNC - : StepResult::WOULD_CREATE; - } - - private function syncConnection(string $name, string $secret, array $options): ?string - { - $authParameters = [ - 'ApiKeyAuthParameters' => [ - 'ApiKeyName' => 'X-Webhook-Secret', - 'ApiKeyValue' => $secret, - ], - ]; - - try { - $connection = Aws::eventBridge()->describeConnection(['Name' => $name]); - - if (! Arr::get($options, 'dry-run')) { - Aws::eventBridge()->updateConnection([ - 'Name' => $name, - 'AuthorizationType' => 'API_KEY', - 'AuthParameters' => $authParameters, - ]); - } - - return $connection['ConnectionArn']; - } catch (EventBridgeException) { - // Does not exist — fall through to create - } - - if (Arr::get($options, 'dry-run')) { - return null; - } - - $result = Aws::eventBridge()->createConnection([ - 'Name' => $name, - 'Description' => 'YOLO managed connection for IVS recording webhook', - 'AuthorizationType' => 'API_KEY', - 'AuthParameters' => $authParameters, - ]); - - return $result['ConnectionArn']; - } - - private function syncApiDestination(string $name, string $connectionArn, string $webhookUrl, array $options): ?string - { - try { - $destination = Aws::eventBridge()->describeApiDestination(['Name' => $name]); - - // Sync the URL in case it has changed - if ($destination['InvocationEndpoint'] !== $webhookUrl && ! Arr::get($options, 'dry-run')) { - Aws::eventBridge()->updateApiDestination([ - 'Name' => $name, - 'ConnectionArn' => $connectionArn, - 'InvocationEndpoint' => $webhookUrl, - 'HttpMethod' => 'POST', - ]); - } - - return $destination['ApiDestinationArn']; - } catch (EventBridgeException) { - // Does not exist — fall through to create - } - - if (Arr::get($options, 'dry-run')) { - return null; - } - - $result = Aws::eventBridge()->createApiDestination([ - 'Name' => $name, - 'Description' => 'YOLO managed API destination for IVS recording webhook', - 'ConnectionArn' => $connectionArn, - 'InvocationEndpoint' => $webhookUrl, - 'HttpMethod' => 'POST', - ]); - - return $result['ApiDestinationArn']; - } -} diff --git a/tests/Unit/ManifestTest.php b/tests/Unit/ManifestTest.php index 2f42a4f..9d564d7 100644 --- a/tests/Unit/ManifestTest.php +++ b/tests/Unit/ManifestTest.php @@ -133,38 +133,6 @@ }); }); -describe('ivsRecordingEnabled', function () { - it('is false when aws.ivs.recording is absent', function () { - writeManifest([]); - - expect(Manifest::ivsRecordingEnabled())->toBeFalse(); - }); - - it('is true for the boolean shorthand', function () { - writeManifest(['aws' => ['ivs' => ['recording' => true]]]); - - expect(Manifest::ivsRecordingEnabled())->toBeTrue(); - }); - - it('is false when aws.ivs.recording is explicitly false', function () { - writeManifest(['aws' => ['ivs' => ['recording' => false]]]); - - expect(Manifest::ivsRecordingEnabled())->toBeFalse(); - }); - - it('is true for the expanded form with a webhook_url', function () { - writeManifest(['aws' => ['ivs' => ['recording' => ['webhook_url' => 'https://example.com/webhook']]]]); - - expect(Manifest::ivsRecordingEnabled())->toBeTrue(); - }); - - it('is false when aws.ivs.recording is null', function () { - writeManifest(['aws' => ['ivs' => ['recording' => null]]]); - - expect(Manifest::ivsRecordingEnabled())->toBeFalse(); - }); -}); - describe('ivsWebhookSecret', function () { beforeEach(function () { if (file_exists(BASE_PATH . '/.env.testing')) { From cc0b7eae2408ed5d8ae1c1465e2ecb35411eab42 Mon Sep 17 00:00:00 2001 From: Seth Sharp <58869086+SethSharp@users.noreply.github.com> Date: Wed, 13 May 2026 15:02:25 +0930 Subject: [PATCH 13/15] drop a bunch of old services we dont need --- resources/lambda/ivs_realtime_remux.py | 71 ------- src/Aws.php | 6 - src/Commands/SyncIamCommand.php | 2 - src/Commands/SyncRecordingCommand.php | 5 +- src/Concerns/RegistersAws.php | 2 - src/Concerns/UsesIam.php | 36 ---- src/Enums/Iam.php | 2 +- src/Manifest.php | 23 --- .../AttachLambdaIvsRemuxRolePoliciesStep.php | 64 ------ src/Steps/Iam/SyncLambdaIvsRemuxRoleStep.php | 63 ------ .../SyncIvsEncoderConfigurationStep.php | 65 ++++++ ...vsRealtimeRecordingEventBridgeRuleStep.php | 73 ------- ...RealtimeRecordingEventBridgeTargetStep.php | 71 ------- .../Recording/SyncIvsRemuxFfmpegLayerStep.php | 118 ----------- .../Recording/SyncIvsRemuxLambdaStep.php | 191 ------------------ tests/Unit/ManifestTest.php | 30 --- 16 files changed, 67 insertions(+), 755 deletions(-) delete mode 100644 resources/lambda/ivs_realtime_remux.py delete mode 100644 src/Steps/Iam/AttachLambdaIvsRemuxRolePoliciesStep.php delete mode 100644 src/Steps/Iam/SyncLambdaIvsRemuxRoleStep.php create mode 100644 src/Steps/Recording/SyncIvsEncoderConfigurationStep.php delete mode 100644 src/Steps/Recording/SyncIvsRealtimeRecordingEventBridgeRuleStep.php delete mode 100644 src/Steps/Recording/SyncIvsRealtimeRecordingEventBridgeTargetStep.php delete mode 100644 src/Steps/Recording/SyncIvsRemuxFfmpegLayerStep.php delete mode 100644 src/Steps/Recording/SyncIvsRemuxLambdaStep.php diff --git a/resources/lambda/ivs_realtime_remux.py b/resources/lambda/ivs_realtime_remux.py deleted file mode 100644 index b427ba3..0000000 --- a/resources/lambda/ivs_realtime_remux.py +++ /dev/null @@ -1,71 +0,0 @@ -import os -import re -import json -import boto3 -import subprocess -import urllib.request -from pathlib import Path - - -def handler(event, context): - detail = event.get('detail', {}) - - if detail.get('event_name') != 'Recording End': - return - - stage_arn = (event.get('resources') or [None])[0] - src_bucket = detail.get('recording_s3_bucket_name') - prefix = detail.get('recording_s3_key_prefix') - - if not stage_arn or not src_bucket or not prefix: - return - - ivs = boto3.client('ivs-realtime', region_name=os.environ['IVS_REGION']) - stage_name = ivs.get_stage(arn=stage_arn)['stage']['name'] - m = re.match(r'^(.+)-live-event-(\d+)$', stage_name) - if not m: - return - - tenant_id, live_event_id = m.group(1), int(m.group(2)) - - s3 = boto3.client('s3') - hls_prefix = f'{prefix}/media/hls/' - local_hls = f'/tmp/{live_event_id}/media/hls' - Path(local_hls).mkdir(parents=True, exist_ok=True) - - paginator = s3.get_paginator('list_objects_v2') - for page in paginator.paginate(Bucket=src_bucket, Prefix=hls_prefix): - for obj in page.get('Contents', []): - key = obj['Key'] - relative = key[len(hls_prefix):] - local_path = Path(local_hls) / relative - local_path.parent.mkdir(parents=True, exist_ok=True) - s3.download_file(src_bucket, key, str(local_path)) - - output_path = f'/tmp/recording_{live_event_id}.mp4' - subprocess.run( - ['/opt/bin/ffmpeg', '-i', f'{local_hls}/multivariant.m3u8', '-c:v', 'copy', '-c:a', 'aac', '-movflags', '+faststart', output_path], - check=True, - capture_output=True, - ) - - dest_bucket = os.environ['MAIN_S3_BUCKET'] - dest_key = f'tmp/realtime-mp4/{live_event_id}/recording.mp4' - s3.upload_file(output_path, dest_bucket, dest_key) - - payload = json.dumps({ - 'tenant_id': tenant_id, - 'live_event_id': live_event_id, - 'mp4_s3_url': f's3://{dest_bucket}/{dest_key}', - }).encode() - - req = urllib.request.Request( - os.environ['WEBHOOK_URL'], - data=payload, - headers={ - 'Content-Type': 'application/json', - 'X-Webhook-Secret': os.environ['WEBHOOK_SECRET'], - }, - method='POST', - ) - urllib.request.urlopen(req) diff --git a/src/Aws.php b/src/Aws.php index bca3f7b..be271c3 100644 --- a/src/Aws.php +++ b/src/Aws.php @@ -12,7 +12,6 @@ use Aws\Sqs\SqsClient; use Aws\Ssm\SsmClient; use Aws\Sts\StsClient; -use Aws\Lambda\LambdaClient; use Aws\Route53\Route53Client; use Aws\CloudWatch\CloudWatchClient; use Aws\CodeDeploy\CodeDeployClient; @@ -116,11 +115,6 @@ public static function iam(): IamClient return Helpers::app('iam'); } - public static function lambda(): LambdaClient - { - return Helpers::app('lambda'); - } - public static function ivs(): IVSClient { return Helpers::app('ivs'); diff --git a/src/Commands/SyncIamCommand.php b/src/Commands/SyncIamCommand.php index a3e848c..fed2674 100644 --- a/src/Commands/SyncIamCommand.php +++ b/src/Commands/SyncIamCommand.php @@ -15,8 +15,6 @@ class SyncIamCommand extends SteppedCommand Steps\Iam\AttachEc2RoleToInstanceProfileStep::class, Steps\Iam\SyncMediaConvertRoleStep::class, Steps\Iam\AttachMediaConvertRolePoliciesStep::class, - Steps\Iam\SyncLambdaIvsRemuxRoleStep::class, - Steps\Iam\AttachLambdaIvsRemuxRolePoliciesStep::class, ]; protected function configure(): void diff --git a/src/Commands/SyncRecordingCommand.php b/src/Commands/SyncRecordingCommand.php index 4a5af27..352298f 100644 --- a/src/Commands/SyncRecordingCommand.php +++ b/src/Commands/SyncRecordingCommand.php @@ -10,10 +10,7 @@ class SyncRecordingCommand extends SteppedCommand protected array $steps = [ Steps\Recording\SyncIvsRealtimeRecordingBucketStep::class, Steps\Recording\SyncIvsStorageConfigurationStep::class, - Steps\Recording\SyncIvsRemuxFfmpegLayerStep::class, - Steps\Recording\SyncIvsRemuxLambdaStep::class, - Steps\Recording\SyncIvsRealtimeRecordingEventBridgeRuleStep::class, - Steps\Recording\SyncIvsRealtimeRecordingEventBridgeTargetStep::class, + Steps\Recording\SyncIvsEncoderConfigurationStep::class, ]; protected function configure(): void diff --git a/src/Concerns/RegistersAws.php b/src/Concerns/RegistersAws.php index 3b178c4..2b3b7f5 100644 --- a/src/Concerns/RegistersAws.php +++ b/src/Concerns/RegistersAws.php @@ -14,7 +14,6 @@ use Aws\Sts\StsClient; use GuzzleHttp\Client; use Codinglabs\Yolo\Aws; -use Aws\Lambda\LambdaClient; use Codinglabs\Yolo\Helpers; use Codinglabs\Yolo\Manifest; use Aws\Route53\Route53Client; @@ -52,7 +51,6 @@ protected function registerAwsServices(): void Helpers::app()->singleton('elasticLoadBalancingV2', fn () => new ElasticLoadBalancingV2Client($arguments)); Helpers::app()->singleton('iam', fn () => new IamClient($arguments)); Helpers::app()->singleton('ivs', fn () => new IVSClient($arguments)); - Helpers::app()->singleton('lambda', fn () => new LambdaClient($arguments)); Helpers::app()->singleton('ivsRealTime', fn () => new IVSRealTimeClient($arguments)); Helpers::app()->singleton('rds', fn () => new RdsClient($arguments)); Helpers::app()->singleton('route53', fn () => new Route53Client($arguments)); diff --git a/src/Concerns/UsesIam.php b/src/Concerns/UsesIam.php index 302800e..d6723e6 100644 --- a/src/Concerns/UsesIam.php +++ b/src/Concerns/UsesIam.php @@ -172,40 +172,4 @@ public static function mediaConvertPolicyDocument(): array ], ]; } - - public static function lambdaIvsRemuxRole(): array - { - $name = Helpers::keyedResourceName(Iam::LAMBDA_IVS_REMUX_ROLE); - $roles = Aws::iam()->listRoles(); - - do { - foreach ($roles['Roles'] as $role) { - if ($role['RoleName'] === $name) { - return $role; - } - } - - if (! $roles['IsTruncated']) { - break; - } - - $roles = Aws::iam()->listRoles(['Marker' => $roles['Marker']]); - } while (true); - - throw new ResourceDoesNotExistException("Could not find IAM role with name $name"); - } - - public static function lambdaIvsRemuxPolicyDocument(): array - { - return [ - 'Version' => '2012-10-17', - 'Statement' => [ - [ - 'Effect' => 'Allow', - 'Principal' => ['Service' => 'lambda.amazonaws.com'], - 'Action' => 'sts:AssumeRole', - ], - ], - ]; - } } diff --git a/src/Enums/Iam.php b/src/Enums/Iam.php index 69315c5..1b5b526 100644 --- a/src/Enums/Iam.php +++ b/src/Enums/Iam.php @@ -6,5 +6,5 @@ enum Iam: string { case INSTANCE_PROFILE = 'instance-profile'; case MEDIA_CONVERT_ROLE = 'mediaconvert-role'; - case LAMBDA_IVS_REMUX_ROLE = 'lambda-ivs-remux-role'; + } diff --git a/src/Manifest.php b/src/Manifest.php index c84a4df..9b3d078 100644 --- a/src/Manifest.php +++ b/src/Manifest.php @@ -2,7 +2,6 @@ namespace Codinglabs\Yolo; -use Dotenv\Dotenv; use Illuminate\Support\Arr; use Symfony\Component\Yaml\Yaml; use Codinglabs\Yolo\Exceptions\IntegrityCheckException; @@ -106,28 +105,6 @@ public static function ivsRealtimeWebhookUrl(): ?string return static::get('aws.ivs.recording.real_time.webhook_url'); } - public static function ivsRealtimeMainBucket(): ?string - { - $envFile = Paths::base('.env.' . Helpers::environment()); - - if (! file_exists($envFile)) { - return null; - } - - return Dotenv::parse(file_get_contents($envFile))['AWS_BUCKET'] ?? null; - } - - public static function ivsWebhookSecret(): ?string - { - $envFile = Paths::base('.env.' . Helpers::environment()); - - if (! file_exists($envFile)) { - return null; - } - - return Dotenv::parse(file_get_contents($envFile))['IVS_WEBHOOK_SECRET'] ?? null; - } - /** * @return arrayputRolePolicy([ - 'RoleName' => $role['RoleName'], - 'PolicyName' => 'LambdaIvsRemuxPolicy', - 'PolicyDocument' => json_encode([ - 'Version' => '2012-10-17', - 'Statement' => [ - [ - 'Effect' => 'Allow', - 'Action' => ['logs:CreateLogGroup', 'logs:CreateLogStream', 'logs:PutLogEvents'], - 'Resource' => '*', - ], - [ - 'Effect' => 'Allow', - 'Action' => ['s3:GetObject', 's3:ListBucket'], - 'Resource' => [ - "arn:aws:s3:::{$realtimeBucket}", - "arn:aws:s3:::{$realtimeBucket}/*", - ], - ], - [ - 'Effect' => 'Allow', - 'Action' => ['s3:PutObject'], - 'Resource' => "arn:aws:s3:::{$mainBucket}/tmp/realtime-mp4/*", - ], - [ - 'Effect' => 'Allow', - 'Action' => ['ivs:GetStage'], - 'Resource' => '*', - ], - ], - ]), - ]); - - return StepResult::SYNCED; - } - - return StepResult::WOULD_SYNC; - } -} diff --git a/src/Steps/Iam/SyncLambdaIvsRemuxRoleStep.php b/src/Steps/Iam/SyncLambdaIvsRemuxRoleStep.php deleted file mode 100644 index 1dbc83d..0000000 --- a/src/Steps/Iam/SyncLambdaIvsRemuxRoleStep.php +++ /dev/null @@ -1,63 +0,0 @@ -updateRole([ - 'RoleName' => $name, - 'Description' => 'YOLO managed Lambda role for IVS Real-Time remux', - ]); - - Aws::iam()->updateAssumeRolePolicy([ - 'RoleName' => $name, - 'PolicyDocument' => json_encode(AwsResources::lambdaIvsRemuxPolicyDocument()), - ]); - - Aws::iam()->tagRole([ - 'RoleName' => $name, - ...Aws::tags(), - ]); - - return StepResult::SYNCED; - } - - return StepResult::WOULD_SYNC; - } catch (ResourceDoesNotExistException) { - if (! Arr::get($options, 'dry-run')) { - Aws::iam()->createRole([ - 'RoleName' => Helpers::keyedResourceName(Iam::LAMBDA_IVS_REMUX_ROLE), - 'Description' => 'YOLO managed Lambda role for IVS Real-Time remux', - 'AssumeRolePolicyDocument' => json_encode(AwsResources::lambdaIvsRemuxPolicyDocument()), - ...Aws::tags(), - ]); - - return StepResult::CREATED; - } - - return StepResult::WOULD_CREATE; - } - } -} diff --git a/src/Steps/Recording/SyncIvsEncoderConfigurationStep.php b/src/Steps/Recording/SyncIvsEncoderConfigurationStep.php new file mode 100644 index 0000000..2863a30 --- /dev/null +++ b/src/Steps/Recording/SyncIvsEncoderConfigurationStep.php @@ -0,0 +1,65 @@ +listEncoderConfigurations(); + $all = $response['encoderConfigurations']; + while ($nextToken = $response['nextToken'] ?? null) { + $response = Aws::ivsRealTime()->listEncoderConfigurations(['nextToken' => $nextToken]); + $all = array_merge($all, $response['encoderConfigurations']); + } + + $existing = collect($all)->first(fn ($config) => $config['name'] === $name); + + if ($existing) { + note(sprintf('IVS EncoderConfiguration ARN: %s', $existing['arn'])); + note(sprintf('Set AWS_IVS_ENCODER_CONFIGURATION_ARN=%s', $existing['arn'])); + + return StepResult::SYNCED; + } + + if (! Arr::get($options, 'dry-run')) { + $result = Aws::ivsRealTime()->createEncoderConfiguration([ + 'name' => $name, + 'video' => [ + 'width' => 1280, + 'height' => 720, + 'framerate' => 30, + 'bitrate' => 2500000, + ], + 'tags' => [ + 'yolo:environment' => Helpers::app('environment'), + 'Name' => $name, + ], + ]); + + $arn = $result['encoderConfiguration']['arn']; + + note(sprintf('IVS EncoderConfiguration ARN: %s', $arn)); + note(sprintf('Set AWS_IVS_ENCODER_CONFIGURATION_ARN=%s', $arn)); + + return StepResult::CREATED; + } + + return StepResult::WOULD_CREATE; + } +} diff --git a/src/Steps/Recording/SyncIvsRealtimeRecordingEventBridgeRuleStep.php b/src/Steps/Recording/SyncIvsRealtimeRecordingEventBridgeRuleStep.php deleted file mode 100644 index 915aeca..0000000 --- a/src/Steps/Recording/SyncIvsRealtimeRecordingEventBridgeRuleStep.php +++ /dev/null @@ -1,73 +0,0 @@ -putRule([ - 'Name' => $name, - 'Description' => 'YOLO managed IVS Real-Time participant recording state change events', - 'EventPattern' => json_encode(self::eventPattern()), - 'State' => 'ENABLED', - ...Aws::tags([ - 'Name' => $name, - ]), - ]); - - return StepResult::SYNCED; - } - - return StepResult::WOULD_SYNC; - } catch (ResourceDoesNotExistException) { - if (! Arr::get($options, 'dry-run')) { - Aws::eventBridge()->putRule([ - 'Name' => $name, - 'Description' => 'YOLO managed IVS Real-Time participant recording state change events', - 'EventPattern' => json_encode(self::eventPattern()), - 'State' => 'ENABLED', - ...Aws::tags([ - 'Name' => $name, - ]), - ]); - - return StepResult::CREATED; - } - - return StepResult::WOULD_CREATE; - } - } - - public static function ruleName(): string - { - return Helpers::keyedResourceName('ivs-rt-recording'); - } - - public static function eventPattern(): array - { - return [ - 'source' => ['aws.ivs'], - 'detail-type' => ['IVS Participant Recording State Change'], - ]; - } -} diff --git a/src/Steps/Recording/SyncIvsRealtimeRecordingEventBridgeTargetStep.php b/src/Steps/Recording/SyncIvsRealtimeRecordingEventBridgeTargetStep.php deleted file mode 100644 index e175e7b..0000000 --- a/src/Steps/Recording/SyncIvsRealtimeRecordingEventBridgeTargetStep.php +++ /dev/null @@ -1,71 +0,0 @@ -listTargetsByRule([ - 'Rule' => $ruleName, - ])['Targets']); - - $existingTarget = $targets->first(fn ($t) => $t['Id'] === 'ivs-realtime-remux'); - $existingLogTarget = $targets->first(fn ($t) => $t['Id'] === 'ivs-recording-logs'); - - if ($existingTarget && $existingTarget['Arn'] === $lambdaArn && $existingLogTarget) { - return StepResult::SYNCED; - } - } catch (ResourceDoesNotExistException) { - // Rule doesn't exist yet — target needs to be created - } - - if (! Arr::get($options, 'dry-run')) { - Aws::eventBridge()->putTargets([ - 'Rule' => $ruleName, - 'Targets' => [ - [ - 'Id' => 'ivs-realtime-remux', - 'Arn' => $lambdaArn, - ], - [ - 'Id' => 'ivs-recording-logs', - 'Arn' => $logGroupArn, - ], - ], - ]); - - return $existingTarget - ? StepResult::SYNCED - : StepResult::CREATED; - } - - return $existingTarget - ? StepResult::WOULD_SYNC - : StepResult::WOULD_CREATE; - } -} diff --git a/src/Steps/Recording/SyncIvsRemuxFfmpegLayerStep.php b/src/Steps/Recording/SyncIvsRemuxFfmpegLayerStep.php deleted file mode 100644 index e3239ec..0000000 --- a/src/Steps/Recording/SyncIvsRemuxFfmpegLayerStep.php +++ /dev/null @@ -1,118 +0,0 @@ - 300]))->get(self::FFMPEG_URL, ['sink' => $tarPath]); - - @mkdir($extractDir, 0755, true); - exec('tar xf ' . escapeshellarg($tarPath) . ' -C ' . escapeshellarg($extractDir)); - - exec('find ' . escapeshellarg($extractDir) . ' -maxdepth 2 -name ffmpeg -type f', $found); - $ffmpegBin = $found[0] ?? null; - - if (! $ffmpegBin) { - throw new \RuntimeException('Could not find ffmpeg binary in extracted archive'); - } - - chmod($ffmpegBin, 0755); - - $zipContent = $this->buildLayerZip($ffmpegBin); - } finally { - @unlink($tarPath); - exec('rm -rf ' . escapeshellarg($extractDir)); - } - - // Lambda direct zip upload limit is 50 MB — route via S3 (the binary zip is ~70 MB) - $bucket = SyncIvsRealtimeRecordingBucketStep::bucketName(); - $s3Key = 'lambda-layers/ffmpeg.zip'; - - Aws::s3()->putObject(['Bucket' => $bucket, 'Key' => $s3Key, 'Body' => $zipContent]); - - try { - $layerName = Helpers::keyedResourceName('ffmpeg-layer'); - - $result = Aws::lambda()->publishLayerVersion([ - 'LayerName' => $layerName, - 'Description' => 'YOLO managed FFmpeg layer for IVS Real-Time remux', - 'Content' => ['S3Bucket' => $bucket, 'S3Key' => $s3Key], - 'CompatibleRuntimes' => ['python3.12'], - 'CompatibleArchitectures' => ['x86_64'], - ]); - } finally { - Aws::s3()->deleteObject(['Bucket' => $bucket, 'Key' => $s3Key]); - } - - note(sprintf('FFmpeg layer published: %s', $result['LayerVersionArn'])); - - return StepResult::CREATED; - } - - public static function latestLayerArn(): ?string - { - $layerName = Helpers::keyedResourceName('ffmpeg-layer'); - - try { - $versions = Aws::lambda()->listLayerVersions(['LayerName' => $layerName])['LayerVersions']; - } catch (LambdaException $e) { - if ($e->getAwsErrorCode() === 'ResourceNotFoundException') { - return null; - } - - throw $e; - } - - return $versions[0]['LayerVersionArn'] ?? null; - } - - private function buildLayerZip(string $ffmpegBin): string - { - $zipPath = tempnam(sys_get_temp_dir(), 'yolo-ffmpeg-layer') . '.zip'; - $zip = new ZipArchive(); - $zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE); - $zip->addFile($ffmpegBin, 'bin/ffmpeg'); - $zip->setCompressionName('bin/ffmpeg', ZipArchive::CM_STORE); - $zip->close(); - - $content = file_get_contents($zipPath); - unlink($zipPath); - - return $content; - } -} diff --git a/src/Steps/Recording/SyncIvsRemuxLambdaStep.php b/src/Steps/Recording/SyncIvsRemuxLambdaStep.php deleted file mode 100644 index 138f085..0000000 --- a/src/Steps/Recording/SyncIvsRemuxLambdaStep.php +++ /dev/null @@ -1,191 +0,0 @@ -getFunction(['FunctionName' => $functionName]); - - if (! Arr::get($options, 'dry-run')) { - Aws::lambda()->updateFunctionCode([ - 'FunctionName' => $functionName, - 'ZipFile' => $this->buildZip(), - ]); - - $this->waitForUpdate($functionName); - - Aws::lambda()->updateFunctionConfiguration([ - 'FunctionName' => $functionName, - 'Runtime' => 'python3.12', - 'Handler' => 'lambda_function.handler', - 'Timeout' => 900, - 'MemorySize' => 1024, - 'EphemeralStorage' => ['Size' => 10240], - 'Environment' => ['Variables' => $this->envVars()], - 'Layers' => $this->layers(), - ]); - - $this->syncEventBridgePermission($functionName); - - return StepResult::SYNCED; - } - - return StepResult::WOULD_SYNC; - } catch (LambdaException $e) { - if ($e->getAwsErrorCode() !== 'ResourceNotFoundException') { - throw $e; - } - } - - if (! Arr::get($options, 'dry-run')) { - $role = AwsResources::lambdaIvsRemuxRole(); - - Aws::lambda()->createFunction([ - 'FunctionName' => $functionName, - 'Runtime' => 'python3.12', - 'Handler' => 'lambda_function.handler', - 'Role' => $role['Arn'], - 'Code' => ['ZipFile' => $this->buildZip()], - 'Timeout' => 900, - 'MemorySize' => 1024, - 'EphemeralStorage' => ['Size' => 10240], - 'Environment' => ['Variables' => $this->envVars()], - 'Layers' => $this->layers(), - ...Aws::tags(['Name' => $functionName], associative: true), - ]); - - $this->waitForActive($functionName); - $this->syncEventBridgePermission($functionName); - - return StepResult::CREATED; - } - - return StepResult::WOULD_CREATE; - } - - private function envVars(): array - { - return [ - 'MAIN_S3_BUCKET' => Manifest::ivsRealtimeMainBucket(), - 'WEBHOOK_URL' => Manifest::ivsRealtimeWebhookUrl(), - 'WEBHOOK_SECRET' => Manifest::ivsWebhookSecret(), - 'IVS_REGION' => Manifest::get('aws.region'), - ]; - } - - private function layers(): array - { - $layerArn = SyncIvsRemuxFfmpegLayerStep::latestLayerArn(); - - return $layerArn ? [$layerArn] : []; - } - - private function buildZip(): string - { - $zipPath = tempnam(sys_get_temp_dir(), 'yolo-lambda') . '.zip'; - $zip = new ZipArchive(); - $zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE); - $zip->addFile(dirname(__DIR__, 3) . '/resources/lambda/ivs_realtime_remux.py', 'lambda_function.py'); - $zip->close(); - - $content = file_get_contents($zipPath); - unlink($zipPath); - - return $content; - } - - private function syncEventBridgePermission(string $functionName): void - { - $region = Manifest::get('aws.region'); - $accountId = Aws::accountId(); - $ruleName = SyncIvsRealtimeRecordingEventBridgeRuleStep::ruleName(); - $ruleArn = "arn:aws:events:{$region}:{$accountId}:rule/{$ruleName}"; - - try { - Aws::lambda()->removePermission([ - 'FunctionName' => $functionName, - 'StatementId' => 'AllowEventBridgeInvoke', - ]); - } catch (\Exception) { - // Permission may not exist yet — that's fine - } - - Aws::lambda()->addPermission([ - 'FunctionName' => $functionName, - 'StatementId' => 'AllowEventBridgeInvoke', - 'Action' => 'lambda:InvokeFunction', - 'Principal' => 'events.amazonaws.com', - 'SourceArn' => $ruleArn, - ]); - } - - private function waitForActive(string $functionName): void - { - $attempts = 0; - - while ($attempts < 30) { - $fn = Aws::lambda()->getFunctionConfiguration(['FunctionName' => $functionName]); - - if ($fn['State'] === 'Active') { - return; - } - - sleep(2); - $attempts++; - } - } - - private function waitForUpdate(string $functionName): void - { - $attempts = 0; - - while ($attempts < 30) { - $fn = Aws::lambda()->getFunctionConfiguration(['FunctionName' => $functionName]); - - if (($fn['LastUpdateStatus'] ?? 'Successful') === 'Successful') { - return; - } - - sleep(2); - $attempts++; - } - } - - public static function functionName(): string - { - return Helpers::keyedResourceName('ivs-realtime-remux'); - } -} diff --git a/tests/Unit/ManifestTest.php b/tests/Unit/ManifestTest.php index 9d564d7..c77a325 100644 --- a/tests/Unit/ManifestTest.php +++ b/tests/Unit/ManifestTest.php @@ -133,36 +133,6 @@ }); }); -describe('ivsWebhookSecret', function () { - beforeEach(function () { - if (file_exists(BASE_PATH . '/.env.testing')) { - unlink(BASE_PATH . '/.env.testing'); - } - }); - - afterEach(function () { - if (file_exists(BASE_PATH . '/.env.testing')) { - unlink(BASE_PATH . '/.env.testing'); - } - }); - - it('returns null when the env file does not exist', function () { - expect(Manifest::ivsWebhookSecret())->toBeNull(); - }); - - it('returns null when the env file exists but the key is absent', function () { - file_put_contents(BASE_PATH . '/.env.testing', "OTHER_KEY=somevalue\n"); - - expect(Manifest::ivsWebhookSecret())->toBeNull(); - }); - - it('returns the secret when the env file exists with the key set', function () { - file_put_contents(BASE_PATH . '/.env.testing', "IVS_WEBHOOK_SECRET=abc123secret\n"); - - expect(Manifest::ivsWebhookSecret())->toBe('abc123secret'); - }); -}); - describe('apex', function () { it('returns the apex domain', function () { writeManifest(['domain' => 'example.com']); From 256505d2404828701aed91ea683ebeaf706cda61 Mon Sep 17 00:00:00 2001 From: Seth Sharp <58869086+SethSharp@users.noreply.github.com> Date: Wed, 13 May 2026 16:12:02 +0930 Subject: [PATCH 14/15] wip --- tests/Unit/PathsTest.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/Unit/PathsTest.php b/tests/Unit/PathsTest.php index e42dc83..32fc338 100644 --- a/tests/Unit/PathsTest.php +++ b/tests/Unit/PathsTest.php @@ -35,11 +35,15 @@ }); it('builds yolo dir for aws instances', function () { + writeManifest([]); + expect(Paths::yoloDir()) ->toBe('/home/ubuntu/yolo/yolo-testing-my-app'); }); it('builds log dir for aws instances', function () { + writeManifest([]); + expect(Paths::logDir()) ->toBe('/var/log/yolo/yolo-testing-my-app'); }); From 5ab881266457f5f704a51fd3ff8ff14633e13a82 Mon Sep 17 00:00:00 2001 From: Seth Sharp <58869086+SethSharp@users.noreply.github.com> Date: Wed, 13 May 2026 16:23:10 +0930 Subject: [PATCH 15/15] cleaning up --- README.md | 2 +- docs/reference/manifest.md | 19 ++++++++++++------- src/Enums/Iam.php | 1 - src/Manifest.php | 4 ++-- .../SyncIvsEncoderConfigurationStep.php | 2 +- .../SyncIvsRealtimeRecordingBucketStep.php | 2 +- .../SyncIvsStorageConfigurationStep.php | 2 +- 7 files changed, 18 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 6bf5b22..999cdb5 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,7 @@ The full list of available sync commands are: - `yolo sync:ci ` prepares the continuous integration pipeline - `yolo sync:iam ` prepares necessary permissions - `yolo sync:logging ` prepares observability infrastructure (e.g. IVS state-change events) -- `yolo sync:recording ` prepares IVS recording infrastructure (S3 buckets, RecordingConfiguration, StorageConfiguration, EventBridge rules) +- `yolo sync:recording ` prepares IVS Real-Time composite recording infrastructure (S3 bucket, StorageConfiguration, EncoderConfiguration) > [!TIP] > All sync commands support a `--dry-run` argument; this is a great starting point to see what resources will be created diff --git a/docs/reference/manifest.md b/docs/reference/manifest.md index a39f4d7..c9b64e8 100644 --- a/docs/reference/manifest.md +++ b/docs/reference/manifest.md @@ -117,26 +117,31 @@ aws: `logging` toggles the EventBridge → CloudWatch pipeline; `log-retention-days` overrides the log retention. -#### IVS recording +#### IVS Real-Time recording -Two optional keys enable S3 recording and webhook delivery for both standard IVS channels and IVS Real-Time stages: +Enable S3 composite recording for IVS Real-Time stages: ```yaml aws: ivs: logging: true recording: - webhook_url: https://your-api.example.com/webhooks/ivs/recording + real_time: true ``` -Setting `recording` provisions S3 buckets, IVS `RecordingConfiguration` and `StorageConfiguration`, and EventBridge rules targeting the webhook URL for both standard channel and Real-Time stage recordings. +Setting `recording.real_time` to `true` provisions the S3 bucket, IVS `StorageConfiguration`, and `EncoderConfiguration` required for composite recording. | Key | Description | |---|---| -| `recording` | Set to `true` or expand with sub-keys to enable IVS recording provisioning. Provisions auto-named S3 buckets (`yolo-{env}-{app}-ivs-recordings` and `yolo-{env}-{app}-ivs-realtime-recordings`) and their corresponding IVS configurations. ARNs are printed after creation for `AWS_IVS_RECORDING_CONFIGURATION_ARN` and `AWS_IVS_STORAGE_CONFIGURATION_ARN`. | -| `recording.webhook_url` | HTTPS endpoint to receive `IVS Recording State Change` and `IVS Participant Recording State Change` events via EventBridge. Required for the EventBridge steps. | +| `recording.real_time` | Set to `true` to enable IVS Real-Time composite recording provisioning. Provisions an auto-named S3 bucket (`yolo-{env}-{app}-ivs-realtime-recordings`), a `StorageConfiguration` pointing to that bucket, and an `EncoderConfiguration` (720p30). | -The EventBridge steps also require `IVS_WEBHOOK_SECRET` to be present in the local `.env.{environment}` file. This secret is sent as the `X-Webhook-Secret` header on every delivery — generate it with `openssl rand -hex 32` and set the same value in the app's environment. Manage it alongside other app secrets via `yolo env:push` / `yolo env:pull`. +After running `sync:recording`, three values are printed for the app's `.env`: + +| Env var | Description | +|---|---| +| `AWS_IVS_REALTIME_RECORDINGS_BUCKET` | Name of the S3 bucket IVS writes recordings to | +| `AWS_IVS_STORAGE_CONFIGURATION_ARN` | ARN passed to `createStage` for automatic participant recording | +| `AWS_IVS_ENCODER_CONFIGURATION_ARN` | ARN passed to `startComposition` to define video resolution and bitrate | Omitting `recording` entirely skips all recording steps without affecting existing resources. diff --git a/src/Enums/Iam.php b/src/Enums/Iam.php index 1b5b526..45c652a 100644 --- a/src/Enums/Iam.php +++ b/src/Enums/Iam.php @@ -6,5 +6,4 @@ enum Iam: string { case INSTANCE_PROFILE = 'instance-profile'; case MEDIA_CONVERT_ROLE = 'mediaconvert-role'; - } diff --git a/src/Manifest.php b/src/Manifest.php index 9b3d078..b6ddef0 100644 --- a/src/Manifest.php +++ b/src/Manifest.php @@ -100,9 +100,9 @@ public static function ivsEnabled(): bool || static::get('aws.ivs.logging') === true; } - public static function ivsRealtimeWebhookUrl(): ?string + public static function ivsRealtimeRecordingEnabled(): bool { - return static::get('aws.ivs.recording.real_time.webhook_url'); + return ! empty(static::get('aws.ivs.recording.real_time')); } /** diff --git a/src/Steps/Recording/SyncIvsEncoderConfigurationStep.php b/src/Steps/Recording/SyncIvsEncoderConfigurationStep.php index 2863a30..df4a648 100644 --- a/src/Steps/Recording/SyncIvsEncoderConfigurationStep.php +++ b/src/Steps/Recording/SyncIvsEncoderConfigurationStep.php @@ -15,7 +15,7 @@ class SyncIvsEncoderConfigurationStep implements Step { public function __invoke(array $options): StepResult { - if (! Manifest::ivsRealtimeWebhookUrl()) { + if (! Manifest::ivsRealtimeRecordingEnabled()) { return StepResult::SKIPPED; } diff --git a/src/Steps/Recording/SyncIvsRealtimeRecordingBucketStep.php b/src/Steps/Recording/SyncIvsRealtimeRecordingBucketStep.php index 11a45d6..7f00d4a 100644 --- a/src/Steps/Recording/SyncIvsRealtimeRecordingBucketStep.php +++ b/src/Steps/Recording/SyncIvsRealtimeRecordingBucketStep.php @@ -16,7 +16,7 @@ class SyncIvsRealtimeRecordingBucketStep implements Step { public function __invoke(array $options): StepResult { - if (! Manifest::ivsRealtimeWebhookUrl()) { + if (! Manifest::ivsRealtimeRecordingEnabled()) { return StepResult::SKIPPED; } diff --git a/src/Steps/Recording/SyncIvsStorageConfigurationStep.php b/src/Steps/Recording/SyncIvsStorageConfigurationStep.php index e503e07..74557e7 100644 --- a/src/Steps/Recording/SyncIvsStorageConfigurationStep.php +++ b/src/Steps/Recording/SyncIvsStorageConfigurationStep.php @@ -15,7 +15,7 @@ class SyncIvsStorageConfigurationStep implements Step { public function __invoke(array $options): StepResult { - if (! Manifest::ivsRealtimeWebhookUrl()) { + if (! Manifest::ivsRealtimeRecordingEnabled()) { return StepResult::SKIPPED; }