【Pulumi de Cloud】 Ubuntu の VM を作成し SSH してみました


## 概要

[Pulumi](https://www.pulumi.com/docs/index.html)は Indrastracture as Code を実現するソフトウェア。 Azure Resource Manager(Template) や Terraform で出来ることと同じですが、一般的なプログラム言語(Node.js,JavaScript,TypeScript,Python,GO)で記述できるのが強みでしょうか、、、

[この記事](https://qiita.com/turupon/items/c249680acea136aff42b) は terraform を利用しましたが、今回は Pulumi を用いて Azure環境上に Ubuntu のVMを作成し、SSH接続してみました。

### 実行環境
macOS Sonoma 14.0
python 3.8.12
Azure CLI 2.53.0
pulumi v3.91.1

---

## Pulumi IaC

### 作業環境の準備

Pulumiの状態管理をローカルで行います

```
## Azureへのログイン
az login

## 作業場所の定義
$ mkdir ~/MyDevelops/Pulumi/vm_azure_python
$ cd ~/MyDevelops/Pulumi/vm_azure_python
```

### プロジェクトの作成

```
## 「vm-azure-python」からのプロジェクト作成
$ pulumi new vm-azure-python
This command will walk you through creating a new Pulumi project.

Enter a value or leave blank to accept the (default), and press <ENTER>.
Press ^C at any time to quit.

project name (vm_azure_python):  
project description (A Python program to deploy a virtual machine on Azure):  
Created project 'vm_azure_python'

stack name (dev):  
Created stack 'dev'
Enter your passphrase to protect config/secrets:  
Re-enter your passphrase to confirm:  

azure-native:location: The Azure location to deploy into (WestUS2): japaneast 
adminUsername: The user account to create on the VM (pulumiuser):  
osImage: The Azure URN of the base image to use for the VM (Debian:debian-11:11:latest): Canonical:UbuntuServer:18.04-LTS:latest 
servicePort: The HTTP service port to expose on the VM (80): 8080 
sshPublicKey: The public key data to use for SSH authentication:  
vmName: The DNS hostname prefix to use for the VM (my-server): vm-ituru-ubuntu 
vmSize: The machine size to use for the VM (Standard_A1_v2): Standard_B2s 
Saved config

Installing dependencies...

Creating virtual environment...
Finished creating virtual environment
Updating pip, setuptools, and wheel in virtual environment...
    :
   中略
    :
Your new project is ready to go! ✨
To perform an initial deployment, run `pulumi up`
```

プロジェクト作成後のフォルダ構成

```
##　フォルダ・ファイルの確認
$ ls -l
total 80
drwxr-xr-x  13 ituru  staff   416 10 30 22:51 ./
drwxr-xr-x  10 ituru  staff   320 10 30 21:45 ../
-rw-r--r--   1 ituru  staff    12 10 30 21:46 .gitignore
drwxr-xr-x   8 ituru  staff   256 10 30 23:15 .pulumi/
-rw-r--r--   1 ituru  staff   974 10 30 23:27 Pulumi.dev.yaml
-rw-r--r--   1 ituru  staff   145 10 30 21:46 Pulumi.yaml
-rw-r--r--   1 ituru  staff  5505 10 30 23:42 __main__.py
drwxr-xr-x   3 ituru  staff    96 10 30 23:51 __pycache__/
-rw-r--r--   1 ituru  staff    82 10 30 21:46 requirements.txt
drwxr-xr-x   6 ituru  staff   192 10 30 21:52 venv/
```

設定情報の確認

```yaml:Pulumi.dev.yaml
encryptionsalt: v1:LGF1nzVAmSs=:v1:rcevXbWYrtXUqi88...............
config:
  azure-native:location: japaneast
  vm_azure_python:adminUsername: pulumiuser
  vm_azure_python:osImage: Canonical:UbuntuServer:18.04-LTS:latest
  vm_azure_python:servicePort: "8080"
  vm_azure_python:vmName: vm-ituru-ubuntu
  vm_azure_python:vmSize: Standard_B2s
```

### sshキー(秘密鍵・公開鍵)の作成

```
## カレントディレクトリに鍵保存ディレクトリの作成
$ mkdir .ssh
$ cd .ssh


## sshキーの作成
$ ssh-keygen
Generating public/private rsa key pair.
Enter file in which to save the key (/Users/ituru/.ssh/id_rsa): /Users/ituru/MyDevelops/Pulumi/vm_azure_python/.ssh/id_rsa
Enter passphrase (empty for no passphrase): <-- パスフレーズの入力（任意）
Enter same passphrase again: <-- もう一度、パスフレーズの入力（任意）
Your identification has been saved in /Users/ituru/MyDevelops/Pulumi/vm_azure_python/.ssh/id_rsa
Your public key has been saved in /Users/ituru/MyDevelops/Pulumi/vm_azure_python/.ssh/id_rsa.pub
The key fingerprint is:
SHA256:0j4NAXLGmq86AUj/k9wMqOIK3aP7777777777777777 ituru@IsseinoMacBook-Pro.local
The key's randomart image is:
+---[RSA 3072]----+
|  +.ooOo.+       |
| o + =.ooo.      |
|o + Eo  + .      |
|o. ooo . + .     |
| .. o.* S .      |
|.o.. =.* o       |
|+ ..o.o o .      |
|.....o   .       |
|o +=.            |
+----[SHA256]-----+


## 生成された鍵の確認
$ ls -l
total 16
drwxr-xr-x   4 ituru  staff   128 10 30 22:51 ./
drwxr-xr-x  13 ituru  staff   416 10 30 22:51 ../
-rw-------   1 ituru  staff  2622 10 30 22:51 id_rsa
-rw-r--r--   1 ituru  staff   584 10 30 22:51 id_rsa.pub
$ cd ..
```

### 作成するリソース（仮想マシン）への公開鍵の設定

必要な構成変数 'vm_azure_python:sshPublicKey' の作成（公開鍵の設定）

```
$ pulumi config set vm_azure_python:sshPublicKey "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQ............"
```

設定情報の確認、sshのPublicKeyの設定を確認できます。

```yaml:Pulumi.dev.yaml
encryptionsalt: v1:LGF1nzVAmSs=:v1:rcevXbWYrtXUqi88...............
config:
  azure-native:location: japaneast
  vm_azure_python:adminUsername: pulumiuser
  vm_azure_python:osImage: Canonical:UbuntuServer:18.04-LTS:latest
  vm_azure_python:servicePort: "8080"
  vm_azure_python:sshPublicKey: ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQ...........
  vm_azure_python:vmName: vm-ituru-ubuntu
  vm_azure_python:vmSize: Standard_B2s
```

### 構築コードの編集

作成したプロジェクト内の \_\_main__.py ファイルの編集

```python:\_\_main__.py（編集）
import pulumi
from pulumi_azure_native import resources, network, compute
from pulumi_random import random_string
import base64

# Import the program's configuration settings.
config = pulumi.Config()
vm_name = config.get("vmName")
vm_size = config.get("vmSize")
os_image = config.get("osImage")
admin_username = config.get("adminUsername")
service_port = config.get("servicePort")
ssh_public_key = config.require("sshPublicKey")
os_image_publisher, os_image_offer, os_image_sku, os_image_version = os_image.split(":")

# Create a resource group.
resource_group = resources.ResourceGroup("rg_ituru_linuxvm_")

# Create a virtual network.
virtual_network = network.VirtualNetwork(
    "network",
    resource_group_name=resource_group.name,
    address_space=network.AddressSpaceArgs(
        address_prefixes=[
            "10.0.0.0/16",
        ],
    ),
    subnets=[
        network.SubnetArgs(
            name=f"{vm_name}-subnet",
            address_prefix="10.0.1.0/24",
        ),
    ],
)
# Use a random string to give the VM a unique DNS name.
domain_name_label = random_string.RandomString(
    "domain-label",
    length=8,
    upper=False,
    special=False,
).result.apply(lambda result: f"{vm_name}-{result}")

# Create a public IP address for the VM.
public_ip = network.PublicIPAddress(
    "public-ip",
    resource_group_name=resource_group.name,
    public_ip_allocation_method=network.IpAllocationMethod.DYNAMIC,
    dns_settings=network.PublicIPAddressDnsSettingsArgs(
        domain_name_label=domain_name_label,
    ),
)

# Create a security group allowing inbound access over ports 8080 (for HTTP) and 22 (for SSH).
security_group = network.NetworkSecurityGroup(
    "security-group",
    resource_group_name=resource_group.name,
    security_rules=[
        network.SecurityRuleArgs(
            name=f"{vm_name}-securityrule",
            priority=1000,
            direction=network.AccessRuleDirection.INBOUND,
            access="Allow",
            protocol="Tcp",
            source_port_range="*",
            source_address_prefix="123.123.123.123",
            destination_address_prefix="*",
            destination_port_ranges=[
                service_port,
                "22",
            ],
        ),
    ],
)

# Create a network interface with the virtual network, IP address, and security group.
network_interface = network.NetworkInterface(
    "network-interface",
    resource_group_name=resource_group.name,
    network_security_group=network.NetworkSecurityGroupArgs(
        id=security_group.id,
    ),
    ip_configurations=[
        network.NetworkInterfaceIPConfigurationArgs(
            name=f"{vm_name}-ipconfiguration",
            private_ip_allocation_method=network.IpAllocationMethod.DYNAMIC,
            subnet=network.SubnetArgs(
                id=virtual_network.subnets.apply(lambda subnets: subnets[0].id),
            ),
            public_ip_address=network.PublicIPAddressArgs(
                id=public_ip.id,
            ),
        ),
    ],
)

# Define a script to be run when the VM starts up.
init_script = f"""#!/bin/bash
    echo '<!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="utf-8">
        <title>Hello, world!</title>
    </head>
    <body>
        <h1>Hello, world! 👋</h1>
        <p>Deployed with 💜 by <a href="https://pulumi.com/">Pulumi</a>.</p>
    </body>
    </html>' > index.html
    sudo python3 -m http.server {service_port} &
    """

# Create the virtual machine.
vm = compute.VirtualMachine(
    "vm",
    resource_group_name=resource_group.name,
    network_profile=compute.NetworkProfileArgs(
        network_interfaces=[
            compute.NetworkInterfaceReferenceArgs(
                id=network_interface.id,
                primary=True,
            )
        ]
    ),
    hardware_profile=compute.HardwareProfileArgs(
        vm_size=vm_size,
    ),
    os_profile=compute.OSProfileArgs(
        computer_name=vm_name,
        admin_username=admin_username,
        custom_data=base64.b64encode(bytes(init_script, "utf-8")).decode("utf-8"),
        linux_configuration=compute.LinuxConfigurationArgs(
            disable_password_authentication=True,
            ssh=compute.SshConfigurationArgs(
                public_keys=[
                    compute.SshPublicKeyArgs(
                        key_data=ssh_public_key,
                        path=f"/home/{admin_username}/.ssh/authorized_keys",
                    ),
                ],
            ),
        ),
    ),
    storage_profile=compute.StorageProfileArgs(
        os_disk=compute.OSDiskArgs(
            name=f"{vm_name}-osdisk",
            create_option=compute.DiskCreateOption.FROM_IMAGE,
        ),
        image_reference=compute.ImageReferenceArgs(
            publisher=os_image_publisher,
            offer=os_image_offer,
            sku=os_image_sku,
            version=os_image_version,
        ),
    ),
)

# Once the machine is created, fetch its IP address and DNS hostname.
vm_address = vm.id.apply(
    lambda id: network.get_public_ip_address_output(
        resource_group_name=resource_group.name,
        public_ip_address_name=public_ip.name,
    )
)

# Export the VM's hostname, public IP address, and HTTP URL.
pulumi.export("ip", vm_address.ip_address)
pulumi.export("hostname", vm_address.dns_settings.apply(lambda settings: settings.fqdn))
pulumi.export(
    "url",
    vm_address.dns_settings.apply(
        lambda settings: f"http://{settings.fqdn}:{service_port}"
    ),
)
```

---

## 構築とテスト

### Linux仮想マシンの構築

```
## Deploy Stack
$ pulumi up                                    
Enter your passphrase to unlock config/secrets
    (set PULUMI_CONFIG_PASSPHRASE or PULUMI_CONFIG_PASSPHRASE_FILE to remember):  *************
Enter your passphrase to unlock config/secrets
Previewing update (dev):
     Type                                          Name                 Plan       
 +   pulumi:pulumi:Stack                           vm_azure_python-dev  create     
 +   ├─ random:index:RandomString                  domain-label         create     
 +   ├─ azure-native:resources:ResourceGroup       rg_ituru_linuxvm_    create     
 +   ├─ azure-native:network:VirtualNetwork        network              create     
 +   ├─ azure-native:network:NetworkSecurityGroup  security-group       create     
 +   ├─ azure-native:network:PublicIPAddress       public-ip            create     
 +   ├─ azure-native:network:NetworkInterface      network-interface    create     
 +   └─ azure-native:compute:VirtualMachine        vm                   create     

Outputs:
    hostname: output<string>
    ip      : output<string>
    url     : output<string>

Resources:
    + 8 to create

Do you want to perform this update? yes
Updating (dev):
     Type                                          Name                 Status              
 +   pulumi:pulumi:Stack                           vm_azure_python-dev  created (91s)       
 +   ├─ random:index:RandomString                  domain-label         created (0.00s)     
 +   ├─ azure-native:resources:ResourceGroup       rg_ituru_linuxvm_    created (0.45s)     
 +   ├─ azure-native:network:VirtualNetwork        network              created (13s)       
 +   ├─ azure-native:network:NetworkSecurityGroup  security-group       created (4s)        
 +   ├─ azure-native:network:PublicIPAddress       public-ip            created (7s)        
 +   ├─ azure-native:network:NetworkInterface      network-interface    created (0.56s)     
 +   └─ azure-native:compute:VirtualMachine        vm                   created (76s)       

Outputs:
    hostname: "vm-ituru-ubuntu-as6zrbef.japaneast.cloudapp.azure.com"
    ip      : "52.140.201.159"
    url     : "http://vm-ituru-ubuntu-as6zrbef.japaneast.cloudapp.azure.com:8080"

Resources:
    + 8 created

Duration: 1m34s
```

### 接続テスト

Webアクセス

```
$ curl http://vm-ituru-ubuntu-as6zrbef.japaneast.cloudapp.azure.com:8080
<!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="utf-8">
        <title>Hello, world!</title>
    </head>
    <body>
        <h1>Hello, world! 👋</h1>
        <p>Deployed with 💜 by <a href="https://pulumi.com/">Pulumi</a>.</p>
    </body>
    </html>
```

SSH接続

```
## 秘密鍵を指定して ssh接続
$ ssh -i ./.ssh/id_rsa pulumiuser@52.140.201.159
The authenticity of host '52.140.201.159 (52.140.201.159)' can't be established.
ED25519 key fingerprint is SHA256:s5fcGz4UslCjpbdXXSiP5wOILvldhhYStnmYvdcQC7o.
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added '52.140.201.159' (ED25519) to the list of known hosts.
Welcome to Ubuntu 18.04.6 LTS (GNU/Linux 5.4.0-1109-azure x86_64)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/advantage

  System information as of Mon Oct 30 14:53:56 UTC 2023

  System load:  0.44              Processes:           135
  Usage of /:   4.5% of 28.89GB   Users logged in:     0
  Memory usage: 5%                IP address for eth0: 10.0.1.4
  Swap usage:   0%

Expanded Security Maintenance for Infrastructure is not enabled.

0 updates can be applied immediately.

Enable ESM Infra to receive additional future security updates.
See https://ubuntu.com/esm or run: sudo pro status

The programs included with the Ubuntu system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.

Ubuntu comes with ABSOLUTELY NO WARRANTY, to the extent permitted by
applicable law.

To run a command as administrator (user "root"), use "sudo <command>".
See "man sudo_root" for details.

pulumiuser@vm-ituru-ubuntu:~$  <--- SSH接続完了


## OSの確認
pulumiuser@vm-ituru-ubuntu:~$ uname -a
Linux vm-ituru-ubuntu 5.4.0-1109-azure #115~18.04.1-Ubuntu SMP Mon May 22 20:06:37 UTC 2023 x86_64 x86_64 x86_64 GNU/Linux
pulumiuser@vm-ituru-ubuntu:~$ exit
```

---

## 後片付け

### リソースの全削除
 
```
$ pulumi destroy
```

---

### まとめ

Pulumiで構築（プログラミング言語でコードを記載）すると単体テスト（同じプログラミング言語を利用して）はやりやすいかもしれませんね。

### 参考記事

以下の記事を参考にさせていただきました。感謝申し上げます。

[PulumiはIaCの革命児になれるか](https://zenn.dev/yuta28/articles/pulumi-ai-revolutionary)
[Pulumi使い始めた所感](https://zenn.dev/koheiyamayama/articles/dd2129eee9f145)