From 41cd94a01ec3bc57c14dd985535ada4dac840ab3 Mon Sep 17 00:00:00 2001 From: Wolfgang Bumiller Date: Tue, 27 Feb 2018 10:45:08 +0100 Subject: [PATCH] cloud-init: nocloud image support With configdrives we end up with the /etc/network/interfaces file containing the interface names we use on the disk, ie. eth0/eth1/..., which doesn't work on systems which do not use this name. With the 'nocloud' image type we can provide a network-config in yaml which matches mac addresses. Ideally we'd use version 2, but debian stretch ships with a too old cloud-init for this, so for now we're writing version 1. Signed-off-by: Wolfgang Bumiller --- PVE/QemuServer.pm | 6 + PVE/QemuServer/Cloudinit.pm | 375 ++++++++++++++++++++++++++++-------- 2 files changed, 306 insertions(+), 75 deletions(-) diff --git a/PVE/QemuServer.pm b/PVE/QemuServer.pm index bfab406..56719be 100644 --- a/PVE/QemuServer.pm +++ b/PVE/QemuServer.pm @@ -540,6 +540,12 @@ EODESCR }; my $confdesc_cloudinit = { + citype => { + optional => 1, + type => 'string', + description => 'Specifies the cloud-init configuration format.', + enum => ['configdrive2', 'nocloud'], + }, searchdomain => { optional => 1, type => 'string', diff --git a/PVE/QemuServer/Cloudinit.pm b/PVE/QemuServer/Cloudinit.pm index 566c723..e64dfa5 100644 --- a/PVE/QemuServer/Cloudinit.pm +++ b/PVE/QemuServer/Cloudinit.pm @@ -12,104 +12,89 @@ use PVE::Storage; use PVE::QemuServer; sub commit_cloudinit_disk { - my ($file_path, $iso_path, $format) = @_; + my ($conf, $drive, $volname, $storeid, $file_path, $label) = @_; + + my $storecfg = PVE::Storage::config(); + my $iso_path = PVE::Storage::path($storecfg, $drive->{file}); + my $scfg = PVE::Storage::storage_config($storecfg, $storeid); + my $format = PVE::QemuServer::qemu_img_format($scfg, $volname); my $size = PVE::Storage::file_size_info($iso_path); - run_command([['genisoimage', '-R', '-V', 'config-2', $file_path], + run_command([['genisoimage', '-R', '-V', $label, $file_path], ['qemu-img', 'dd', '-f', 'raw', '-O', $format, 'isize=0', "osize=$size", "of=$iso_path"]]); } -sub generate_cloudinitconfig { - my ($conf, $vmid) = @_; - - PVE::QemuServer::foreach_drive($conf, sub { - my ($ds, $drive) = @_; - - my ($storeid, $volname) = PVE::Storage::parse_volume_id($drive->{file}, 1); +sub get_cloudinit_format { + my ($conf) = @_; + if (defined(my $format = $conf->{citype})) { + return $format; + } - return if !$volname || $volname !~ m/vm-$vmid-cloudinit/; + # No format specified, default based on ostype because windows' + # cloudbased-init only supports configdrivev2, whereas on linux we need + # to use mac addresses because regular cloudinit doesn't map 'ethX' to + # the new predicatble network device naming scheme. + if (defined(my $ostype = $conf->{ostype})) { + return 'configdrive2' + if PVE::QemuServer::windows_version($ostype); + } - my $path = "/tmp/cloudinit/$vmid"; - - mkdir "/tmp/cloudinit"; - mkdir $path; - mkdir "$path/drive"; - mkdir "$path/drive/openstack"; - mkdir "$path/drive/openstack/latest"; - mkdir "$path/drive/openstack/content"; - my $digest_data = generate_cloudinit_userdata($conf, $path) - . generate_cloudinit_network($conf, $path); - generate_cloudinit_metadata($conf, $path, $digest_data); - - my $storecfg = PVE::Storage::config(); - my $iso_path = PVE::Storage::path($storecfg, $drive->{file}); - my $scfg = PVE::Storage::storage_config($storecfg, $storeid); - my $format = PVE::QemuServer::qemu_img_format($scfg, $volname); - #fixme : add meta as drive property to compare - commit_cloudinit_disk("$path/drive", $iso_path, $format); - rmtree("$path/drive"); - }); + return 'nocloud'; } - -sub generate_cloudinit_userdata { - my ($conf, $path) = @_; - - my $content = "#cloud-config\n"; - my $hostname = $conf->{hostname}; - if (!defined($hostname)) { - $hostname = $conf->{name}; +sub get_fqdn { + my ($conf) = @_; + my $fqdn = $conf->{hostname}; + if (!defined($fqdn)) { + $fqdn = $conf->{name}; if (my $search = $conf->{searchdomain}) { - $hostname .= ".$search"; + $fqdn .= ".$search"; } } - $content .= "fqdn: $hostname\n"; - $content .= "manage_etc_hosts: true\n"; - $content .= "bootcmd: \n"; - $content .= " - ifdown -a\n"; - $content .= " - ifup -a\n"; - - my $keys = $conf->{sshkeys}; - if ($keys) { + return $fqdn; +} + +sub cloudinit_userdata { + my ($conf) = @_; + + my $fqdn = get_fqdn($conf); + + my $content = <<"EOF"; +#cloud-config +manage_resolv_conf: true +EOF + + my $username = 'blub'; + my $encpw = PVE::Tools::encrypt_pw('foo'); + + $content .= "user: $username\n" if defined($username); + $content .= "password: $encpw\n" if defined($encpw); + + if (defined(my $keys = $conf->{sshkeys})) { $keys = URI::Escape::uri_unescape($keys); $keys = [map { chomp $_; $_ } split(/\n/, $keys)]; $keys = [grep { /\S/ } @$keys]; - - $content .= "users:\n"; - $content .= " - default\n"; - $content .= " - name: root\n"; - $content .= " ssh-authorized-keys:\n"; + $content .= "ssh_authorized_keys:\n"; foreach my $k (@$keys) { - $content .= " - $k\n"; + $content .= " - $k\n"; } } + $content .= "chpasswd:\n"; + $content .= " expire: False\n"; + + # FIXME: we probably need an option to disable this? + $content .= "users:\n"; + $content .= " - default\n"; $content .= "package_upgrade: true\n"; - my $fn = "$path/drive/openstack/latest/user_data"; - file_set_contents($fn, $content); return $content; } -sub generate_cloudinit_metadata { - my ($conf, $path, $digest_data) = @_; - - my $uuid_str = Digest::SHA::sha1_hex($digest_data); - - my $content = "{\n"; - $content .= " \"uuid\": \"$uuid_str\",\n"; - $content .= " \"network_config\" :{ \"content_path\": \"/content/0000\"}\n"; - $content .= "}\n"; - - my $fn = "$path/drive/openstack/latest/meta_data.json"; - - file_set_contents($fn, $content); -} - -sub generate_cloudinit_network { - my ($conf, $path) = @_; +sub configdrive2_network { + my ($conf) = @_; my $content = "auto lo\n"; $content .="iface lo inet loopback\n\n"; @@ -118,7 +103,7 @@ sub generate_cloudinit_network { foreach my $iface (@ifaces) { (my $id = $iface) =~ s/^net//; next if !$conf->{"ipconfig$id"}; - my $net = PVE::QemuServer::parse_ipconfig($conf->{"ipconfig$id"}); + my $net = PVE::QemuServer::parse_ipconfig($conf->{"ipconfig$id"}); $id = "eth$id"; $content .="auto $id\n"; @@ -149,10 +134,250 @@ sub generate_cloudinit_network { $content .=" dns_nameservers $conf->{nameserver}\n" if $conf->{nameserver}; $content .=" dns_search $conf->{searchdomain}\n" if $conf->{searchdomain}; - my $fn = "$path/drive/openstack/content/0000"; - file_set_contents($fn, $content); return $content; } +sub configdrive2_metadata { + my ($uuid) = @_; + return <<"EOF"; +{ + "uuid": "$uuid", + "network_config": { "content_path": "/content/0000" } +} +EOF +} + +sub generate_configdrive2 { + my ($conf, $vmid, $drive, $volname, $storeid) = @_; + + my $user_data = cloudinit_userdata($conf); + my $network_data = configdrive2_network($conf); + + my $digest_data = $user_data . $network_data; + my $uuid_str = Digest::SHA::sha1_hex($digest_data); + + my $meta_data = configdrive2_metadata($uuid_str); + + mkdir "/tmp/cloudinit"; + my $path = "/tmp/cloudinit/$vmid"; + mkdir $path; + mkdir "$path/drive"; + mkdir "$path/drive/openstack"; + mkdir "$path/drive/openstack/latest"; + mkdir "$path/drive/openstack/content"; + file_set_contents("$path/drive/openstack/latest/user_data", $user_data); + file_set_contents("$path/drive/openstack/content/0000", $network_data); + file_set_contents("$path/drive/openstack/latest/meta_data.json", $meta_data); + + commit_cloudinit_disk($conf, $drive, $volname, $storeid, "$path/drive", 'config-2'); + + rmtree("$path/drive"); +} + +sub nocloud_network_v2 { + my ($conf) = @_; + + my $content = ''; + + my $head = "version: 2\n" + . "ethernets:\n"; + + my $nameservers_done; + + my @ifaces = grep(/^net(\d+)$/, keys %$conf); + foreach my $iface (@ifaces) { + (my $id = $iface) =~ s/^net//; + next if !$conf->{"ipconfig$id"}; + + # indentation - network interfaces are inside an 'ethernets' hash + my $i = ' '; + + my $net = PVE::QemuServer::parse_net($conf->{$iface}); + my $ipconfig = PVE::QemuServer::parse_ipconfig($conf->{"ipconfig$id"}); + + my $mac = $net->{macaddr} + or die "network interface '$iface' has no mac address\n"; + + my $data = "${i}$iface:\n"; + $i .= ' '; + $data .= "${i}match:\n" + . "${i} macaddress: \"$mac\"\n" + . "${i}set-name: eth$id\n"; + my @addresses; + if (defined(my $ip = $ipconfig->{ip})) { + if ($ip eq 'dhcp') { + $data .= "${i}dhcp4: true\n"; + } else { + push @addresses, $ip; + } + } + if (defined(my $ip = $ipconfig->{ip6})) { + if ($ip eq 'dhcp') { + $data .= "${i}dhcp6: true\n"; + } else { + push @addresses, $ip; + } + } + if (@addresses) { + $data .= "${i}addresses:\n"; + $data .= "${i}- $_\n" foreach @addresses; + } + if (defined(my $gw = $ipconfig->{gw})) { + $data .= "${i}gateway4: $gw\n"; + } + if (defined(my $gw = $ipconfig->{gw6})) { + $data .= "${i}gateway6: $gw\n"; + } + + if (!$nameservers_done) { + $nameservers_done = 1; + + my $nameserver = $conf->{nameserver} // ''; + my $searchdomain = $conf->{searchdomain} // ''; + my @nameservers = PVE::Tools::split_list($nameserver); + my @searchdomains = PVE::Tools::split_list($searchdomain); + if (@nameservers || @searchdomains) { + $data .= "${i}nameservers:\n"; + $data .= "${i} addresses: [".join(',', @nameservers)."]\n" + if @nameservers; + $data .= "${i} search: [".join(',', @searchdomains)."]\n" + if @searchdomains; + } + } + + + $content .= $data; + } + + return $head.$content; +} + +sub nocloud_network { + my ($conf) = @_; + + my $content = "version: 1\n" + . "config:\n"; + + my @ifaces = grep(/^net(\d+)$/, keys %$conf); + foreach my $iface (@ifaces) { + (my $id = $iface) =~ s/^net//; + next if !$conf->{"ipconfig$id"}; + + # indentation - network interfaces are inside an 'ethernets' hash + my $i = ' '; + + my $net = PVE::QemuServer::parse_net($conf->{$iface}); + my $ipconfig = PVE::QemuServer::parse_ipconfig($conf->{"ipconfig$id"}); + + my $mac = $net->{macaddr} + or die "network interface '$iface' has no mac address\n"; + + my $data = "${i}- type: physical\n" + . "${i} name: eth$id\n" + . "${i} mac_address: $mac\n" + . "${i} subnets:\n"; + $i .= ' '; + if (defined(my $ip = $ipconfig->{ip})) { + if ($ip eq 'dhcp') { + $data .= "${i}- type: dhcp4\n"; + } else { + $data .= "${i}- type: static\n" + . "${i} address: $ip\n"; + if (defined(my $gw = $ipconfig->{gw})) { + $data .= "${i} gateway: $gw\n"; + } + } + } + if (defined(my $ip = $ipconfig->{ip6})) { + if ($ip eq 'dhcp') { + $data .= "${i}- type: dhcp6\n"; + } else { + $data .= "${i}- type: static6\n" + . "${i} address: $ip\n"; + if (defined(my $gw = $ipconfig->{gw6})) { + $data .= "${i} gateway: $gw\n"; + } + } + } + + $content .= $data; + } + + my $nameserver = $conf->{nameserver} // ''; + my $searchdomain = $conf->{searchdomain} // ''; + my @nameservers = PVE::Tools::split_list($nameserver); + my @searchdomains = PVE::Tools::split_list($searchdomain); + if (@nameservers || @searchdomains) { + my $i = ' '; + $content .= "${i}- type: nameserver\n"; + if (@nameservers) { + $content .= "${i} address:\n"; + $content .= "${i} - $_\n" foreach @nameservers; + } + if (@searchdomains) { + $content .= "${i} search:\n"; + $content .= "${i} - $_\n" foreach @searchdomains; + } + } + + return $content; +} + +sub nocloud_metadata { + my ($uuid, $hostname) = @_; + return <<"EOF"; +instance-id: $uuid +local-hostname: $hostname +EOF +} + +sub generate_nocloud { + my ($conf, $vmid, $drive, $volname, $storeid) = @_; + + my $hostname = $conf->{hostname} // ''; + my $user_data = cloudinit_userdata($conf); + my $network_data = nocloud_network($conf); + + my $digest_data = $user_data . $network_data . "local-hostname: $hostname\n"; + my $uuid_str = Digest::SHA::sha1_hex($digest_data); + + my $meta_data = nocloud_metadata($uuid_str, $hostname); + + mkdir "/tmp/cloudinit"; + my $path = "/tmp/cloudinit/$vmid"; + mkdir $path; + rmtree("$path/drive"); + mkdir "$path/drive"; + file_set_contents("$path/drive/user-data", $user_data); + file_set_contents("$path/drive/network-config", $network_data); + file_set_contents("$path/drive/meta-data", $meta_data); + + commit_cloudinit_disk($conf, $drive, $volname, $storeid, "$path/drive", 'cidata'); + +} + +my $cloudinit_methods = { + configdrive2 => \&generate_configdrive2, + nocloud => \&generate_nocloud, +}; + +sub generate_cloudinitconfig { + my ($conf, $vmid) = @_; + + my $format = get_cloudinit_format($conf); + + PVE::QemuServer::foreach_drive($conf, sub { + my ($ds, $drive) = @_; + + my ($storeid, $volname) = PVE::Storage::parse_volume_id($drive->{file}, 1); + + return if !$volname || $volname !~ m/vm-$vmid-cloudinit/; + + my $generator = $cloudinit_methods->{$format} + or die "missing cloudinit methods for format '$format'\n"; + + $generator->($conf, $vmid, $drive, $volname, $storeid); + }); +} 1; -- 2.39.2