AWS IoT TwinMaker Sample(クッキー工場)のリソース作成を AWS CDK で IaC 化してみた

2024.01.09

こんにちは、CX 事業本部 Delivery 部の若槻です。

AWS IoT TwinMaker Sample は、AWS IoT TwinMaker を使ったクッキー工場(Cookie Factory)のデジタルツインアプリケーションを構築できるデモサンプルです。

以前にこの AWS IoT TwinMaker Sample を実際に試してみた内容を記事にしました。

AWS IoT TwinMaker Sample ではコマンドやスクリプトを使ってリソースの作成や設定を行っていますが、以下の一部リソースについては手動で作成する手順となっています。

  • AWS Cloud9 環境
  • Amazon Managed Grafana ワークスペース
  • AWS IoT TwinMaker ワークスペース

しかし手順を試すたびにこれらのリソースを手動で作成するのは手間が掛かります。そこで今回は、これらのリソースを AWS CDK で IaC 化して作成するようにしてみました。

手順概要

一部リソースの作成を AWS CDK で IaC 化した場合の手順概要は以下のようになります。

  • AWS CDK によるリソース作成
  • Prerequisites
  • Deploying the Sample Cookie Factory Workspace
    • 1. Set up environment variables.
    • 2. Install Python Libraries.
    • 3. Create an IoT TwinMaker workspace.
      • c. Create a Grafana dashboard IAM role
    • 4. Deploy an Instance of the Timestream Telemetry module.
    • 5. Use the following commands to import the Cookie Factory content.

「Prerequisites」では Cloud9 環境上で前提条件の確認を行うのですが、その Cloud9 環境の作成を CDK で行います。また「3. Create an IoT TwinMaker workspace.」の a および b の手順(AWS IoT TwinMaker の実行ロールおよびワークスペースの作成)は、CDK による作成で置き換えます。

AWS CDK コード

AWS CDK のコードのディレクトリ構成は次のような構成になります。作成するリソースが多いため Construct を機能で分割しています。

  • bin/
    • cdk_sample_app.ts
  • lib/
    • cdk-sample-stack.ts
    • construct/
      • cloud9.ts
      • grafana.ts
      • iottwinmaker.ts

Cloud9 Construct

AWS Cloud9 環境を作成する Construct です。この上で後続の手順のコマンドやスクリプトを実行します。

lib/construct/cloud9.ts

import { Construct } from 'constructs';
import { aws_ec2, Stack, CfnOutput, Duration } from 'aws-cdk-lib';
import * as aws_cloud9_alpha from '@aws-cdk/aws-cloud9-alpha';

interface Cloud9ConstructProps {
  readonly cloud9AssumeRoleName: string;
}

export class Cloud9Construct extends Construct {
  constructor(scope: Construct, id: string, props: Cloud9ConstructProps) {
    super(scope, id);

    const { cloud9AssumeRoleName } = props;
    const accounId = Stack.of(this).account;

    // デフォルト VPC の取得
    const defaultVpc = aws_ec2.Vpc.fromLookup(this, 'DefaultVPC', {
      isDefault: true,
    });

    // Cloud9 環境のオーナーとして使用する IAM Assume された Role
    const cloud9EnvOwner = aws_cloud9_alpha.Owner.assumedRole(
      accounId,
      cloud9AssumeRoleName
    );

    // Cloud9 環境を作成
    const cloud9Environment = new aws_cloud9_alpha.Ec2Environment(
      this,
      'Environment',
      {
        instanceType: new aws_ec2.InstanceType('t2.large'),
        imageId: aws_cloud9_alpha.ImageId.AMAZON_LINUX_2023,
        vpc: defaultVpc,
        connectionType: aws_cloud9_alpha.ConnectionType.CONNECT_SSM,
        owner: cloud9EnvOwner,
        automaticStop: Duration.minutes(30),
      }
    );

    // Cloud9 環境の IDE の接続 URL を出力
    new CfnOutput(this, 'IdeUrl', { value: cloud9Environment.ideUrl });
  }
}

  • Cloud9 環境の IDE に接続するための Assume された Role(cloud9AssumeRoleName)の指定は、後述の CDK App で指定して注入します。

参考

Grafana Construct

Amazon Managed Grafana ワークスペースを作成する Construct です。この上で IoT TwinMaker のデジタルツインを可視化するためのダッシュボードを作成します。

lib/construct/grafana.ts

import { Construct } from 'constructs';
import { aws_iam, aws_grafana, Stack, CfnOutput } from 'aws-cdk-lib';

interface GrafanaConstructProps {
  readonly grafanaWorkspaceSamlAdminRoles: string[];
  readonly grafanaWorkspaceSamlIdpMetadataUrl: string;
}

export class GrafanaConstruct extends Construct {
  public readonly grafanaDashboard: aws_grafana.CfnWorkspace;
  constructor(scope: Construct, id: string, props: GrafanaConstructProps) {
    super(scope, id);

    const {
      grafanaWorkspaceSamlAdminRoles,
      grafanaWorkspaceSamlIdpMetadataUrl,
    } = props;
    const accounId = Stack.of(this).account;
    const region = Stack.of(this).region;

    // Grafana ワークスペース用の IAM Role を作成
    const principal = new aws_iam.ServicePrincipal(
      'grafana.amazonaws.com'
    ).withConditions({
      StringEquals: {
        'aws:SourceAccount': accounId,
      },
      StringLike: {
        'aws:SourceArn': `arn:aws:grafana:${region}:${accounId}:/workspaces/*`,
      },
    });
    const executionRole = new aws_iam.Role(this, 'ExecutionRole', {
      assumedBy: principal,
    });
    const executionRoleArn = executionRole.roleArn;

    // Grafana ワークスペース用の IAM Role の ARN を出力
    new CfnOutput(this, 'ExecutionRoleArn', { value: executionRoleArn });

    // Grafana ワークスペースを作成
    const workspace = new aws_grafana.CfnWorkspace(this, 'Workspace', {
      accountAccessType: 'CURRENT_ACCOUNT',
      authenticationProviders: ['SAML'],
      permissionType: 'SERVICE_MANAGED',
      roleArn: executionRoleArn,
      pluginAdminEnabled: true,
      grafanaVersion: '9.4',
      // 2 回目の CDK デプロイで設定
      // SAML 連携の設定
      samlConfiguration: {
        assertionAttributes: {
          name: 'nickname',
          login: 'email',
          email: 'email',
          role: 'email',
        },
        loginValidityDuration: 1440,
        roleValues: {
          admin: grafanaWorkspaceSamlAdminRoles,
        },
        idpMetadata: {
          url: grafanaWorkspaceSamlIdpMetadataUrl,
        },
      },
    });
    const workspaceId = workspace.ref;
    this.grafanaDashboard = workspace;

    // 1 回目の CDK デプロイでのみ出力
    // // Service provider identifier (Entity ID) の出力
    // new CfnOutput(this, 'WorkspaceServiceProviderIdentifier', {
    //   value: `https://${grafanaWorkspaceId}.grafana-workspace.${region}.amazonaws.com/saml/metadata`,
    // });

    // // Service provider reply URL (Assertion consumer service URL) の出力
    // new CfnOutput(this, 'WorkspaceServiceProviderReplyUrl', {
    //   value: `https://${grafanaWorkspaceId}.grafana-workspace.${region}.amazonaws.com/saml/acs`,
    // });

    // Grafana ダッシュボード URL の出力
    new CfnOutput(this, 'DashboardUrl', {
      value: `https://${workspaceId}.grafana-workspace.${region}.amazonaws.com`,
    });
  }
}

  • ダッシュボードの管理者権限を持つユーザーのメールアドレス(grafanaWorkspaceSamlAdminRoles)、および SAML 連携の設定を行うための IdP メタデータの URL(grafanaWorkspaceSamlIdpMetadataUrl)を後述の CDK App で指定して注入します。
  • Amazon Managed Grafana の SAML の構成は 2 回に分けて CDK デプロイして行う必要があります。

参考

IoT TwinMaker Construct

AWS IoT TwinMaker ワークスペースを作成する Construct です。

lib/construct/iottwinmaker.ts

import { Construct } from 'constructs';
import {
  aws_s3,
  aws_grafana,
  aws_iam,
  aws_iottwinmaker,
  Stack,
  RemovalPolicy,
} from 'aws-cdk-lib';

interface IoTTwinMakerConstructProps {
  readonly grafanaDashboard: aws_grafana.CfnWorkspace;
  readonly iotTwinMakerWorkspaceId: string;
}

export class IoTTwinMakerConstruct extends Construct {
  constructor(scope: Construct, id: string, props: IoTTwinMakerConstructProps) {
    super(scope, id);

    const { grafanaDashboard, iotTwinMakerWorkspaceId } = props;
    const accounId = Stack.of(this).account;
    const region = Stack.of(this).region;

    // IoT TwinMaker ワークスペース用リソース保管バケット
    const workspaceResourceBucket = new aws_s3.Bucket(
      this,
      'WorkspaceResourceBucket',
      {
        removalPolicy: RemovalPolicy.DESTROY,
        autoDeleteObjects: true,
        cors: [
          {
            allowedHeaders: ['*'],
            allowedMethods: [
              aws_s3.HttpMethods.GET,
              aws_s3.HttpMethods.POST,
              aws_s3.HttpMethods.PUT,
              aws_s3.HttpMethods.DELETE,
              aws_s3.HttpMethods.HEAD,
            ],
            allowedOrigins: [
              `https://${grafanaDashboard.ref}.grafana-workspace.${region}.amazonaws.com`,
            ],
            exposedHeaders: ['ETag'],
            maxAge: 300,
          },
        ],
      }
    );

    // IoT TwinMaker ワークスペース実行ロール用プリンシパル
    const principal = new aws_iam.ServicePrincipal(
      'iottwinmaker.amazonaws.com'
    ).withConditions({
      StringEquals: {
        'aws:SourceAccount': accounId,
      },
      StringLike: {
        'aws:SourceArn': `arn:aws:iottwinmaker:${region}:${accounId}:workspace/${iotTwinMakerWorkspaceId}`,
      },
    });

    // IoT TwinMaker ワークスペース実行ロール
    const workspaceExecutionRole = new aws_iam.Role(
      this,
      'WorkspaceExecutionRole',
      {
        assumedBy: principal,
        inlinePolicies: {
          readWorkspaceResourceBucket: aws_iam.PolicyDocument.fromJson({
            Version: '2012-10-17',
            Statement: [
              {
                Effect: 'Allow',
                Action: [
                  's3:GetBucket',
                  's3:GetObject',
                  's3:ListBucket',
                  's3:PutObject',
                  's3:ListObjects',
                  's3:ListObjectsV2',
                  's3:GetBucketLocation',
                ],
                Resource: [
                  workspaceResourceBucket.bucketArn,
                  workspaceResourceBucket.arnForObjects('*'),
                ],
              },
              {
                Effect: 'Allow',
                Action: ['s3:DeleteObject'],
                Resource: [
                  workspaceResourceBucket.arnForObjects(
                    `DO_NOT_DELETE_WORKSPACE_${iotTwinMakerWorkspaceId}`
                  ),
                ],
              },
            ],
          }),
          accessComponentType: aws_iam.PolicyDocument.fromJson({
            Version: '2012-10-17',
            Statement: [
              {
                Action: ['iotsitewise:*', 'kinesisvideo:*'],
                Resource: ['*'],
                Effect: 'Allow',
              },
              {
                Action: ['lambda:invokeFunction'],
                Resource: ['*'],
                Effect: 'Allow',
              },
              {
                Condition: {
                  StringEquals: {
                    'iam:PassedToService': 'lambda.amazonaws.com',
                  },
                },
                Action: ['iam:PassRole'],
                Resource: ['*'],
                Effect: 'Allow',
              },
            ],
          }),
        },
      }
    );

    // IoT TwinMaker ワークスペース
    new aws_iottwinmaker.CfnWorkspace(this, 'Workspace', {
      workspaceId: iotTwinMakerWorkspaceId,
      s3Location: workspaceResourceBucket.bucketArn,
      role: workspaceExecutionRole.roleArn,
    });
  }
}

  • iotTwinMakerWorkspaceIdCookieFactory を指定します。
  • ここで作成する「IoT TwinMaker ワークスペース用リソース保管バケット」は、Grafana のダッシュボードからアクセスできるように CORS を設定する必要があります。設定しない場合は次のエラーが発生します。
    • > Access to fetch at 'https://xxxxxxxxxxxxx.s3.us-east-1.amazonaws.com/CookieFactory.json?x-id=GetObject' from origin 'https://yyyyyyyyyyyy.grafana-workspace.us-east-1.amazonaws.com' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

参考

CDK Stack & App

AWS CDK の Stack と App は次のようになります。

lib/cdk-sample-stack.ts

import { Construct } from 'constructs';
import { Stack, StackProps } from 'aws-cdk-lib';

import { Cloud9Construct } from './construct/cloud9';
import { GrafanaConstruct } from './construct/grafana';
import { IoTTwinMakerConstruct } from './construct/iottwinmaker';

interface CdkSampleStackProps extends StackProps {
  readonly cloud9AssumeRoleName: string;
  readonly iotTwinMakerWorkspaceId: string;
  readonly grafanaWorkspaceSamlAdminRoles: string[];
  readonly grafanaWorkspaceSamlIdpMetadataUrl: string;
}

export class CdkSampleStack extends Stack {
  constructor(scope: Construct, id: string, props: CdkSampleStackProps) {
    super(scope, id, props);

    const {
      cloud9AssumeRoleName,
      iotTwinMakerWorkspaceId,
      grafanaWorkspaceSamlAdminRoles,
      grafanaWorkspaceSamlIdpMetadataUrl,
    } = props;

    // Cloud9 環境を作成
    new Cloud9Construct(this, 'Cloud9', { cloud9AssumeRoleName });

    // Grafana ワークスペースを作成
    const { grafanaDashboard } = new GrafanaConstruct(this, 'Grafana', {
      grafanaWorkspaceSamlAdminRoles,
      grafanaWorkspaceSamlIdpMetadataUrl,
    });

    // IoT TwinMaker ワークスペースを作成
    new IoTTwinMakerConstruct(this, 'IoTTwinMaker', {
      iotTwinMakerWorkspaceId,
      grafanaDashboard,
    });
  }
}

bin/cdk_sample_app.ts

import { App } from 'aws-cdk-lib';
import { CdkSampleStack } from '../lib/cdk-sample-stack';

const app = new App();

new CdkSampleStack(app, 'CdkSampleStack', {
  env: {
    account: 'XXXXXXXXXXXX',
    region: 'us-east-1',
  },
  iotTwinMakerWorkspaceId: 'CookieFactory',
  cloud9AssumeRoleName: 'YYYYYYYYYYYY',
  grafanaWorkspaceSamlAdminRoles: ['hoge@example.com'],
  grafanaWorkspaceSamlIdpMetadataUrl:
    'https://aaaaaaaa.jp.auth0.com/samlp/metadata/bbbbbbbb', // 2 回目の CDK デプロイで指定
});

動作確認

前述の CDK コードをデプロイした上で、「Prerequisites」および「Deploying the Sample Cookie Factory Workspace」の手順を実施します。

これにより、次のようなデジタルツインアプリケーションによりクッキー工場を可視化したダッシュボードを構築することができました。

おわりに

AWS IoT TwinMaker Sample(クッキー工場)のリソース作成を AWS CDK で IaC 化してみたのでご紹介しました。

こちらの手順は今後も繰り返し実施することになると思うので重宝しそうです。しかし今後元の手順が更新される可能性もあるので、定期的に見直すことも忘れずに行いたいと思います。

以上