【GCP】ParsecサーバーをGCEで建てる【Parsec】

# 概要
GCP上にParsecサーバーを構築する。


# 目的
+ Parsec による低遅延リモートデスクトップの検証。
+ デスクトップ共有により、オンラインプレイ未対応ゲームを無理矢理オンラインプレイする。

# 事前調査
+ **Parsec**
Parsecとは、デスクトップキャプチャアプリケーション……と言うとわかりにくいが、簡単に言うとリモートデスクトップアプリケーションである。
　
[Connect to Work or Games from Anywhere | Parsec](https://parsec.app/)
　
特徴としては下記が挙げられる。
　
　① 非常に低遅延である
　② デスクトップ共有ソフトウェアとして使用出来る　
　
どれくらい低遅延かと言うと、ホスト上で実行したゲームをローカルからプレイするリモートプレイがサクサクなのである。
しかも、それを共有出来てしまうので、ローカル対戦やローカルCo-opしか対応していないアプリ・ゲームを、遠隔地にいる人とオンラインプレイすることが出来る。
　
もちろん、ローカルPC上にインストールしたParsecでも良いのだが、フレーム単位のラグがプレイに大きく影響するようなゲームだと、ホスト側とクライアント側でのフレーム差から不公平感が生まれてしまうので、今回はクラウド上に構築することにした。

+ **コスト**
アプリケーションの性質上、GPU機能が必須となるため、Minecraftサーバーなどと比較するとインスタンス料金はやや高くなる。
調べた感じ、1時間あたり100～200円程度かかるようだ。
ゲーセンで遊ぶコストと比較すれば全然安いが、ガッツリやると思わぬ請求が来るかもしれないので気をつけよう。


# 1. 構築
インフラ部分については、以前構築したMinecraftサーバー用の物を流用するので、詳細は割愛。

インフラ部分の構築、Minecraftサーバー構築に興味のある方は、下記記事を見ていただけると嬉しい。
[【GCP】統合版MinecraftサーバーをGCEで建てる【統合版Minecraft】](https://qiita.com/Hikoly/items/c9b9d92efc61a14f5dca)

Minecraftサーバーについては、統合版→Java版に変更する記事や、バージョンアップの記事なども書いているので、こちらも興味があれば是非。
[【GCP】統合版MinecraftサーバーをGCEで建てる2【統合版Minecraft】](https://qiita.com/Hikoly/items/5bbfe415b6df57e818d5)
[【GCP】統合版MinecraftサーバーをGCEで建てる3【統合版Minecraft】](https://qiita.com/Hikoly/items/5e7a84ca89eebbc740f9)
[【GCP】Java版MinecraftサーバーをGCEで建てる【Java版Minecraft】](https://qiita.com/Hikoly/items/f10fb18892b4ac6ed7b2)
[【GCP】GCEで建てたJava版Minecraftサーバー（Spigot）をバージョンアップする（1.18.2→1.19）【Java版Minecraft】](https://qiita.com/Hikoly/items/aeb8dddef86ca6be96ef)

今回のParsecサーバーについては、Windows Server 2022 上に構築する。
また、「Parsec（もしくはリモートデスクトップ）でリモート接続する」以外には、今のところ外部からサーバーに対して何か操作をしたりすることは想定していないので、サービスアカウントやらロールやらと、細かい設計・構築は不要としているが、Firewallについては「3389」の穴開けが必要である。

本記事では、自動構築のためのterraformコードや、sysprep時に実行するスクリプトなどを主な内容とする。

## 1.1. GCE設計
主要スペックは一旦下記とした。
| Parameter | Value |
|:-:|:-:|
|OS|windows-server-2022-dc-v20220712|
|インスタンスタイプ|n1-standard-4|
|GPU|nvidia-tesla-t4-vws|
|ディスクタイプ|pd-standard|
|ディスク容量|50GB|
|リージョン|asia-northeast1-a|
|preemptible|true|

ポイントは、GPUが必要なことと、インスタンスタイプ・リージョン・ゾーンなどの組み合わせによって、利用可能なGPUが異なる。
2022年8月現在では、「NVIDIA Tesla T4」を利用する場合、「asia-northeast1-a」もしくは「asia-northeast1-c」を選択する必要がある。

また、Windows Server のバージョンによって、ディスクの最小容量が異なる。
今回選択した「Windows Server 2022 Datacenter」では、最小容量50GBとなる。

## 1.2. GCE作成用コード
GCE作成用のコードは下記。
元々、Linux想定のコードであったため、Windowsにも対応出来るよう修正した。
<details><summary>service01\service01_gce.tf</summary><div>

```service01\service01_gce.tf
resource "google_compute_instance" "service01_gce" {
  for_each = var.gce[terraform.workspace]

  name         = join("-", [var.gcp_common.org_name, var.service01_pj, each.key, terraform.workspace])
  machine_type = each.value.machine_type
  #zone         = var.gcp_common.zone
  zone         = each.value.zone
  project      = join("-", [var.gcp_common.org_name, var.service01_pj, terraform.workspace])

  deletion_protection = each.value.deletion_protection

  tags         = [terraform.workspace, each.key]

  boot_disk {
    auto_delete = true
    device_name = join("-", [var.gcp_common.org_name, var.service01_pj, each.key, terraform.workspace])
    initialize_params {
      image = each.value.image
      type  = each.value.type
      size  = each.value.size
    }
  }

  network_interface {
    subnetwork = google_compute_subnetwork.service01-gce-subnets.id
    access_config {
      #nat_ip       = google_compute_address.mcs-ip.address
      #network_tier = "STANDARD"
    }
  }

  service_account {
    # Google recommends custom service accounts that have cloud-platform scope and permissions granted via IAM Roles.
    email  = google_service_account.minecraft.email
    scopes = ["cloud-platform"]
  }

  #preemptible設定
  dynamic scheduling {
    for_each = each.value.scheduling

    content {
      preemptible         = scheduling.value.preemptible
      automatic_restart   = scheduling.value.automatic_restart
      on_host_maintenance = scheduling.value.on_host_maintenance
    }
  }

  metadata = {
    startup-script = each.value.metadata_startup_script
    shutdown-script = each.value.metadata_shutdown_script

    block-project-ssh-keys = each.value.block-project-ssh-keys
    # GCE の metadata に必要な形式に整形して設定（改行コードの除去等）
    ssh-keys = "${var.gce_ssh.user}:${replace("${tls_private_key.keygen[each.value.key_name].public_key_openssh}", "\n", "")} ${var.gce_ssh.user}"

    user-data = "${each.value.user-data == null ? null : file("./templates/${each.value.user-data}")}"

    sysprep-specialize-script-ps1 = each.value.init_script == null ? null : file("./templates/${each.value.init_script}")
  }

  dynamic guest_accelerator {
    for_each = each.value.guest_accelerator

    content {
      type = guest_accelerator.value.type
      count = guest_accelerator.value.count
    }
  }

  lifecycle {
    ignore_changes = [
      metadata["ssh-keys"],
    ]
  }

}
```
</div></details>


ポイントは２つ。
```service01\service01_gce.tf
  dynamic guest_accelerator {
    for_each = each.value.guest_accelerator

    content {
      type = guest_accelerator.value.type
      count = guest_accelerator.value.count
    }
  }
```
今回、用途的にGPUが必要となるため、「guest_accelerator」ブロックが必要となる。
GPU不要なインスタンスともコードを共有するため、dynamicブロックを使用している。
　
```service01\service01_gce.tf
  metadata = {
    startup-script = each.value.metadata_startup_script
    shutdown-script = each.value.metadata_shutdown_script

    block-project-ssh-keys = each.value.block-project-ssh-keys
    # GCE の metadata に必要な形式に整形して設定（改行コードの除去等）
    ssh-keys = "${var.gce_ssh.user}:${replace("${tls_private_key.keygen[each.value.key_name].public_key_openssh}", "\n", "")} ${var.gce_ssh.user}"

    user-data = "${each.value.user-data == null ? null : file("./templates/${each.value.user-data}")}"

    sysprep-specialize-script-ps1 = each.value.init_script == null ? null : file("./templates/${each.value.init_script}")
  }
```
もう１つは、初期設定のための「metadata」ブロック。
Linux系では「user-data」を使用していたが、Windows系では「sysprep～」などになるため、null指定を活用して両対応としている。

また、sysprepの中でも、実行タイミングやスクリプト言語（PowerShell Scriptなのかバッチファイルなのか等）によって、指定するメタデータキーが異なる。
詳しくは下記を参照。
[Windows VM での起動スクリプトの使用](https://cloud.google.com/compute/docs/instances/startup-scripts/windows?hl=ja)
[Windows on GCEにおける起動スクリプト Tips](https://blog.jicoman.info/2018/11/startup-script-on-windows-on-gce-tips/)

今回は、PowerShell Scriptにて初期設定やユーザー作成などを行いたかったので「sysprep-specialize-script-ps1」としている。

ちなみにファイルパスが「./templates/～」となっているが、terraformのtemplate機能は今回使っていない。
元々は使っていたのだが、変数が多くなりすぎてyaml形式のtfファイルでの記述では管理がしづらくなってしまったのと、スクリプト内でも変数を扱うため、スクリプト内での宣言・使用に寄せた。
スクリプトの詳細については後述する。

tfファイルで宣言・使用している変数は下記。
<details><summary>service01\vars_gce.tf</summary><div>

```service01\vars_gce.tf
variable "gce" {
  default = {
    dev = {
      minecraft-game-server = {
        machine_type             = "e2-standard-4"
        image                    = "ubuntu-os-cloud/ubuntu-1804-lts"
        type                     = "pd-standard"
        size                     = "30"
        block-project-ssh-keys   = true
        key_name                 = "mcs_key"
        deletion_protection      = false
        metadata_startup_script  = "cd /opt/minecraft/\nscreen -dmS tskserver java -Xms1024M -Xmx8G -jar spigot.jar --nogui"
        metadata_shutdown_script = null
        zone                     = "asia-northeast1-b"
        user-data                = "cloud-init_ubuntu.yaml"
        init_script              = null
        scheduling = {
          content ={
            preemptible            = true
            automatic_restart      = false
            on_host_maintenance    = "TERMINATE"
          }
        }
        guest_accelerator = {
        }
      }
      parsec-game-server = {
        machine_type             = "n1-standard-4"
        image                    = "windows-cloud/windows-server-2022-dc-v20220712"
        type                     = "pd-standard"
        size                     = "50"
        block-project-ssh-keys   = true
        key_name                 = "pgs_key"
        deletion_protection      = false
        metadata_startup_script  = null
        metadata_shutdown_script = null
        zone                     = "asia-northeast1-a"
        user-data                = null
        init_script              = "winsv2022_init.ps1"
        scheduling = {
          content ={
            preemptible            = true
            automatic_restart      = false
            on_host_maintenance    = "TERMINATE"
          }
        }
        guest_accelerator = {
          content ={
            type = "nvidia-tesla-t4-vws"
            count = 1
          }
        }
      }
    }
    prd = {
    }
  }
}
```
</div></details>

詳細は「1.1. GCE設計」参照。


## 1.3. Windows初期設定スクリプト
今回の主役とも言える、Windows初期設定スクリプト。
PowerShellを使用している。
死ぬほど長いので、一旦畳んでおき、少しづつ解説していく。
<details><summary>service01\templates\winsv2022_init.ps1</summary><div>

```service01\templates\winsv2022_init.ps1
# Discord 通知用Webhook
$WEB_HOOK_URL = "https://discord.com/api/webhooks/hoge
$DISCORD_NOTIFY = 'true'
$FILE_DOWNLOAD = 'true'

# 追加作成ユーザー
$USERS = @{
    'hoge' = @{
        PASSWORD  = 'hage';
        GROUP     = 'Administrators';
        AUTOLOGON = 'false'
    };
    'parsec' = @{
        PASSWORD = 'parsec';
        GROUP    = 'Administrators';
        AUTOLOGON = 'true'
    };
};

# Output with UTC-8
chcp 65001

# Output error message in english
[Threading.Thread]::CurrentThread.CurrentUICulture = 'en-US'

# Discord へ通知
If( $DISCORD_NOTIFY -match "true" ){
    Invoke-WebRequest -UseBasicParsing -Headers @{"Content-Type"="application/json"} -Method Post -Body "{`"username`": `"hoge`", `"content`": `"Construction of server started. (1/25)`"}" $WEB_HOOK_URL
}

# 『パスワードは、複雑さの要件を満たす必要がある』をOFFにする、パスワードの長さ制限を0文字以上にする
secedit /export /cfg cfg.txt
(Get-Content cfg.txt) -Replace("PasswordComplexity = 1", "PasswordComplexity = 0") | Out-File cfg.txt
(Get-Content cfg.txt) -Replace("MinimumPasswordLength = 8", "MinimumPasswordLength = 6") | Out-File cfg.txt
secedit /configure /db new.sdb /cfg cfg.txt /areas SecurityPolicy
del cfg.txt

# ポリシー適用
gpupdate /force

# ユーザー追加
foreach($user in $USERS.Keys){
    $password = $USERS[$user]['PASSWORD']
    $group = $USERS[$user]['GROUP']
    $autologon = $USERS[$user]['AUTOLOGON']

    # 『パスワードを無期限にする』を有効状態(チェックあり)とした新規ユーザーを追加
    New-LocalUser -Name "$user" -Password (ConvertTo-SecureString "$password" -AsPlainText -Force) -PasswordNeverExpires

    # Administrators グループへ追加
    Add-LocalGroupMember -Group "$group" -Member "$user"

    If( $autologon -match "true" ){
        # 自動ログオン設定
        $RegPath = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon"
        New-ItemProperty -Path $RegPath -Name "AutoAdminLogon" -PropertyType DWord -Value "1"
        Set-ItemProperty -Path $RegPath -Name "DefaultUserName" -Value "$user"
        Set-ItemProperty -Path $RegPath -Name "DefaultPassword" -Value "$password"
    }
}

# サーバーマネージャーの自動起動を無効化
$RegPath = "HKLM:\SOFTWARE\Microsoft\ServerManager"
Set-ItemProperty -Path $RegPath -Name "DoNotOpenServerManagerAtLogon" -Value 1

# IEのセキュリティ強化設定無効化
$RegPath = "HKLM:\SOFTWARE\Microsoft\Active Setup\Installed Components"
Set-ItemProperty -Path "$RegPath\{A509B1A7-37EF-4b3f-8CFC-4F3A74704073}" -Name "IsInstalled" -Value "0" # Administrators
Set-ItemProperty -Path "$RegPath\{A509B1A8-37EF-4b3f-8CFC-4F3A74704073}" -Name "IsInstalled" -Value "0" # Users

# UAC 無効化
$RegPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System"
New-ItemProperty -Path $RegPath -Name 'ConsentPromptBehaviorAdmin' -Value 0 -PropertyType DWORD -Force | Out-Null
New-ItemProperty -Path $RegPath -Name 'ConsentPromptBehaviorUser' -Value 3 -PropertyType DWORD -Force | Out-Null
New-ItemProperty -Path $RegPath -Name 'EnableInstallerDetection' -Value 1 -PropertyType DWORD -Force | Out-Null
New-ItemProperty -Path $RegPath -Name 'EnableLUA' -Value 1 -PropertyType DWORD -Force | Out-Null
New-ItemProperty -Path $RegPath -Name 'EnableVirtualization' -Value 1 -PropertyType DWORD -Force | Out-Null
New-ItemProperty -Path $RegPath -Name 'PromptOnSecureDesktop' -Value 0 -PropertyType DWORD -Force | Out-Null
New-ItemProperty -Path $RegPath -Name 'ValidateAdminCodeSignatures' -Value 0 -PropertyType DWORD -Force | Out-Null
New-ItemProperty -Path $RegPath -Name 'FilterAdministratorToken' -Value 0 -PropertyType DWORD -Force | Out-Null

# Discord へ通知
If( $DISCORD_NOTIFY -match "true" ){
    Invoke-WebRequest -UseBasicParsing -Headers @{"Content-Type"="application/json"} -Method Post -Body "{`"username`": `"hoge`", `"content`": `"Initial setting of server completed. (2/25)`"}" $WEB_HOOK_URL
}

# アプリケーションフォルダ作成
$folder_path = "C:\Applications"
If( $FILE_DOWNLOAD -match "true" ){
    New-Item $folder_path -ItemType Directory
}

# ドライバダウンロードフォルダ作成
$folder_path = "C:\Drivers"
If( $FILE_DOWNLOAD -match "true" ){
    New-Item $folder_path -ItemType Directory
}

# GPU ドライバダウンロード
# Discord へ通知
If( $DISCORD_NOTIFY -match "true" ){
    Invoke-WebRequest -UseBasicParsing -Headers @{"Content-Type"="application/json"} -Method Post -Body "{`"username`": `"hoge`", `"content`": `"Downloading GPU driver... (3/25)`"}" $WEB_HOOK_URL
}
$file_name = "472.39_grid_win10_win11_server2016_server2019_server2022_64bit_international.exe"
$file_path = "https://storage.googleapis.com/nvidia-drivers-us-public/GRID/GRID13.1/"
$gpu_driver_path = (Join-Path $folder_path $file_name)
If( $FILE_DOWNLOAD -match "true" ){
    Invoke-WebRequest -UseBasicParsing $file_path$file_name -OutFile $gpu_driver_path
}

# Discord へ通知
If( $DISCORD_NOTIFY -match "true" ){
    Invoke-WebRequest -UseBasicParsing -Headers @{"Content-Type"="application/json"} -Method Post -Body "{`"username`": `"hoge`", `"content`": `"...Download GPU driver completed. (4/25)`"}" $WEB_HOOK_URL
}

# Parsec Virtual Display Driver ダウンロード
# Discord へ通知
If( $DISCORD_NOTIFY -match "true" ){
    Invoke-WebRequest -UseBasicParsing -Headers @{"Content-Type"="application/json"} -Method Post -Body "{`"username`": `"hoge`", `"content`": `"Downloading Parsec Virtual Display Driver... (5/25)`"}" $WEB_HOOK_URL
}

$file_name = "parsec-vdd-0.37.0.0.exe"
$file_path = "https://builds.parsec.app/vdd/"
$gpu_driver_path = (Join-Path $folder_path $file_name)
If( $FILE_DOWNLOAD -match "true" ){
    Invoke-WebRequest -UseBasicParsing $file_path$file_name -OutFile $gpu_driver_path
}

# Discord へ通知
If( $DISCORD_NOTIFY -match "true" ){
    Invoke-WebRequest -UseBasicParsing -Headers @{"Content-Type"="application/json"} -Method Post -Body "{`"username`": `"hoge`", `"content`": `"...Download Parsec Virtual Display Driver completed. (6/25)`"}" $WEB_HOOK_URL
}

# ViGEmBus(Virtual Game Controller Driver) ダウンロード
# Discord へ通知
If( $DISCORD_NOTIFY -match "true" ){
    Invoke-WebRequest -UseBasicParsing -Headers @{"Content-Type"="application/json"} -Method Post -Body "{`"username`": `"hoge`", `"content`": `"Downloading ViGEmBus(Virtual Game Controller Driver)... (7/25)`"}" $WEB_HOOK_URL
}

$file_name = "ViGEmBusSetup_x64.msi"
$file_path = "https://github.com/ViGEm/ViGEmBus/releases/download/setup-v1.17.333/"
$vgc_driver_path = (Join-Path $folder_path $file_name)
If( $FILE_DOWNLOAD -match "true" ){
    Invoke-WebRequest -UseBasicParsing $file_path$file_name -OutFile $vgc_driver_path
}

# Discord へ通知
If( $DISCORD_NOTIFY -match "true" ){
    Invoke-WebRequest -UseBasicParsing -Headers @{"Content-Type"="application/json"} -Method Post -Body "{`"username`": `"hoge`", `"content`": `"...Download ViGEmBus(Virtual Game Controller Driver) completed. (8/25)`"}" $WEB_HOOK_URL
}

# Xbox360 Controller Driver ダウンロード
# Discord へ通知
If( $DISCORD_NOTIFY -match "true" ){
    Invoke-WebRequest -UseBasicParsing -Headers @{"Content-Type"="application/json"} -Method Post -Body "{`"username`": `"hoge`", `"content`": `"Downloading Xbox360 controller driver... (9/25)`"}" $WEB_HOOK_URL
}

$file_name = "2060_8edb3031ef495d4e4247e51dcb11bef24d2c4da7.cab"
$file_path = "http://www.download.windowsupdate.com/msdownload/update/v3-19990518/cabpool/"
$xbox_driver_path = (Join-Path $folder_path "Xbox360_64Eng.cab")
If( $FILE_DOWNLOAD -match "true" ){
    Invoke-WebRequest -UseBasicParsing $file_path$file_name -OutFile $xbox_driver_path
}

# Discord へ通知
If( $DISCORD_NOTIFY -match "true" ){
    Invoke-WebRequest -UseBasicParsing -Headers @{"Content-Type"="application/json"} -Method Post -Body "{`"username`": `"hoge`", `"content`": `"...Download Xbox360 controller driver completed. (10/25)`"}" $WEB_HOOK_URL
}

# Parsec 証明書作成
$cert_name = "parsec_certificate.cer"
$script = @'
-----BEGIN CERTIFICATE-----
hoge
hoge
hage
hage
-----END CERTIFICATE-----
'@
New-Item (Join-Path $folder_path $cert_name) -Value $script -Force

# アプリケーションインストールスクリプト作成
$file_name = "install_application.ps1"
$script = @'
# Discord 通知用Webhook
$WEB_HOOK_URL = "https://discord.com/api/webhooks/hoge
$DISCORD_NOTIFY = 'true'

# Output with UTC-8
chcp 65001

# Output error message in english
[Threading.Thread]::CurrentThread.CurrentUICulture = 'en-US'

# Discord へ通知
If( $DISCORD_NOTIFY -match "true" ){
    Invoke-WebRequest -UseBasicParsing -Headers @{"Content-Type"="application/json"} -Method Post -Body "{`"username`": `"hoge`", `"content`": `"...Restarted the server, Then start a setup script for parsec. (12/25)`"}" $WEB_HOOK_URL
}

# Timezone 変更
Set-TimeZone -id "Tokyo Standard Time"

# カレントディレクトリをスクリプトルートに変更
cd $PSScriptRoot

# Parsec 証明書インストール
# Discord へ通知
If( $DISCORD_NOTIFY -match "true" ){
    Invoke-WebRequest -UseBasicParsing -Headers @{"Content-Type"="application/json"} -Method Post -Body "{`"username`": `"hoge`", `"content`": `"Installing parsec_certificate.cer... (13/25)`"}" $WEB_HOOK_URL
}
certutil -addstore "TrustedPublisher" parsec_certificate.cer

# Discord へ通知
If( $DISCORD_NOTIFY -match "true" ){
    Invoke-WebRequest -UseBasicParsing -Headers @{"Content-Type"="application/json"} -Method Post -Body "{`"username`": `"hoge`", `"content`": `"...Installed parsec_certificate.cer. (14/25)`"}" $WEB_HOOK_URL
}

# インストールパッケージを取得
$items = Get-ChildItem -include *.exe,*.msi -Name

$num = 14
foreach ($item in $items) {
    # インストールパッケージを順次実行

    # Discord へ通知
    $num = $num + 1
    If( $DISCORD_NOTIFY -match "true" ){
        $body = "{`"username`": `"hoge`", `"content`": `"Installing " + ${item} + "... (" + ${num} + "/25)`"}"
        Invoke-WebRequest -UseBasicParsing -Headers @{"Content-Type"="application/json"} -Method Post -Body $body $WEB_HOOK_URL
    }

    switch ( (Get-ChildItem $item).Extension )
    {
        .exe { $option = '/s' }
        .msi { $option = '/qn' }
    }

    If( $item -match "Xbox360_64Eng.exe" ){
        # Xbox360 Controller Driver インストール
        Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force
        Install-Module -Name 7Zip4Powershell -Force
        Expand-7Zip -ArchiveFileName $item -TargetPath $item.Substring(0, $item.LastIndexOf('.'))

        $inf_path = (Join-Path $item.Substring(0, $item.LastIndexOf('.')) "xbox360\setup64\files\driver\win7\xusb21.inf")
        pnputil.exe /add-driver $inf_path /install
    }Elseif( $item -match "parsec-vdd-0.37.0.0.exe" ){
        # parsec-vdd-0.37.0.0.exe インストーラー実行
        Start-Process $item $option

        # parsec-vdd-0.37.0.0.exe インストール完了待ち
        do 
        {
            Start-Sleep -s 5
            $tmp = Get-PnpDevice | Where-Object {$_.Name -eq "Parsec Virtual Display Adapter"}
        }
        while($tmp.FriendlyName -notmatch "Parsec Virtual Display Adapter")

        # parsec-vdd-0.37.0.0.exe インストーラーを強制停止する
        Stop-Process -name parsec-vdd-0.37.0.0 -Force
    }Else{
        Start-Process -Wait $item $option
    }

    # Discord へ通知
    $num = $num + 1
    If( $DISCORD_NOTIFY -match "true" ){
        $body = "{`"username`": `"hoge`", `"content`": `"...Installed " + ${item} + ". (" + ${num} + "/25)`"}"
        Invoke-WebRequest -UseBasicParsing -Headers @{"Content-Type"="application/json"} -Method Post -Body $body $WEB_HOOK_URL
    }

    #Start-Sleep -s 60
}

# Parsec インストール
# Discord へ通知
$num = $num + 1
If( $DISCORD_NOTIFY -match "true" ){
    $body = "{`"username`": `"hoge`", `"content`": `"Installing Parsec... (" + ${num} + "/25)`"}"
    Invoke-WebRequest -UseBasicParsing -Headers @{"Content-Type"="application/json"} -Method Post -Body $body $WEB_HOOK_URL
}

[Net.ServicePointManager]::SecurityProtocol = "tls12, tls11, tls"
$ScriptWebArchive = "https://github.com/parsec-cloud/Parsec-Cloud-Preparation-Tool/archive/master.zip"
$LocalArchivePath = "$ENV:UserProfile\Downloads\Parsec-Cloud-Preparation-Tool"
(New-Object System.Net.WebClient).DownloadFile($ScriptWebArchive, "$LocalArchivePath.zip")
Expand-Archive "$LocalArchivePath.zip" -DestinationPath $LocalArchivePath -Force
CD $LocalArchivePath\Parsec-Cloud-Preparation-Tool-master\PostInstall
powershell.exe .\PostInstall.ps1 -DontPromptPasswordUpdateGPU

# Parsec インストール完了待ち
do 
{
    Start-Sleep -s 5
    $tmp = Get-Process | Where-Object { $_.ProcessName -match "parsecd" }
}
while($tmp.ProcessName -notmatch "parsecd")

# Discord へ通知
$num = $num + 1
If( $DISCORD_NOTIFY -match "true" ){
    $body = "{`"username`": `"hoge`", `"content`": `"...Installed Parsec. (" + ${num} + "/25)`"}"
    Invoke-WebRequest -UseBasicParsing -Headers @{"Content-Type"="application/json"} -Method Post -Body $body $WEB_HOOK_URL
}

# Parsec セットアップスクリプトを実行するタスクを削除
Unregister-ScheduledTask -TaskName "setup_parsec" -Confirm:$false

# Discord へ通知
$num = $num + 1
If( $DISCORD_NOTIFY -match "true" ){
    $body = "{`"username`": `"hoge`", `"content`": `"Construction of server completed, and shutdown server. (" + ${num} + "/25)`"}"
    Invoke-WebRequest -UseBasicParsing -Headers @{"Content-Type"="application/json"} -Method Post -Body $body $WEB_HOOK_URL
}

# サーバー停止
Stop-Computer -Force
'@
New-Item (Join-Path $folder_path $file_name) -Value $script -Force

# Parsec ユーザーでログオンした際に、アプリケーションインストールスクリプトを実行するタスクを登録
$powerShell = (Join-Path $env:windir "system32\WindowsPowerShell\v1.0\powershell.exe")
$script = (Join-Path $folder_path $file_name)
$task_name = "setup_parsec"
$trigger = New-ScheduledTaskTrigger -AtLogOn
$action = New-ScheduledTaskAction -Execute "$powerShell" -Argument "-ExecutionPolicy RemoteSigned -File ${script}"
Register-ScheduledTask -TaskName $task_name -Trigger $trigger -Action $action -User parsec -RunLevel Highest -Force

# Discord へ通知
If( $DISCORD_NOTIFY -match "true" ){
    Invoke-WebRequest -UseBasicParsing -Headers @{"Content-Type"="application/json"} -Method Post -Body "{`"username`": `"hoge`", `"content`": `"Created a parsec certificate and setup script for parsec, Then restart the server... (11/25)`"}" $WEB_HOOK_URL
}

# サーバー再起動
Restart-Computer -Force
```
</div></details>

### 1.3.1. Discordへ進捗状況通知
```service01\templates\winsv2022_init.ps1
# Discord 通知用Webhook
$WEB_HOOK_URL = "https://discord.com/api/webhooks/hoge"
$DISCORD_NOTIFY = 'true'
・
・
・
# Discord へ通知
If( $DISCORD_NOTIFY -match "true" ){
    Invoke-WebRequest -Headers @{"Content-Type"="application/json"} -Method Post -Body "{`"username`": `"hoge`", `"content`": `"Construction of server started. (1/23)`"}" $WEB_HOOK_URL
}
```
今回、ドライバダウンロードなどを含む関係でトータル構築時間がかなり長いのと、sysprep中の処理を画面上で確認出来ないため、進捗状況をDiscordへ通知している。
デバッグ中など通知したくない場合もあるので、一括でON/OFF出来るようにもしている。

こんな感じで指定したチャンネルに進捗通知が出るようにしている。
![discord_notify.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2608228/cb1cd9b6-36d5-4ba6-fcdd-9f3182e305b0.png)


### 1.3.1. ユーザー作成
```service01\templates\winsv2022_init.ps1
# 追加作成ユーザー
$USERS = @{
    'hoge' = @{
        PASSWORD  = 'hage';
        GROUP     = 'Administrators';
        AUTOLOGON = 'false'
    };
    'hoge' = @{
        PASSWORD = 'hage';
        GROUP    = 'Administrators';
        AUTOLOGON = 'true'
    };
};
・
・
・
# 『パスワードは、複雑さの要件を満たす必要がある』をOFFにする、パスワードの長さ制限を0文字以上にする
secedit /export /cfg cfg.txt
(Get-Content cfg.txt) -Replace("PasswordComplexity = 1", "PasswordComplexity = 0") | Out-File cfg.txt
(Get-Content cfg.txt) -Replace("MinimumPasswordLength = 8", "MinimumPasswordLength = 6") | Out-File cfg.txt
secedit /configure /db new.sdb /cfg cfg.txt /areas SecurityPolicy
del cfg.txt

# ポリシー適用
gpupdate /force

# ユーザー追加
foreach($user in $USERS.Keys){
    $password = $USERS[$user]['PASSWORD']
    $group = $USERS[$user]['GROUP']
    $autologon = $USERS[$user]['AUTOLOGON']

    # 『パスワードを無期限にする』を有効状態(チェックあり)とした新規ユーザーを追加
    New-LocalUser -Name "$user" -Password (ConvertTo-SecureString "$password" -AsPlainText -Force) -PasswordNeverExpires

    # Administrators グループへ追加
    Add-LocalGroupMember -Group "$group" -Member "$user"

    If( $autologon -match "true" ){
        # 自動ログオン設定
        $RegPath = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon"
        New-ItemProperty -Path $RegPath -Name "AutoAdminLogon" -PropertyType DWord -Value "1"
        Set-ItemProperty -Path $RegPath -Name "DefaultUserName" -Value "$user"
        Set-ItemProperty -Path $RegPath -Name "DefaultPassword" -Value "$password"
    }
}
```
パスワード要件を変更した上で、管理用ユーザー・Parsec用ユーザーなどを作成している。
また、Parsecユーザーで自動ログオンさせたいため、必要なレジストリキーも作成。
ちなみに、「New-ItemProperty」か「Set-ItemProperty」か、オプション「-Force」の有無で微妙に挙動が異なる。
（「New-ItemProperty」だと、既に同名キーが存在した場合、エラーとなる）

今回使用するOSイメージでは問題なかったので上記としているが、「Set-ItemProperty」+「-Force」で統一した方が確実かもしれない。


### 1.3.2. Windows デフォルト設定変更
```service01\templates\winsv2022_init.ps1
# サーバーマネージャーの自動起動を無効化
$RegPath = "HKLM:\SOFTWARE\Microsoft\ServerManager"
Set-ItemProperty -Path $RegPath -Name "DoNotOpenServerManagerAtLogon" -Value 1

# IEのセキュリティ強化設定無効化
$RegPath = "HKLM:\SOFTWARE\Microsoft\Active Setup\Installed Components"
Set-ItemProperty -Path "$RegPath\{A509B1A7-37EF-4b3f-8CFC-4F3A74704073}" -Name "IsInstalled" -Value "0" # Administrators
Set-ItemProperty -Path "$RegPath\{A509B1A8-37EF-4b3f-8CFC-4F3A74704073}" -Name "IsInstalled" -Value "0" # Users

# UAC 無効化
$RegPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System"
New-ItemProperty -Path $RegPath -Name 'ConsentPromptBehaviorAdmin' -Value 0 -PropertyType DWORD -Force | Out-Null
New-ItemProperty -Path $RegPath -Name 'ConsentPromptBehaviorUser' -Value 3 -PropertyType DWORD -Force | Out-Null
New-ItemProperty -Path $RegPath -Name 'EnableInstallerDetection' -Value 1 -PropertyType DWORD -Force | Out-Null
New-ItemProperty -Path $RegPath -Name 'EnableLUA' -Value 1 -PropertyType DWORD -Force | Out-Null
New-ItemProperty -Path $RegPath -Name 'EnableVirtualization' -Value 1 -PropertyType DWORD -Force | Out-Null
New-ItemProperty -Path $RegPath -Name 'PromptOnSecureDesktop' -Value 0 -PropertyType DWORD -Force | Out-Null
New-ItemProperty -Path $RegPath -Name 'ValidateAdminCodeSignatures' -Value 0 -PropertyType DWORD -Force | Out-Null
New-ItemProperty -Path $RegPath -Name 'FilterAdministratorToken' -Value 0 -PropertyType DWORD -Force | Out-Null

# Discord へ通知
If( $DISCORD_NOTIFY -match "true" ){
    Invoke-WebRequest -Headers @{"Content-Type"="application/json"} -Method Post -Body "{`"username`": `"hoge`", `"content`": `"Initial setting of server completed. (2/21)`"}" $WEB_HOOK_URL
}
```
Windows Server OSのデフォルト設定をいろいろ変更（無効化）
このあたりは、利便性とセキュリティとのトレードオフではあるが、基本的にプライベートでの利用しかしない想定なので、いろいろ無効化している。


### 1.3.3. 必要ファイルのダウンロード
```service01\templates\winsv2022_init.ps1
# ドライバダウンロードフォルダ作成
$folder_path = "C:\Drivers"
If( $FILE_DOWNLOAD -match "true" ){
    New-Item $folder_path -ItemType Directory
}

# GPU ドライバダウンロード
# Discord へ通知
If( $DISCORD_NOTIFY -match "true" ){
    Invoke-WebRequest -UseBasicParsing -Headers @{"Content-Type"="application/json"} -Method Post -Body "{`"username`": `"hoge`", `"content`": `"Downloading GPU driver... (3/25)`"}" $WEB_HOOK_URL
}
$file_name = "472.39_grid_win10_win11_server2016_server2019_server2022_64bit_international.exe"
$file_path = "https://storage.googleapis.com/nvidia-drivers-us-public/GRID/GRID13.1/"
$gpu_driver_path = (Join-Path $folder_path $file_name)
If( $FILE_DOWNLOAD -match "true" ){
    Invoke-WebRequest -UseBasicParsing $file_path$file_name -OutFile $gpu_driver_path
}

# Discord へ通知
If( $DISCORD_NOTIFY -match "true" ){
    Invoke-WebRequest -UseBasicParsing -Headers @{"Content-Type"="application/json"} -Method Post -Body "{`"username`": `"hoge`", `"content`": `"...Download GPU driver completed. (4/25)`"}" $WEB_HOOK_URL
}

# Parsec Virtual Display Driver ダウンロード
# Discord へ通知
If( $DISCORD_NOTIFY -match "true" ){
    Invoke-WebRequest -UseBasicParsing -Headers @{"Content-Type"="application/json"} -Method Post -Body "{`"username`": `"hoge`", `"content`": `"Downloading Parsec Virtual Display Driver... (5/25)`"}" $WEB_HOOK_URL
}

$file_name = "parsec-vdd-0.37.0.0.exe"
$file_path = "https://builds.parsec.app/vdd/"
$gpu_driver_path = (Join-Path $folder_path $file_name)
If( $FILE_DOWNLOAD -match "true" ){
    Invoke-WebRequest -UseBasicParsing $file_path$file_name -OutFile $gpu_driver_path
}

# Discord へ通知
If( $DISCORD_NOTIFY -match "true" ){
    Invoke-WebRequest -UseBasicParsing -Headers @{"Content-Type"="application/json"} -Method Post -Body "{`"username`": `"hoge`", `"content`": `"...Download Parsec Virtual Display Driver completed. (6/25)`"}" $WEB_HOOK_URL
}

# ViGEmBus(Virtual Game Controller Driver) ダウンロード
# Discord へ通知
If( $DISCORD_NOTIFY -match "true" ){
    Invoke-WebRequest -UseBasicParsing -Headers @{"Content-Type"="application/json"} -Method Post -Body "{`"username`": `"hoge`", `"content`": `"Downloading ViGEmBus(Virtual Game Controller Driver)... (7/25)`"}" $WEB_HOOK_URL
}

$file_name = "ViGEmBusSetup_x64.msi"
$file_path = "https://github.com/ViGEm/ViGEmBus/releases/download/setup-v1.17.333/"
$vgc_driver_path = (Join-Path $folder_path $file_name)
If( $FILE_DOWNLOAD -match "true" ){
    Invoke-WebRequest -UseBasicParsing $file_path$file_name -OutFile $vgc_driver_path
}

# Discord へ通知
If( $DISCORD_NOTIFY -match "true" ){
    Invoke-WebRequest -UseBasicParsing -Headers @{"Content-Type"="application/json"} -Method Post -Body "{`"username`": `"hoge`", `"content`": `"...Download ViGEmBus(Virtual Game Controller Driver) completed. (8/25)`"}" $WEB_HOOK_URL
}

# Xbox360 Controller Driver ダウンロード
# Discord へ通知
If( $DISCORD_NOTIFY -match "true" ){
    Invoke-WebRequest -UseBasicParsing -Headers @{"Content-Type"="application/json"} -Method Post -Body "{`"username`": `"hoge`", `"content`": `"Downloading Xbox360 controller driver... (9/25)`"}" $WEB_HOOK_URL
}

$file_name = "2060_8edb3031ef495d4e4247e51dcb11bef24d2c4da7.cab"
$file_path = "http://www.download.windowsupdate.com/msdownload/update/v3-19990518/cabpool/"
$xbox_driver_path = (Join-Path $folder_path "Xbox360_64Eng.cab")
If( $FILE_DOWNLOAD -match "true" ){
    Invoke-WebRequest -UseBasicParsing $file_path$file_name -OutFile $xbox_driver_path
}

# Discord へ通知
If( $DISCORD_NOTIFY -match "true" ){
    Invoke-WebRequest -UseBasicParsing -Headers @{"Content-Type"="application/json"} -Method Post -Body "{`"username`": `"hoge`", `"content`": `"...Download Xbox360 controller driver completed. (10/25)`"}" $WEB_HOOK_URL
}
```
基本的にはParsecのインストールスクリプトが必要なファイルは勝手に入れてくれるのだが、いくつかの事情により手動インストールが必要なファイルをダウンロードしている。
+ GPU ドライバ
　これはParsecのインストールスクリプトに入っていない。
　一応、インストール（アップデート）するためのショートカットを作成してくれはするのだが、構築を自動化するためにダウンロード（及びインストール）している。
 
+ Parsec Virtual Display Driver
　構築自動化のため。

+ ViGEmBus(Virtual Game Controller Driver)
　構築自動化のため。

+ Xbox360 Controller Driver
　構築自動化のため。


### 1.3.4. Parsec証明書作成
```service01\templates\winsv2022_init.ps1
# Parsec 証明書作成
$cert_name = "parsec_certificate.cer"
$script = @'
-----BEGIN CERTIFICATE-----
hoge
hoge
hage
hage
-----END CERTIFICATE-----
'@
New-Item (Join-Path $folder_path $cert_name) -Value $script -Force
```
Parsecの公式インストールスクリプト内で「Parsec Virtual Display Driver」もインストールされるようにはなっているのだが、インストールスクリプトを公式で案内されているサイレントインストールコマンドで実行しても、Windows Securityにより証明書のインストールダイアログが出てしまい、自動インストール出来なかった。
これを回避するため、事前に１度Parsecをインストールした環境から、Parsecの証明書をエクスポートし、同内容をPowershell スクリプト内で作成・インポートする、というステップを踏んでいる（ここが作成ステップ）

※念のため、証明書内容は伏せておく


### 1.3.5. アプリケーションインストールスクリプト作成
```service01\templates\winsv2022_init.ps1
# アプリケーションインストールスクリプト作成
・
・
・
New-Item (Join-Path $folder_path $file_name) -Value $script -Force
```
「ダウンロードしたファイルをインストールするコマンドや、Parsecインストールコマンドを記述したスクリプト」を作成している（詳細は後述）
というのも、ここまでのスクリプト処理はsysprep中に実行されているため、おそらくSYSTEMユーザーで実行されており、
ドライバやアプリケーションのインストールは実際に使用するPrasecユーザーでログオンした状態で実行した方が良いと考えたためである。
Prasecユーザーは自動ログオンユーザーとしているため、再起動後にこのスクリプトを実行する仕組みを入れる（これも詳細は後述）

### 1.3.6. アプリケーションインストールスクリプトを実行するタスクを登録
```service01\templates\winsv2022_init.ps1
# Parsec ユーザーでログオンした際に、アプリケーションインストールスクリプトを実行するタスクを登録
$powerShell = (Join-Path $env:windir "system32\WindowsPowerShell\v1.0\powershell.exe")
$script = (Join-Path $folder_path $file_name)
$task_name = "setup_parsec"
$trigger = New-ScheduledTaskTrigger -AtLogOn
$action = New-ScheduledTaskAction -Execute "$powerShell" -Argument "-ExecutionPolicy RemoteSigned -File ${script}"
Register-ScheduledTask -TaskName $task_name -Trigger $trigger -Action $action -User parsec -RunLevel Highest -Force
```
タスクスケジューラに、先述のアプリケーションインストールスクリプトを実行するタスクを登録する。
実行条件は、「Parsecユーザーがログオン時、最上位特権で」としている。


### 1.3.7. サーバー再起動
```service01\templates\winsv2022_init.ps1
# サーバー再起動
Restart-Computer -Force
```
Parsecユーザーでログオンするため、サーバーを再起動。
自動ログオンユーザーに設定しているため、Parsecユーザーで自動ログオン→先述のタスクが実行、となる。


### 1.3.8. アプリケーションインストールスクリプト
<details><summary>install_application.ps1</summary><div>

```install_application.ps1
# Discord 通知用Webhook
$WEB_HOOK_URL = "https://discord.com/api/webhooks/hoge
$DISCORD_NOTIFY = 'true'

# Output with UTC-8
chcp 65001

# Output error message in english
[Threading.Thread]::CurrentThread.CurrentUICulture = 'en-US'

# Discord へ通知
If( $DISCORD_NOTIFY -match "true" ){
    Invoke-WebRequest -UseBasicParsing -Headers @{"Content-Type"="application/json"} -Method Post -Body "{`"username`": `"hoge`", `"content`": `"...Restarted the server, Then start a setup script for parsec. (12/25)`"}" $WEB_HOOK_URL
}

# Timezone 変更
Set-TimeZone -id "Tokyo Standard Time"

# カレントディレクトリをスクリプトルートに変更
cd $PSScriptRoot

# Parsec 証明書インストール
# Discord へ通知
If( $DISCORD_NOTIFY -match "true" ){
    Invoke-WebRequest -UseBasicParsing -Headers @{"Content-Type"="application/json"} -Method Post -Body "{`"username`": `"hoge`", `"content`": `"Installing parsec_certificate.cer... (13/25)`"}" $WEB_HOOK_URL
}
certutil -addstore "TrustedPublisher" parsec_certificate.cer

# Discord へ通知
If( $DISCORD_NOTIFY -match "true" ){
    Invoke-WebRequest -UseBasicParsing -Headers @{"Content-Type"="application/json"} -Method Post -Body "{`"username`": `"hoge`", `"content`": `"...Installed parsec_certificate.cer. (14/25)`"}" $WEB_HOOK_URL
}

# インストールパッケージを取得
$items = Get-ChildItem -include *.exe,*.msi -Name

$num = 14
foreach ($item in $items) {
    # インストールパッケージを順次実行

    # Discord へ通知
    $num = $num + 1
    If( $DISCORD_NOTIFY -match "true" ){
        $body = "{`"username`": `"hoge`", `"content`": `"Installing " + ${item} + "... (" + ${num} + "/25)`"}"
        Invoke-WebRequest -UseBasicParsing -Headers @{"Content-Type"="application/json"} -Method Post -Body $body $WEB_HOOK_URL
    }

    switch ( (Get-ChildItem $item).Extension )
    {
        .exe { $option = '/s' }
        .msi { $option = '/qn' }
    }

    If( $item -match "Xbox360_64Eng.exe" ){
        # Xbox360 Controller Driver インストール
        Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force
        Install-Module -Name 7Zip4Powershell -Force
        Expand-7Zip -ArchiveFileName $item -TargetPath $item.Substring(0, $item.LastIndexOf('.'))

        $inf_path = (Join-Path $item.Substring(0, $item.LastIndexOf('.')) "xbox360\setup64\files\driver\win7\xusb21.inf")
        pnputil.exe /add-driver $inf_path /install
    }Elseif( $item -match "parsec-vdd-0.37.0.0.exe" ){
        # parsec-vdd-0.37.0.0.exe インストーラー実行
        Start-Process $item $option

        # parsec-vdd-0.37.0.0.exe インストール完了待ち
        do 
        {
            Start-Sleep -s 5
            $tmp = Get-PnpDevice | Where-Object {$_.Name -eq "Parsec Virtual Display Adapter"}
        }
        while($tmp.FriendlyName -notmatch "Parsec Virtual Display Adapter")

        # parsec-vdd-0.37.0.0.exe インストーラーを強制停止する
        Stop-Process -name parsec-vdd-0.37.0.0 -Force
    }Else{
        Start-Process -Wait $item $option
    }

    # Discord へ通知
    $num = $num + 1
    If( $DISCORD_NOTIFY -match "true" ){
        $body = "{`"username`": `"hoge`", `"content`": `"...Installed " + ${item} + ". (" + ${num} + "/25)`"}"
        Invoke-WebRequest -UseBasicParsing -Headers @{"Content-Type"="application/json"} -Method Post -Body $body $WEB_HOOK_URL
    }

    #Start-Sleep -s 60
}

# Parsec インストール
# Discord へ通知
$num = $num + 1
If( $DISCORD_NOTIFY -match "true" ){
    $body = "{`"username`": `"hoge`", `"content`": `"Installing Parsec... (" + ${num} + "/25)`"}"
    Invoke-WebRequest -UseBasicParsing -Headers @{"Content-Type"="application/json"} -Method Post -Body $body $WEB_HOOK_URL
}

[Net.ServicePointManager]::SecurityProtocol = "tls12, tls11, tls"
$ScriptWebArchive = "https://github.com/parsec-cloud/Parsec-Cloud-Preparation-Tool/archive/master.zip"
$LocalArchivePath = "$ENV:UserProfile\Downloads\Parsec-Cloud-Preparation-Tool"
(New-Object System.Net.WebClient).DownloadFile($ScriptWebArchive, "$LocalArchivePath.zip")
Expand-Archive "$LocalArchivePath.zip" -DestinationPath $LocalArchivePath -Force
CD $LocalArchivePath\Parsec-Cloud-Preparation-Tool-master\PostInstall
powershell.exe .\PostInstall.ps1 -DontPromptPasswordUpdateGPU

# Parsec インストール完了待ち
do 
{
    Start-Sleep -s 5
    $tmp = Get-Process | Where-Object { $_.ProcessName -match "parsecd" }
}
while($tmp.ProcessName -notmatch "parsecd")

# Discord へ通知
$num = $num + 1
If( $DISCORD_NOTIFY -match "true" ){
    $body = "{`"username`": `"hoge`", `"content`": `"...Installed Parsec. (" + ${num} + "/25)`"}"
    Invoke-WebRequest -UseBasicParsing -Headers @{"Content-Type"="application/json"} -Method Post -Body $body $WEB_HOOK_URL
}

# Parsec セットアップスクリプトを実行するタスクを削除
Unregister-ScheduledTask -TaskName "setup_parsec" -Confirm:$false

# Discord へ通知
$num = $num + 1
If( $DISCORD_NOTIFY -match "true" ){
    $body = "{`"username`": `"hoge`", `"content`": `"Construction of server completed, and shutdown server. (" + ${num} + "/25)`"}"
    Invoke-WebRequest -UseBasicParsing -Headers @{"Content-Type"="application/json"} -Method Post -Body $body $WEB_HOOK_URL
}

# サーバー停止
Stop-Computer -Force
```
</div></details>


#### 1.3.8.1. 文字コード・言語設定
```install_application.ps1
# Output with UTC-8
chcp 65001

# Output error message in english
[Threading.Thread]::CurrentThread.CurrentUICulture = 'en-US'
・
・
・
# Timezone 変更
Set-TimeZone -id "Tokyo Standard Time"
```
スクリプト実行中の標準出力は、シリアルポートに出力されており、Webコンソールで見ることが出来るのだが、英語OSの場合適切に文字コード等が設定されていないと文字化けするため、スクリプトの最初で設定している。
ついでにタイムゾーンも変更。


#### 1.3.8.2. カレントディレクトリ変更
```install_application.ps1
# カレントディレクトリをスクリプトルートに変更
cd $PSScriptRoot
```
後続のコマンド入力を楽にするため、カレントディレクトリをスクリプトファイルのディレクトリに変更している。
ただ、コマンドによってはフルパスで指定しないとうまくいかないケースもあるため、過信は禁物。


#### 1.3.8.3. Parsec証明書インストール
```install_application.ps1
# Parsec 証明書インストール
# Discord へ通知
If( $DISCORD_NOTIFY -match "true" ){
    Invoke-WebRequest -UseBasicParsing -Headers @{"Content-Type"="application/json"} -Method Post -Body "{`"username`": `"hoge`", `"content`": `"Installing parsec_certificate.cer... (13/25)`"}" $WEB_HOOK_URL
}
certutil -addstore "TrustedPublisher" parsec_certificate.cer

# Discord へ通知
If( $DISCORD_NOTIFY -match "true" ){
    Invoke-WebRequest -UseBasicParsing -Headers @{"Content-Type"="application/json"} -Method Post -Body "{`"username`": `"hoge`", `"content`": `"...Installed parsec_certificate.cer. (14/25)`"}" $WEB_HOOK_URL
}
```
先程作成したParsec証明をインストール。
実行ユーザーが「Administrators」に属しているだけではダメで、管理者権限でコマンドを実行する必要があるため、本スクリプトを実行するタスクを作成する際に「-RunLevel Highest」を指定している。

```install_application.ps1
Register-ScheduledTask -TaskName $task_name -Trigger $trigger -Action $action -User parsec -RunLevel Highest -Force
```


#### 1.3.8.4. インストールパッケージを順次実行
```install_application.ps1
# インストールパッケージを取得
$items = Get-ChildItem -include *.exe,*.msi -Name

$num = 14
foreach ($item in $items) {
    # インストールパッケージを順次実行

    # Discord へ通知
    $num = $num + 1
    If( $DISCORD_NOTIFY -match "true" ){
        $body = "{`"username`": `"hoge`", `"content`": `"Installing " + ${item} + "... (" + ${num} + "/25)`"}"
        Invoke-WebRequest -UseBasicParsing -Headers @{"Content-Type"="application/json"} -Method Post -Body $body $WEB_HOOK_URL
    }

    switch ( (Get-ChildItem $item).Extension )
    {
        .exe { $option = '/s' }
        .msi { $option = '/qn' }
    }
```
スクリプトと同階層にあるインストールパッケージのファイル名を取得し、foreachで順次実行。
ただ、インストールパッケージ（exeなのかmsiなのか等）によってサイレントインストールの指定オプションが異なっていたり、実行方法も変えたりしなければならず、例外処理が多くなってしまい、あまりループ処理にした意味はなかったかも…。


#### 1.3.8.5. Xbox360 Controller Driver インストール
```install_application.ps1
    If( $item -match "Xbox360_64Eng.cab" ){
        # Xbox360 Controller Driver インストール
        $targetPath = './' + $item.Substring(0, $item.LastIndexOf('.'))
        New-Item $targetPath -ItemType Directory
        expand $item -F:* $targetPath

        $inf_path = (Join-Path $targetPath "xusb21.inf")
        pnputil.exe /add-driver $inf_path /install
```
例外処理その１。
Xbox360 Controller Driverはインストーラ（exe形式）がXbox公式サイトから削除されており（WindowsUpdateに吸収されたと思われる）、Webアーカイブから取得しようと思えば出来るのだが、無理矢理入手してもサイレントインストールが出来なかった。
そのため、WindowsUpdateからcab形式のドライバをダウンロード・解凍し、infファイルをコマンドでインストールする、という面倒な手段を取らざるを得なかった。


#### 1.3.8.7. Parsec Virtual Display Adapterインストール
```install_application.ps1
    }Elseif( $item -match "parsec-vdd-0.37.0.0.exe" ){
        # parsec-vdd-0.37.0.0.exe インストーラー実行
        Start-Process $item $option

        # parsec-vdd-0.37.0.0.exe インストール完了待ち
        do 
        {
            Start-Sleep -s 5
            $tmp = Get-PnpDevice | Where-Object {$_.Name -eq "Parsec Virtual Display Adapter"}
        }
        while($tmp.FriendlyName -notmatch "Parsec Virtual Display Adapter")

        # parsec-vdd-0.37.0.0.exe インストーラーを強制停止する
        Stop-Process -name parsec-vdd-0.37.0.0 -Force
```
例外処理その２。
証明書の項でも触れたが、「Parsec Virtual Display Driver」もサイレントインストールが出来なかった。
正確には、「証明書を事前インストールした上でサイレントインストールオプション付きでインストーラを実行すると、自動的にインストールはされる」のだが、「Parsec Virtual Display Driver」のインストーラはインストール終了後、「Closeボタン押下待ち」となる。

基本的にインストーラを実行する際は、「Start-Process -Wait」というコマンドで外部実行し、インストーラの終了を待ってから次の処理に移行する、という作りにしているのだが、この「Closeボタン押下待ち」が終了扱いとならずスクリプトが止まってしまう（おそらくCloseボタン押下時に終了コードを発しているため）
かといって、「-Wait」を外すと、インストール終了を待たずに処理が進んでしまうため、あまり良くない。

これをなんとか回避するために下記仕組みとした。

　①「-Wait」無しで実行
　②デバイス登録を監視
　③デバイス登録確認後にインストーラを無理矢理プロセス停止

かなり強引ではあったが、なんとか対応することが出来た。


#### 1.3.8.8. Parsec インストール
```install_application.ps1
# Parsec インストール
# Discord へ通知
$num = $num + 1
If( $DISCORD_NOTIFY -match "true" ){
    $body = "{`"username`": `"hoge`", `"content`": `"Installing Parsec... (" + ${num} + "/25)`"}"
    Invoke-WebRequest -UseBasicParsing -Headers @{"Content-Type"="application/json"} -Method Post -Body $body $WEB_HOOK_URL
}

[Net.ServicePointManager]::SecurityProtocol = "tls12, tls11, tls"
$ScriptWebArchive = "https://github.com/parsec-cloud/Parsec-Cloud-Preparation-Tool/archive/master.zip"
$LocalArchivePath = "$ENV:UserProfile\Downloads\Parsec-Cloud-Preparation-Tool"
(New-Object System.Net.WebClient).DownloadFile($ScriptWebArchive, "$LocalArchivePath.zip")
Expand-Archive "$LocalArchivePath.zip" -DestinationPath $LocalArchivePath -Force
CD $LocalArchivePath\Parsec-Cloud-Preparation-Tool-master\PostInstall
powershell.exe .\PostInstall.ps1 -DontPromptPasswordUpdateGPU

# Parsec インストール完了待ち
do 
{
    Start-Sleep -s 5
    $tmp = Get-Process | Where-Object { $_.ProcessName -match "parsecd" }
}
while($tmp.ProcessName -notmatch "parsecd")
```    
最後にようやく、Parsec本体のインストールとなる。
公式に提供されているサイレントインストール用のスクリプトを利用しているが、インストールスクリプトから様々な処理が実行されるせいか、「Start-Process -Wait」により完了待ちがうまく動かなかったため、最後に起動してくるParsec本体のプロセス待ちをしている。


#### 1.3.8.9. 後処理
```install_application.ps1
# Parsec セットアップスクリプトを実行するタスクを削除
Unregister-ScheduledTask -TaskName "setup_parsec" -Confirm:$false

# Discord へ通知
$num = $num + 1
If( $DISCORD_NOTIFY -match "true" ){
    $body = "{`"username`": `"hoge`", `"content`": `"Construction of server completed, and shutdown server. (" + ${num} + "/25)`"}"
    Invoke-WebRequest -UseBasicParsing -Headers @{"Content-Type"="application/json"} -Method Post -Body $body $WEB_HOOK_URL
}

# サーバー停止
Stop-Computer -Force
```
Parsec セットアップスクリプトを実行するタスクの削除と、サーバーのシャットダウンを行っている。
（トータル構築時間がかなり長い（1時間近くかかる）ため、放置構築→完了後自動シャットダウン、とするため）

## 1.4. まとめ
自動化の部分で大分苦労したが、なんとか「terraform applyしたら、Parsec使用可能なサーバーが出来上がる」というところまで実現出来た。

※最終手順として、サーバー起動後に、Parsecユーザーでリモートデスクトップ接続すると、Parsecが自動起動しておりログイン画面になっているので、ログインとメール認証をすることで使用可能となる。

構築に非常に時間がかかるため（主な原因はGPUドライバのダウンロードとインストール）、1ヶ月近く試行錯誤する中で大分料金が嵩んでしまったが（1万円弱位…）、良い経験になったと思うので良しとしよう。

ちなみに、実際に複数人でゲームしたりしてみたが、かなりヌルヌルというか、ローカルでプレイしているのと遜色ないくらいの動きで感動した。