Fix real-deployment blockers and scope down to router-only VMs
Confirmed working against a real VK Cloud PROD deployment (3 routers, 19 resources, apply succeeded end to end). Fixes found along the way: - provider "vkcs" was never configured (versions.tf) - username/password/ project_id/region were declared but wired to nothing; added auth_url and user_domain_name to complete it. - Nova keypairs are per-user, not per-project - added an optional vkcs_compute_keypair resource (var.ssh_public_key) so Terraform can register a keypair under the deploying service account itself. - router_priv_port used a hand-computed fixed_ip offset that collided with VKCS's own auto-created service ports on each network (observed: a "network:dns" port) - now left unset so Neutron's IPAM auto-assigns, which is collision-free by construction. - vkcs_compute_instance set image_id at the top level while also booting from a volume via block_device - the provider docs say not to do this; Nova echoes back a sentinel string for image_id on a volume-booted server, which Terraform read as drift on a ForceNew attribute and wanted to destroy+recreate every already-created instance on every subsequent plan. - private_network_cidrs bumped from /29 to /28 - too tight once the platform's own reserved ports are accounted for. Also removed the priv_srv_01/02/03 demo instances and the LAN network/ security group only they used - this deployment provisions router VMs only, confirmed with the user. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hXR2ftXZZhJ4Y3XuSoR8r
This commit is contained in:
1 parent
b5d6367fd8
commit
8886c1baad
13 files changed
+506
-171
No files matched your search
+35
-136
@@ -28,25 +28,18 @@ locals {
|
||||
)
|
||||
}
|
||||
|
||||
# LAN Network
|
||||
resource "vkcs_networking_network" "lan_net" {
|
||||
name = "router-lan-net"
|
||||
sdn = "sprut"
|
||||
admin_state_up = true
|
||||
# Nova keypairs are per-user, not per-project - a keypair uploaded under a
|
||||
# different account is invisible to whichever account Terraform deploys as.
|
||||
# Register it here from var.ssh_public_key when supplied; otherwise assume
|
||||
# var.ssh_key_name already exists under the deploying account.
|
||||
resource "vkcs_compute_keypair" "router" {
|
||||
count = var.ssh_public_key != null ? 1 : 0
|
||||
name = var.ssh_key_name
|
||||
public_key = var.ssh_public_key
|
||||
}
|
||||
|
||||
resource "vkcs_networking_subnet" "lan_subnet" {
|
||||
network_id = vkcs_networking_network.lan_net.id
|
||||
name = "router-lan-subnet"
|
||||
cidr = "10.200.10.0/24"
|
||||
gateway_ip = "10.200.10.1"
|
||||
dns_nameservers = ["8.8.8.8", "1.1.1.1"]
|
||||
sdn = "sprut"
|
||||
|
||||
allocation_pool {
|
||||
start = "10.200.10.100"
|
||||
end = "10.200.10.200"
|
||||
}
|
||||
locals {
|
||||
ssh_key_name = var.ssh_public_key != null ? vkcs_compute_keypair.router[0].name : var.ssh_key_name
|
||||
}
|
||||
|
||||
# Security Groups
|
||||
@@ -55,33 +48,6 @@ resource "vkcs_networking_secgroup" "router_sg" {
|
||||
sdn = "sprut"
|
||||
}
|
||||
|
||||
resource "vkcs_networking_secgroup" "private_sg" {
|
||||
name = "private-sg"
|
||||
sdn = "sprut"
|
||||
}
|
||||
|
||||
# Private SG rules
|
||||
resource "vkcs_networking_secgroup_rule" "from_rfc_net192_in" {
|
||||
direction = "ingress"
|
||||
remote_ip_prefix = "192.168.0.0/16"
|
||||
security_group_id = vkcs_networking_secgroup.private_sg.id
|
||||
sdn = "sprut"
|
||||
}
|
||||
|
||||
resource "vkcs_networking_secgroup_rule" "from_rfc_net172_in" {
|
||||
direction = "ingress"
|
||||
remote_ip_prefix = "172.16.0.0/12"
|
||||
security_group_id = vkcs_networking_secgroup.private_sg.id
|
||||
sdn = "sprut"
|
||||
}
|
||||
|
||||
resource "vkcs_networking_secgroup_rule" "from_rfc_net10_in" {
|
||||
direction = "ingress"
|
||||
remote_ip_prefix = "10.0.0.0/8"
|
||||
security_group_id = vkcs_networking_secgroup.private_sg.id
|
||||
sdn = "sprut"
|
||||
}
|
||||
|
||||
# Router SG rules
|
||||
resource "vkcs_networking_secgroup_rule" "router_ssh" {
|
||||
direction = "ingress"
|
||||
@@ -122,8 +88,8 @@ resource "vkcs_networking_secgroup_rule" "router_ipsec_nat_t" {
|
||||
}
|
||||
|
||||
# Per-router private interfaces: one shared private network per admin-supplied
|
||||
# CIDR in private_network_cidrs (no shared LAN with the priv_srv_* segment,
|
||||
# no VRRP), each router getting its own port/IP inside every such network.
|
||||
# CIDR in private_network_cidrs (no VRRP), each router getting its own
|
||||
# port/IP inside every such network.
|
||||
locals {
|
||||
private_roles = [for idx in range(length(var.private_network_cidrs)) : "priv${idx + 1}"]
|
||||
|
||||
@@ -147,36 +113,50 @@ resource "vkcs_networking_subnet" "router_priv_subnet" {
|
||||
cidr = each.value
|
||||
gateway_ip = cidrhost(each.value, 1)
|
||||
sdn = "sprut"
|
||||
|
||||
# No in-guest DHCP client is needed for these interfaces: with
|
||||
# config_drive = true (set on every router instance), cloud-init learns
|
||||
# each interface's assigned address from the config-drive metadata and
|
||||
# renders it directly, then network-init.sh.tpl re-pins it deterministically
|
||||
# to ethN by MAC - no actual DHCP protocol exchange with this subnet ever
|
||||
# happens. (Note: this does NOT avoid IP collisions with VKCS's own
|
||||
# auto-created service ports on the network, e.g. a "network:dns" port -
|
||||
# see router_priv_port below, which leaves ip_address unset instead.)
|
||||
enable_dhcp = false
|
||||
}
|
||||
|
||||
resource "vkcs_networking_port" "router_priv_port" {
|
||||
for_each = {
|
||||
for pair in setproduct(range(var.router_count), local.private_roles) :
|
||||
"router${pair[0] + 1}-${pair[1]}" => {
|
||||
router_index = pair[0]
|
||||
role = pair[1]
|
||||
}
|
||||
"router${pair[0] + 1}-${pair[1]}" => pair[1]
|
||||
}
|
||||
name = "router-${each.key}-port"
|
||||
network_id = vkcs_networking_network.router_priv_net[each.value.role].id
|
||||
network_id = vkcs_networking_network.router_priv_net[each.value].id
|
||||
admin_state_up = true
|
||||
port_security_enabled = false
|
||||
full_security_groups_control = true
|
||||
security_group_ids = []
|
||||
sdn = "sprut"
|
||||
|
||||
# No explicit ip_address: let Neutron's IPAM auto-assign one from the
|
||||
# subnet's pool. VKCS auto-creates its own service ports on this network
|
||||
# (observed: a "network:dns" port) that can silently consume whichever
|
||||
# low address a hand-computed offset would have picked, causing
|
||||
# IpAddressAlreadyAllocated - IPAM guarantees no double-booking, our own
|
||||
# arithmetic doesn't. network-init.sh.tpl matches interfaces by which
|
||||
# declared CIDR their live IP falls into, not by an exact expected IP, so
|
||||
# the assigned address doesn't need to be known in advance.
|
||||
fixed_ip {
|
||||
subnet_id = vkcs_networking_subnet.router_priv_subnet[each.value.role].id
|
||||
ip_address = cidrhost(local.private_network_cidr[each.value.role], each.value.router_index + 2)
|
||||
subnet_id = vkcs_networking_subnet.router_priv_subnet[each.value].id
|
||||
}
|
||||
}
|
||||
|
||||
resource "vkcs_compute_instance" "router" {
|
||||
count = var.router_count
|
||||
name = "router${count.index + 1}"
|
||||
image_id = data.vkcs_images_image.ubuntu24.id
|
||||
flavor_name = "STD3-4-4"
|
||||
availability_zone = element(var.router_availability_zones, count.index % length(var.router_availability_zones))
|
||||
key_pair = var.ssh_key_name
|
||||
key_pair = local.ssh_key_name
|
||||
|
||||
security_group_ids = [
|
||||
vkcs_networking_secgroup.router_sg.id,
|
||||
@@ -220,84 +200,3 @@ resource "vkcs_compute_instance" "router" {
|
||||
}
|
||||
}
|
||||
|
||||
resource "vkcs_compute_instance" "priv_srv_01" {
|
||||
name = "Priv-SRV-01"
|
||||
image_id = data.vkcs_images_image.ubuntu24.id
|
||||
flavor_name = "STD3-4-4"
|
||||
availability_zone = "ME1"
|
||||
key_pair = var.ssh_key_name
|
||||
|
||||
security_group_ids = [
|
||||
vkcs_networking_secgroup.private_sg.id,
|
||||
local.default_security_group_id
|
||||
]
|
||||
|
||||
network {
|
||||
uuid = vkcs_networking_network.lan_net.id
|
||||
}
|
||||
|
||||
block_device {
|
||||
uuid = data.vkcs_images_image.ubuntu24.id
|
||||
source_type = "image"
|
||||
volume_size = 20
|
||||
boot_index = 0
|
||||
destination_type = "volume"
|
||||
volume_type = "ceph-ssd"
|
||||
delete_on_termination = true
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
resource "vkcs_compute_instance" "priv_srv_02" {
|
||||
name = "Priv-SRV-02"
|
||||
image_id = data.vkcs_images_image.ubuntu24.id
|
||||
flavor_name = "STD3-4-4"
|
||||
availability_zone = "ME1"
|
||||
key_pair = var.ssh_key_name
|
||||
|
||||
security_group_ids = [
|
||||
vkcs_networking_secgroup.private_sg.id,
|
||||
local.default_security_group_id
|
||||
]
|
||||
|
||||
network {
|
||||
uuid = vkcs_networking_network.lan_net.id
|
||||
}
|
||||
|
||||
block_device {
|
||||
uuid = data.vkcs_images_image.ubuntu24.id
|
||||
source_type = "image"
|
||||
volume_size = 20
|
||||
boot_index = 0
|
||||
destination_type = "volume"
|
||||
volume_type = "ceph-ssd"
|
||||
delete_on_termination = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "vkcs_compute_instance" "priv_srv_03" {
|
||||
name = "Priv-SRV-03"
|
||||
image_id = data.vkcs_images_image.ubuntu24.id
|
||||
flavor_name = "STD3-4-4"
|
||||
availability_zone = "MS1"
|
||||
key_pair = var.ssh_key_name
|
||||
|
||||
security_group_ids = [
|
||||
vkcs_networking_secgroup.private_sg.id,
|
||||
local.default_security_group_id
|
||||
]
|
||||
|
||||
network {
|
||||
uuid = vkcs_networking_network.lan_net.id
|
||||
}
|
||||
|
||||
block_device {
|
||||
uuid = data.vkcs_images_image.ubuntu24.id
|
||||
source_type = "image"
|
||||
volume_size = 20
|
||||
boot_index = 0
|
||||
destination_type = "volume"
|
||||
volume_type = "ceph-ssd"
|
||||
delete_on_termination = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
# Example of the *.auto.tfvars overlay pattern for real credentials.
|
||||
#
|
||||
# terraform.tfvars stays a committed, anonymized template. To deploy with
|
||||
# real credentials (e.g. from an openrc.sh for a service account), copy this
|
||||
# file to prod.auto.tfvars (gitignored - see .gitignore) and fill in real
|
||||
# values. Terraform auto-loads *.auto.tfvars in addition to terraform.tfvars,
|
||||
# so this layers on top of the template without ever modifying/committing it.
|
||||
#
|
||||
# Mapping from an OpenStack-style openrc.sh:
|
||||
# OS_AUTH_URL -> auth_url
|
||||
# OS_USERNAME -> username
|
||||
# OS_PASSWORD -> password
|
||||
# OS_PROJECT_ID -> project_id
|
||||
# OS_REGION_NAME -> region
|
||||
# OS_USER_DOMAIN_NAME -> user_domain_name
|
||||
|
||||
auth_url = "https://infra.mail.ru:35357/v3/"
|
||||
username = "svc-<project_id>-svc-deployer"
|
||||
password = "<real password - never commit this>"
|
||||
project_id = "<project_id>"
|
||||
region = "RegionOne"
|
||||
user_domain_name = "service-users"
|
||||
|
||||
# Optional: Nova keypairs are per-user, not per-project - a keypair uploaded
|
||||
# under a different account (e.g. your personal VK Cloud login) is invisible
|
||||
# to a service account. Set this to have Terraform register var.ssh_key_name
|
||||
# under the deploying account from this public key. Leave unset if a keypair
|
||||
# with that name already exists under the deploying account.
|
||||
ssh_public_key = "ssh-ed25519 AAAA... or ssh-rsa AAAA... your-public-key-content"
|
||||
@@ -1,15 +1,18 @@
|
||||
username = "user@domain.local"
|
||||
password = "DemoUserPassw"
|
||||
project_id = "XXXXXd424998422XXXXXf13ac9XXXXX"
|
||||
ssh_key_name = "AdminSSH"
|
||||
ssh_key_name = "mcs_ru"
|
||||
|
||||
# Adaptive router VM count (has a default - see variables.tf - uncomment to override).
|
||||
# router_count = 3
|
||||
router_count = 3
|
||||
# router_availability_zones = ["ME1"]
|
||||
|
||||
# One CIDR per private network that router VMs get an interface into.
|
||||
# No default - must be supplied explicitly. List order determines eth1..ethN.
|
||||
# /28 (not /29): VKCS auto-creates its own service ports on each network
|
||||
# (observed: a "network:dns" port) that consume addresses from the pool
|
||||
# too, and a /29 (5 usable) proved too tight in practice.
|
||||
private_network_cidrs = [
|
||||
"10.90.0.0/29",
|
||||
"10.90.0.8/29",
|
||||
"10.90.0.0/28",
|
||||
"10.90.0.16/28",
|
||||
]
|
||||
@@ -97,7 +97,9 @@ def find_data_sources(doc, dtype):
|
||||
REQUIRED_FILES = [
|
||||
"main.tf",
|
||||
"variables.tf",
|
||||
"versions.tf",
|
||||
"terraform.tfvars",
|
||||
"prod.auto.tfvars.example",
|
||||
"scripts/network-init.sh.tpl",
|
||||
]
|
||||
|
||||
@@ -286,7 +288,7 @@ def test_default_security_group_id_override_accepted(override, should_pass):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("relpath", ["main.tf", "variables.tf", "images.tf"])
|
||||
@pytest.mark.parametrize("relpath", ["main.tf", "variables.tf", "versions.tf", "images.tf"])
|
||||
def test_hcl_file_parses(relpath):
|
||||
# images.tf is intentionally fully commented out (disabled helper data
|
||||
# source) - it must still parse cleanly, just possibly to an empty doc.
|
||||
@@ -294,6 +296,86 @@ def test_hcl_file_parses(relpath):
|
||||
assert isinstance(doc, dict)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# versions.tf: the provider is actually configured (auth variables used to
|
||||
# be declared in variables.tf but never wired to anything - regression
|
||||
# guard against that gap reappearing)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_provider_vkcs_wires_all_auth_variables():
|
||||
versions = load_tf("versions.tf")
|
||||
provider_blocks = versions.get("provider", [])
|
||||
vkcs_provider = None
|
||||
for block in provider_blocks:
|
||||
if "vkcs" in block:
|
||||
vkcs_provider = block["vkcs"]
|
||||
assert vkcs_provider is not None, "expected a provider \"vkcs\" block in versions.tf"
|
||||
|
||||
expected = {
|
||||
"auth_url": "${var.auth_url}",
|
||||
"username": "${var.username}",
|
||||
"password": "${var.password}",
|
||||
"project_id": "${var.project_id}",
|
||||
"region": "${var.region}",
|
||||
"user_domain_name": "${var.user_domain_name}",
|
||||
}
|
||||
for attr, expr in expected.items():
|
||||
assert vkcs_provider.get(attr) == [expr], (
|
||||
f"provider \"vkcs\" must set {attr} = var.{attr} - "
|
||||
f"auth variables must not be left orphaned/unwired"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["auth_url", "user_domain_name", "region"])
|
||||
def test_auth_variable_has_a_default(name):
|
||||
v = find_variable(load_tf("variables.tf"), name)
|
||||
assert v is not None, f"variable {name} is missing"
|
||||
assert "default" in v, f"{name} should have a sane default so it doesn't have to be set explicitly for the common case"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Real secrets must never land in the git-tracked terraform.tfvars template -
|
||||
# they belong in a gitignored *.auto.tfvars overlay instead (see
|
||||
# prod.auto.tfvars.example)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_gitignore_excludes_auto_tfvars_overlay():
|
||||
gitignore_text = (REPO_ROOT / ".gitignore").read_text()
|
||||
assert "*.auto.tfvars" in gitignore_text, (
|
||||
"*.auto.tfvars must be gitignored - real credentials are meant to be "
|
||||
"layered on top of terraform.tfvars via such a file, never committed"
|
||||
)
|
||||
|
||||
|
||||
def test_prod_auto_tfvars_example_is_not_gitignored():
|
||||
"""The *.example file documents the overlay pattern and must ship in the
|
||||
repo (unlike the real *.auto.tfvars it documents)."""
|
||||
result = subprocess.run(
|
||||
["git", "check-ignore", "terraform/prod.auto.tfvars.example"],
|
||||
cwd=REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode != 0, "prod.auto.tfvars.example must NOT be gitignored"
|
||||
|
||||
|
||||
def test_tfvars_template_has_no_auth_credentials():
|
||||
"""terraform.tfvars is a committed, anonymized template - auth_url/
|
||||
user_domain_name/real credentials belong in a gitignored *.auto.tfvars
|
||||
overlay, not here."""
|
||||
tfvars_text = (TERRAFORM_DIR / "terraform.tfvars").read_text()
|
||||
active_lines = [
|
||||
line for line in tfvars_text.splitlines() if not line.strip().startswith("#")
|
||||
]
|
||||
for forbidden in ("auth_url", "user_domain_name"):
|
||||
assert not any(re.match(rf"^\s*{forbidden}\s*=", line) for line in active_lines), (
|
||||
f"{forbidden} should not be set in the committed terraform.tfvars "
|
||||
f"template - use a gitignored *.auto.tfvars overlay instead"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# variables.tf: the scaling knobs exist as expected
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -319,17 +401,21 @@ def test_private_network_cidrs_variable():
|
||||
)
|
||||
|
||||
|
||||
def test_router_count_not_pinned_in_tfvars():
|
||||
"""TF_VAR_router_count only takes effect if terraform.tfvars doesn't set
|
||||
an active (non-comment) value for it."""
|
||||
def test_router_count_pinned_in_tfvars_is_a_valid_number():
|
||||
"""terraform.tfvars now pins a real router_count for this project's
|
||||
actual PROD deployment (a deliberate choice, confirmed with the user) -
|
||||
a tfvars-file value always beats a TF_VAR_ environment variable in
|
||||
Terraform's precedence order, so TF_VAR_router_count no longer has any
|
||||
effect while this stays set. Just sanity-check it's a positive integer,
|
||||
not that it's absent."""
|
||||
tfvars_text = (TERRAFORM_DIR / "terraform.tfvars").read_text()
|
||||
active_lines = [
|
||||
line for line in tfvars_text.splitlines() if not line.strip().startswith("#")
|
||||
]
|
||||
assert not any(re.match(r"^\s*router_count\s*=", line) for line in active_lines), (
|
||||
"router_count must not be set in terraform.tfvars, or the "
|
||||
"TF_VAR_router_count environment variable would be shadowed"
|
||||
)
|
||||
matches = [line for line in active_lines if re.match(r"^\s*router_count\s*=", line)]
|
||||
assert matches, "expected router_count to be pinned in terraform.tfvars for this deployment"
|
||||
value = int(matches[0].split("=", 1)[1].strip())
|
||||
assert value >= 1
|
||||
|
||||
|
||||
def test_private_network_cidrs_is_set_in_tfvars():
|
||||
@@ -357,6 +443,24 @@ def test_router_resource_uses_count_variable():
|
||||
assert instances["router"]["count"] == ["${var.router_count}"]
|
||||
|
||||
|
||||
def test_compute_instances_do_not_set_top_level_image_id():
|
||||
"""Regression guard: the provider docs say 'Do not specify [image_id]
|
||||
if booting from a volume' - doing so anyway caused every already-created
|
||||
instance to be flagged for destroy+recreate on the next plan, because
|
||||
Nova reports back a sentinel string ("Attempt to boot from volume - no
|
||||
image supplied") instead of echoing the image UUID for a volume-booted
|
||||
server, which Terraform then sees as configuration drift on a
|
||||
ForceNew attribute. The image only belongs inside block_device."""
|
||||
main = load_tf("main.tf")
|
||||
for name, attrs in find_resources(main, "vkcs_compute_instance"):
|
||||
assert "image_id" not in attrs, (
|
||||
f"vkcs_compute_instance.{name} must not set top-level image_id "
|
||||
f"when booting from a volume via block_device"
|
||||
)
|
||||
assert attrs["block_device"][0]["source_type"] == ["image"]
|
||||
assert "uuid" in attrs["block_device"][0]
|
||||
|
||||
|
||||
def test_no_legacy_hardcoded_router_resources():
|
||||
main = load_tf("main.tf")
|
||||
instance_names = {name for name, _ in find_resources(main, "vkcs_compute_instance")}
|
||||
@@ -371,6 +475,22 @@ def test_no_legacy_hardcoded_router_resources():
|
||||
)
|
||||
|
||||
|
||||
def test_deployment_is_router_only():
|
||||
"""This deployment provisions only the router VMs - the demo's
|
||||
priv_srv_01/02/03 instances and the shared LAN network/security group
|
||||
that only they used were removed as out of scope (confirmed with the
|
||||
user)."""
|
||||
main = load_tf("main.tf")
|
||||
instance_names = {name for name, _ in find_resources(main, "vkcs_compute_instance")}
|
||||
assert instance_names == {"router"}, f"expected only the router instance, found {instance_names}"
|
||||
|
||||
network_names = {name for name, _ in find_resources(main, "vkcs_networking_network")}
|
||||
assert "lan_net" not in network_names
|
||||
|
||||
secgroup_names = {name for name, _ in find_resources(main, "vkcs_networking_secgroup")}
|
||||
assert secgroup_names == {"router_sg"}, f"expected only router_sg, found {secgroup_names}"
|
||||
|
||||
|
||||
def test_private_roles_local_driven_by_variable():
|
||||
main = load_tf("main.tf")
|
||||
private_roles = find_local(main, "private_roles")
|
||||
@@ -437,12 +557,109 @@ def test_all_instances_use_default_security_group_local():
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# main.tf: SSH keypair - Nova keypairs are per-user, not per-project, so a
|
||||
# keypair uploaded under a different account is invisible to the deploying
|
||||
# one. var.ssh_public_key lets Terraform register it itself.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ssh_public_key_variable_defaults_to_null():
|
||||
v = find_variable(load_tf("variables.tf"), "ssh_public_key")
|
||||
assert v is not None, "variable ssh_public_key is missing"
|
||||
assert v["default"] == [None], "ssh_public_key should default to null (register nothing unless supplied)"
|
||||
|
||||
|
||||
def test_keypair_resource_conditionally_registers_public_key():
|
||||
main = load_tf("main.tf")
|
||||
keypairs = dict(find_resources(main, "vkcs_compute_keypair"))
|
||||
assert "router" in keypairs, "expected a vkcs_compute_keypair.router resource"
|
||||
kp = keypairs["router"]
|
||||
assert kp["count"] == ["${var.ssh_public_key != None ? 1 : 0}"], (
|
||||
"the keypair should only be created when ssh_public_key is actually supplied"
|
||||
)
|
||||
assert kp["name"] == ["${var.ssh_key_name}"]
|
||||
assert kp["public_key"] == ["${var.ssh_public_key}"]
|
||||
|
||||
|
||||
def test_ssh_key_name_local_prefers_registered_keypair():
|
||||
main = load_tf("main.tf")
|
||||
value = find_local(main, "ssh_key_name")
|
||||
assert value == [
|
||||
"${var.ssh_public_key != None ? vkcs_compute_keypair.router[0].name : var.ssh_key_name}"
|
||||
], (
|
||||
"locals.ssh_key_name must use the keypair Terraform registers itself "
|
||||
"when ssh_public_key is supplied, falling back to var.ssh_key_name "
|
||||
"(an already-existing keypair) otherwise"
|
||||
)
|
||||
|
||||
|
||||
def test_all_instances_use_ssh_key_name_local():
|
||||
main = load_tf("main.tf")
|
||||
for name, attrs in find_resources(main, "vkcs_compute_instance"):
|
||||
assert attrs["key_pair"] == ["${local.ssh_key_name}"], (
|
||||
f"vkcs_compute_instance.{name} must reference local.ssh_key_name, "
|
||||
f"not var.ssh_key_name directly, so it depends on the keypair "
|
||||
f"resource when one is registered"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# main.tf: router private subnets don't need Neutron's DHCP service - with
|
||||
# config_drive = true, cloud-init learns each interface's address from
|
||||
# config-drive metadata rather than an actual DHCP exchange, and
|
||||
# network-init.sh.tpl re-pins it deterministically to ethN afterwards
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_router_priv_subnet_has_dhcp_disabled():
|
||||
main = load_tf("main.tf")
|
||||
subnets = dict(find_resources(main, "vkcs_networking_subnet"))
|
||||
assert "router_priv_subnet" in subnets
|
||||
assert subnets["router_priv_subnet"]["enable_dhcp"] == [False], (
|
||||
"router_priv_subnet must have enable_dhcp = false - no in-guest "
|
||||
"DHCP client is ever used on these interfaces"
|
||||
)
|
||||
|
||||
|
||||
def test_router_priv_port_leaves_ip_address_unset():
|
||||
"""Regression guard: a hand-computed fixed_ip.ip_address collided with
|
||||
VKCS's own auto-created service ports on the network (observed: a
|
||||
"network:dns" port silently consuming an address) during a real PROD
|
||||
deployment. Leaving ip_address unset lets Neutron's IPAM auto-assign
|
||||
one, which is guaranteed collision-free; network-init.sh.tpl matches
|
||||
interfaces by which declared CIDR their live IP falls into, not by an
|
||||
exact expected IP, so this doesn't need to be known in advance."""
|
||||
main = load_tf("main.tf")
|
||||
ports = dict(find_resources(main, "vkcs_networking_port"))
|
||||
assert "router_priv_port" in ports
|
||||
fixed_ip = ports["router_priv_port"]["fixed_ip"][0]
|
||||
assert "ip_address" not in fixed_ip, (
|
||||
"router_priv_port.fixed_ip must not set ip_address - let Neutron's "
|
||||
"IPAM auto-assign to avoid colliding with platform-reserved ports"
|
||||
)
|
||||
assert "subnet_id" in fixed_ip
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# terraform.tfvars example CIDRs: sanity-check the values actually shipped
|
||||
# (no auto-carving anymore - these come straight from the admin/example)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _configured_router_count():
|
||||
"""The router_count actually in effect: whatever terraform.tfvars pins,
|
||||
else the variable's default (a tfvars value always beats the default)."""
|
||||
tfvars_text = (TERRAFORM_DIR / "terraform.tfvars").read_text()
|
||||
for line in tfvars_text.splitlines():
|
||||
if line.strip().startswith("#"):
|
||||
continue
|
||||
m = re.match(r"^\s*router_count\s*=\s*(\d+)", line)
|
||||
if m:
|
||||
return int(m.group(1))
|
||||
return find_variable(load_tf("variables.tf"), "router_count")["default"][0]
|
||||
|
||||
|
||||
def test_tfvars_private_network_cidrs_do_not_overlap_and_have_room_for_routers():
|
||||
tfvars = load_tf("terraform.tfvars")
|
||||
raw_cidrs = tfvars["private_network_cidrs"][0]
|
||||
@@ -453,13 +670,17 @@ def test_tfvars_private_network_cidrs_do_not_overlap_and_have_room_for_routers()
|
||||
for b in networks[i + 1 :]:
|
||||
assert not a.overlaps(b), f"{a} overlaps {b} in terraform.tfvars"
|
||||
|
||||
router_count_default = find_variable(load_tf("variables.tf"), "router_count")["default"][0]
|
||||
router_count = _configured_router_count()
|
||||
for net in networks:
|
||||
# offset scheme: cidrhost(cidr, router_index + 2) for router_index in
|
||||
# 0..router_count-1, so we need at least router_count + 2 addresses.
|
||||
assert net.num_addresses >= router_count_default + 2, (
|
||||
f"{net} has too few addresses for {router_count_default} routers "
|
||||
f"(offset scheme needs router_count + 2)"
|
||||
# ip_address is left unset on each port (Neutron IPAM auto-assigns -
|
||||
# see router_priv_port in main.tf), so there's no fixed per-router
|
||||
# offset to reserve room for - but VKCS auto-creates its own service
|
||||
# ports on the network (observed: one "network:dns" port consuming
|
||||
# an address), so there must be room for router_count routers plus
|
||||
# at least one such reservation, on top of network/gateway/broadcast.
|
||||
assert net.num_addresses >= router_count + 3, (
|
||||
f"{net} has too few addresses for {router_count} routers plus "
|
||||
f"platform-reserved ports (e.g. VKCS's network:dns service port)"
|
||||
)
|
||||
|
||||
|
||||
|
||||
+20
-2
@@ -15,9 +15,21 @@ variable "project_id" {
|
||||
}
|
||||
|
||||
variable "region" {
|
||||
description = "Region"
|
||||
description = "OpenStack region (VK Cloud typically has a single region, \"RegionOne\" - not to be confused with availability zones like \"ME1\"/\"MS1\")"
|
||||
type = string
|
||||
default = "ME1"
|
||||
default = "RegionOne"
|
||||
}
|
||||
|
||||
variable "auth_url" {
|
||||
description = "VK Cloud Identity (Keystone) auth URL"
|
||||
type = string
|
||||
default = "https://infra.mail.ru:35357/v3/"
|
||||
}
|
||||
|
||||
variable "user_domain_name" {
|
||||
description = "Keystone domain the auth user resides in ('users' for regular accounts, 'service-users' for svc-* service accounts)"
|
||||
type = string
|
||||
default = "users"
|
||||
}
|
||||
|
||||
variable "ssh_key_name" {
|
||||
@@ -25,6 +37,12 @@ variable "ssh_key_name" {
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "ssh_public_key" {
|
||||
description = "OpenSSH public key content to register as the ssh_key_name keypair under the deploying account (Nova keypairs are per-user, not per-project - a key uploaded under a different account is invisible here). Leave null if a keypair with that name already exists under the deploying account."
|
||||
type = string
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "router_count" {
|
||||
description = "Number of IaaS Router VMs to provision"
|
||||
type = number
|
||||
|
||||
@@ -6,3 +6,12 @@ terraform {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provider "vkcs" {
|
||||
auth_url = var.auth_url
|
||||
username = var.username
|
||||
password = var.password
|
||||
project_id = var.project_id
|
||||
region = var.region
|
||||
user_domain_name = var.user_domain_name
|
||||
}
|
||||
Reference in new issue
Block a user