diff --git a/README.md b/README.md index 17ec43d..f188d64 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,8 @@ rather than CloudFormation / Terraform / K8s / Elastic Beanstalk / [!IMPORTANT] > While YOLO has been battle-tested on apps serving millions of requests per day, it is not supposed to be a > set-and-forget solution for busy apps, but rather allows you to proactively manage, grow and adapt your infrastructure -> as requirements change over time. +> as requirements +> change over time. It goes without saying, but use YOLO at your own risk. @@ -86,7 +87,6 @@ The full list of available sync commands are: - `yolo sync:tenant ` prepares tenant resources (multitenancy apps only) - `yolo sync:compute ` prepares the compute resources - `yolo sync:ci ` prepares the continuous integration pipeline -- `yolo sync:iam ` prepares necessary permissions > [!TIP] > All sync commands support a `--dry-run` argument; this is a great starting point to see what resources will be created @@ -107,22 +107,13 @@ Run `yolo image:create ` to generate a new AMI. ### b) Prepare the image for traffic -To prepare a new stage, run `yolo stage `. +To prepare the new image for traffic, run `yolo image:prepare `. -This interactive command walks you through updating or replacing the current stage configuration. +You will be prompted to select the AMI (the newest image will be at the top of the list). -New stages have the benefit of allowing testing before migrating production workloads over, however simply updating the -existing stage is recommended for minor changes. +After selecting which image to use, servers will be spun up, ready to receive app deployments. -| Situation | Recommended strategy | -|-----------------------------|----------------------| -| Update EC2 security group | update | -| Update EC2 type | update | -| Update EC2 instance profile | update | -| Update AMI | create | - -When creating a new stage, the yolo.yml manifest will also be updated to point to the new autoscaling groups on the next -deployment. +The yolo.yml manifest will also be configured to point to the new autoscaling groups. > [!NOTE] > Rotating in a new image does not have any impact on existing traffic until the updated manifest is deployed - the @@ -173,12 +164,12 @@ environments: artefacts-bucket: cloudfront: alb: + security-group-id: transcoder: false autoscaling: web: queue: scheduler: - combine: false ec2: instance-type: t3.small queue-instance-type: @@ -192,9 +183,11 @@ environments: codedeploy: strategy: without-load-balancing|with-load-balancing - asset-url: # defaults to aws.cloudfront - pulse-worker: false - mysqldump: false + build: + - composer install --no-cache --no-interaction --optimize-autoloader --no-progress --classmap-authoritative --no-dev + - npm ci + - npm run build + - rm -rf package-lock.json resources/js resources/css node_modules database/seeders database/factories resources/seeding domain: example.com # standalone apps only apex: # standalone apps only @@ -206,22 +199,13 @@ environments: fishing: # unique key for the tenant domain: fishing-with-yolo.com - build: - - composer install --no-cache --no-interaction --optimize-autoloader --no-progress --classmap-authoritative --no-dev - - npm ci - - npm run build - - rm -rf package-lock.json resources/js resources/css node_modules database/seeders database/factories resources/seeding + pulse-worker: false + mysqldump: false - deploy: #runs on scheduler + deploy: - php artisan migrate --force - deploy-queue: # runs on queue - - - - deploy-web: # runs on web - - - - deploy-all: # runs on all instances + deploy-all: - php artisan optimize ``` diff --git a/src/Commands/DeployCommand.php b/src/Commands/DeployCommand.php index ce3f5df..1535642 100644 --- a/src/Commands/DeployCommand.php +++ b/src/Commands/DeployCommand.php @@ -26,8 +26,8 @@ class DeployCommand extends SteppedCommand Steps\Deploy\PushAssetsToS3Step::class, Steps\Deploy\UpdateCodeDeployDeploymentGroupStep::class, Steps\Deploy\CreateCodeDeployDeploymentsStep::class, - // Steps\Deploy\SyncStandaloneRecordSetStep::class, // todo: temp - // Steps\Deploy\SyncMultitenancyRecordSetStep::class, // todo: temp + Steps\Deploy\SyncStandaloneRecordSetStep::class, + Steps\Deploy\SyncMultitenancyRecordSetStep::class, Steps\Build\PurgeBuildStep::class, ]; diff --git a/src/Commands/SyncMultitenancyTenantsCommand.php b/src/Commands/SyncMultitenancyTenantsCommand.php index 6f733b0..e6ad906 100644 --- a/src/Commands/SyncMultitenancyTenantsCommand.php +++ b/src/Commands/SyncMultitenancyTenantsCommand.php @@ -8,10 +8,10 @@ class SyncMultitenancyTenantsCommand extends SteppedCommand { protected array $steps = [ - // Steps\Tenant\SyncHostedZoneStep::class, - // Steps\Deploy\SyncMultitenancyRecordSetStep::class, - // Steps\Tenant\SyncSslCertificateStep::class, - // Steps\Tenant\AttachSslCertificateToLoadBalancerListenerStep::class, + Steps\Tenant\SyncHostedZoneStep::class, + Steps\Tenant\SyncRecordSetStep::class, + Steps\Tenant\SyncSslCertificateStep::class, + Steps\Tenant\AttachSslCertificateToLoadBalancerListenerStep::class, Steps\Tenant\SyncQueueStep::class, Steps\Tenant\SyncQueueAlarmStep::class, ]; diff --git a/src/Concerns/UsesAutoscaling.php b/src/Concerns/UsesAutoscaling.php index b638585..1a359fd 100644 --- a/src/Concerns/UsesAutoscaling.php +++ b/src/Concerns/UsesAutoscaling.php @@ -9,18 +9,38 @@ trait UsesAutoscaling { + protected static array $asgWeb; + + protected static array $asgQueue; + + protected static array $asgScheduler; + + protected static array $asgWebScalingPolicies; + public static function autoScalingGroupWeb(): array { + if (isset(static::$asgWeb)) { + return static::$asgWeb; + } + return static::autoScalingGroup(Manifest::get('aws.autoscaling.web')); } public static function autoScalingGroupQueue(): array { + if (isset(static::$asgQueue)) { + return static::$asgQueue; + } + return static::autoScalingGroup(Manifest::get('aws.autoscaling.queue')); } public static function autoScalingGroupScheduler(): array { + if (isset(static::$asgScheduler)) { + return static::$asgScheduler; + } + return static::autoScalingGroup(Manifest::get('aws.autoscaling.scheduler')); } @@ -51,6 +71,10 @@ public static function autoScalingGroupWebScaleDownPolicy(): array protected static function autoScalingGroupWebScalingPolicies(): array { + if (isset(static::$asgWebScalingPolicies)) { + return static::$asgWebScalingPolicies; + } + return static::autoScalingGroupScalingPolicies(Manifest::get('aws.autoscaling.web')); } diff --git a/src/Concerns/UsesCodeDeploy.php b/src/Concerns/UsesCodeDeploy.php index c813b9a..92dbc12 100644 --- a/src/Concerns/UsesCodeDeploy.php +++ b/src/Concerns/UsesCodeDeploy.php @@ -11,6 +11,16 @@ trait UsesCodeDeploy { + protected static string $application; + + protected static array $oneThirdAtATimeDeploymentConfig; + + protected static array $webDeploymentGroup; + + protected static array $queueDeploymentGroup; + + protected static array $schedulerDeploymentGroup; + public static function applicationName(): string { return Helpers::keyedResourceName(); @@ -47,18 +57,30 @@ public static function OneThirdAtATimeDeploymentConfig(): array /** @throws ResourceDoesNotExistException */ public static function webDeploymentGroup(): array { + if (isset(static::$webDeploymentGroup)) { + return static::$webDeploymentGroup; + } + return static::deploymentGroup(Helpers::keyedResourceName(ServerGroup::WEB)); } /** @throws ResourceDoesNotExistException */ public static function queueDeploymentGroup(): array { + if (isset(static::$queueDeploymentGroup)) { + return static::$queueDeploymentGroup; + } + return static::deploymentGroup(Helpers::keyedResourceName(ServerGroup::QUEUE)); } /** @throws ResourceDoesNotExistException */ public static function schedulerDeploymentGroup(): array { + if (isset(static::$schedulerDeploymentGroup)) { + return static::$schedulerDeploymentGroup; + } + return static::deploymentGroup(Helpers::keyedResourceName(ServerGroup::SCHEDULER)); } diff --git a/src/Concerns/UsesElasticTranscoder.php b/src/Concerns/UsesElasticTranscoder.php index c34b88d..90e7c98 100644 --- a/src/Concerns/UsesElasticTranscoder.php +++ b/src/Concerns/UsesElasticTranscoder.php @@ -8,6 +8,10 @@ trait UsesElasticTranscoder { + protected static array $elasticTranscoderPipeline; + + protected static array $elasticTranscoderPreset; + public static function elasticTranscoderPipeline(): array { $name = Helpers::keyedResourceName(); diff --git a/src/Exceptions/YoloException.php b/src/Exceptions/YoloException.php index 1c1f039..12b195b 100644 --- a/src/Exceptions/YoloException.php +++ b/src/Exceptions/YoloException.php @@ -24,4 +24,12 @@ public function getSuggestion(): string { return $this->suggestion; } + + /** + * @throws self + */ + public function throw(): void + { + throw $this; + } } diff --git a/src/Steps/Compute/SyncApplicationLoadBalancerStep.php b/src/Steps/Compute/SyncApplicationLoadBalancerStep.php new file mode 100644 index 0000000..48a0118 --- /dev/null +++ b/src/Steps/Compute/SyncApplicationLoadBalancerStep.php @@ -0,0 +1,70 @@ +createLoadBalancer([ + 'Name' => Helpers::keyedResourceName(exclusive: false), + 'SecurityGroups' => [AwsResources::loadBalancerSecurityGroup()['GroupId']], + 'Subnets' => collect(AwsResources::subnets()) + ->pluck('SubnetId') + ->toArray(), + ...Aws::tags([ + 'Name' => Helpers::keyedResourceName(exclusive: false), + ]), + ]); + + while (true) { + // wait for load balancer to provision + $loadBalancer = AwsResources::loadBalancer(); + + if ($loadBalancer['State']['Code'] === 'active') { + break; + } + + sleep(3); + } + + // todo: this is disabled due to issues dynamically generating the bucket policy + // Aws::elasticLoadBalancingV2()->modifyLoadBalancerAttributes([ + // 'LoadBalancerArn' => AwsResources::loadBalancer()['LoadBalancerArn'], + // 'Attributes' => [ + // [ + // 'Key' => 'access_logs.s3.enabled', + // 'Value' => 'true', + // ], + // [ + // 'Key' => 'access_logs.s3.bucket', + // 'Value' => Paths::s3ArtefactsBucket(), + // ], + // [ + // 'Key' => 'access_logs.s3.prefix', + // 'Value' => 'logs', + // ], + // ], + // ]); + + return StepResult::CREATED; + } + + return StepResult::WOULD_CREATE; + } + } +} diff --git a/src/Steps/Compute/SyncListenerOnPort443Step.php b/src/Steps/Compute/SyncListenerOnPort443Step.php new file mode 100644 index 0000000..f930c4c --- /dev/null +++ b/src/Steps/Compute/SyncListenerOnPort443Step.php @@ -0,0 +1,50 @@ +createListener([ + 'LoadBalancerArn' => AwsResources::loadBalancer()['LoadBalancerArn'], + 'Protocol' => 'HTTPS', + 'Port' => 443, + 'Certificates' => [ + [ + 'CertificateArn' => AwsResources::certificate(Manifest::apex())['CertificateArn'], + ], + ], + 'DefaultActions' => [ + [ + 'Type' => 'forward', + 'TargetGroupArn' => AwsResources::targetGroup()['TargetGroupArn'], + ], + ], + ...Aws::tags([ + 'Name' => Helpers::keyedResourceName('https', exclusive: false), + ]), + ]); + + return StepResult::CREATED; + } + + return StepResult::WOULD_CREATE; + } + } +} diff --git a/src/Steps/Compute/SyncListenerOnPort80Step.php b/src/Steps/Compute/SyncListenerOnPort80Step.php new file mode 100644 index 0000000..65e6953 --- /dev/null +++ b/src/Steps/Compute/SyncListenerOnPort80Step.php @@ -0,0 +1,44 @@ +createListener([ + 'LoadBalancerArn' => AwsResources::loadBalancer()['LoadBalancerArn'], + 'Protocol' => 'HTTP', + 'Port' => 80, + 'DefaultActions' => [ + [ + 'Type' => 'forward', + 'TargetGroupArn' => AwsResources::targetGroup()['TargetGroupArn'], + ], + ], + ...Aws::tags([ + 'Name' => Helpers::keyedResourceName('http', exclusive: false), + ]), + ]); + + return StepResult::CREATED; + } + + return StepResult::WOULD_CREATE; + } + } +} diff --git a/src/Steps/Compute/SyncMultitenancyListenerOnPort443Step.php b/src/Steps/Compute/SyncMultitenancyListenerOnPort443Step.php new file mode 100644 index 0000000..d1541c4 --- /dev/null +++ b/src/Steps/Compute/SyncMultitenancyListenerOnPort443Step.php @@ -0,0 +1,50 @@ +createListener([ + 'LoadBalancerArn' => AwsResources::loadBalancer()['LoadBalancerArn'], + 'Protocol' => 'HTTPS', + 'Port' => 443, + 'Certificates' => [ + [ + 'CertificateArn' => AwsResources::certificate(Manifest::tenants()[0]['apex'])['CertificateArn'], + ], + ], + 'DefaultActions' => [ + [ + 'Type' => 'forward', + 'TargetGroupArn' => AwsResources::targetGroup()['TargetGroupArn'], + ], + ], + ...Aws::tags([ + 'Name' => Helpers::keyedResourceName(exclusive: false), + ]), + ]); + + return StepResult::CREATED; + } + + return StepResult::WOULD_CREATE; + } + } +} diff --git a/src/Steps/Compute/SyncTargetGroupStep.php b/src/Steps/Compute/SyncTargetGroupStep.php new file mode 100644 index 0000000..48d4bbc --- /dev/null +++ b/src/Steps/Compute/SyncTargetGroupStep.php @@ -0,0 +1,67 @@ +createTargetGroup([ + 'VpcId' => AwsResources::vpc()['VpcId'], + 'Name' => Helpers::keyedResourceName(exclusive: false), + 'Port' => 80, + 'Protocol' => 'HTTP', + 'HealthyThresholdCount' => 2, + 'UnhealthyThresholdCount' => 2, + 'HealthCheckEnabled' => true, + 'HealthCheckIntervalSeconds' => 10, + 'HealthCheckPath' => '/healthy', + 'HealthCheckTimeoutSeconds' => 5, + ...Aws::tags([ + 'Name' => Helpers::keyedResourceName(exclusive: false), + ]), + ]); + + Aws::elasticLoadBalancingV2()->modifyTargetGroupAttributes([ + 'TargetGroupArn' => AwsResources::targetGroup()['TargetGroupArn'], + 'Attributes' => [ + [ + 'Key' => 'deregistration_delay.timeout_seconds', + 'Value' => '30', + ], + [ + 'Key' => 'stickiness.enabled', + 'Value' => 'true', + ], + [ + 'Key' => 'stickiness.type', + 'Value' => 'lb_cookie', + ], + [ + 'Key' => 'stickiness.lb_cookie.duration_seconds', + 'Value' => '30', + ], + ], + ]); + + return StepResult::CREATED; + } + + return StepResult::WOULD_CREATE; + } + } +} diff --git a/src/Steps/Tenant/SyncRecordSetStep.php b/src/Steps/Tenant/SyncRecordSetStep.php new file mode 100644 index 0000000..403b751 --- /dev/null +++ b/src/Steps/Tenant/SyncRecordSetStep.php @@ -0,0 +1,63 @@ + 'UPSERT', // CREATE|DELETE|UPSERT + 'ResourceRecordSet' => [ + 'AliasTarget' => [ + 'DNSName' => $ALB['DNSName'], + 'EvaluateTargetHealth' => false, + 'HostedZoneId' => $ALB['CanonicalHostedZoneId'], + ], + 'Name' => $this->config['domain'], + 'Type' => 'A', + ], + ], + ]; + + if (! $this->config['subdomain']) { + // add a www. domain alias for non-subdomains + $changes[] = [ + 'Action' => 'UPSERT', + 'ResourceRecordSet' => [ + 'AliasTarget' => [ + 'DNSName' => $ALB['DNSName'], + 'EvaluateTargetHealth' => false, + 'HostedZoneId' => $ALB['CanonicalHostedZoneId'], + ], + 'Name' => "www.{$this->config['domain']}", + 'Type' => 'A', + ], + ]; + } + + Aws::route53()->changeResourceRecordSets([ + 'ChangeBatch' => [ + 'Changes' => $changes, + 'Comment' => 'Created by yolo CLI', + ], + 'HostedZoneId' => AwsResources::hostedZone($this->config['apex'])['Id'], + ]); + + return StepResult::SYNCED; + } + + return StepResult::WOULD_SYNC; + } +}