From 59d2fbb797dd59d1775b9fa84cbcbca6db55fcab Mon Sep 17 00:00:00 2001 From: Ihor S Date: Wed, 3 Sep 2025 10:43:58 +0200 Subject: [PATCH] test cicd --- .github/workflows/deploy-dev.yml | 65 +++++++++------ README.md | 19 +++++ infra/bin/infra.ts | 4 +- infra/config.dev.ts | 10 +-- infra/lib/infra-stack.ts | 137 +++++++++++++++++++++++++++---- 5 files changed, 190 insertions(+), 45 deletions(-) diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index 9c4418d..f66e0d7 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -11,10 +11,9 @@ jobs: if: github.event.pull_request.merged runs-on: ubuntu-latest env: - EB_BUCKET: ${{vars.EB_BUCKET}} - EB_APP_NAME: ${{vars.EB_APP_NAME}} - EB_API_ENV_NAME: ${{vars.EB_API_ENV_NAME}} - EB_WORKER_ENV_NAME: ${{vars.EB_WORKER_ENV_NAME}} + CODE_BUCKET: ${{vars.CODE_BUCKET}} + REGION: ${{ vars.AWS_REGION }} + EC2_INSTANCE_ID: ${{ vars.EC2_INSTANCE_ID }} steps: - name: Checkout @@ -56,30 +55,48 @@ jobs: name: Upload to S3 run: | FILE_NAME="app-${{ github.sha }}.zip" - aws s3 cp app.zip s3://$EB_BUCKET/$FILE_NAME + aws s3 cp app.zip s3://$CODE_BUCKET/$FILE_NAME echo "FILE_NAME=$FILE_NAME" >> $GITHUB_ENV - if: ${{ steps.cache-api.outputs.cache-hit != 'true' }} - name: Create new Elastic Beanstalk Application Version + name: Update EC2 from S3 and restart docker-compose run: | - VERSION_LABEL="v-${{ github.run_number }}-${{ github.sha }}" - aws elasticbeanstalk create-application-version \ - --application-name "$EB_APP_NAME" \ - --version-label $VERSION_LABEL \ - --source-bundle S3Bucket="$EB_BUCKET",S3Key="$FILE_NAME" - echo "VERSION_LABEL=$VERSION_LABEL" >> $GITHUB_ENV + # Send SSM command to sync code, rebuild, restart, and run migrations + aws ssm send-command \ + --document-name "AWS-RunShellScript" \ + --targets "Key=instanceids,Values=$EC2_INSTANCE_ID" \ + --comment "Deploy $GITHUB_SHA" \ + --parameters commands='[ + "set -e", + "cd /var/www/app", + "aws s3 cp s3://'"$CODE_BUCKET"'/'"$FILE_NAME"' app.zip --region '"$REGION"'", + "unzip -o app.zip, + "chown -R ec2-user:ec2-user /var/www/app", #//?? + "sudo -u ec2-user /usr/local/bin/docker-compose up -d --build", #//?? + "sleep 15", + "sudo -u ec2-user /usr/local/bin/docker-compose exec -T api npm run migration:run" + ]' / + --region "$REGION" \ + --query "Command.CommandId" \ + --output text > command_id.txt + + COMMAND_ID=$(cat command_id.txt) + echo "SSM CommandId: $COMMAND_ID" + + # Optionally wait for completion + aws ssm wait command-executed \ + --command-id "$COMMAND_ID" \ + --instance-id "$EC2_INSTANCE_ID" \ + --region "$REGION" + + # Fetch and print last 100 lines of output for visibility + aws ssm list-command-invocations \ + --command-id "$COMMAND_ID" \ + --details --region "$REGION" \ + --query "CommandInvocations[0].CommandPlugins[0].Output" \ + --output text | tail -n 100 || true + + - - if: ${{ steps.cache-api.outputs.cache-hit != 'true' }} - name: Update Environment API - run: | - aws elasticbeanstalk update-environment \ - --environment-name "$EB_API_ENV_NAME" \ - --version-label "$VERSION_LABEL" - - if: ${{ steps.cache-api.outputs.cache-hit != 'true' }} - name: Update Environment WORKER - run: | - aws elasticbeanstalk update-environment \ - --environment-name "$EB_WORKER_ENV_NAME" \ - --version-label "$VERSION_LABEL" diff --git a/README.md b/README.md index 467d24c..deb485c 100644 --- a/README.md +++ b/README.md @@ -69,3 +69,22 @@ echo $INSTANCE_IP ```bash ssh -i ~/.ssh/simplenestjs-dev-key.pem ec2-user@$INSTANCE_IP ``` + +# CI/CD on GitHub + +## 1. add sectrets + +gather these secrets from your aws console (create access key for user ionicapp-userdeployer) + +AWS_ACCESS_KEY_ID=... (access key form console) +AWS_SECRET_ACCESS_KEY=... (secret key form console) + +## 2. add variables + +gather these variables from cdk output or aws cli + +CODE_BUCKET=... (cdk output: ionicapp-devStack.CodeBucketName) +EC2_INSTANCE_ID=... (cdk output: ionicapp-devStack.InstanceId) +AWS_REGION=... (aws cli command: aws configure get region) + + diff --git a/infra/bin/infra.ts b/infra/bin/infra.ts index 81a9f86..625387e 100644 --- a/infra/bin/infra.ts +++ b/infra/bin/infra.ts @@ -8,8 +8,8 @@ export interface IAppStackConfig { databaseName: string; domainName: string; projectName: string; - subDomainNameApi: string; - fullSubDomainNameApi: string; + subDomainNameApp: string; + fullSubDomainNameApp: string; userDeploerName: string; databaseUsername: string; targetNodeEnv: string; diff --git a/infra/config.dev.ts b/infra/config.dev.ts index cec1fce..f998e33 100644 --- a/infra/config.dev.ts +++ b/infra/config.dev.ts @@ -10,9 +10,9 @@ const projectName = projectShortName + suffix; // define your registered domain (you must have one at Route53) const domainName = 'for-test.click'; -// subdomain for api (will be created) -const subDomainNameApi = `api.${projectName}`; -const fullSubDomainNameApi = `${subDomainNameApi}.${domainName}`; +// subdomain for app (will be created, and route .../api will be used to serve api ) +const subDomainNameApp = `${projectName}`; +const fullSubDomainNameApp = `${subDomainNameApp}.${domainName}`; // user for deployment using CI/CD (will be created) const userDeploerName = `${projectName}-deployer`; @@ -32,8 +32,8 @@ export const config: IAppStackConfig = { databaseName, domainName, projectName, - subDomainNameApi, - fullSubDomainNameApi, + subDomainNameApp, + fullSubDomainNameApp, userDeploerName, databaseUsername, targetNodeEnv, diff --git a/infra/lib/infra-stack.ts b/infra/lib/infra-stack.ts index 9ebad65..2260444 100644 --- a/infra/lib/infra-stack.ts +++ b/infra/lib/infra-stack.ts @@ -20,8 +20,13 @@ export class InfraStack extends cdk.Stack { props?: cdk.StackProps, ) { super(scope, id, { ...props, crossRegionReferences: true }); - const { projectName, domainName, fullSubDomainNameApi, subDomainNameApi } = - config; + const { + projectName, + domainName, + fullSubDomainNameApp, + subDomainNameApp, + userDeploerName, + } = config; /** * * @@ -147,7 +152,7 @@ export class InfraStack extends cdk.Stack { certificateStack, `${projectName}Certificate`, { - domainName: fullSubDomainNameApi, + domainName: fullSubDomainNameApp, validation: acm.CertificateValidation.fromDns( route53.HostedZone.fromLookup( certificateStack, @@ -231,7 +236,7 @@ export class InfraStack extends cdk.Stack { const userData = ec2.UserData.forLinux(); userData.addCommands( 'yum update -y', - 'yum install -y docker git', + 'yum install -y docker git unzip', 'systemctl start docker', 'systemctl enable docker', 'usermod -a -G docker ec2-user', @@ -308,7 +313,8 @@ export class InfraStack extends cdk.Stack { ), }); - // Create key pair + // Tag instance for easy SSM targeting + cdk.Tags.of(ec2Instance).add('Name', `${projectName}-ec2`); /** * @@ -320,34 +326,127 @@ export class InfraStack extends cdk.Stack { * */ + // S3 bucket for frontend + const frontendBucket = new s3.Bucket(this, `${projectName}FrontendBucket`, { + bucketName: `${projectName}-frontend-${this.account}`, + removalPolicy: cdk.RemovalPolicy.DESTROY, + autoDeleteObjects: true, + websiteIndexDocument: 'index.html', + websiteErrorDocument: 'index.html', // For SPA routing + publicReadAccess: true, + blockPublicAccess: s3.BlockPublicAccess.BLOCK_ACLS_ONLY, + }); + // add cloudfront distribution to handle https const distribution = new cloudfront.Distribution( this, `${projectName}Distribution`, { defaultBehavior: { - origin: new origins.HttpOrigin(ec2Instance.instancePublicDnsName, { - httpPort: 3000, - protocolPolicy: cloudfront.OriginProtocolPolicy.HTTP_ONLY, // Force HTTP to origin - }), + // Frontend as default behavior + origin: new origins.S3StaticWebsiteOrigin(frontendBucket), viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, - allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL, // Important for REST (GET, POST, PUT, DELETE) - cachePolicy: cloudfront.CachePolicy.CACHING_DISABLED, // For API responses + cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED, + }, + additionalBehaviors: { + '/api/*': { + // API behavior + origin: new origins.HttpOrigin(ec2Instance.instancePublicDnsName, { + httpPort: 3000, + protocolPolicy: cloudfront.OriginProtocolPolicy.HTTP_ONLY, + }), + viewerProtocolPolicy: + cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, + allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL, + cachePolicy: cloudfront.CachePolicy.CACHING_DISABLED, + }, }, - domainNames: [fullSubDomainNameApi], + + domainNames: [fullSubDomainNameApp], certificate: certificate, }, ); new route53.ARecord(this, `${projectName}ARecord`, { zone: zone, - recordName: subDomainNameApi, + recordName: subDomainNameApp, target: route53.RecordTarget.fromAlias( new route53targets.CloudFrontTarget(distribution), ), }); + /** + * + * + * + * USER DEPLOYER + * + * + * + */ + + // Add IAM user to deploy code + const userDeploer = new iam.User(this, `${projectName}Deployer`, { + userName: userDeploerName, + }); + + userDeploer.attachInlinePolicy( + new iam.Policy(this, `${projectName}DeployerPolicy`, { + policyName: `publish-to-${projectName}`, + statements: [ + new iam.PolicyStatement({ + actions: ['ssm:GetParameter'], + effect: iam.Effect.ALLOW, + resources: [ + `arn:aws:ssm:${this.region}:${this.account}:parameter/${projectName}*`, + ], + }), + + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: ['s3:*'], + resources: [ + `arn:aws:s3:::cdk-hnb659fds-assets-${this.account}-${this.region}`, + `arn:aws:s3:::cdk-hnb659fds-assets-${this.account}-${this.region}/*`, + ], + }), + + // Allow publishing artifacts to the dedicated code bucket + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: ['s3:*'], + resources: [codeBucket.bucketArn, `${codeBucket.bucketArn}/*`], + }), + + // Allow triggering SSM RunCommand to restart docker on the instance + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: [ + 'ssm:SendCommand', + 'ssm:ListCommandInvocations', + 'ssm:ListCommands', + ], + resources: ['*'], + }), + + // EC2 permissions for EB environment management + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: ['ec2:Describe*'], + resources: ['*'], + }), + + // CloudWatch Logs permissions + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: ['logs:*'], + resources: ['*'], + }), + ], + }), + ); + /** * * @@ -368,9 +467,19 @@ export class InfraStack extends cdk.Stack { }); // Output the EC2 instance ID - new cdk.CfnOutput(this, 'InstanceId', { + new cdk.CfnOutput(this, 'EC2 InstanceId', { value: ec2Instance.instanceId, description: 'EC2 instance ID', }); + + new cdk.CfnOutput(this, 'UserDeploerName', { + value: userDeploerName, + description: 'User deployer name', + }); + + new cdk.CfnOutput(this, 'FrontendBucketName', { + value: frontendBucket.bucketName, + description: 'S3 bucket for frontend deployment', + }); } }