--- /dev/null
+#!/usr/bin/perl
+
+use lib '../../src';
+use strict;
+use warnings;
+
+use Carp;
+use POSIX;
+use IO::Handle;
+
+use PVE::INotify;
+
+# Current config, r() parses a network interface string into this variable
+our $config;
+
+##
+## Temporary files:
+##
+# perl conveniently lets you open a string as filehandle so we allow tests
+# to temporarily save interface files to virtual files:
+my %saved_files;
+
+# Load a temp-file and return it as a string, if it didn't exist, try loading
+# a real file.
+sub load($) {
+ my ($from) = @_;
+
+ if (my $local = $saved_files{$from}) {
+ return $local;
+ }
+
+ open my $fh, '<', $from or die "failed to open $from: $!";
+ local $/ = undef;
+ my $data = <$fh>;
+ close $fh;
+ return $data;
+}
+
+# Save a temporary file.
+sub save($$) {
+ my ($file, $data) = @_;
+ $saved_files{$file} = $data;
+}
+
+# Delete a temporary file
+sub delfile($) {
+ my $file = @_;
+ die "no such file: $file" if !delete $saved_files{$file};
+}
+
+# Delete all temporary files.
+sub flush_files() {
+ foreach (keys %saved_files) {
+ delete $saved_files{$_} if $_ !~ m,^shared/,;
+ }
+}
+
+##
+## Interface parsing:
+##
+
+# Read an interfaces file with optional /proc/net/dev and /proc/net/if_inet6
+# file content strings, which default to the provided ones.
+sub r($;$$) {
+ my ($ifaces, $proc_net_dev, $proc_net_if_inet6) = @_;
+ $proc_net_dev //= load('proc_net_dev');
+ $proc_net_if_inet6 //= load('proc_net_if_inet6');
+ open my $fh1, '<', \$ifaces;
+ open my $fh2, '<', \$proc_net_dev;
+ open my $fh3, '<', \$proc_net_if_inet6;
+ $config = PVE::INotify::__read_etc_network_interfaces($fh1, $fh2, $fh3);
+ close $fh1;
+}
+
+# Turn the current network config into a string.
+sub w() {
+ return PVE::INotify::__write_etc_network_interfaces($config);
+}
+
+##
+## Interface modification helpers
+##
+
+# Update an interface
+sub update_iface($$%) {
+ my ($name, $families, %extra) = @_;
+
+ my $ifaces = $config->{ifaces};
+ my $if = $ifaces->{$name};
+
+ die "no such interface: $name\n" if !$if;
+
+ $if->{exists} = 1;
+
+ # merge extra flags (like bridge_ports, ovs_*) directly
+ $if->{$_} = $extra{$_} foreach keys %extra;
+
+ return if !$families;
+
+ my $if_families = $if->{families} ||= [];
+ foreach my $family (@$families) {
+ my $type = delete $family->{family};
+ @$if_families = ((grep { $_ ne $type } @$if_families), $type);
+
+ (my $suffix = $type) =~ s/^inet//;
+ $if->{"method$suffix"} = $family->{address} ? 'static' : 'manual';
+ foreach(qw(address netmask gateway options)) {
+ if (my $value = delete $family->{$_}) {
+ $if->{"$_${suffix}"} = $value;
+ }
+ }
+ }
+}
+
+# Create an interface and error if it already exists.
+sub new_iface($$$%) {
+ my ($name, $type, $families, %extra) = @_;
+ my $ifaces = $config->{ifaces};
+ croak "interface already exists: $name" if $ifaces->{$name};
+ $ifaces->{$name} = { type => $type };
+ update_iface($name, $families, %extra);
+}
+
+# Delete an interface and error if it did not exist.
+sub delete_iface($;$) {
+ my ($name, $family) = @_;
+ my $ifaces = $config->{ifaces};
+ my $if = $ifaces->{$name} ||= {};
+ croak "interface doesn't exist: $name" if !$if;
+
+ if (!$family) {
+ delete $ifaces->{$name};
+ return;
+ }
+
+ my $families = $if->{families};
+ @$families = grep {$_ ne $family} @$families;
+ (my $suffix = $family) =~ s/^inet//;
+ delete $if->{"$_$suffix"} foreach qw(address netmask gateway options);
+}
+
+##
+## Test helpers:
+##
+
+# Compare two strings line by line and show a diff/error if they differ.
+sub diff($$) {
+ my ($a, $b) = @_;
+ return if $a eq $b;
+
+ my ($ra, $wa) = POSIX::pipe();
+ my ($rb, $wb) = POSIX::pipe();
+ my $ha = IO::Handle->new_from_fd($wa, 'w');
+ my $hb = IO::Handle->new_from_fd($wb, 'w');
+
+ open my $diffproc, '-|', 'diff', '-up', "/dev/fd/$ra", "/dev/fd/$rb"
+ or die "failed to run program 'diff': $!";
+ POSIX::close($ra);
+ POSIX::close($rb);
+
+ open my $f1, '<', \$a;
+ open my $f2, '<', \$b;
+ my ($line1, $line2);
+ do {
+ $ha->print($line1) if defined($line1 = <$f1>);
+ $hb->print($line2) if defined($line2 = <$f2>);
+ } while (defined($line1 // $line2));
+ close $f1;
+ close $f2;
+ close $ha;
+ close $hb;
+
+ local $/ = undef;
+ my $diff = <$diffproc>;
+ close $diffproc;
+ die "files differ:\n$diff";
+}
+
+# Write the current interface config and compare the result to a string.
+sub expect($) {
+ my ($expected) = @_;
+ my $got = w();
+ diff($expected, $got);
+}
+
+##
+## Main test execution:
+##
+# (sorted, it's not used right now but tests could pass on temporary files by
+# prefixing the name with shared/ and thus you might want to split a larger
+# test into t.01.first-part.pl, t.02.second-part.pl, etc.
+my $total = 0;
+my $failed = 0;
+for our $Test (sort <t.*.pl>) {
+ $total++;
+ flush_files();
+ eval {
+ require $Test;
+ };
+ if ($@) {
+ print "FAIL: $Test\n$@\n\n";
+ $failed++;
+ } else {
+ print "PASS: $Test\n";
+ }
+}
+
+die "$failed out of $total tests failed\n" if $failed;
--- /dev/null
+use strict;
+
+my $ip = '192.168.0.100';
+my $nm = '255.255.255.0';
+my $gw = '192.168.0.1';
+
+# replace proc_net_dev with one with a bunch of interfaces
+save('proc_net_dev', <<'/proc/net/dev');
+eth0:
+eth1:
+eth2:
+eth3:
+/proc/net/dev
+
+r('');
+
+new_iface('vmbr0', 'OVSBridge',
+ [ { family => 'inet',
+ address => $ip,
+ netmask => $nm,
+ gateway => $gw } ],
+ autostart => 1);
+
+update_iface('eth0', [], autostart => 1);
+update_iface('eth1', [], autostart => 1);
+update_iface('eth2', [], autostart => 1);
+#update_iface('eth3', [], autostart => 1);
+
+# Check the bridge and eth interfaces
+expect load('loopback') . <<"/etc/network/interfaces";
+auto eth0
+iface eth0 inet manual
+
+auto eth1
+iface eth1 inet manual
+
+auto eth2
+iface eth2 inet manual
+
+iface eth3 inet manual
+
+auto vmbr0
+iface vmbr0 inet static
+ address $ip
+ netmask $nm
+ gateway $gw
+ ovs_type OVSBridge
+
+/etc/network/interfaces
+
+# Adding an interface to the bridge needs to add allow- lines:
+update_iface('vmbr0', [], ovs_ports => 'eth1 eth2');
+expect load('loopback') . <<"/etc/network/interfaces";
+auto eth0
+iface eth0 inet manual
+
+auto eth1
+allow-vmbr0 eth1
+iface eth1 inet manual
+ ovs_type OVSPort
+ ovs_bridge vmbr0
+
+auto eth2
+allow-vmbr0 eth2
+iface eth2 inet manual
+ ovs_type OVSPort
+ ovs_bridge vmbr0
+
+iface eth3 inet manual
+
+auto vmbr0
+iface vmbr0 inet static
+ address $ip
+ netmask $nm
+ gateway $gw
+ ovs_type OVSBridge
+ ovs_ports eth1 eth2
+
+/etc/network/interfaces
+
+# Idempotency - make sure "allow-$BRIDGE $IFACE" don't get duplicated
+# they're stripped from $config->{options} at load-time since they're
+# auto-generated when writing OVSPorts.
+save('idem', w());
+r(load('idem'));
+expect load('idem');
+
+# Removing an ovs_port also has to remove the corresponding allow- line!
+# Also remember that adding interfaces to the ovs bridge removed their
+# autostart property, so eth2 is now without an autostart!
+update_iface('vmbr0', [], ovs_ports => 'eth1');
+# eth2 is now autoremoved and thus loses its priority, so it appears after eth3
+expect load('loopback') . <<"/etc/network/interfaces";
+auto eth0
+iface eth0 inet manual
+
+allow-vmbr0 eth1
+iface eth1 inet manual
+ ovs_type OVSPort
+ ovs_bridge vmbr0
+
+iface eth3 inet manual
+
+iface eth2 inet manual
+
+auto vmbr0
+iface vmbr0 inet static
+ address $ip
+ netmask $nm
+ gateway $gw
+ ovs_type OVSBridge
+ ovs_ports eth1
+
+/etc/network/interfaces
+
+1;