Usecase: CS Course with individual Windows instances for students

Last changed: 2024-09-24

This document describes and tries to offer a solution on how to spin up an arbitrary number of Windows Server instances. The idea is that each student is given their own pre-configured instance with a set of credentials.

Overview

In this usecase study, we will demonstrate with examples and code the entire process:

  1. Install a master Windows instance, which we will later use as a template for new instances

  2. Do whatever configuration changes, install software etc. that is required. All changes that should be identical on the students’ instances should be done in this step

  3. Make a snapshot of the master instance. This is the template template that will be used in the next step

  4. Use Terraform to spin up a number of instances for students based on the template created in the previous step

  5. Use Ansible to make individual configuration on each of the student instances. I our case we add an individual user with an autogenerated password for each instance

Prerequisites

This guide assumes that you have installed and know how to use Terraform and Ansible. For more information, see

You also need to have the OpenStack CLI tools installed.

Preparing the master Windows instance

For this step, consult the documentation available here:

Step by step example

  1. If you haven’t already, create an SSH key of type RSA in PEM format:

    $ ssh-keygen -t rsa -b 4096 -m PEM -a 100 -f ~/.ssh/winkey
    

    In order to utilize the key to retrieve the admin password, the key must be RSA and in PEM format. Also, if you would like to retrieve the password in the GUI, the key cannot have a passphrase.

  2. Import the public key ~/.ssh/winkey.pub into openstack

  3. Create a Windows instance. In this demo, we have chosen:

    • Name: in9999-master

    • Image: GOLD Windows Server 2022 Standard

    • Flavor: d1.medium

    • Network: IPv6

    • Security Groups: default and others

    • Key Pair: winkey (created above)

    You should add security groups that allow SSH and RDP from your current IP address.

  4. Wait for the instance to be ready. With Windows it takes a long time, at least 10 minutes

    Master instance

    When the instance responds to SSH logins, you can proceed:

    $ ssh 2001:700:2:8201::13a7 -l Admin -i ~/.ssh/winkey
    
    Microsoft Windows [Version 10.0.20348.2655]
    (c) Microsoft Corporation. All rights reserved.
    
    admin@IN9999-MASTER C:\Users\Admin>
    
  5. Retrieve the Admin password. This can be done with GUI by selecting the Retrieve Password action, or by using the nova CLI tool:

    $ openstack server show in9999-master -c id -f value
    c318ce46-845f-4254-8955-3ae910de8835
    
    $ nova get-password c318ce46-845f-4254-8955-3ae910de8835 ~/.ssh/winkey
    nova CLI is deprecated and will be a removed in a future release
    0T9uzBckWHloDVLL8QqX
    
  6. Log into the master Windows instance using RDP. There are a number of ways to do this, depending on your OS and preference. In our case, we connect using xfreerdp:

    $ xfreerdp /cert:ignore /d:workgroup /u:Admin /p:0T9uzBckWHloDVLL8QqX /v:[2001:700:2:8201::13a7] /h:1050 /w:1400
    
    RDP to master instance
  7. Install software and make any changes as required. For the purposes of this demonstration, we install Visual Studio Code

    Master instance VSCode installation
  8. Reboot the instance. This is required before running sysprep and proceeding with creating a snapshot.

Take a snapshot

The procedure for creating a snapshot is described here:

Step by step example

  1. Log into the master instance again via RDP:

  2. Run Powershell as administrator:

    Run powershell as administrator
  3. In the elevated Powershell, run Sysprep with the proper arguments:

    PS C:\Users\Admin> $unattendedXmlPath = "c:\Program Files\Cloudbase Solutions\Cloudbase-Init\conf\Unattend.xml" ; ipconfig /release6 ; c:\windows\system32\sysprep\Sysprep /generalize /oobe /shutdown /unattend:"$unattendedXmlPath"
    

    This will take a few minutes, ending with the instance being shut off. If you connected via IPv6, the connection will be broken immediately, but Sysprep should do its job regardless. Proceed when the instance is properly shut down:

    $ openstack server show in9999-master -c status -f value
    SHUTOFF
    
  4. Make a snapshot of the instance while it is shut off

    Master instance snapshot (1)

    We name the snapshot «master-snap-01»:

    Master instance snapshot (2)

We are now ready to proceed with creating student instances.

Create student instances

This next step uses Terraform to create a number of instances for students. First, create an empty directory and cd into it, e.g.:

$ mkdir ~/in9999-h2024
$ cd ~/in9999-h2024

Copy the following files info this directory:

These files are from Terraform and NREC: Part IV - Pairing with Ansible, but with adjustments for this usecase. Edit these files to suit your needs. You should most likely want a lot of changes in variables.tf and terraform.tfvars.

Run terraform init:

$ terraform init
(...output omitted...)
Terraform has been successfully initialized!

Run terraform plan:

$ terraform plan
(...output omitted...)
Plan: 29 to add, 0 to change, 0 to destroy.

Fix any errors from the plan command, then run terraform apply:

$ terraform apply
(...output omitted...)
Apply complete! Resources: 29 added, 0 changed, 0 destroyed.

The instances are now created, we are ready to make the final configuration with Ansible. The end result is:

$ openstack server list --name in9999 --sort-column Name -c Name -c Status -c Image
+--------------------+---------+-----------------------------------+
| Name               | Status  | Image                             |
+--------------------+---------+-----------------------------------+
| in9999-h2024-lab-0 | ACTIVE  | master-snap-01                    |
| in9999-h2024-lab-1 | ACTIVE  | master-snap-01                    |
| in9999-h2024-lab-2 | ACTIVE  | master-snap-01                    |
| in9999-h2024-lab-3 | ACTIVE  | master-snap-01                    |
| in9999-h2024-lab-4 | ACTIVE  | master-snap-01                    |
| in9999-master      | SHUTOFF | GOLD Windows Server 2022 Standard |
+--------------------+---------+-----------------------------------+

Configure student instances

Download the following files into the same directory as the Terraform files:

Edit these files as necessary. At minimum you need to edit the terraform.yaml file.

Test that ansible works:

$ ansible -i terraform.yaml all -m win_ping
[WARNING]: Collection cloud.terraform does not support Ansible version 2.14.14
[WARNING]: Invalid characters were found in group names but not replaced, use -vvvv to see details
in9999-h2024-lab-1 | SUCCESS => {
    "changed": false,
    "ping": "pong"
}
in9999-h2024-lab-0 | SUCCESS => {
    "changed": false,
    "ping": "pong"
}
in9999-h2024-lab-2 | SUCCESS => {
    "changed": false,
    "ping": "pong"
}
in9999-h2024-lab-4 | SUCCESS => {
    "changed": false,
    "ping": "pong"
}
in9999-h2024-lab-3 | SUCCESS => {
    "changed": false,
    "ping": "pong"
}

Run the add-labuser.yaml playbook:

$ ansible-playbook -i terraform.yaml add-labuser.yaml
(...output omitted...)

The credentials are saved in a file called labusers.csv, which is located in the same directory as the playbook. Example contents:

HOST,IPADDR,USERNAME,PASSWORD
in9999-h2024-lab-0,2001:700:2:8201::100e,labuser,Msho!nLKCCo)yIAvB$UC
in9999-h2024-lab-1,2001:700:2:8201::1270,labuser,cXhm_q%xvwvBmLM6rPF6
in9999-h2024-lab-2,2001:700:2:8201::1485,labuser,sGecTMBp0u11x0.OpEGn

Adding or removing instances

In order to to increase or decrease the number of instances, change the number in variables.tf:

variables.tf
1# Mapping between role and number of instances (count)
2variable "role_count" {
3  type = map(string)
4  default = {
5    "students" = 5
6  }
7}

Then run:

terraform plan
terraform apply

Create users as before with:

ansible-playbook -i terraform.yaml add-labuser.yaml

The credentials file labusers.csv will be updated to reflect the changes. Note that the passwords are randomly generated but idempotent, thus changing the number of instances will not change passwords for existing instances.


File listing

A complete listing of the example files used in this document is provided below.

terraform.yaml
1plugin: cloud.terraform.terraform_provider
2project_path: /path/to/project
3# Terraform binary (available in the $PATH) or full path to the binary.
4binary_path: terraform
main.tf
 1# Define required providers
 2terraform {
 3  required_version = ">= 1.0"
 4  required_providers {
 5    openstack = {
 6      source  = "terraform-provider-openstack/openstack"
 7    }
 8    ansible = {
 9      version = "~> 1.3.0"
10      source  = "ansible/ansible"
11    }
12  }
13}
14provider "openstack" {}
15
16# SSH key
17resource "openstack_compute_keypair_v2" "keypair" {
18  region     = var.region
19  name       = "${var.name}-key"
20  public_key = file(var.ssh_public_key)
21}
22
23# Student servers
24resource "openstack_compute_instance_v2" "student_instance" {
25  region      = var.region
26  count       = lookup(var.role_count, "students", 0)
27  name        = "${var.name}-lab-${count.index}"
28  image_name  = lookup(var.role_image, "snapshot", "unknown")
29  flavor_name = lookup(var.role_flavor, "flavor", "unknown")
30
31  key_pair = "${var.name}-key"
32  security_groups = [
33    "${var.name}-icmp",
34    "${var.name}-ssh",
35    "${var.name}-rdp",
36  ]
37
38  network {
39    name = "${var.network}"
40  }
41
42  lifecycle {
43    ignore_changes = [image_name,image_id]
44  }
45
46  depends_on = [
47    openstack_networking_secgroup_v2.instance_icmp_access,
48    openstack_networking_secgroup_v2.instance_ssh_access,
49    openstack_networking_secgroup_v2.instance_rdp_access,
50  ]
51}
52
53# Ansible student hosts
54resource "ansible_host" "student_instance" {
55  count  = lookup(var.role_count, "students", 0)
56  name   = "${var.name}-lab-${count.index}"
57  groups = ["${var.name}"] # Groups this host is part of
58
59  variables = {
60    ansible_host = trim(openstack_compute_instance_v2.student_instance[count.index].access_ip_v6, "[]")
61  }
62}
63
64# Ansible student group
65resource "ansible_group" "student_instances_group" {
66  name     = "student_instances"
67  children = ["${var.name}"]
68  variables = {
69    ansible_user = "Admin"
70    ansible_connection = "ssh"
71    remote_tmp = "C:\\Users\\Admin\\Tmp"
72    become_method = "runas"
73    ansible_shell_type = "cmd"
74    shell_type = "cmd"
75  }
76}
secgroup.tf
 1# Security group ICMP
 2resource "openstack_networking_secgroup_v2" "instance_icmp_access" {
 3  region      = var.region
 4  name        = "${var.name}-icmp"
 5  description = "Security group for allowing ICMP access"
 6}
 7
 8# Security group SSH
 9resource "openstack_networking_secgroup_v2" "instance_ssh_access" {
10  region      = var.region
11  name        = "${var.name}-ssh"
12  description = "Security group for allowing SSH access"
13}
14
15# Security group RDP
16resource "openstack_networking_secgroup_v2" "instance_rdp_access" {
17  region      = var.region
18  name        = "${var.name}-rdp"
19  description = "Security group for allowing RDP access"
20}
21
22# Allow ssh from IPv4 net
23resource "openstack_networking_secgroup_rule_v2" "rule_ssh_access_ipv4" {
24  region            = var.region
25  count             = length(var.allow_ssh_from_v4)
26  direction         = "ingress"
27  ethertype         = "IPv4"
28  protocol          = "tcp"
29  port_range_min    = 22
30  port_range_max    = 22
31  remote_ip_prefix  = var.allow_ssh_from_v4[count.index]
32  security_group_id = openstack_networking_secgroup_v2.instance_ssh_access.id
33}
34
35# Allow ssh from IPv6 net
36resource "openstack_networking_secgroup_rule_v2" "rule_ssh_access_ipv6" {
37  region            = var.region
38  count             = length(var.allow_ssh_from_v6)
39  direction         = "ingress"
40  ethertype         = "IPv6"
41  protocol          = "tcp"
42  port_range_min    = 22
43  port_range_max    = 22
44  remote_ip_prefix  = var.allow_ssh_from_v6[count.index]
45  security_group_id = openstack_networking_secgroup_v2.instance_ssh_access.id
46}
47
48# Allow icmp from IPv4 net
49resource "openstack_networking_secgroup_rule_v2" "rule_icmp_access_ipv4" {
50  region            = var.region
51  count             = length(var.allow_icmp_from_v4)
52  direction         = "ingress"
53  ethertype         = "IPv4"
54  protocol          = "icmp"
55  remote_ip_prefix  = var.allow_icmp_from_v4[count.index]
56  security_group_id = openstack_networking_secgroup_v2.instance_icmp_access.id
57}
58
59# Allow icmp from IPv6 net
60resource "openstack_networking_secgroup_rule_v2" "rule_icmp_access_ipv6" {
61  region            = var.region
62  count             = length(var.allow_icmp_from_v6)
63  direction         = "ingress"
64  ethertype         = "IPv6"
65  protocol          = "ipv6-icmp"
66  remote_ip_prefix  = var.allow_icmp_from_v6[count.index]
67  security_group_id = openstack_networking_secgroup_v2.instance_icmp_access.id
68}
69
70# Allow RDP from IPv4 net
71resource "openstack_networking_secgroup_rule_v2" "rule_rdp_access_ipv4" {
72  region            = var.region
73  count             = length(var.allow_rdp_from_v4)
74  direction         = "ingress"
75  ethertype         = "IPv4"
76  protocol          = "tcp"
77  port_range_min    = 3389
78  port_range_max    = 3389
79  remote_ip_prefix  = var.allow_rdp_from_v4[count.index]
80  security_group_id = openstack_networking_secgroup_v2.instance_rdp_access.id
81}
82
83# Allow RDP from IPv6 net
84resource "openstack_networking_secgroup_rule_v2" "rule_rdp_access_ipv6" {
85  region            = var.region
86  count             = length(var.allow_rdp_from_v6)
87  direction         = "ingress"
88  ethertype         = "IPv6"
89  protocol          = "tcp"
90  port_range_min    = 3389
91  port_range_max    = 3389
92  remote_ip_prefix  = var.allow_rdp_from_v6[count.index]
93  security_group_id = openstack_networking_secgroup_v2.instance_rdp_access.id
94}
variables.tf
 1# Variables
 2variable "region" {
 3}
 4
 5variable "name" {
 6  default = "in9999-h2024"
 7}
 8
 9variable "ssh_public_key" {
10  default = "~/.ssh/id_ed25519.pub"
11}
12
13variable "network" {
14  default = "IPv6"
15}
16
17# Security group defaults
18variable "allow_icmp_from_v6" {
19  type    = list(string)
20  default = []
21}
22
23variable "allow_icmp_from_v4" {
24  type    = list(string)
25  default = []
26}
27
28variable "allow_ssh_from_v6" {
29  type    = list(string)
30  default = []
31}
32
33variable "allow_ssh_from_v4" {
34  type    = list(string)
35  default = []
36}
37
38variable "allow_rdp_from_v6" {
39  type    = list(string)
40  default = []
41}
42
43variable "allow_rdp_from_v4" {
44  type    = list(string)
45  default = []
46}
47
48# Mapping between role and image
49variable "role_image" {
50  type = map(string)
51  default = {
52    "snapshot" = "master-snap-01"
53  }
54}
55
56# Mapping between role and flavor
57variable "role_flavor" {
58  type = map(string)
59  default = {
60    "flavor" = "d1.medium"
61  }
62}
63
64# Mapping between role and number of instances (count)
65variable "role_count" {
66  type = map(string)
67  default = {
68    "students" = 5
69  }
70}
terraform.tfvars
 1# Region
 2region = "osl"
 3
 4# This is needed for ICMP access
 5allow_icmp_from_v6 = [
 6  "2001:700:100::/48"
 7]
 8allow_icmp_from_v4 = [
 9  "129.240.0.0/16"
10]
11
12# This is needed to access the instance over ssh
13allow_ssh_from_v6 = [
14  "2001:700:100:8070::/64",
15  "2001:700:100:8071::/64",
16]
17allow_ssh_from_v4 = [
18  "129.240.114.32/28",
19  "129.240.114.48/28",
20]
21
22# This is needed to access the instance over RDP
23allow_rdp_from_v6 = [
24  "2001:700:100::/48"
25]
26allow_rdp_from_v4 = [
27  "129.240.0.0/16"
28]
add-labuser.yaml
 1- hosts: "{{ myhosts | default('all') }}"
 2  gather_facts: no
 3
 4  vars:
 5    csvfile: "{{ playbook_dir }}/labusers.csv"
 6    username: "labuser"
 7
 8  tasks:
 9    - name: Create random but idempotent password
10      ansible.builtin.set_fact:
11        password: "{{ lookup('ansible.builtin.password', '/dev/null',
12                              seed=inventory_hostname+ansible_host,
13                              chars=['ascii_letters', 'digits', '().@%!-_']) }}"
14
15    - name: Ensure lab user is present
16      ansible.windows.win_user:
17        name: "{{ username }}"
18        password: "{{ password }}"
19        state: present
20        groups:
21          - Users
22          - Administrators
23          - Remote Desktop Users
24      register: labuser
25
26    - name: Print credentials if new/changed
27      ansible.builtin.debug:
28        msg: "NEW credential: {{ username }}:{{ password }}"
29      when: labuser.changed
30
31    - name: Create temporary directory
32      ansible.builtin.tempfile:
33        state: directory
34      register: tmpdir
35      delegate_to: localhost
36      run_once: true
37    
38    - name: Create CSV header
39      ansible.builtin.lineinfile:
40        line: "HOST,IPADDR,USERNAME,PASSWORD"
41        path: "{{ tmpdir.path }}/000.csv"
42        create: yes
43      delegate_to: localhost
44      run_once: true
45
46    - name: Save credentials in individual files
47      ansible.builtin.lineinfile:
48        line: "{{ inventory_hostname }},{{ ansible_host }},{{ username }},{{ password }}"
49        path: "{{ tmpdir.path }}/{{ inventory_hostname }}.csv"
50        create: yes
51      delegate_to: localhost
52
53    - name: Assemble CSV file from fragments
54      ansible.builtin.assemble:
55        src: "{{ tmpdir.path }}"
56        dest: "{{ csvfile }}"
57      delegate_to: localhost
58      run_once: true
59
60    - name: Remove temporary dir
61      ansible.builtin.file:
62        path: "{{ tmpdir.path }}"
63        state: absent
64      when: tmpdir.path is defined
65      delegate_to: localhost
66      run_once: true