Scope: this covers how OpenStack tenant projects in this lab are provisioned declaratively via Terraform’s openstack provider — projects, quotas, users/roles, security-group rules, custom compute flavors, and the per-project provider VLAN network. For the surrounding physical network, BGP, and OpenStack-Ansible install itself, see the OpenStack Lab Deployment article — this one is scoped narrowly to project/quota management on top of that cloud. Credentials, private keys and real passwords are intentionally omitted or replaced with Terraform variable references — see the referenced files directly in the repo for those, and rotate anything that’s ever touched git history.
1. Why Terraform for tenant projects
Every OpenStack tenant project in this lab (compute/storage/network quotas, the project’s admin user, its default security-group rules, its custom flavor set, its provider network) is defined as Terraform resources against the openstack provider, rather than clicked through Horizon or scripted with one-off openstack CLI calls. Same rationale as everything else “as code” in this repo: the project’s exact quota numbers, flavor list, and network layout are reviewable in a diff and reproducible from scratch — re-running terraform apply after a project’s quotas change is safer than hoping someone remembers what was set by hand in Horizon.
This lab runs two tenant projects side by side — project-a and project-b — each with its own quotas, admin user, keypair, flavor set, and provider VLAN (VLAN 14 and VLAN 15 respectively; see the network article’s VLAN table for how these fit into the wider physical network).
2. Project + quotas
Each project is a plain openstack_identity_project_v3, with three separate quota resources — Nova (compute), Cinder (block storage), and Neutron (networking) are each their own quota API in OpenStack, so each gets its own Terraform resource pointed at the same project_id:
resource "openstack_identity_project_v3" "project_a" {
name = "project-a"
description = "Project A"
domain_id = "default"
}
# Compute quotas (Nova)
resource "openstack_compute_quotaset_v2" "project_a_quota" {
project_id = openstack_identity_project_v3.project_a.id
instances = 100 # Max instances
cores = 240 # Max vCPUs
ram = 348 * 1024 # Max RAM (MB)
key_pairs = 10
server_groups = 50
server_group_members = 10
}
# Block storage quotas (Cinder)
resource "openstack_blockstorage_quotaset_v3" "project_a_quota" {
project_id = openstack_identity_project_v3.project_a.id
volumes = 50 # Max volumes
snapshots = 20 # Max snapshots
gigabytes = 3000 # Total storage (GB)
backups = 20
backup_gigabytes = 3000
per_volume_gigabytes = 1000
}
# Network quotas (Neutron)
resource "openstack_networking_quota_v2" "project_a_quota" {
project_id = openstack_identity_project_v3.project_a.id
network = 5
subnet = 10
router = 10
port = 500
floatingip = 30
security_group = 500
security_group_rule = 2000
}project-b is the smaller of the two — a deliberately tighter quota envelope (15 instances / 100 vCPUs / 128GB RAM vs. project-a’s 100 instances / 240 vCPUs / 348GB RAM), since it hosts a narrower workload than the primary project.
3. User + role assignment
Each project gets one admin-scoped user. The password is intentionally shown here as a variable reference rather than a literal — in the real Terraform it should come from a .tfvars file kept out of git (or a proper secret store), never hardcoded in the .tf source itself:
resource "openstack_identity_user_v3" "project_a_user" {
name = "project-a-admin"
password = var.project_a_user_password
default_project_id = openstack_identity_project_v3.project_a.id
domain_id = "default"
}
data "openstack_identity_role_v3" "admin_role" {
name = "admin"
}
resource "openstack_identity_role_assignment_v3" "project_a_member" {
user_id = openstack_identity_user_v3.project_a_user.id
project_id = openstack_identity_project_v3.project_a.id
role_id = data.openstack_identity_role_v3.admin_role.id
}
# SSH keypair for this user -- keypairs in OpenStack are project-scoped, so this
# is created under the project's own credentials, not the admin/cloud-admin ones.
resource "openstack_compute_keypair_v2" "project_a_key" {
name = "project-a-cloud"
public_key = "ssh-rsa AAAA... your-key-here"
user_id = openstack_identity_user_v3.project_a_user.id
}4. Default security group rules
Every project’s auto-created default security group starts with no ingress rules at all — Terraform reads it as a data source (it already exists once the project exists) and adds the two baseline rules every instance needs: SSH and ICMP:
data "openstack_networking_secgroup_v2" "default_secgroup_project_a" {
name = "default"
tenant_id = openstack_identity_project_v3.project_a.id
depends_on = [openstack_identity_project_v3.project_a]
}
resource "openstack_networking_secgroup_rule_v2" "default_ssh_project_a" {
direction = "ingress"
ethertype = "IPv4"
protocol = "tcp"
port_range_min = 22
port_range_max = 22
remote_ip_prefix = "0.0.0.0/0"
security_group_id = data.openstack_networking_secgroup_v2.default_secgroup_project_a.id
}
resource "openstack_networking_secgroup_rule_v2" "default_icmp_project_a" {
direction = "ingress"
ethertype = "IPv4"
protocol = "icmp"
remote_ip_prefix = "0.0.0.0/0"
security_group_id = data.openstack_networking_secgroup_v2.default_secgroup_project_a.id
}5. Custom compute flavors
Flavors are defined once as a locals list and expanded with for_each, rather than one resource block per flavor. A representative slice:
locals {
base_flavors = [
{ name = "4xCPU_8GB", vcpus = 4, ram = 8192, disk = 0, extra_specs = {} },
{ name = "8xCPU_24GB", vcpus = 8, ram = 24576, disk = 0, extra_specs = {} },
{ name = "c.4xCPU_24GB", vcpus = 4, ram = 24576, disk = 0, extra_specs = {
"hw:cpu_policy" = "dedicated"
"hw:cpu_mode" = "host-passthrough"
"hw:cpu_model_extra_flags" = "pcid,pdpe1gb"
"hw:cpu_thread_policy" = "prefer"
"hw:cpu_sockets" = "1"
"hw:cpu_cores" = "4"
"hw:cpu_threads" = "1"
} },
]
}
resource "openstack_compute_flavor_v2" "flavors_project_a" {
for_each = { for f in local.base_flavors : f.name => f }
name = each.value.name
vcpus = each.value.vcpus
ram = each.value.ram
disk = each.value.disk
is_public = false
# Present all vCPUs as cores inside a single socket so Windows guests don't
# ignore CPUs. Windows client editions only recognize a limited number of
# physical sockets (Pro = 2, Home = 1); without an explicit topology, libvirt
# exposes 1 socket per vCPU, so an 8-vCPU VM would only show 2 usable CPUs.
extra_specs = merge(
{
"hw:cpu_sockets" = "1"
"hw:cpu_cores" = tostring(each.value.vcpus)
"hw:cpu_threads" = "1"
},
each.value.extra_specs,
)
}
resource "openstack_compute_flavor_access_v2" "flavors_project_a_access" {
for_each = openstack_compute_flavor_v2.flavors_project_a
flavor_id = each.value.id
tenant_id = openstack_identity_project_v3.project_a.id
}is_public = false + the explicit openstack_compute_flavor_access_v2 grant keeps each project’s custom flavors private to that project instead of visible cloud-wide — every project in this lab defines its own flavor set this way rather than sharing one global list.
6. Provider VLAN network + subnet
Each project’s tenant network rides its own dedicated VLAN on the provider trunk (see the Lab Deployment article’s VLAN table — VLAN 14 for project-a, VLAN 15 for project-b):
resource "openstack_networking_network_v2" "vlan14" {
name = "vlan-14"
admin_state_up = true
external = true
shared = false
mtu = 1500
tenant_id = openstack_identity_project_v3.project_a.id
segments {
network_type = "vlan"
physical_network = "provider"
segmentation_id = 14
}
}
resource "openstack_networking_subnet_v2" "vlan14_subnet" {
name = "vlan-14"
network_id = openstack_networking_network_v2.vlan14.id
tenant_id = openstack_identity_project_v3.project_a.id
cidr = "10.14.0.0/24"
gateway_ip = "10.14.0.1"
ip_version = 4
dns_nameservers = ["1.1.1.1"]
allocation_pool {
start = "10.14.0.10"
end = "10.14.0.200"
}
enable_dhcp = true
}Summary
- Project (
openstack_identity_project_v3) + three separate quota resources (compute/block-storage/network) — §2 - Admin user + role assignment, password sourced from a Terraform variable, never hardcoded — §3
- Baseline SSH/ICMP security-group rules on the project’s default security group — §4
- Private, project-scoped custom compute flavors via
for_eachover alocalslist — §5 - Provider VLAN network + subnet tying the project into the physical network from the Lab Deployment article — §6