]> git.proxmox.com Git - pve-network.git/commitdiff
tests: test VNets functionality as a blackbox
authorStefan Lendl <s.lendl@proxmox.com>
Fri, 5 Apr 2024 13:18:04 +0000 (15:18 +0200)
committerThomas Lamprecht <t.lamprecht@proxmox.com>
Mon, 8 Apr 2024 15:57:18 +0000 (17:57 +0200)
Add several tests for Vnets in test_vnets_blackbox. State setup as well
as testing results is done only via the API to test on the API
boundaries not not against the internal state. Internal state is mocked
to avoid requiring access to system files or pmxcfs.

Mocking is done by reading and writing to a hash that holds the entire
state of SDN. The state is reset after every test run.

Testing is done via helper functions: nic_join and nic_start.
When a nic joins a Vnet, currently it always - and only - calls
add_next_free_cidr(). The same is true if a nic starts on Vnet, which
only calles add_dhcp_mapping.

These test functions homogenize the parameter list in contrast to the
current calls to the current functions.  The intention for the functions
is that they can be moved to Vnets.pm to be called from QemuServer and
LXC!

The tests are composed of a test function which can be parameterized. To
call the test function, the run_test function takes the function pointer
and passes the rest of the arguments to the test functions. It also
takes care of resetting the test state.
This allows fine-grained parameterization per-test directly in the code
instead of separated files that require the entire state to be passed
in.

The tests setup the SDN by creating a simple zone and a simple vnet. The
nic_join and nic_start function is called with different subnet
configuration wiht and without a dhcp-range configured and with or
without an already present IP in the IPAM.

Signed-off-by: Stefan Lendl <s.lendl@proxmox.com>
Reviewed-by: Max Carrara <m.carrara@proxmox.com>
Tested-by: Max Carrara <m.carrara@proxmox.com>
Reviewed-by: Stefan Hanreich <s.hanreich@proxmox.com>
Tested-by: Stefan Hanreich <s.hanreich@proxmox.com>
src/test/Makefile
src/test/run_test_vnets_blackbox.pl [new file with mode: 0755]

index eb59d5fe5af662ad0cdb1c3c64e633d2feb2f86f..5a937a4172a8301345db34bee471af83851dfabf 100644 (file)
@@ -1,6 +1,6 @@
 all: test
 
-test: test_zones test_ipams test_dns test_subnets
+test: test_zones test_ipams test_dns test_subnets test_vnets_blackbox
 
 test_zones: run_test_zones.pl
        ./run_test_zones.pl
@@ -14,4 +14,7 @@ test_dns: run_test_dns.pl
 test_subnets: run_test_subnets.pl
        ./run_test_subnets.pl
 
+test_vnets_blackbox: run_test_vnets_blackbox.pl
+       ./run_test_vnets_blackbox.pl
+
 clean:
diff --git a/src/test/run_test_vnets_blackbox.pl b/src/test/run_test_vnets_blackbox.pl
new file mode 100755 (executable)
index 0000000..f7caca2
--- /dev/null
@@ -0,0 +1,894 @@
+#!/usr/bin/perl
+
+use strict;
+use warnings;
+
+use lib qw(..);
+use File::Slurp;
+use List::Util qw(first all);
+use NetAddr::IP qw(:lower);
+
+use Test::More;
+use Test::MockModule;
+
+use PVE::Tools qw(extract_param file_set_contents);
+
+use PVE::Network::SDN;
+use PVE::Network::SDN::Zones;
+use PVE::Network::SDN::Zones::Plugin;
+use PVE::Network::SDN::Controllers;
+use PVE::Network::SDN::Dns;
+use PVE::Network::SDN::Vnets;
+
+use PVE::RESTEnvironment;
+
+use PVE::API2::Network::SDN::Zones;
+use PVE::API2::Network::SDN::Subnets;
+use PVE::API2::Network::SDN::Vnets;
+use PVE::API2::Network::SDN::Ipams;
+
+my $TMP_ETHERS_FILE = "/tmp/ethers";
+
+my $test_state = undef;
+sub clear_test_state {
+    $test_state = {
+       locks => {},
+       datacenter_config => {},
+       subnets_config => {},
+       controller_config => {},
+       dns_config => {},
+       zones_config => {},
+       vnets_config => {},
+       macdb => {},
+       ipamdb => {},
+       ipam_config => {
+           'ids' => {
+               'pve' => {
+                   'type' => 'pve'
+               },
+           }
+       },
+    };
+    PVE::Tools::file_set_contents($TMP_ETHERS_FILE, "\n");
+}
+clear_test_state();
+
+my $mocked_cfs_lock_file = sub {
+    my ($filename, $timeout, $code, @param) = @_;
+
+    die "$filename already locked\n" if ($test_state->{locks}->{$filename});
+
+    $test_state->{locks}->{$filename} = 1;
+
+    my $res = eval { $code->(@param); };
+
+    delete $test_state->{locks}->{$filename};
+
+    return $res;
+};
+
+sub read_sdn_config {
+    my ($file) = @_;
+    # Read structure back in again
+    open my $in, '<', $file or die $!;
+    my $sdn_config;
+    {
+       local $/;    # slurp mode
+       $sdn_config = eval <$in>;
+    }
+    close $in;
+    return $sdn_config;
+}
+
+my $mocked_pve_sdn;
+$mocked_pve_sdn = Test::MockModule->new('PVE::Network::SDN');
+$mocked_pve_sdn->mock(
+    cfs_lock_file => $mocked_cfs_lock_file,
+);
+
+my $mocked_pve_tools = Test::MockModule->new('PVE::Tools');
+$mocked_pve_tools->mock(
+    lock_file => $mocked_cfs_lock_file,
+);
+
+my $mocked_sdn_zones;
+$mocked_sdn_zones = Test::MockModule->new('PVE::Network::SDN::Zones');
+$mocked_sdn_zones->mock(
+    config => sub {
+       return $test_state->{zones_config};
+    },
+    write_config => sub {
+       my ($cfg) = @_;
+       $test_state->{zones_config} = $cfg;
+    },
+);
+
+my $mocked_sdn_zones_super_plugin;
+$mocked_sdn_zones_super_plugin = Test::MockModule->new('PVE::Network::SDN::Zones::Plugin');
+$mocked_sdn_zones_super_plugin->mock(
+    datacenter_config => sub {
+       return $test_state->{datacenter_config};
+    },
+);
+
+my $mocked_sdn_vnets;
+$mocked_sdn_vnets = Test::MockModule->new('PVE::Network::SDN::Vnets');
+$mocked_sdn_vnets->mock(
+    config => sub {
+       return $test_state->{vnets_config};
+    },
+    write_config => sub {
+       my ($cfg) = @_;
+       $test_state->{vnets_config} = $cfg;
+    },
+    cfs_lock_file => $mocked_cfs_lock_file,
+);
+
+my $mocked_sdn_subnets;
+$mocked_sdn_subnets = Test::MockModule->new('PVE::Network::SDN::Subnets');
+$mocked_sdn_subnets->mock(
+    config => sub {
+       return $test_state->{subnets_config};
+    },
+    write_config => sub {
+       my ($cfg) = @_;
+       $test_state->{subnets_config} = $cfg;
+    },
+    cfs_lock_file => $mocked_cfs_lock_file,
+);
+
+my $mocked_sdn_controller;
+$mocked_sdn_controller = Test::MockModule->new('PVE::Network::SDN::Controllers');
+$mocked_sdn_controller->mock(
+    config => sub {
+       return $test_state->{controller_config};
+    },
+    write_config => sub {
+       my ($cfg) = @_;
+       $test_state->{controller_config} = $cfg;
+    },
+    cfs_lock_file => $mocked_cfs_lock_file,
+);
+
+
+my $mocked_sdn_dns;
+$mocked_sdn_dns = Test::MockModule->new('PVE::Network::SDN::Dns');
+$mocked_sdn_dns->mock(
+    config => sub {
+       return $test_state->{dns_config};
+    },
+    write_config => sub {
+       my ($cfg) = @_;
+       $test_state->{dns_config} = $cfg;
+    },
+    cfs_lock_file => $mocked_cfs_lock_file,
+);
+
+
+my $mocked_sdn_ipams;
+$mocked_sdn_ipams = Test::MockModule->new('PVE::Network::SDN::Ipams');
+$mocked_sdn_ipams->mock(
+    config => sub {
+       return $test_state->{ipam_config};
+    },
+    write_config => sub {
+       my ($cfg) = @_;
+       $test_state->{ipam_config} = $cfg;
+    },
+    read_macdb => sub {
+       return $test_state->{macdb};
+    },
+    write_macdb => sub {
+       my ($cfg) = @_;
+       $test_state->{macdb} = $cfg;
+    },
+    cfs_lock_file => $mocked_cfs_lock_file,
+);
+
+my $ipam_plugin = PVE::Network::SDN::Ipams::Plugin->lookup("pve"); # NOTE this is hard-coded to pve
+my $mocked_ipam_plugin = Test::MockModule->new($ipam_plugin);
+$mocked_ipam_plugin->mock(
+    read_db => sub {
+       return $test_state->{ipamdb};
+    },
+    write_db => sub {
+       my ($cfg) = @_;
+       $test_state->{ipamdb} = $cfg;
+    },
+    cfs_lock_file => $mocked_cfs_lock_file,
+);
+
+my $mocked_sdn_dhcp_dnsmasq = Test::MockModule->new('PVE::Network::SDN::Dhcp::Dnsmasq');
+$mocked_sdn_dhcp_dnsmasq->mock(
+    assert_dnsmasq_installed => sub { return 1; },
+    before_configure => sub {},
+    ethers_file => sub { return "/tmp/ethers"; },
+    systemctl_service => sub {},
+    update_lease => sub {},
+);
+
+my $mocked_api_zones = Test::MockModule->new('PVE::API2::Network::SDN::Zones');
+$mocked_api_zones->mock(
+    create_etc_interfaces_sdn_dir => sub {},
+);
+
+my $rpcenv = PVE::RESTEnvironment->init('priv');
+$rpcenv->init_request();
+$rpcenv->set_language("en_US.UTF-8");
+$rpcenv->set_user('root@pam');
+
+my $mocked_rpc_env_obj = Test::MockModule->new('PVE::RESTEnvironment');
+$mocked_rpc_env_obj->mock(
+    check_any => sub { return 1; },
+);
+
+my $mocked_pve_cluster_obj = Test::MockModule->new('PVE::Cluster');
+$mocked_pve_cluster_obj->mock(
+    check_cfs_quorum => sub { return 1; },
+);
+
+# ------- TEST FUNCTIONS --------------
+
+sub nic_join {
+    my ($vnetid, $mac, $hostname, $vmid) = @_;
+    return PVE::Network::SDN::Vnets::add_next_free_cidr($vnetid, $hostname, $mac, "$vmid", undef, 1);
+}
+
+sub nic_leave {
+    my ($vnetid, $mac, $hostname) = @_;
+    return PVE::Network::SDN::Vnets::del_ips_from_mac($vnetid, $mac, $hostname);
+}
+
+sub nic_start {
+    my ($vnetid, $mac, $vmid, $hostname) = @_;
+    return PVE::Network::SDN::Vnets::add_dhcp_mapping($vnetid, $mac, $vmid, $hostname);
+}
+
+
+# ---- API HELPER FUNCTIONS FOR THE TESTS -----
+
+my $t_invalid;
+sub get_zone {
+    my ($id) = @_;
+    return eval { PVE::API2::Network::SDN::Zones->read({zone => $id}); };
+}
+# verify get_zone actually fails if invalid
+$t_invalid = get_zone("invalid");
+die("getting an invalid zone must fail") if (!$@);
+fail("getting an invalid zone must fail") if (defined $t_invalid);
+
+sub create_zone {
+    my ($params) = @_;
+    my $zoneid = $params->{zone};
+    # die if failed!
+    eval { PVE::API2::Network::SDN::Zones->create($params); };
+    die("creating zone failed: $@") if ($@);
+
+    my $zone = get_zone($zoneid);
+    die ("test setup: zone ($zoneid) not defined") if (!defined $zone);
+    return $zone;
+}
+
+sub get_vnet {
+    my ($id) = @_;
+    return eval { PVE::API2::Network::SDN::Vnets->read({vnet => $id}); };
+}
+# verify get_vnet
+$t_invalid = get_vnet("invalid");
+die("getting an invalid vnet must fail") if (!$@);
+fail("getting an invalid vnet must fail") if (defined $t_invalid);
+
+sub create_vnet {
+    my ($params) = @_;
+    my $vnetid = $params->{vnet};
+    PVE::API2::Network::SDN::Vnets->create($params);
+
+    my $vnet = get_vnet($vnetid);
+    die ("test setup: vnet ($vnetid) not defined") if (!defined $vnet);
+    return $vnet;
+}
+
+sub get_subnet {
+    my ($id) = @_;
+    return eval { PVE::API2::Network::SDN::Subnets->read({subnet => $id}); };
+}
+# verify get_subnet
+$t_invalid = get_subnet("invalid");
+die("getting an invalid subnet must fail") if (!$@);
+fail("getting an invalid subnet must fail") if (defined $t_invalid);
+
+sub create_subnet {
+    my ($params) = @_;
+    PVE::API2::Network::SDN::Subnets->create($params);
+}
+
+sub get_ipam_entries {
+    return PVE::API2::Network::SDN::Ipams->ipamindex({ipam => "pve"});
+}
+
+sub create_ip {
+    my ($param) = @_;
+    return PVE::API2::Network::SDN::Ips->ipcreate($param);
+}
+
+sub run_test {
+    my $test = shift;
+    clear_test_state();
+    $test->(@_);
+}
+
+sub get_ips_from_mac {
+    my ($mac) = @_;
+    my $ipam_entries = get_ipam_entries();
+    return grep { $_->{mac} eq $mac if defined $_->{mac} } $ipam_entries->@* if $ipam_entries;
+}
+
+sub get_ip4 {
+    my $ip4 = first { Net::IP::ip_is_ipv4($_->{ip}) } @_;
+    return $ip4->{ip} if defined $ip4;
+}
+
+sub get_ip6 {
+    my $ip6 = first { Net::IP::ip_is_ipv6($_->{ip}) } @_;
+    return $ip6->{ip} if defined $ip6;
+}
+
+
+# -------------- ACTUAL TESTS  -----------------------
+
+sub test_create_vnet_with_gateway {
+    my $test_name = (split(/::/,(caller(0))[3]))[-1];
+    my $zoneid = "TESTZONE";
+    my $vnetid = "testvnet";
+
+    my $zone = create_zone({
+       type => "simple",
+       dhcp => "dnsmasq",
+       ipam => "pve",
+       zone => $zoneid,
+    });
+
+    my $vnet = create_vnet({
+       type => "vnet",
+       zone => $zoneid,
+       vnet => $vnetid,
+    });
+
+    create_subnet({
+       type => "subnet",
+       vnet => $vnetid,
+       subnet => "10.0.0.0/24",
+       gateway => "10.0.0.1",
+       'dhcp-range' => ["start-address=10.0.0.100,end-address=10.0.0.200"],
+    });
+
+    my ($p) = first { $_->{gateway} == 1 } get_ipam_entries()->@*;
+    ok ($p, "$test_name: Gateway IP was created in IPAM");
+}
+run_test(\&test_create_vnet_with_gateway);
+
+
+sub test_without_subnet {
+    my $test_name = (split(/::/,(caller(0))[3]))[-1];
+
+    my $zoneid = "TESTZONE";
+    my $vnetid = "testvnet";
+
+    my $zone = create_zone({
+       type => "simple",
+       dhcp => "dnsmasq",
+       ipam => "pve",
+       zone => $zoneid,
+    });
+
+    my $vnet = create_vnet({
+       type => "vnet",
+       zone => $zoneid,
+       vnet => $vnetid,
+    });
+
+    my $hostname = "testhostname";
+    my $mac = "da:65:8f:18:9b:6f";
+    my $vmid = "999";
+
+    eval {
+       nic_join($vnetid, $mac, $hostname, $vmid);
+    };
+
+    if ($@) {
+       fail("$test_name: $@");
+       return;
+    }
+
+    my @ips = get_ips_from_mac($mac);
+    my $num_ips = scalar @ips;
+    is ($num_ips, 0, "$test_name: No IP allocated in IPAM");
+}
+run_test(\&test_without_subnet);
+
+
+sub test_nic_join {
+    my ($test_name, $subnets) = @_;
+
+    die "$test_name: we're expecting an array of subnets" if !$subnets;
+    my $num_subnets = scalar $subnets->@*;
+    die "$test_name: we're expecting an array of subnets. $num_subnets elements found" if ($num_subnets < 1);
+    my $num_dhcp_ranges = scalar grep { $_->{'dhcp-range'} } $subnets->@*;
+
+    my $zoneid = "TESTZONE";
+    my $vnetid = "testvnet";
+
+    my $zone = create_zone({
+       type => "simple",
+       dhcp => "dnsmasq",
+       ipam => "pve",
+       zone => $zoneid,
+    });
+
+    my $vnet = create_vnet({
+       type => "vnet",
+       zone => $zoneid,
+       vnet => $vnetid,
+    });
+
+    foreach my $subnet ($subnets->@*) {
+       $subnet->{type} = "subnet";
+       $subnet->{vnet} = $vnetid;
+       create_subnet($subnet);
+    };
+
+    my $hostname = "testhostname";
+    my $mac = "da:65:8f:18:9b:6f";
+    my $vmid = "999";
+
+    eval {
+       nic_join($vnetid, $mac, $hostname, $vmid);
+    };
+
+    if ($@) {
+       fail("$test_name: $@");
+       return;
+    }
+
+    my @ips = get_ips_from_mac($mac);
+    my $num_ips = scalar @ips;
+    is ($num_ips, $num_dhcp_ranges, "$test_name: Expecting $num_dhcp_ranges IPs, found $num_ips");
+    ok ((all { ($_->{vnet} eq $vnetid && $_->{zone} eq $zoneid) } @ips),
+       "$test_name: all IPs in correct vnet and zone"
+    );
+}
+
+run_test(
+    \&test_nic_join,
+    "nic_join IPv4 no dhcp",
+    [{
+       subnet => "10.0.0.0/24",
+       gateway => "10.0.0.1",
+    },
+]);
+
+run_test(
+    \&test_nic_join,
+    "nic_join IPv6 no dhcp",
+    [{
+       subnet => "8888::/64",
+       gateway => "8888::1",
+    },
+]);
+
+run_test(
+    \&test_nic_join,
+    "nic_join IPv4+6 no dhcp",
+    [{
+       subnet => "10.0.0.0/24",
+       gateway => "10.0.0.1",
+    }, {
+       subnet => "8888::/64",
+       gateway => "8888::1",
+    },
+]);
+
+run_test(
+    \&test_nic_join,
+    "nic_join IPv4 with dhcp",
+    [{
+       subnet => "10.0.0.0/24",
+       gateway => "10.0.0.1",
+       'dhcp-range' => ["start-address=10.0.0.100,end-address=10.0.0.200"],
+    },
+]);
+
+run_test(
+    \&test_nic_join,
+    "nic_join IPv6 with dhcp",
+    [{
+       subnet => "8888::/64",
+       gateway => "8888::1",
+       'dhcp-range' => ["start-address=8888::100,end-address=8888::200"],
+    },
+]);
+
+run_test(
+    \&test_nic_join,
+    "nic_join IPv4+6 with dhcp",
+    [{
+       subnet => "10.0.0.0/24",
+       gateway => "10.0.0.1",
+       'dhcp-range' => ["start-address=10.0.0.100,end-address=10.0.0.200"],
+    }, {
+       subnet => "8888::/64",
+       gateway => "8888::1",
+       'dhcp-range' => ["start-address=8888::100,end-address=8888::200"],
+    },
+]);
+
+run_test(
+    \&test_nic_join,
+    "nic_join IPv4 no DHCP, IPv6 with DHCP",
+    [{
+       subnet => "10.0.0.0/24",
+       gateway => "10.0.0.1",
+    }, {
+       subnet => "8888::/64",
+       gateway => "8888::1",
+       'dhcp-range' => ["start-address=8888::100,end-address=8888::200"],
+    },
+]);
+
+run_test(
+    \&test_nic_join,
+    "nic_join IPv4 with DHCP, IPv6 no DHCP",
+    [{
+       subnet => "10.0.0.0/24",
+       gateway => "10.0.0.1",
+       'dhcp-range' => ["start-address=10.0.0.100,end-address=10.0.0.200"],
+    }, {
+       subnet => "8888::/64",
+       gateway => "8888::1",
+    },
+]);
+
+
+sub test_nic_join_full_dhcp_range {
+    my ($test_name, $subnets, $expected_ip4, $expected_ip6) = @_;
+
+    die "$test_name: we're expecting an array of subnets" if !$subnets;
+    my $num_subnets = scalar $subnets->@*;
+    die "$test_name: we're expecting an array of subnets. $num_subnets elements found" if ($num_subnets < 1);
+
+    my $zoneid = "TESTZONE";
+    my $vnetid = "testvnet";
+
+    my $zone = create_zone({
+       type => "simple",
+       dhcp => "dnsmasq",
+       ipam => "pve",
+       zone => $zoneid,
+    });
+
+    my $vnet = create_vnet({
+       type => "vnet",
+       zone => $zoneid,
+       vnet => $vnetid,
+    });
+
+    foreach my $subnet ($subnets->@*) {
+       $subnet->{type} = "subnet";
+       $subnet->{vnet} = $vnetid;
+       create_subnet($subnet);
+    };
+
+    my $hostname = "testhostname";
+    my $mac = "da:65:8f:18:9b:6f";
+    my $vmid = "999";
+
+    eval {
+       nic_join($vnetid, $mac, $hostname, $vmid);
+    };
+
+    if (! $@) {
+       fail ("$test_name: nic_join() is expected to fail because we cannot allocate all IPs");
+    }
+
+    my @ips = get_ips_from_mac($mac);
+    my $num_ips = scalar @ips;
+    is ($num_ips, 0, "$test_name: No IP allocated in IPAM");
+}
+
+run_test(
+    \&test_nic_join_full_dhcp_range,
+    "nic_join IPv4 with DHCP, dhcp-range full",
+    [{
+       subnet => "10.0.0.0/24",
+       gateway => "10.0.0.100", # the gateway uses the only available IP in the dhcp-range
+       'dhcp-range' => ["start-address=10.0.0.100,end-address=10.0.0.100"],
+    }
+]);
+
+run_test(
+    \&test_nic_join_full_dhcp_range,
+    "nic_join IPv6 with DHCP, dhcp-range full",
+    [{
+       subnet => "8888::/64",
+       gateway => "8888::100", # the gateway uses the only available IP in the dhcp-range
+       'dhcp-range' => ["start-address=8888::100,end-address=8888::100"],
+    },
+]);
+
+run_test(
+    \&test_nic_join_full_dhcp_range,
+    "nic_join IPv4+6 with DHCP, dhcp-range full for both",
+    [{
+       subnet => "10.0.0.0/24",
+       gateway => "10.0.0.100",
+       'dhcp-range' => ["start-address=10.0.0.100,end-address=10.0.0.100"],
+    }, {
+       subnet => "8888::/64",
+       gateway => "8888::100",
+       'dhcp-range' => ["start-address=8888::100,end-address=8888::100"],
+    }
+]);
+
+run_test(
+    \&test_nic_join_full_dhcp_range,
+    "nic_join IPv4+6 with DHCP, dhcp-range full for IPv4",
+    [{
+       subnet => "10.0.0.0/24",
+       gateway => "10.0.0.100", # the gateway uses the only available IP in the dhcp-range
+       'dhcp-range' => ["start-address=10.0.0.100,end-address=10.0.0.100"],
+    }, {
+       subnet => "8888::/64",
+       gateway => "8888::1",
+       'dhcp-range' => ["start-address=8888::100,end-address=8888::100"],
+    }],
+);
+
+run_test(
+    \&test_nic_join_full_dhcp_range,
+    "nic_join IPv4+6 with DHCP, dhcp-range full for IPv6",
+    [{
+       subnet => "10.0.0.0/24",
+       gateway => "10.0.0.1",
+       'dhcp-range' => ["start-address=10.0.0.100,end-address=10.0.0.100"],
+    }, {
+       subnet => "8888::/64",
+       gateway => "8888::100",
+       'dhcp-range' => ["start-address=8888::100,end-address=8888::100"],
+    }],
+);
+
+run_test(
+    \&test_nic_join_full_dhcp_range,
+    "nic_join IPv4 no DHCP, dhcp-range full for IPv6",
+    [{
+       subnet => "10.0.0.0/24",
+       gateway => "10.0.0.1",
+    }, {
+       subnet => "8888::/64",
+       gateway => "8888::100",
+       'dhcp-range' => ["start-address=8888::100,end-address=8888::100"],
+    }],
+);
+
+
+# -------------- nic_start
+sub test_nic_start {
+    my ($test_name, $subnets, $current_ip4, $current_ip6, $num_expected_ips) = @_;
+
+    die "$test_name: we're expecting an array of subnets" if !$subnets;
+    my $num_subnets = scalar $subnets->@*;
+    die "$test_name: we're expecting an array of subnets. $num_subnets elements found" if ($num_subnets < 1);
+
+    $num_expected_ips = scalar grep { $_->{'dhcp-range'} } $subnets->@* if !defined $num_expected_ips;
+
+    my $zoneid = "TESTZONE";
+    my $vnetid = "testvnet";
+
+    my $zone = create_zone({
+       type => "simple",
+       dhcp => "dnsmasq",
+       ipam => "pve",
+       zone => $zoneid,
+    });
+
+    my $vnet = create_vnet({
+       type => "vnet",
+       zone => $zoneid,
+       vnet => $vnetid,
+    });
+
+    foreach my $subnet ($subnets->@*) {
+       $subnet->{type} = "subnet";
+       $subnet->{vnet} = $vnetid;
+       create_subnet($subnet);
+    };
+
+    my $hostname = "testhostname";
+    my $mac = "da:65:8f:18:9b:6f";
+    my $vmid = "999";
+
+    if ($current_ip4) {
+       create_ip({
+           zone => $zoneid,
+           vnet => $vnetid,
+           mac => $mac,
+           ip => $current_ip4,
+       });
+    }
+
+    if ($current_ip6) {
+       create_ip({
+           zone => $zoneid,
+           vnet => $vnetid,
+           mac => $mac,
+           ip => $current_ip6,
+       });
+    }
+    my @current_ips = get_ips_from_mac($mac);
+    is ( get_ip4(@current_ips), $current_ip4, "$test_name: setup current IPv4: $current_ip4" ) if defined $current_ip4;
+    is ( get_ip6(@current_ips), $current_ip6, "$test_name: setup current IPv6: $current_ip6" ) if defined $current_ip6;
+
+    eval {
+       nic_start($vnetid, $mac, $hostname, $vmid);
+    };
+
+    if ($@) {
+       fail("$test_name: $@");
+       return;
+    }
+
+    my @ips = get_ips_from_mac($mac);
+    my $num_ips = scalar @ips;
+    is ($num_ips, $num_expected_ips, "$test_name: Expecting $num_expected_ips IPs, found $num_ips");
+    ok ((all { ($_->{vnet} eq $vnetid && $_->{zone} eq $zoneid) } @ips),
+       "$test_name: all IPs in correct vnet and zone"
+    );
+
+    is ( get_ip4(@ips), $current_ip4, "$test_name: still current IPv4: $current_ip4" ) if $current_ip4;
+    is ( get_ip6(@ips), $current_ip6, "$test_name: still current IPv6: $current_ip6" ) if $current_ip6;
+}
+
+run_test(
+    \&test_nic_start,
+    "nic_start no IP, IPv4 without dhcp",
+    [{
+       subnet => "10.0.0.0/24",
+       gateway => "10.0.0.1",
+    },
+]);
+
+run_test(
+    \&test_nic_start,
+    "nic_start already IP, IPv4 without dhcp",
+    [{
+       subnet => "10.0.0.0/24",
+       gateway => "10.0.0.1",
+    }],
+    "10.0.0.99",
+    undef,
+    1
+);
+
+run_test(
+    \&test_nic_start,
+    "nic_start already IPv6, IPv6 without dhcp",
+    [{
+       subnet => "8888::/64",
+       gateway => "8888::1",
+    }],
+    undef,
+    "8888::99",
+    1
+);
+
+run_test(
+    \&test_nic_start,
+    "nic_start no IP, IPv4 subnet with dhcp",
+    [{
+       subnet => "10.0.0.0/24",
+       gateway => "10.0.0.1",
+       'dhcp-range' => ["start-address=10.0.0.100,end-address=10.0.0.200"],
+    },
+]);
+
+run_test(
+    \&test_nic_start,
+    "nic_start already IP, IPv4 subnet with dhcp",
+    [{
+       subnet => "10.0.0.0/24",
+       gateway => "10.0.0.1",
+       'dhcp-range' => ["start-address=10.0.0.100,end-address=10.0.0.200"],
+    }],
+    "10.0.0.99"
+);
+
+run_test(
+    \&test_nic_start,
+    "nic_start already IP, IPv6 subnet with dhcp",
+    [{
+       subnet => "8888::/64",
+       gateway => "8888::1",
+       'dhcp-range' => ["start-address=8888::100,end-address=8888::200"],
+    }],
+    undef,
+    "8888::99"
+);
+
+run_test(
+    \&test_nic_start,
+    "nic_start IP, IPv4+6 subnet with dhcp",
+    [{
+       subnet => "10.0.0.0/24",
+       gateway => "10.0.0.1",
+       'dhcp-range' => ["start-address=10.0.0.100,end-address=10.0.0.200"],
+    }, {
+       subnet => "8888::/64",
+       gateway => "8888::1",
+       'dhcp-range' => ["start-address=8888::100,end-address=8888::200"],
+    },
+]);
+
+run_test(
+    \&test_nic_start,
+    "nic_start already IPv4, IPv4+6 subnet with dhcp",
+    [{
+       subnet => "10.0.0.0/24",
+       gateway => "10.0.0.1",
+       'dhcp-range' => ["start-address=10.0.0.100,end-address=10.0.0.200"],
+    }, {
+       subnet => "8888::/64",
+       gateway => "8888::1",
+       'dhcp-range' => ["start-address=8888::100,end-address=8888::200"],
+    }],
+    "10.0.0.99"
+);
+
+run_test(
+    \&test_nic_start,
+    "nic_start already IPv6, IPv4+6 subnet with dhcp",
+    [{
+       subnet => "10.0.0.0/24",
+       gateway => "10.0.0.1",
+       'dhcp-range' => ["start-address=10.0.0.100,end-address=10.0.0.200"],
+    }, {
+       subnet => "8888::/64",
+       gateway => "8888::1",
+       'dhcp-range' => ["start-address=8888::100,end-address=8888::200"],
+    }],
+    undef,
+    "8888::99"
+);
+
+run_test(
+    \&test_nic_start,
+    "nic_start already IPv4+6, IPv4+6 subnets with dhcp",
+    [{
+       subnet => "10.0.0.0/24",
+       gateway => "10.0.0.1",
+       'dhcp-range' => ["start-address=10.0.0.100,end-address=10.0.0.200"],
+    }, {
+       subnet => "8888::/64",
+       gateway => "8888::1",
+       'dhcp-range' => ["start-address=8888::100,end-address=8888::200"],
+    }],
+    "10.0.0.99",
+    "8888::99"
+);
+
+run_test(
+    \&test_nic_start,
+    "nic_start already IPv4+6, only IPv4 subnet with dhcp",
+    [{
+       subnet => "10.0.0.0/24",
+       gateway => "10.0.0.1",
+       'dhcp-range' => ["start-address=10.0.0.100,end-address=10.0.0.200"],
+    }, {
+       subnet => "8888::/64",
+       gateway => "8888::1",
+    }],
+    "10.0.0.99",
+    "8888::99",
+    2
+);
+
+done_testing();