スクラップ&ビルドを繰り返せる検証環境をCDKで作成する記事

記事タイトルとURLをコピーする

前書き

CS1の石井です。

AWSでOS以上の検証を行いたいと考えた時、EC2を作成して検証するのが最も手軽だと考えています。

EC2の場合、OS以上の設定が壊れても立て直しが容易であり、場合によっては別プロジェクトのメンバーへの提供も容易だからです。

しかし、何度もスクラップ&ビルドを繰り返すと段々EC2を削除したり作成しなおすのも億劫になってきます。

そのため、検証用の環境を用意する一連のプロセスをCDKにまとめて記事にしたいと思い本記事を記載しました。

作成したい環境

  • 作成されるEC2はCloudFormationStackで管理。(後片付けをスタックベースで行いたいため)
  • 別プロジェクトのメンバーへの提供用に、WorkspacesをEC2の踏み台として作成する
  • 各Workspacesは自分に割り当てられたEC2しか接続できない

以下の図がイメージです。

全体的な進め方と対象読者

本記事ではCDKを主に使用します。

そのため、CDKのワークショップを終わらせている方、またはCDKをある程度理解している方を対象としています。

https://catalog.workshops.aws/typescript-and-cdk-for-beginner/ja-JP

コードだけほしい人向け

https://github.com/nov03/workspaces_sample

完成品のサンプルのURLです。git cloneしてご利用ください。

ただし、AdtStack以外のスタックはAdtStackで作成されたVPCがあることが前提となっています。

そのため、mainからAdtStack以外をコメントアウトし、AdtStackをデプロイし、ADにユーザを作成した後、他のスタックをデプロイしてください。

CDKディレクトリの構成

記事の中でデプロイするスタックの内容を記載していますが、基本的に npx cdk init --language typescript で作成した空プロジェクトのlibにスタックファイルを作成して利用することを想定しています。

binのエンドポイントとなるファイルは記事の中では bin/main.ts として記載しています。

binディレクトリの構成

binディレクトリはmain.tsのみ格納されており、main.tsはlibの中にあるスタックファイルを呼び出す構成となっております。

libディレクトリの構成

libディレクトリは作成するリソースのスタックファイルが格納されます。

本記事では、最終的に以下の構成となります。

nob@inspiron16:~/work/pres/sbs/pentest$ tree lib
lib
├── ad-stack.ts
├── api-gateway-codebuild-stack.ts
├── ec2-stack.ts
└── workspaces-stack.ts

0 directories, 4 files
nob@inspiron16:~/work/pres/sbs/pentest$ 

ManagedADの作成

まずはManagedADを作成します。 lib/ad-stack.ts というファイルを作成し、以下の内容で記載します。

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as directoryservice from 'aws-cdk-lib/aws-directoryservice';
import * as ssm from 'aws-cdk-lib/aws-ssm';
import { Tags } from 'aws-cdk-lib';

export class AdtStack extends cdk.Stack {
    constructor(scope: Construct, id: string, props?: cdk.StackProps) {
        super(scope, id, props);

        // VPCの作成
        const vpc = new ec2.Vpc(this, 'AdtVpc', {
            maxAzs: 2,
            natGateways: 1,
        });

        // VPCにネームタグを追加
        Tags.of(vpc).add('Name', 'ADVPC');

        // マネージドADの作成
        const managedAd = new directoryservice.CfnMicrosoftAD(this, 'ManagedAd', {
            name: 'example.com',
            password: 'SuperSecretPassword123!',
            vpcSettings: {
                vpcId: vpc.vpcId,
                subnetIds: vpc.privateSubnets.map(subnet => subnet.subnetId),
            },
            edition: 'Standard',
        });

        // パラメータストアにManaged ADのIDを保存
        new ssm.StringParameter(this, 'ManagedAdIdParameter', {
            parameterName: '/managedAd/id',
            stringValue: managedAd.ref,
        });

        // 出力
        new cdk.CfnOutput(this, 'AdtVpcId', {
            value: vpc.vpcId,
            description: 'VPC ID of the created VPC',
            exportName: 'AdtVpcId'
        });
        new cdk.CfnOutput(this, 'AdtManagedAdId', {
            value: managedAd.ref,
            description: 'ID of the created Managed AD',
            exportName: 'AdtManagedAdId'
        });
    }
}

上記のコードでは、作成したManagedADのIDをSSMパラメータの「/managedAd/id」に格納しています。

なお、ドメインアドミンはデフォルトで「Admin」であり、CDKで設定されている「SuperSecretPassword123!」というパスワードは「Admin」に付与されます。

※ここの仕様を分かっておらず、時間を食ってしまいました....自戒のために太文字にしました。

main.tsの記載

上記のAD作成スタックのファイルを呼び出すCDKのエンドポイントのファイルも編集する必要があります。

bin/main.ts に以下のように記載してください。

#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';
import { AdtStack } from '../lib/ad-stack';

const app = new cdk.App();

new AdtStack(app, 'AdtStack', {
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEFAULT_REGION
  }
});

上記で記載が完了した後、 npx cdk deploy AdtStack でデプロイを行います。

デプロイが完了したらSSMのパラメータからManagedADのIDを取得してみます。

nob@inspiron16:~/work/pres$ managedAdId=$(aws ssm get-parameter --name /managedAd/id --query "Parameter.Value" --output text)
nob@inspiron16:~/work/pres$ echo $managedAdId
d-95674547d8
nob@inspiron16:~/work/pres$

問題なく取得できればOKです。

操作用EC2の作成

WorkspacesはADにユーザーが登録されている必要があります。

ManagedADはAWS上から操作することができないため、操作用のEC2を作成します。

操作用のEC2はマネジメントコンソールで下記のボタンをクリックすれば作成可能です。 ※続くパラメータの画面では適宜スペックや鍵を指定可能ですが、操作用であればすべてデフォルトのままで問題ありません。

なお、すでにDirectory IDは取得済みなので以下のコマンドでも作成可能です。

aws ssm start-automation-execution --document-name "AWS-CreateDSManagementInstance" --document-version "\$DEFAULT" --parameters "{\"DirectoryId\":[\"$managedAdId\"],\"KeyPairName\":[\"NoKeyPair\"],\"IamInstanceProfileName\":[\"AmazonSSMDirectoryServiceInstanceProfileRole\"],\"SecurityGroupName\":[\"AmazonSSMDirectoryServiceSecurityGroup\"],\"AmiId\":[\"{{ssm:/aws/service/ami-windows-latest/Windows_Server-2019-English-Full-Base}}\"],\"InstanceType\":[\"t3.medium\"],\"RemoteAccessCidr\":[\"10.0.0.0/16\"],\"MetadataOptions\":[\"{\\\"HttpEndpoint\\\":\\\"enabled\\\",\\\"HttpTokens\\\":\\\"optional\\\"}\"]}" --region ap-northeast-1

SSMのセッションマネージャで初期状態を確認

作成したEC2でManagedADの作成状況を確認してみます。

PS C:\Windows\system32> $securePassword = ConvertTo-SecureString "SuperSecretPassword123!" -AsPlainText -Force
PS C:\Windows\system32> $adminCred = New-Object System.Management.Automation.PSCredential("example\Admin", $securePassword)
PS C:\Windows\system32>  Get-ADUser -Filter * -Credential $adminCred -Server 'example.com' | Select-Object Name, SamAccountName

Name          SamAccountName
----          --------------
Guest         Guest
krbtgt        krbtgt
Administrator Administrator
Admin         Admin


PS C:\Windows\system32> 

初期状態のユーザーは上記のような結果になりますが、EC2作成後、ManagedADへの接続にラグがあります。 

体感30分ぐらいは接続用EC2からManagedADに接続できずにエラーとなります。 エラーが出てしまったら、時間をおいて再度実行してみてください。

SSMのRunCommandでユーザーを追加

以下のコマンドでSSM経由でユーザーを追加してみます。

instanceIdsは先ほど構築したEC2のIDを入力し、parameters の部分は適宜変更して実行してください。

※下記の例では「P@ssw0rd2024!」というログインパスワードが設定された「hoge.fuga」というユーザが作成されます。

aws ssm send-command \
    --document-name "AWS-RunPowerShellScript" \
    --targets "Key=instanceIds,Values=i-XXXXXX" \
    --parameters 'commands=["$Username = \"hoge.fuga\"",
                           "$Firstname = \"hoge\"",
                           "$Lastname = \"fuga\"",
                           "$UserPrincipalName = \"hoge.fuga@example.com\"",
                           "$Email = \"hoge.fuga@example.com\"",
                           "$Password = ConvertTo-SecureString \"P@ssw0rd2024!\" -AsPlainText -Force",
                           "$AdminPassword = ConvertTo-SecureString \"SuperSecretPassword123!\" -AsPlainText -Force",
                           "$Credential = New-Object System.Management.Automation.PSCredential (\"example\\Admin\", $AdminPassword)",
                           "New-ADUser -Name \"$Firstname $Lastname\" `",
                           "    -GivenName $Firstname -Surname $Lastname `",
                           "    -SamAccountName $Username `",
                           "    -UserPrincipalName $UserPrincipalName `",
                           "    -Path \"OU=Users,OU=example,DC=example,DC=com\" `",
                           "    -AccountPassword $Password `",
                           "    -PasswordNeverExpires $true -Enabled $true -Credential $Credential `",
                           "    -EmailAddress $Email",
                           "Set-ADUser -Identity $Username -EmailAddress $Email -Credential $Credential"]' \
    --comment "Create user hoge.fuga in Active Directory with email" \
    --region ap-northeast-1

SSMのセッションマネージャで接続してユーザーが追加されているか確認

SSMで追加したユーザーが正常に追加されているか、SSMのセッションマネージャにて確認してみます。

PS C:\Windows\system32> $securePassword = ConvertTo-SecureString "SuperSecretPassword123!" -AsPlainText -Force
PS C:\Windows\system32> $adminCred = New-Object System.Management.Automation.PSCredential("example\Admin", $securePassword)
PS C:\Windows\system32>  Get-ADUser -Filter * -Credential $adminCred -Server 'example.com' | Select-Object Name, SamAccountName

Name          SamAccountName
----          --------------
Guest         Guest
krbtgt        krbtgt
Administrator Administrator
Admin         Admin
hoge fuga     hoge.fuga


PS C:\Windows\system32>

hoge fuga というユーザーが最後に追加されており、問題なさそうです。

Workspacesを作成する

ManagedADにユーザ登録が完了しましたので、Workspacesを作成してみます。

Webアクセスを有効にする

今回作成するWorkspacesの端末はWEBブラウザからアクセスさせたいため、WebAccessを有効化する必要があります。

以下の画像のように、デフォルトではWEB Accessがすべて拒否になっているので、WebAccessを有効化します。

DirectoryServiceの登録

WorkspacesはDirectoryServiceと紐づいている必要があるため、手動でWorkspacesとDirectoryServiceの紐づけを行います。

※登録する際パブリックサブネットを2つ指定しますが、特に指定しなくても既に作成されているパブリックサブネットを自動的に2つ選択されます。

Workspacesのデプロイ

ここからWorkspacesをデプロイします。

Directory ServiceのIDが必要なので、SSMからDirectoryServiceのIDを以下のコマンドで取得します。

managedAdId=$(aws ssm get-parameter --name /managedAd/id --query "Parameter.Value" --output text)

DirectoryIDが「managedAdId」に格納され、CDK実行時に引数として渡します。

引数を受け取るため lib/workspaces-stack.ts は以下の内容を記載します。

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as workspaces from 'aws-cdk-lib/aws-workspaces';

export interface WorkspacesStackProps extends cdk.StackProps {
    userName: string; // ユーザー名を指定するためのプロパティ
    managedAdId: string; // Managed AD IDを指定するプロパティ
}

export class WorkspacesStack extends cdk.Stack {
    constructor(scope: Construct, id: string, props: WorkspacesStackProps) {
        super(scope, id, props);

        // WorkSpacesの作成
        const workspace = new workspaces.CfnWorkspace(this, 'User1Workspace', {
            bundleId: 'wsb-55rrhyyg1', // WorkSpacesのバンドルIDを指定
            directoryId: props.managedAdId, // Managed AD IDを使用
            userName: props.userName, // デプロイ時の引数で指定されたユーザー名を使用
            workspaceProperties: {
                runningMode: 'AUTO_STOP',
                runningModeAutoStopTimeoutInMinutes: 60, // 1時間で自動シャットダウン
                rootVolumeSizeGib: 80,
                userVolumeSizeGib: 50,
                computeTypeName: 'STANDARD' // インスタンスのタイプを指定
            }
        });
    }
}

上記のスタックファイルを呼び出すため bin/main.ts にも編集を行います。

#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';
import { AdtStack } from '../lib/ad-stack';
import { WorkspacesStack } from '../lib/workspaces-stack';

const app = new cdk.App();

new AdtStack(app, 'AdtStack', {
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEFAULT_REGION
  }
});

// SSMパラメータからManaged ADのIDを取得
const managedAdId = app.node.tryGetContext('managedAdId') || 'dummyManagedAdId';

// ユーザー名をコンテキストから取得、指定がなければデフォルトのユーザー名を使用
const userName = app.node.tryGetContext('userName') || 'defaultDummyUser';

// ワークスペーススタックの作成
new WorkspacesStack(app, `WorkspacesStack`, {
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEFAULT_REGION,
  },
  managedAdId: managedAdId, // SSMパラメータから取得したManaged ADのIDを使用
  userName: userName, // ユーザー名を指定、コンテキスト引数がない場合はダミーのユーザー名を使用
});

上記のCDKを cdk deploy WorkspacesStack --context managedAdId=$managedAdId --context userName=hoge.fuga を実行しデプロイを行います。

デプロイされたWorkspacesのIPを調べる

Workspaceから検証用のEC2にアクセスする際、無関係のEC2にアクセスする必要はないので、EC2のSGにWorkspacesのIPだけに接続元を絞ります。

ただし、これはCDKだけでは完結しないため、AWS CLIを使用します。

nob@inspiron16:~/work/pres$  aws workspaces describe-workspaces --directory-id $managedAdId --user-name hoge.fuga --query "Workspaces[0].IpAddress"
"10.0.48.133"
nob@inspiron16:~/work/pres$ 

上記のコマンドで「 hoge.fuga 」というユーザーが使用するWorkspacesのIPが判明しました。これをEC2のSGに接続元のIPとして指定します。

EC2の作成

EC2はWorkspaceのIPだけに絞ってSGを設定したいと思います。

WorkspacesのIPはEC2を作成するCDKの中に記載する必要がありますが、今回はcdk deployコマンドの実行時に引数としてIPを指定して実行したいと思います。

以下のように lib/ec2-stack.ts を記載してください。

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as iam from 'aws-cdk-lib/aws-iam';

interface Ec2StackProps extends cdk.StackProps {
    workspaceIp: string;
}

export class Ec2Stack extends cdk.Stack {
    constructor(scope: Construct, id: string, props?: Ec2StackProps) {
        super(scope, id, props);

        // ネームタグ 'ADVPC' でVPCを取得
        const vpc = ec2.Vpc.fromLookup(this, 'Vpc', {
            vpcName: 'ADVPC'
        });

        // セキュリティグループの作成
        const securityGroup = new ec2.SecurityGroup(this, 'EC2SecurityGroup', {
            vpc,
            allowAllOutbound: true,
        });

        // IPアドレスが指定されている場合のみ、セキュリティグループにルールを追加
        if (props?.workspaceIp) {
            securityGroup.addIngressRule(ec2.Peer.ipv4(props.workspaceIp + '/32'), ec2.Port.tcp(22), 'Allow SSH from WorkSpace IP');
        }

        // IAMロールの作成
        const role = new iam.Role(this, 'EC2InstanceRole', {
            assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com'),
            managedPolicies: [
                iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonSSMManagedInstanceCore'),
                iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonEC2ReadOnlyAccess')
            ],
        });

        // パスワード認証用のユーザーデータスクリプト
        const userDataScript = ec2.UserData.forLinux();
        userDataScript.addCommands(
            'sudo sed -i "s/PasswordAuthentication no/PasswordAuthentication yes/" /etc/ssh/sshd_config',
            'echo "ec2-user:SuperSecretPassword123!" | sudo chpasswd',
            'sudo systemctl restart sshd'
        );

        // EC2インスタンスの作成
        const instance1 = new ec2.Instance(this, 'EC2Instance1', {
            vpc,
            instanceType: ec2.InstanceType.of(ec2.InstanceClass.T3, ec2.InstanceSize.MEDIUM),
            machineImage: ec2.MachineImage.latestAmazonLinux2023(),
            securityGroup,
            role,
            vpcSubnets: {
                subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
            },
            userData: userDataScript
        });

        const instance2 = new ec2.Instance(this, 'EC2Instance2', {
            vpc,
            instanceType: ec2.InstanceType.of(ec2.InstanceClass.T3, ec2.InstanceSize.MEDIUM),
            machineImage: ec2.MachineImage.latestAmazonLinux2023(),
            securityGroup,
            role,
            vpcSubnets: {
                subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
            },
            userData: userDataScript
        });

        const privateDnsName1 = instance1.instancePrivateDnsName;
        const privateDnsName2 = instance2.instancePrivateDnsName;
        new cdk.CfnOutput(this, 'EC2Instance1PrivateDnsName', { value: privateDnsName1 });
        new cdk.CfnOutput(this, 'EC2Instance2PrivateDnsName', { value: privateDnsName2 });
    }
}


EC2スタックを呼び出すため bin/main.ts にも以下のように追記を行います。

import { Ec2Stack } from '../lib/ec2-stack';


// EC2スタックの作成
new Ec2Stack(app, `Ec2Stack-${stackUserName}`, {  // ユーザー名を含めたスタック名を使用
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEFAULT_REGION
  },
  workspaceIp: app.node.tryGetContext('workspaceIp') || '0.0.0.0',  // 必要に応じて変更
});

上記の修正が完了したのち npx cdk deploy Ec2Stack --context workspaceIp=10.0.48.133 といったコマンドで実行します。 ※IPは先ほど調べたIPを入力してください。

WorkspacesからEC2に接続

Workspacesは今回WEBブラウザからアクセス可能な状態となっております。

そのため https://clients.amazonworkspaces.com/ にアクセスし、画面右上のWeb Access Loginをクリックします。

続いて登録コードを入力します。

WorkSpacesの登録コードは、WorkSpacesにユーザーが接続する際に使用する一意のコードです。

このコードは、ユーザーがWorkSpacesクライアントソフトウェアにサインインするときに必要です。

登録コードは、AWS管理コンソールのWorkSpacesサービスのセクションで確認できます。

登録コードはDirectoryServiceごとに払い出されるため、今回の構成だと全ユーザーが同一の登録コードになりますが、ADに登録されたパスワードで各ユーザのログインを制御しています。

登録コードを入力後、画面からサインインを行い、ログイン情報を入力します。

前ステップのCDKのサンプルを実行していれば以下のユーザーでログイン可能です。

ユーザ:hoge.fuga

パスワード:P@ssw0rd2024!

WorkspacesのWindowsからPowershellを起動し、EC2にSSHを行ってみます。

※今回は事前に接続先のEC2にパスワード認証をOKとする設定を行いました。 EC2への接続用のSSHキーはデプロイされるWorkspacesのイメージの中に埋め込んで使用したほうが便利かなと思います。

SSHのコマンドは以下のように、CDKデプロイ時に出力されたプライベートDNSでも接続ができます。

パスワードはEC2のサンプルCDKをデプロイしていた場合「SuperSecretPassword123!」となります。

ssh ec2-user@ip-10-0-161-118.ap-northeast-1.compute.internal

検証環境を自動的に作成する

ここからが本題です。

SSHできるところまで確認したので、これで土台となるIaC部分は完成です。ここから楽にスクラップ & ビルドするための方式を整えたいと思います。

具体的な使用イメージとしては、AWSアカウントに対してCDKを実行できるユーザがcurlコマンドを実行すれば作成可能な状態にしたいと思います。

自動的に作成するために必要な要素

今回の記事はIaCツールにCDKを選択しているため、ソースコードのCDKをgit cloneし、ビルド、デプロイする環境が必要です。そのためCodeCommitとCodeBuildを使います。

デプロイされるCDKはスタックをWorkspaces用とEC2作成用の2つに分割します。理由はWorkspacesの作成時間が長い事と、スクラップ&ビルドを行う対象はEC2だからです。

想定した運用イメージでは、利用者はCURLコマンドで環境を作成する使い方のため、ApiGatewayを作成し、CodeBuildを実行させる構成となります。

図にすると以下の構成となります。

要件に伴いCDKを修正・作成を行う

要件は上記で大体まとまったので、デプロイ手順となるCDKを考えます。

ApiGatewayはWorkspacesデプロイ用のパスとEC2デプロイ用のパスを持たせる必要があります。

デプロイ時はユーザーを特定する必要があるため、CURL実行時の引数にはADに登録されたユーザ名を指定する形式にします。

CodeBuildのビルド手順は本記事前段で記載した通り、WorkspacesのIPやManagedADのIDが必要ですが、これはデプロイ前のコマンドに仕込めばよさそうです。

また、使用するユーザは複数人での利用を想定しているため、スタック名に各ユーザの名前が載っていると管理がしやすいと思います。

これらの要件を考慮し、ApiGatewayとCodeBuildのCDKを考えると、以下のCDKとなります。このコードを lib/api-gateway-codebuild-stack.ts として保存します。

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import * as codebuild from 'aws-cdk-lib/aws-codebuild';
import * as codecommit from 'aws-cdk-lib/aws-codecommit';
import * as iam from 'aws-cdk-lib/aws-iam';

export class ApiGatewayAndCodeBuildStack extends cdk.Stack {
    constructor(scope: Construct, id: string, props: cdk.StackProps) {
        super(scope, id, props);

        // CodeCommitリポジトリの作成
        const repository = new codecommit.Repository(this, 'Ec2StackRepository', {
            repositoryName: 'ec2-stack-repo',
            description: 'Repository for EC2 stack CDK project',
        });

        // CodeBuildの実行ロールを作成し、AdministratorAccessを付与
        const codeBuildRole = new iam.Role(this, 'CodeBuildProjectRole', {
            assumedBy: new iam.ServicePrincipal('codebuild.amazonaws.com'),
            managedPolicies: [
                iam.ManagedPolicy.fromAwsManagedPolicyName('AdministratorAccess'), // AdministratorAccessを付与
            ],
        });

        // EC2Stack用のCodeBuildプロジェクトの作成
        const codeBuildProject = new codebuild.Project(this, 'CDKDeployProject', {
            role: codeBuildRole,
            source: codebuild.Source.codeCommit({
                repository,
                branchOrRef: 'main',
            }),
            environment: {
                buildImage: codebuild.LinuxBuildImage.STANDARD_7_0,
                privileged: true,
            },
            buildSpec: codebuild.BuildSpec.fromObject({
                version: '0.2',
                phases: {
                    install: {
                        runtime_versions: {
                            nodejs: 18
                        },
                        commands: [
                            'npm install -g aws-cdk',
                            'export PATH=$PATH:$(npm bin -g)',
                            'npm ci',
                            'npm install typescript ts-node @types/node',
                            'npm install aws-cdk-lib constructs'
                        ],
                    },
                    build: {
                        commands: [
                            'echo $USER_NAME',  // 確認のための出力
                            'managedAdId=$(aws ssm get-parameter --name /managedAd/id --query "Parameter.Value" --output text)',
                            'workspaceIp=$(aws workspaces describe-workspaces --directory-id $managedAdId --user-name $USER_NAME --query "Workspaces[0].IpAddress" --output text)',
                            `cdk deploy Ec2Stack-$(echo $USER_NAME | sed "s/\\./-/g") --require-approval never -c workspaceIp=$workspaceIp -c userName=$USER_NAME`
                        ],

                    },
                },
                env: {
                    variables: {
                        USER_NAME: "defaultDummyUser",
                    }
                }
            }),
        });

        // WorkspacesStack用のCodeBuildプロジェクトの作成
        const workspacesBuildProject = new codebuild.Project(this, 'WorkspacesDeployProject', {
            role: codeBuildRole,
            source: codebuild.Source.codeCommit({
                repository,
                branchOrRef: 'main',
            }),
            environment: {
                buildImage: codebuild.LinuxBuildImage.STANDARD_7_0,
                privileged: true,
            },
            buildSpec: codebuild.BuildSpec.fromObject({
                version: '0.2',
                phases: {
                    install: {
                        runtime_versions: {
                            nodejs: 18
                        },
                        commands: [
                            'npm install -g aws-cdk',
                            'export PATH=$PATH:$(npm bin -g)',
                            'npm ci',
                            'npm install typescript ts-node @types/node',
                            'npm install aws-cdk-lib constructs'
                        ],
                    },
                    build: {
                        commands: [
                            'echo $USER_NAME',  // 確認のための出力
                            'managedAdId=$(aws ssm get-parameter --name /managedAd/id --query "Parameter.Value" --output text)',
                            'cdk deploy WorkspacesStack-$(echo $USER_NAME | sed "s/\\./-/g") --require-approval never -c userName=$USER_NAME -c managedAdId=$managedAdId'
                        ],
                    },
                },
                env: {
                    variables: {
                        USER_NAME: "defaultDummyUser",
                    }
                }
            }),
        });

        // API Gatewayの作成
        const api = new apigateway.RestApi(this, 'DeployApi', {
            restApiName: 'CDK Deploy API',
            description: 'This API deploys CDK stacks based on the provided stack name.',
        });

        const requestTemplate = `
        {
          "projectName": "${codeBuildProject.projectName}",
          "environmentVariablesOverride": [
            {
              "name": "USER_NAME",
              "value": "$input.path('$.userName')",
              "type": "PLAINTEXT"
            }
          ]
        }`;

        // API Gateway統合の作成
        const integration = new apigateway.AwsIntegration({
            service: 'codebuild',
            action: 'StartBuild',
            integrationHttpMethod: 'POST',
            options: {
                credentialsRole: new iam.Role(this, 'ApiGatewayCodeBuildRole', {
                    assumedBy: new iam.ServicePrincipal('apigateway.amazonaws.com'),
                    managedPolicies: [
                        iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonAPIGatewayInvokeFullAccess'),
                        iam.ManagedPolicy.fromAwsManagedPolicyName('AWSCodeBuildDeveloperAccess'),
                    ],
                }),
                requestTemplates: {
                    'application/json': requestTemplate,
                },
                integrationResponses: [
                    {
                        statusCode: '200',
                    },
                ],
                requestParameters: {
                    'integration.request.header.Content-Type': "'application/x-amz-json-1.1'",
                    'integration.request.header.X-Amz-Target': "'CodeBuild_20161006.StartBuild'",
                },
            },
        });

        // 既存のPOSTメソッドの作成
        api.root.addMethod('POST', integration, {
            methodResponses: [
                {
                    statusCode: '200',
                },
            ],
        });

        // 新しいメソッド (workspacesdeploy) の追加
        const workspacesdeploy = api.root.addResource('workspacesdeploy');
        const workspacesRequestTemplate = `
        {
          "projectName": "${workspacesBuildProject.projectName}",
          "environmentVariablesOverride": [
            {
              "name": "USER_NAME",
              "value": "$input.path('$.userName')",
              "type": "PLAINTEXT"
            }
          ]
        }`;

        const workspacesIntegration = new apigateway.AwsIntegration({
            service: 'codebuild',
            action: 'StartBuild',
            integrationHttpMethod: 'POST',
            options: {
                credentialsRole: new iam.Role(this, 'WorkspacesApiGatewayCodeBuildRole', {
                    assumedBy: new iam.ServicePrincipal('apigateway.amazonaws.com'),
                    managedPolicies: [
                        iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonAPIGatewayInvokeFullAccess'),
                        iam.ManagedPolicy.fromAwsManagedPolicyName('AWSCodeBuildDeveloperAccess'),
                    ],
                }),
                requestTemplates: {
                    'application/json': workspacesRequestTemplate,
                },
                integrationResponses: [
                    {
                        statusCode: '200',
                    },
                ],
                requestParameters: {
                    'integration.request.header.Content-Type': "'application/x-amz-json-1.1'",
                    'integration.request.header.X-Amz-Target': "'CodeBuild_20161006.StartBuild'",
                },
            },
        });

        workspacesdeploy.addMethod('POST', workspacesIntegration, {
            methodResponses: [
                {
                    statusCode: '200',
                },
            ],
        });
    }
}


エンドポイントの追加

上記のApiGatewayとCodeBuildのデプロイと、ユーザ名をスタック名に入れるという要件を反映させるため bin/main.ts を修正します。

EC2とWorkspacesをデプロイするスタックの名前にユーザ名を追加します。 また、EC2にもユーザ名でタグが付いているとわかりやすいかなと思い、EC2のスタック側にもタグを入れる修正を加えました。

bin/main.ts に以下の内容で修正を行います。

#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';
import { AdtStack } from '../lib/ad-stack';
import { WorkspacesStack } from '../lib/workspaces-stack';
import { ApiGatewayAndCodeBuildStack } from '../lib/api-gateway-codebuild-stack';
import { Ec2Stack } from '../lib/ec2-stack';

const app = new cdk.App();

new AdtStack(app, 'AdtStack', {
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEFAULT_REGION
  }
});

// SSMパラメータからManaged ADのIDを取得
const managedAdId = app.node.tryGetContext('managedAdId') || 'dummyManagedAdId';

// ユーザー名をコンテキストから取得、指定がなければデフォルトのユーザー名を使用
const userName = app.node.tryGetContext('userName') || 'defaultDummyUser';

// ユーザー名のドットをハイフンに置換して`stackUserName`を作成
const stackUserName = userName.replace(/\./g, '-');

// ワークスペーススタックの作成
new WorkspacesStack(app, `WorkspacesStack-${stackUserName}`, {
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEFAULT_REGION,
  },
  managedAdId: managedAdId, // SSMパラメータから取得したManaged ADのIDを使用
  userName: userName, // ユーザー名を指定、コンテキスト引数がない場合はダミーのユーザー名を使用
});

// EC2スタックの作成
new Ec2Stack(app, `Ec2Stack-${stackUserName}`, {  // ユーザー名を含めたスタック名を使用
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEFAULT_REGION
  },
  workspaceIp: app.node.tryGetContext('workspaceIp') || '0.0.0.0',  // 必要に応じて変更
  userName: userName,  // ユーザー名を渡す
});

// API GatewayとCodeBuildスタックの作成
new ApiGatewayAndCodeBuildStack(app, 'ApiGatewayAndCodeBuildStack', {
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEFAULT_REGION,
  },
});

次にEC2を作成するスタックの ec2-stack.ts の冒頭部分を修正します。

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as iam from 'aws-cdk-lib/aws-iam';

interface Ec2StackProps extends cdk.StackProps {
    workspaceIp: string;
    userName: string;
}

export class Ec2Stack extends cdk.Stack {
    constructor(scope: Construct, id: string, props: Ec2StackProps) {
        super(scope, id, props);

        // スタック全体にタグを追加
        cdk.Tags.of(this).add('Name', props.userName);

        // ネームタグ 'ADVPC' でVPCを取得
        const vpc = ec2.Vpc.fromLookup(this, 'Vpc', {
            vpcName: 'ADVPC'
        });


bin/main.ts の修正が完了したら npx cdk deploy ApiGatewayAndCodeBuildStack コマンドでApiGatewayとCodeBuildのデプロイを行います。

CodeCommitに対してコードをプッシュする

上記でスタックファイルはすべて記載ができたと思います。 また、ApiGatewayとCodeBuildもデプロイできたため、以降のCDKデプロイを行う環境はCodeBuildとなります。

CodeBuildはCodeCommitを参照しているため、上記の api-gateway-codebuild-stack.ts で作成したCodeCommitにプッシュを行います。

ApiGatewayに対しCurlでリソース作成を実行する

ApiGatewayはCURLで実行可能な状態になっているため、実際にコマンドを実行し、CodeBuildが動くか確認してみます。

Workspacesの作成

Workspaces作成の場合のcurlコマンドは以下です。

 curl -X POST https://XXXX.execute-api.ap-northeast-1.amazonaws.com/prod/workspacesdeploy -H "Content-Type: application/json" -d '{"userName": "hoge.fuga"}'

正常に実行されれば以下のようにCodeBuildが作動します。

また、CFnのスタックも以下のようになります。

※以下の画像は実行したときに「nob.ishii」というユーザで実行したため「WorkspacesStack-nob-ishii」というスタック名で作成されていることが確認できます。

EC2の作成

EC2の場合、接続元IPをWorkspacesのIPでSGで絞っているため、先にWorkspacesが作成されていることが前提です。

作成のCURLコマンドのサンプルは以下となります。

curl -X POST https://XXXX.execute-api.ap-northeast-1.amazonaws.com/prod/ -H "Content-Type: application/json" -d '{"userName": "hoge.fuga"}'

以上でEC2の作成も削除も簡単な環境を作ることができました。

ここからさらにworkspacesからのsshプロセスが一定期間なければEC2をシャットダウンとか、1日以上利用がなければcdk destroyを行うようなEventBridgeを作ればさらに費用を節約できそうです。

また、ADに登録されていないユーザーをcurlで指定された場合のエラーハンドリングといった要素も入れ込めばよいかなぁと思います。

まとめ

CDKのコードがかなり長くなってしまいましたが、大したことはやっていません。EC2作成の部分以外は手でやってしまってもいいんじゃないかなと思いました。

また、記事で使用したコードの8割はAIに作ってもらいました。

手順書もCDKベースで書けるとAIの支援が受けやすいので、IaCという選択肢も段々お手軽感が増してきたなぁと思います。

なお、個人的にWorkspacesを使わなくともAppstream 2.0のほうがADを使わなくて手軽かな?と思いましたが、別の機会に試したいと思います。