diff --git a/README.md b/README.md index bb03e1a..999cdb5 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 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 9b5adfc..c9b64e8 100644 --- a/docs/reference/manifest.md +++ b/docs/reference/manifest.md @@ -117,6 +117,34 @@ aws: `logging` toggles the EventBridge → CloudWatch pipeline; `log-retention-days` overrides the log retention. +#### IVS Real-Time recording + +Enable S3 composite recording for IVS Real-Time stages: + +```yaml +aws: + ivs: + logging: true + recording: + real_time: true +``` + +Setting `recording.real_time` to `true` provisions the S3 bucket, IVS `StorageConfiguration`, and `EncoderConfiguration` required for composite recording. + +| Key | Description | +|---|---| +| `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). | + +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. + ### `mysqldump` Enable scheduled MySQL backups via `mysqldump`. 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/SyncRecordingCommand.php b/src/Commands/SyncRecordingCommand.php new file mode 100644 index 0000000..352298f --- /dev/null +++ b/src/Commands/SyncRecordingCommand.php @@ -0,0 +1,25 @@ +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/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..b6ddef0 100644 --- a/src/Manifest.php +++ b/src/Manifest.php @@ -100,6 +100,11 @@ public static function ivsEnabled(): bool || static::get('aws.ivs.logging') === true; } + public static function ivsRealtimeRecordingEnabled(): bool + { + return ! empty(static::get('aws.ivs.recording.real_time')); + } + /** * @return arraylistEncoderConfigurations(); + $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/SyncIvsRealtimeRecordingBucketStep.php b/src/Steps/Recording/SyncIvsRealtimeRecordingBucketStep.php new file mode 100644 index 0000000..7f00d4a --- /dev/null +++ b/src/Steps/Recording/SyncIvsRealtimeRecordingBucketStep.php @@ -0,0 +1,99 @@ +putBucketPolicy($bucket); + } + + return StepResult::SYNCED; + } catch (ResourceDoesNotExistException) { + if (! Arr::get($options, 'dry-run')) { + Aws::s3()->createBucket(['Bucket' => $bucket]); + Aws::s3()->waitUntil('BucketExists', ['Bucket' => $bucket]); + Aws::s3()->putBucketTagging([ + 'Bucket' => $bucket, + 'Tagging' => [...Aws::tags(['Name' => $bucket], wrap: 'TagSet')], + ]); + $this->putBucketPolicy($bucket); + + return StepResult::CREATED; + } + + return StepResult::WOULD_CREATE; + } + } + + 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/SyncIvsStorageConfigurationStep.php b/src/Steps/Recording/SyncIvsStorageConfigurationStep.php new file mode 100644 index 0000000..74557e7 --- /dev/null +++ b/src/Steps/Recording/SyncIvsStorageConfigurationStep.php @@ -0,0 +1,62 @@ +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'])); + 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; + } +} 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() 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'); });