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:
Install a master Windows instance, which we will later use as a template for new instances
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
Make a snapshot of the master instance. This is the template template that will be used in the next step
Use Terraform to spin up a number of instances for students based on the template created in the previous step
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¶
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.
Import the public key
~/.ssh/winkey.pub
into openstackCreate 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 othersKey Pair:
winkey
(created above)
You should add security groups that allow SSH and RDP from your current IP address.
Wait for the instance to be ready. With Windows it takes a long time, at least 10 minutes
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>
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
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
Install software and make any changes as required. For the purposes of this demonstration, we install Visual Studio Code
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¶
Log into the master instance again via RDP:
Run Powershell as administrator:
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
Make a snapshot of the instance while it is shut off
We name the snapshot «master-snap-01»:
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
:
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.
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
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}
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}
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}
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]
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