Visual Studio入りAMIが使える様になった件をライセンス面から調査してみた

2022.08.06

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

しばたです。

先日AWSからVisual Studio入りのAMIが使える様になった旨のアナウンスがありました。

これらのアナウンスではかなりあっさり目の説明しかされていませんが、Microsoftライセンスの都合上、Visual Studio入りのAMIを利用するにはかなり面倒な前提条件がありますので本記事ではライセンス面を中心に解説してきたいと思います。

免責事項

本記事ではMicrosoftライセンスの話をしますので例によって免責事項をば。

極力間違いの無い様に努めて書いていますが、あくまでも私いち個人の解釈にすぎず、本記事の内容はライセンスに対する記述の正確さを保証しません。
仮に本記事の内容に誤りがあり、それによりいかなる不利益を被ったとしても一切の責任を負えませんので予めご了承ください。

ライセンスに関する正式な判断が必要になる場合は必ず Microsoft および AWS に確認してください。

概要 (やってみた)

弊社吉井が基本的な手順を記事にしていますのでまずはこちらをご覧ください。

公式AMI

追加されたAMIはAWS Marketplaceにあります。

Visual Studio 2022 Professional入りAMI(Windows Server 2022)とVisual Studio 2022 Enterprise入りAMI(Windows Server 2022)の二種類が追加されました。
これら以外のAMIは従来からあるAWSでない企業が提供するAMIです。

Visual Studio 2022 Professional入りのほうの詳細画面はこんな感じです。

とりあえず起動してみるが...

弊社吉井の記事ではAWS Managed Microsoft ADを使っていますが、これが必須なのか気になる方もいると思います。
なので、まずは特に何の前準備もなくいきなりこのAMIをサブスクライブして起動してみます。

が、起動後すぐにインスタンスが停止し「終了(Terminate)」されてしまいます。

Visual Studio入りAMI利用の前提条件

この挙動は意図したものであり、Visual Studio入りのAMIを使う場合は事前にAWS License ManagerとAWS Managed Microsoft ADを使いソフトウェアのライセンス管理設定を準備しておく必要があります。
また、起動するEC2もライセンス管理のためにSSMが利用できる環境(ネットワークおよびIAMロール設定)にある必要があります。

SPLAライセンスからの観点

なぜAWS License ManagerとAWS Managed Microsoft ADが必要になるのか、Microsoft Services Provider License Agreement(SPLA)の観点から解説します。

以降の内容は私個人による予測を含みます。

まず、AWS環境においてMicrosoft製品を使う場合は基本的にSPLA契約に基づいてサービスプロバイダー(SP、ここではAWSのこと)から環境が提供されます。

Windows Server OSはSPLAコアライセンス、Visual StudioはVisual Studio SAL(SPLAユーザーライセンス)で提供されます。
そしてWindows Serverにサーバー管理用途以外でRDP接続する場合はRemote Desktop SAL(SPLAユーザーライセンス)も必要となります。

ここで言う「ユーザー」は同時接続数ではなく特定の個人と紐づく形のライセンスです。
このためVisual Studioを使う場合はどこかでユーザー管理をする必要があり、ここにAWS License Managerが使われる形になっています。

加えてRDS SALを使う場合はOSの仕様によりActive Directory環境下にあるRD License Serverを別途構築しなければなりません。 *1
RDS SALの利用のためにAWS Managed Microsoft ADが必要なんだと推測されます。

また、OSがWindowsクライアントOSで無くWindows ServerなのもMicrosoftライセンスの都合により「Micorosftが認めたベンダー(QMTH)しかWindows 10/Windows 11をクラウド環境で提供できない」という制約があるためです。 *2

SPLAの仕組みを知っていると「AWSではこの実装にせざるを得ないな」という感じで割と納得がいきます。

ちなみにWindows Server OSのライセンスはAWSが管理するMicrosoft KMSサーバー(169.254.169.250,169.254.169.251)で管理されています。こちらは従来通りの話です。

ネットワーク前提条件

ネットワークの前提条件として以下の要件があります。

  • AWS Managed Microsoft AD用に2つのAZに分かれた2つのサブネットが必要
  • Visual Studio入りEC2でAWS Systems Managerを使用するためサービスエンドポイントへのアクセスが必要
    • インターネットへの経路 or VPC Endpoint どちらでも可

AWS Managed Microsoft ADの用意

Microsoftライセンス管理のためにAWS Managed Microsoft AD環境が必要です。
エディションは「Standard Edition」「Enterprise Edition」どちらでも構いません。

本記事ではcorp.contoso.comというディレクトリ環境を用意しておきます。

AWS License Managerの用意

続けてAWS License Managerの設定が必要です。

マネジメントコンソールからAWS License Managerを開きます。
初回起動時は「サービス利用のオプトイン」と「サービスリンクロールの作成」を求められますので、表示された場合は指示に従って進めてください。

ダッシュボードでは画面左側に新しく「ユーザーベースのサブスクリプション」の表示欄が増えています。
右側の「ライセンス使用権限」欄はMarketplaceでサブスクライブした製品の表示欄です。

AWS License Mangerでは

  • Marketplaceのサブスクライブで「Visual Studio入りAMIを使うこと」に同意
  • 各製品(Visual Stduio, RDS SAL)のサブスクライブで利用ライセンス数を管理

と2種類の管理をしています。
本記事のメインとなるのは後者の方で、左ペインの「ユーザーベースのサブスクリプション」を選択すると製品ごとのライセンス管理ができます。

画面上部に「AWS Managed Microsoft ADの設定が検出されません」と警告が出てる様に、はじめにAWS Managed Microsoft ADとの紐づけを行ってやる必要があります。

左ペインの「設定」から「AWS Managed Microsoft Active Directory」を選んでやり、

対象のディレクトリと利用する製品を指定してやります。
製品は「Visual Studio Professional」「Visual Studio Enterprise」の二種類から選べます。
(下図は「Visual Studio Professional」のみ選んだ状態)

設定後しばらく待つと完了となります。

ちなみに現状ひとつのリージョンのAWS License Managerに紐づけできるディレクトリは一つだけの様です。
残念ながら複数のディレクトリをそれぞれ個別に管理することは出来ません。

また、この時にAWS Managed Microsoft ADの配置されているサブネットに追加のENIが増やされます。

このENIについて調べてみるとドメイン環境にAWS管理のWindows Server 2019が一台増えているのが確認できました。(ユーザーからは見えない環境にいます)
どうやらこのマネージドなインスタンスからActive Diretoryに対する各種操作を行ってる様です。

IAM Roleの用意

利用するEC2がSSMを利用できる(SSM Run Commandの対象となる)必要があるため、最低限AmazonSSMManagedInstanceCoreポリシーがアタッチされたIAM Roleが必要です。
また、Marketplace AMIのためaws-marketplace:MeterUsageの権限も必要なのでAWSMarketplaceMeteringFullAccessポリシーもアタッチしてやります。

ざっくり以下のCloudFormationテンプレートで必要な権限を持つIAMロールが作れます。
CloudFormationからよしなに作ってください。

AWSTemplateFormatVersion: 2010-09-09
Parameters:
  RoleName:
    Description: "Input IAM role name."
    Type: String
    Default: "EC2RoleForVisualStudioInstance"
Resources:
  # IAM Role
  VisualStudioInstanceRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName:
        Fn::Sub: "${RoleName}"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: "Allow"
            Principal:
              Service:
                - "ec2.amazonaws.com"
            Action:
              - "sts:AssumeRole"
      Path: "/"
      ManagedPolicyArns:
        - "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
        - "arn:aws:iam::aws:policy/AWSMarketplaceMeteringFullAccess"
  # Instance Profile
  VisualStudioInstanceProfile:
    Type: "AWS::IAM::InstanceProfile"
    Properties:
      InstanceProfileName:
        Fn::Sub: "${RoleName}"
      Path: "/"
      Roles:
        - Ref: VisualStudioInstanceRole

EC2の起動

AWS License ManagerとIAMロールの準備ができたらEC2を起動可能になります。

MarcketplaceのAMI(今回はVisual Studio Professonalのほう)を選び環境に応じた設定をしてやりEC2を作成します。

IAMロールの設定を忘れずにしてください。

これでEC2を作成してやれば、直ちに終了することなく、しばらく待てばEC2が利用可能になります。
ただし、通常のEC2の作成とは異なりAWS License ManagerがEC2の起動を検知して以下の処理を行います。

  • License MangaerがEC2に対してSSM Run CommandでAWS-RunPowerShellドキュメントを実行
    • 処理の内容は連携したディレクトリ情報を使い、ワンタイムパスワードを使ってActive Directoryに参加する
    • Active Directory参加後に一度再起動される
  • License ManagerがEC2の起動を検知するも、SSM Run Command出来ない場合は直ちにTerminateする

加えてこのインスタンスはローカルユーザーでRDP接続出来ない様にグループポリシーで設定されており、RDP接続しようとするとこんな感じで拒否されます。
(後述のAWS License Managerで関連付けしたユーザーのみ接続可能に制限されます)

そしてEC2が利用可能になったあとはAWS License Manager側でもアクティブ化済みインスタンスとして扱われます。

ユーザーライセンスの割り当て

ここで改めてAWS License Mangerに戻りVisual Stuidoを利用できるドメインユーザーの設定(サブスクライブ)を行います。
割り当てはVisual Studioと同時にRDS SALも行われますのでMarketplaceで両方ともサブスクライブしておいてください。

この状態からVisual Studioを選択し「ユーザーをサブスクライブ」すると登録画面に遷移します。
Visual Studioを利用したいドメインユーザーを追加してください。

少し待つと登録が完了します。

製品欄をクリックすると内訳が表示されます。

ちなみに、RDS SALをMarketplaceでサブスクライブせずに処理を進めると以下の様なエラーになります。

製品 Visual Studio Professional について、次のユーザーはサブスクライブされていませんでした。 xxxxxxxx - Subscription is not present for the specified product REMOTE_DESKTOP_SERVICES

ユーザーとインスタンスの紐づけ

これでライセンスの割り当ては完了かと思ったのですが、AWS環境では上記に加えて「インスタンスと利用者の紐づけ」も必要でした。
SPLAの規約上Visual Studio SALやRDS SALはユーザーライセンスであり、ライセンスを割り当てられたユーザーは利用環境を制限されることは無いのですがAWSとしてはインスタンス毎の利用者も管理する様です。

ライセンスマネージャーから「ユーザーベースのサブスクリプション→ユーザーの関連付け」に移動し、利用したいインスタンスを選択します。

ちょっとややこしいですが「ユーザーを関連付ける」をクリックして登録画面に遷移します。

こちらも少し待つと登録が完了します。

インスタンスIDをクリックすると内訳が表示されます。

これでやっと対象のインスタンスにRDP接続できます。

ちなみにOSおよびVisual Studioは英語版でした。

費用感

本日(2022年8月)時点の費用感についてはMarketplaceのページを見る限り以下の様です。

製品 価格(税抜) (参考)Visual Studioサブスクリプション 備考
Visual Studio 2022 Professional 48.51 USD/unit 45 USD/month
Visual Studio 2022 Enterprise 304.15 USD/unit 250 USD/month
Remote Desktop Services SAL 10 USD/unit - RDS SALに決まった定価は無くSP次第

Visual Studioサブスクリプションの価格より若干上乗せされた価格となっています。
RDS SALについてはSPによって価格が変わるのですが、だいたい8 USD/month前後で提供されているので一般的な価格設定かと思います。

ちなみにAppStream 2.0はユーザー一人当たりのRDS SALが 4.19 USD/month とかなりの破格です。
こちらはActive Directoryの利用も必須でないですし、AWSとMicrosoft間でかなり特別な契約が結ばれているものと予測されます。

また、料金の日割りについてはSPLAの基本が「日割り無し(SPからMicrosoftへの支払いが日割り無し *3)」のためほとんどの場合でユーザーへの請求も日割り無しとなります。
EC2でのWindows Server OSの利用は秒単位の課金となっていますがこれはかなり例外的な扱いです。あくまで推測ですが、こちらはコアライセンスなので融通を利かせやすいのでしょう。

【2022/08/07】追記

記事公開後にVisual Studio分のコストが計上されました。

サービス名が「Visual Studio Professional」という形で、サブスクライブした日付で48.51 USD計上されました。
また、8/1日付けで消費税も 4.85 USD計上されました。税抜価格だったんですね、コレ...

詳細はこんな感じです。RDS SALの料金が計上されたらまた追記します。(追記しました)
RDS SALが北米リージョンの利用費になっているのは謎ですが、まあ、金額は正しいので気にしないことにします。

サービス 記述 数量 費用(USD)
Visual Studio Professional AWS Marketplace software usage ap-northeast-1 Metering on per user basis 1 48.51
Visual Studio Professional Tax for product code xxxxxxxxxxxxxxxxxxxxxx usage type APN1-MP:PerUserMeter-Units operation Usage 1 4.85
Remote Desktop Service SAL AWS Marketplace software usage us-east-1 Per user / Per month 1 10
Remote Desktop Service SAL Tax for product code xxxxxxxxxxxxxxxxxxxxxx usage type MP:PerUserMeter-Units operation Usage 1 1

Visual Studioに関する補足

ここから何点かVisual Studioに関する補足をします。

補足1 : Visual Studioとライセンスモビリティ

Microsoftのソフトウェアアシュアランス(SA)特典の一つにオンプレ向けボリュームライセンスのソフトウェアをクラウド環境へ移動できる「ライセンスモビリティ」があります。

本日時点でライセンスモビリティの対象にVisual Studioは含まれておらず、通常のVisual StudioをEC2に持ち込みインストールするのはライセンス違反となりますのでご注意ください。

こちらはVisual Studio以外のソフトウェア、例えばOfficeも同様です。
基本的に「オンプレとクラウドは全く別環境でありライセンスは共用できない(=手持ちのMicrosoft製品をクラウド環境にインストールできない)」と考えておくと安全です。

補足2 : Visual Studio Community Editionの利用

SPLAでの各種ソフトウェアの利用条件を記載したServices Provider Use Rights (SPUR)を確認すると、Visual Studioは

  • Visual Studio Enterprise
  • Visual Studio Professional
  • Visual Studio Test Professional

がSPLAの対象となっており、無償版であるVisual Studio CommunityはSPLAの対象外です。
Visual Studio Communityは個人、教育用途、または小規模な組織の限定的な利用で利用可能で、その条件はライセンス条項に記載されています。

こちらの条項を見る限り利用環境に対する制限は明記されていません。
(対してVisual Studio ProfessionalおよびEnterpriseのライセンス条項には利用環境に対する制限が記載されています)
このため、おそらくですが、クラウド環境でも利用できるだろうと推測されます。

当然ですが「禁止されていないから許可されているわけでは無い」ので、言質が必要な場合はMicrosoftに確認してください。
本記事ではクラウド環境におけるVisual Studio Communityの利用について一切保証は出来ませんので予めご了承ください。

補足3 : Azure VMでのVisual Studio利用

こちらはAWSでなくAzureの話になります。

Azure VMではVisual Studio入りのWindows 10/Windows 11イメージが利用可能であり、AWS環境の様な制約はありません。

Visual Studioの利用に関してはサブスクリプション版の特典の一つとして「Azure VMでの使用権」がありそれを行使する形で実現している様です。(実際にサブスクリプション版を買ったことが無いので推測になります...)

そしてOSについて、AzureではWindows ServerではないクライアントOSであるWindows 10/Windows 11が利用可能です。
クライアントOSなのでRDS SALは不要です。 *4
RDS SALが不要なおかげでActive Direcotry環境やRD License Serverも要りません。

この様なかたちでAzureではライセンス上の優位性によりVisual Studioの利用が容易となっています。

その他補足

ここからはVisual Studio以外に気づいた点を補足していきます。

補足1 : RDS SAL用のRD License Server

通常RDS SALを使う場合はActive Directory環境に別途RD License Serverを構築しそこに利用ライセンス数を登録する必要があります。
その上で各インスタンスにRDSHの機能を追加しRD License Serverのライセンスを使用する様設定します。

この設定を行わないと内部的なRemote Desktop接続のモードが変わらないため同時2セッション以上の接続ができません。

本記事で試したインスタンスを調べてみましたが、上記のRD License Serverに対する設定は一切変更されておらずデフォルトのままでした。
これだと多人数で1台のEC2を共用するユースケースに対応できないのですが、これが意図した制限なのか不具合なのかはわかりませんでした...

こちらについてはAWSサポートに問い合わせみたいと思います。
なお、問い合わせ結果を公開できる保証はありませんのでご了承ください。

ちなみにVisual Studioの利用はどう考えても「サーバー管理用途」からは外れますので、RD License Serverの有無にかかわらずRDS SALの契約は必要です。

補足2 : ローカル管理者の利用

前に述べた様にVisual Studio入りEC2ではRDP接続が制限されており、許可されたユーザー以外はAdministratorですらRDP接続できません。
Visual Studio入りEC2においてローカル管理者での利用は想定されていない模様です。

補足3 : 定期的な状態チェック

RDP接続の制限はグループポリシーによって実現されています。
インスタンス毎、利用ソフトウェア毎でポリシーが作成され適用されます。

そしてAWS License Managerによってグループポリシーの適用状況をチェックするSSM Run Commandがだいたい1時間に1回実行されます。
実行内容はざっくりこんな感じで、チェック結果をJSONで取得して利用してる様です。

展開して表示
$username = "RO-LM-NRT"
$pwd = ConvertTo-SecureString "xxxxxxxxxx (専用ユーザーパスワード) xxxxxxxxxx" -AsPlainText -Force
$region = "NRT"
# SSM Neti Audit Powershell Script

#==================================================
# Utility Functions
#==================================================
Function Write-TerminatingError {
    <#
    .SYNOPSIS
    write the error to the console anSd exit the program.
    #>
    Param (
        [String]$Message
    )
    Write-Error $Message
    exit 1
}

$isSecurityPolicyProcessingValid = $false
$isGroupPoliciesBackgroundRefreshEnabled = $false
$isGroupPoliciesRefreshIntervalValid = $false
$isRestrictedGroupRuleValid = $false
$isAllowLogonRuleValid = $false
$isDenyLogonRuleValid = $false

# Check if restricted group contains computer user group
if (!(Get-Module -Name RSAT -ErrorAction SilentlyContinue) | out-null) {Install-WindowsFeature RSAT}
if (!(Get-Module -Name GPMC -ErrorAction SilentlyContinue) | out-null) {Install-WindowsFeature GPMC}

$cred = New-Object System.Management.Automation.PSCredential ($username, $pwd)
$instanceId = (New-Object System.Net.WebClient).DownloadString("http://169.254.169.254/latest/meta-data/instance-id")
$hostName = hostname

try {
    $rsopReportPath = Join-Path ([IO.Path]::GetTempPath())([IO.Path]::GetRandomFileName())
    gpresult /scope computer /X $rsopReportPath
            $report = Get-Content $rsopReportPath
            $restrictedGroupMembers = $report.Rsop.ComputerResults.ExtensionData.Extension.RestrictedGroups.Member.Name.'#text'
    $groupName = $instanceId + "-user-group"
    $isRestrictedGroupRuleValid = $restrictedGroupMembers.Contains($groupName)

    foreach ($gpo in $report.Rsop.ComputerResults.ExtensionData.Extension.UserRightsAssignment) {
        if (($gpo.Name -eq "SeRemoteInteractiveLogonRight")) {
            $isAllowLogonRuleValid = $gpo.Member.Name.'#text'.Contains($instanceId)
        } elseif ($gpo.Name -eq "SeDenyRemoteInteractiveLogonRight") {
            $isDenyLogonRuleValid = $gpo.Member.Name.'#text'.Contains("Local account")
        }
    }
    Remove-Item -Path $rsopReportPath
} catch [System.Exception] {
    Write-TerminatingError -Message 'Failed to get isAllowedLogonRuleValid and isDenyLogonRuleValid. Error: $_'
}

# Check registry
$securityProcessing = 0
$bkgndGroupPolicy = 0
$groupPolicyRefreshTimeOffset = 0
$groupPolicyRefreshTime = 0

$expectedRefreshTime = 15
$expectedRereshTimeOffset = 5
$securityProcessingConfigPath = 'HKLM:\Software\Policies\Microsoft\Windows\Group Policy\{827D319E-6EAC-11D2-A4EA-00C04F79F83A}'
$bkgndGroupPolicyPath = 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Policies\System'
$groupPolicyRefreshTimePath = 'HKLM:\Software\Policies\Microsoft\Windows\System'
$bkgndGroupPolicyRegistryKey = 'DisableBkGndGroupPolicy'

Function Test-RegistryValue {
    param(
        [Parameter(Mandatory = $true)][String]$Path,
        [Parameter(Mandatory = $true)][String]$Name
    )

    process {
        if (Test-Path $Path) {
            $Key = Get-Item -LiteralPath $Path
            if ($Key.GetValue($Name, $null) -ne $null) {
                if ($PassThru) {
                    Get-ItemProperty $Path $Name
                } else {
                    $true
                }
            } else {
                $false
            }
        } else {
            $false
        }
    }
}

try {
    $securityProcessing = Get-ItemPropertyValue -Path $securityProcessingConfigPath -Name 'NoBackgroundPolicy'
    if ($securityProcessing -eq 0) {
        $isSecurityPolicyProcessingValid = $true
    }
} catch [System.Exception] {
    Write-TerminatingError -Message 'Failed to get isSecurityPolicyProcessingValid. Error: $_'
}

if (Test-RegistryValue $bkgndGroupPolicyPath $bkgndGroupPolicyRegistryKey) {
    $bkgndGroupPolicy = Get-ItemPropertyValue -Path $bkgndGroupPolicyPath -Name $bkgndGroupPolicyRegistryKey
    if ($bkgndGroupPolicy -eq 0) {
        $isGroupPoliciesBackgroundRefreshEnabled = $true
    }
} else { # DisableBkGndGroupPolicy is sometimes not set, and if that is the case let the check pass
    $isGroupPoliciesBackgroundRefreshEnabled = $true
}

try {
    $groupPolicyRefreshTime = Get-ItemPropertyValue -Path $groupPolicyRefreshTimePath -Name 'GroupPolicyRefreshTime'
    $groupPolicyRefreshTimeOffset = Get-ItemPropertyValue -Path $groupPolicyRefreshTimePath -Name 'GroupPolicyRefreshTimeOffset'
    if (($groupPolicyRefreshTime -eq $expectedRefreshTime) -and ($groupPolicyRefreshTimeOffset -eq $expectedRereshTimeOffset)) {
        $isGroupPoliciesRefreshIntervalValid = $true
    }
} catch [System.Exception] {
    Write-TerminatingError -Message 'Failed to get isGroupPoliciesRefreshIntervalValid. Error: $_'
}

$ouPath = "OU=$($instanceId)-OU,OU=$region,OU=LicenseManager,OU=AWS Reserved"
$computer = Get-ADComputer -Identity $hostname -Credential $cred -Properties DistinguishedName
$isValidOUPath = $computer.DistinguishedName.Contains($ouPath)
$auditT = [DateTimeOffset]::Now.ToUnixTimeSeconds()
$json = @"
{
    "allowLogonRuleValid": $isAllowLogonRuleValid,
    "denyLogonRuleValid": $isDenyLogonRuleValid,
    "restrictedGroupRuleValid": $isRestrictedGroupRuleValid,
    "securityPolicyProcessingValid": $isSecurityPolicyProcessingValid,
    "groupPoliciesBackgroundRefreshEnabled": $isGroupPoliciesBackgroundRefreshEnabled,
    "groupPoliciesRefreshIntervalValid": $isGroupPoliciesRefreshIntervalValid,
    "validOUPath": $isValidOUPath,
    "auditTs": $auditT
}
"@
echo $json

チェックに失敗するとどうなるのかは全くわかりません...

補足4 : 自動ドメイン参加スクリプト

EC2初回起動時にAWS License Managerによって実行されるドメイン参加のスクリプトはこんな感じです。
AWS License Managerの管理する専用OU配下にEC2を登録しています。

展開して表示
<#
 .SYNOPSIS
 Domain joins an instance to the customers MAD

 .DESCRIPTION
 This script will domain join an instance in the MAD under the given OU, this script will run on customers instance using SSM

 It will perform the follow actions:
 1. Validation : Check if instance is part of domain already and under the correct OU, if not proceed
 2. Install AD tools on the instance
 3. Set the DNS Address to a MAD Domain Controllers IP
 4. Domain join the instance to the computer object already created using One time machine pascode
 5. Exit with status 3010 which tells ssm to restart the computer and rerun the script for validation on 1

As this script is running on the cutomers instance we will not be placing it on the instance rather will be running it by directly passing the script in a SSM RunPowershell document.

#>

#==================================================
# Utility Functions 
#==================================================
Function Write-TerminatingError {
    <#
    .SYNOPSIS
    write the error to the console and exit the program.
    #>
    Param (
        [String]$Message
    )
    Write-Error $Message
    exit 1
}

Function Install-ADTools {
    $attempts=3
    $sleepInSeconds=1
    do
    {
        try
        {
            Write-Output 'Installing AD tools on the instance...'
            Install-WindowsFeature -Name 'RSAT-AD-Tools' -ErrorAction Stop
            break;
        }
        catch [System.Exception]
        {
            Write-Output "Unable to install AD tools on the machine, exception : $_"
            if($attempts -eq 1)
            {
                 Write-TerminatingError -Message "Unable to install AD tools on the machine, failing error :  $_"
            }
        }
        $attempts--
        if ($attempts -gt 0) { sleep $sleepInSeconds }
    } while ($attempts -gt 0)
}

#==================================================
# Main
#==================================================
Function Domain-Join-Instance{
    <# The script accepts the below paramters
        DCIpAddress1,DCIpAddress2 : Domain Controller IPs for DCs of the directory, Domain: customers domain, OneTimeMachinePasscode : Password for the Computer Object for this instance
    #>
    Param(
        [string]$DCIpAddress1,
        [string]$DCIpAddress2,
        [string]$Domain,
        [string]$OneTimeMachinePasscode,
        [string]$ROUserPass,
        [string]$ROUsername,
        [string]$Region,
        [string]$InstanceId

    )

    $LMReservedOUName = 'LicenseManager'
    $sleepInSeconds=1

    try{
        $CurrentDomain = Get-CimInstance -ClassName 'Win32_ComputerSystem' -ErrorAction Stop | Select-Object -ExpandProperty 'Domain'
    }
    catch [System.Exception] {
        Write-TerminatingError -Message "Unable to Computer details to check current domain: $_"
    }

    # We validate if gpo update is working, if not we need to perform the domain join.
    # when we are recovering a broken instance, it's possible that the local domain name is right but the computer object has been replaced.
    $GPUpdateResult = gpupdate /force
    $GPUpdateResult = Out-String -InputObject $GPUpdateResult
    $GPErrorMsg = "Computer policy could not be updated successfully."

    if(($CurrentDomain -eq $Domain) -and (-not ($GPUpdateResult -match $GPErrorMsg))){
        Write-Output "Instance is part of domain :  $CurrentDomain"

        $username = "$Domain\$ROUsername"
        $password = $ROUserPass | ConvertTo-SecureString -asPlainText -Force
        $credential = New-Object System.Management.Automation.PSCredential($username,$password)

        #Getting OU Path of computer object
        $attempts=3
        do
        {
            try
            {
                Write-Output 'Getting AD Computer details...'
                $ADComputer = Get-ADComputer -Identity $ENV:ComputerName -Credential $credential -ErrorAction Stop
                break;
            }
            catch [System.Exception]
            {
                Write-Output "Unable to get AD Computer details..., exception : $_"
                if($attempts -eq 1)
                {
                     Write-TerminatingError -Message "Unable to fetch details for the computer object: $_"
                }
            }
            $attempts--
            if ($attempts -gt 0) { sleep $sleepInSeconds }
        } while ($attempts -gt 0)

        $DistinguishedName = $ADComputer | Select-Object -ExpandProperty DistinguishedName
        # Example for DistinguishedName : CN=EC2AMAZ-PCQS7MN,OU=i-05f83f681c65dfd6c-OU,OU=IAD,OU=LicenseManager,OU=AWS Reserved,DC=example,DC=com

        $OUPathArr = $DistinguishedName -split ',',2
        $CurrentOUPath = $OUPathArr[1]

        #Building the OU Path from customers domain and reserved ou path
        $attempts=3
        do
        {
            try
            {
                Write-Output 'Getting AD Domain details...'
                $ADDomain = Get-ADDomain -credential $credential -ErrorAction Stop
                break;
            }
            catch [System.Exception]
            {
                Write-Output "Unable to get AD Domain details......, exception : $_"
                if($attempts -eq 1)
                {
                     Write-TerminatingError -Message "Unable to fetch AD domain details: $_"
                }
            }
            $attempts--
            if ($attempts -gt 0) { sleep $sleepInSeconds }
        } while ($attempts -gt 0)

        $DomainFQDN = $ADDomain.DNSRoot
        $BasePath = $ADDomain.DistinguishedName
        $ReservedOUPath = "OU=$LMReservedOUName,OU=AWS Reserved"
        $ExpectedOUPath = "OU=$InstanceId-OU,OU=$Region,$ReservedOUPath,$BasePath"

        #SUCCESS : If computer is part of OU - exit 0
        if($CurrentOUPath -eq $ExpectedOUPath){
            Write-Output "Success... Instance was domain joined under the correct OU, at path : $ExpectedOUPath"
            exit 0
        } else{
            Write-TerminatingError -Message "Failure... Instance is joined under OU path : $CurrentOUPath `nInstead of expected OU path : $ExpectedOUPath"
        }
    } else{
        Write-Output "Instance is not part of domain so proceeding to join it to the domain: $Domain"
    }

    # in case of recovery, we also need to remove it from any domain explicitly even if it's already in the target domain.
    Write-Output "Removing the computer from the current domain if it's in any, failure to do so will not terminate the script."
    $hostname = hostname
    netdom remove $hostname /domain: /force

    #Install AD Tools
    Install-ADTools

    Write-Output 'Setting the DNS address to MAD Domain Controller IP...'
    try{
        $dns = Get-DnsClientServerAddress
        Write-Output "Current Value for index " $dns.InterfaceIndex[0] "-" $dns.ServerAddresses
        Set-DnsClientServerAddress -InterfaceIndex $dns.InterfaceIndex[0] -ServerAddresses ($DCIpAddress1, $DCIpAddress2) -ErrorAction Stop
        $dns = Get-DnsClientServerAddress
        Write-Output "Updated Value for index " $dns.InterfaceIndex[0] "-" $dns.ServerAddresses
        Write-Output 'Successfully! updated the DNS address to MAD Domain Controller IP...'
    }
    catch [System.Exception] {
        Write-TerminatingError -Message "Unable to set DNS address: $_"
    }

    #Domain join this instance to the AD and connect to the Computer Object created already using OneTimeMachinePasscode
    $OneTimeMachinePasscodeSecure = $OneTimeMachinePasscode | ConvertTo-SecureString -AsPlainText -Force
    Write-Output 'Domain joining the instance...'
    try{
        $JoinCred = New-Object -TypeName 'System.Management.Automation.PSCredential' -ArgumentList ([PSCustomObject]@{
                UserName = $Null
                Password = ($OneTimeMachinePasscodeSecure)[0]
            })
        Add-Computer -Domain $Domain -Options UnsecuredJoin, PasswordPass -Credential $JoinCred -ErrorAction Stop
        Write-Output 'Successfully! domain joined the instance'
    }
    catch [System.Exception] {
        Write-TerminatingError -Message "Unable to domain join the instance: $_"
    }

    Write-Output 'Restarting the instance to reflect changes...'
    #exit 3010 sends a request to ssm to restart instance and rerun the script again : https://docs.aws.amazon.com/systems-manager/latest/userguide/send-commands-reboot.html
    exit 3010
}

#Function call that runs the script, it will be replaced in code with param and values while calling the script
Domain-Join-Instance -DCIpAddress1 '10.0.21.214' -DCIpAddress2 '10.0.22.207' -Domain 'corp.contoso.com' -OneTimeMachinePasscode 'xxxxxxxxxxx (ワンタイムパスワード) xxxxxxxxxxx' -ROUsername 'RO-LM-NRT' -ROUserPass 'xxxxxxxxxxx (専用ユーザーパスワード) xxxxxxxxxxx' -Region 'NRT' -InstanceId 'i-0c9d06734cd031f10'

最後に

以上となります。

ここまでの説明でAWS環境でVisual Studioを利用するためにライセンス上多くの制約があることがご理解いただけたかと思います。
これらの制約はほぼ全てMicrosoftが定めたルールであり、ルールを遵守するにはどうしてもこの様にならざるを得ません。

Microsoft製品を使う以上ライセンスの不条理さは受け入れるしかありませんので、まあ、Microsoftに依存しすぎない程度に無理なく付き合っていくしかないでしょう。

脚注

  1. こちらはオンプレ環境でユーザーRDS CALを使う場合と同様です。
  2. なお、AWSへのWindowsクライアントOSのBYOLはさらに別の話です。
  3. Microsoftとの契約によって変わりうるのかは不明です。私の知る限り常に日割り無しでした。
  4. 代わりにAzure ADベースのライセンスが必要。ざっくりデスクトップ利用に必要なライセンスの種類が違うとお考え下さい。