Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ The full list of available sync commands are:
- `yolo sync:ci <environment>` prepares the continuous integration pipeline
- `yolo sync:iam <environment>` prepares necessary permissions
- `yolo sync:logging <environment>` prepares observability infrastructure (e.g. IVS state-change events)
- `yolo sync:recording <environment>` 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
Expand Down
28 changes: 28 additions & 0 deletions docs/reference/manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
12 changes: 12 additions & 0 deletions src/Aws.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -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');
Expand Down
25 changes: 25 additions & 0 deletions src/Commands/SyncRecordingCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

namespace Codinglabs\Yolo\Commands;

use Codinglabs\Yolo\Steps;
use Symfony\Component\Console\Input\InputArgument;

class SyncRecordingCommand extends SteppedCommand
{
protected array $steps = [
Steps\Recording\SyncIvsRealtimeRecordingBucketStep::class,
Steps\Recording\SyncIvsStorageConfigurationStep::class,
Steps\Recording\SyncIvsEncoderConfigurationStep::class,
];

protected function configure(): void
{
$this
->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');
}
}
4 changes: 4 additions & 0 deletions src/Concerns/RegistersAws.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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));
Expand Down
5 changes: 5 additions & 0 deletions src/Manifest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 array<int, array{
* domain: string,
Expand Down
65 changes: 65 additions & 0 deletions src/Steps/Recording/SyncIvsEncoderConfigurationStep.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

namespace Codinglabs\Yolo\Steps\Recording;

use Codinglabs\Yolo\Aws;
use Illuminate\Support\Arr;
use Codinglabs\Yolo\Helpers;
use Codinglabs\Yolo\Manifest;
use Codinglabs\Yolo\Contracts\Step;
use Codinglabs\Yolo\Enums\StepResult;

use function Laravel\Prompts\note;

class SyncIvsEncoderConfigurationStep implements Step
{
public function __invoke(array $options): StepResult
{
if (! Manifest::ivsRealtimeRecordingEnabled()) {
return StepResult::SKIPPED;
}

$name = Helpers::keyedResourceName('ivs-encoder');

$response = Aws::ivsRealTime()->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;
}
}
99 changes: 99 additions & 0 deletions src/Steps/Recording/SyncIvsRealtimeRecordingBucketStep.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

namespace Codinglabs\Yolo\Steps\Recording;

use Codinglabs\Yolo\Aws;
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;
use Codinglabs\Yolo\Exceptions\ResourceDoesNotExistException;

class SyncIvsRealtimeRecordingBucketStep implements Step
{
public function __invoke(array $options): StepResult
{
if (! Manifest::ivsRealtimeRecordingEnabled()) {
return StepResult::SKIPPED;
}

$bucket = self::bucketName();

try {
AwsResources::bucket($bucket);

if (! Arr::get($options, 'dry-run')) {
$this->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');
}
}
62 changes: 62 additions & 0 deletions src/Steps/Recording/SyncIvsStorageConfigurationStep.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

namespace Codinglabs\Yolo\Steps\Recording;

use Codinglabs\Yolo\Aws;
use Illuminate\Support\Arr;
use Codinglabs\Yolo\Helpers;
use Codinglabs\Yolo\Manifest;
use Codinglabs\Yolo\Contracts\Step;
use Codinglabs\Yolo\Enums\StepResult;

use function Laravel\Prompts\note;

class SyncIvsStorageConfigurationStep implements Step
{
public function __invoke(array $options): StepResult
{
if (! Manifest::ivsRealtimeRecordingEnabled()) {
return StepResult::SKIPPED;
}

$bucket = SyncIvsRealtimeRecordingBucketStep::bucketName();
$name = Helpers::keyedResourceName('ivs-storage');

$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']));
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;
}
}
1 change: 1 addition & 0 deletions src/Yolo.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ class Yolo
Commands\SyncCiCommand::class,
Commands\SyncIamCommand::class,
Commands\SyncLoggingCommand::class,
Commands\SyncRecordingCommand::class,
];

public function __construct()
Expand Down
4 changes: 4 additions & 0 deletions tests/Unit/PathsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Expand Down
Loading