package PVE::Storage::ISCSIPlugin; use strict; use warnings; use File::stat; use IO::Dir; use IO::File; use PVE::JSONSchema qw(get_standard_option); use PVE::Storage::Plugin; use PVE::Tools qw(run_command file_read_firstline trim dir_glob_regex dir_glob_foreach $IPV4RE $IPV6RE); use base qw(PVE::Storage::Plugin); # iscsi helper function my $ISCSIADM = '/usr/bin/iscsiadm'; my $found_iscsi_adm_exe; my sub assert_iscsi_support { my ($noerr) = @_; return $found_iscsi_adm_exe if $found_iscsi_adm_exe; # assume it won't be removed if ever found $found_iscsi_adm_exe = -x $ISCSIADM; if (!$found_iscsi_adm_exe) { die "error: no iscsi support - please install open-iscsi\n" if !$noerr; warn "warning: no iscsi support - please install open-iscsi\n"; } return $found_iscsi_adm_exe; } # Example: 192.168.122.252:3260,1 iqn.2003-01.org.linux-iscsi.proxmox-nfs.x8664:sn.00567885ba8f my $ISCSI_TARGET_RE = qr/^((?:$IPV4RE|\[$IPV6RE\]):\d+)\,\S+\s+(\S+)\s*$/; sub iscsi_session_list { assert_iscsi_support(); my $cmd = [$ISCSIADM, '--mode', 'session']; my $res = {}; eval { run_command( $cmd, errmsg => 'iscsi session scan failed', outfunc => sub { my $line = shift; # example: tcp: [1] 192.168.122.252:3260,1 iqn.2003-01.org.linux-iscsi.proxmox-nfs.x8664:sn.00567885ba8f (non-flash) if ($line =~ m/^tcp:\s+\[(\S+)\]\s+((?:$IPV4RE|\[$IPV6RE\]):\d+)\,\S+\s+(\S+)\s+\S+?\s*$/ ) { my ($session_id, $portal, $target) = ($1, $2, $3); # there can be several sessions per target (multipath) push @{ $res->{$target} }, { session_id => $session_id, portal => $portal }; } }, ); }; if (my $err = $@) { die $err if $err !~ m/: No active sessions.$/i; } return $res; } sub iscsi_test_session { my ($sid) = @_; if ($sid !~ m/^[0-9]+$/) { die "session_id: '$sid' is not a number\n"; } my $state = file_read_firstline("/sys/class/iscsi_session/session${sid}/state"); return defined($state) && $state eq 'LOGGED_IN'; } sub iscsi_test_portal { my ($target, $portal, $cache) = @_; $cache //= {}; if (defined($target)) { # check session state instead if available my $sessions = iscsi_session($cache, $target); for my $session ($sessions->@*) { next if $session->{portal} ne $portal; my $state = iscsi_test_session($session->{session_id}); return $state if $state; } } # check portal via tcp my ($server, $port) = PVE::Tools::parse_host_and_port($portal); return 0 if !$server; return PVE::Network::tcp_ping($server, $port || 3260, 2); } sub iscsi_portals { my ($target, $portal_in) = @_; assert_iscsi_support(); my $res = []; my $cmd = [$ISCSIADM, '--mode', 'node']; eval { run_command( $cmd, outfunc => sub { my $line = shift; if ($line =~ $ISCSI_TARGET_RE) { my ($portal, $portal_target) = ($1, $2); if ($portal_target eq $target) { push @{$res}, $portal; } } }, ); }; my $err = $@; warn $err if $err; if ($err || !scalar(@$res)) { return [$portal_in]; } else { return $res; } } sub iscsi_discovery { my ($target_in, $portals, $cache) = @_; assert_iscsi_support(); my $res = {}; for my $portal ($portals->@*) { next if !iscsi_test_portal($target_in, $portal, $cache); # fixme: raise exception here? my $cmd = [$ISCSIADM, '--mode', 'discovery', '--type', 'sendtargets', '--portal', $portal]; eval { run_command( $cmd, outfunc => sub { my $line = shift; if ($line =~ $ISCSI_TARGET_RE) { my ($portal, $target) = ($1, $2); # one target can have more than one portal (multipath) # and sendtargets should return all of them in single call push @{ $res->{$target} }, $portal; } }, ); }; # In case of multipath we can stop after receiving targets from any available portal last if scalar(keys %$res) > 0; } return $res; } sub iscsi_login { my ($target, $portals, $cache) = @_; assert_iscsi_support(); eval { iscsi_discovery($target, $portals, $cache); }; warn $@ if $@; # Disable retries to avoid blocking pvestatd for too long, next iteration will retry anyway eval { my $cmd = [ $ISCSIADM, '--mode', 'node', '--targetname', $target, '--op', 'update', '--name', 'node.session.initial_login_retry_max', '--value', '0', ]; run_command($cmd); }; warn $@ if $@; run_command([$ISCSIADM, '--mode', 'node', '--targetname', $target, '--login']); } sub iscsi_logout { my ($target) = @_; assert_iscsi_support(); run_command([$ISCSIADM, '--mode', 'node', '--targetname', $target, '--logout']); } my $rescan_filename = "/var/run/pve-iscsi-rescan.lock"; sub iscsi_session_rescan { my $session_list = shift; assert_iscsi_support(); my $rstat = stat($rescan_filename); if (!$rstat) { if (my $fh = IO::File->new($rescan_filename, "a")) { utime undef, undef, $fh; close($fh); } } else { my $atime = $rstat->atime; my $tdiff = time() - $atime; # avoid frequent rescans return if !($tdiff < 0 || $tdiff > 10); utime undef, undef, $rescan_filename; } foreach my $session (@$session_list) { my $cmd = [$ISCSIADM, '--mode', 'session', '--sid', $session->{session_id}, '--rescan']; eval { run_command($cmd, outfunc => sub { }); }; warn $@ if $@; } } sub load_stable_scsi_paths { my $stable_paths = {}; my $stabledir = "/dev/disk/by-id"; if (my $dh = IO::Dir->new($stabledir)) { foreach my $tmp (sort $dh->read) { # exclude filenames with part in name (same disk but partitions) # use only filenames with scsi(with multipath i have the same device # with dm-uuid-mpath , dm-name and scsi in name) if ($tmp !~ m/-part\d+$/ && ($tmp =~ m/^scsi-/ || $tmp =~ m/^dm-uuid-mpath-/)) { my $path = "$stabledir/$tmp"; my $bdevdest = readlink($path); if ($bdevdest && $bdevdest =~ m|^../../([^/]+)|) { $stable_paths->{$1} = $tmp; } } } $dh->close; } return $stable_paths; } sub iscsi_device_list { my $res = {}; my $dirname = '/sys/class/iscsi_session'; my $stable_paths = load_stable_scsi_paths(); dir_glob_foreach( $dirname, 'session(\d+)', sub { my ($ent, $session) = @_; my $target = file_read_firstline("$dirname/$ent/targetname"); return if !$target; my (undef, $host) = dir_glob_regex("$dirname/$ent/device", 'target(\d+):.*'); return if !defined($host); dir_glob_foreach( "/sys/bus/scsi/devices", "$host:" . '(\d+):(\d+):(\d+)', sub { my ($tmp, $channel, $id, $lun) = @_; my $type = file_read_firstline("/sys/bus/scsi/devices/$tmp/type"); return if !defined($type) || $type ne '0'; # list disks only my $bdev; if (-d "/sys/bus/scsi/devices/$tmp/block") { # newer kernels (undef, $bdev) = dir_glob_regex("/sys/bus/scsi/devices/$tmp/block/", '([A-Za-z]\S*)'); } else { (undef, $bdev) = dir_glob_regex("/sys/bus/scsi/devices/$tmp", 'block:(\S+)'); } return if !$bdev; #check multipath if (-d "/sys/block/$bdev/holders") { my $multipathdev = dir_glob_regex("/sys/block/$bdev/holders", '[A-Za-z]\S*'); $bdev = $multipathdev if $multipathdev; } my $blockdev = $stable_paths->{$bdev}; return if !$blockdev; my $size = file_read_firstline("/sys/block/$bdev/size"); return if !$size; my $volid = "$channel.$id.$lun.$blockdev"; $res->{$target}->{$volid} = { 'format' => 'raw', 'size' => int($size * 512), 'vmid' => 0, # not assigned to any vm 'channel' => int($channel), 'id' => int($id), 'lun' => int($lun), }; #print "TEST: $target $session $host,$bus,$tg,$lun $blockdev\n"; }, ); }, ); return $res; } # Configuration sub type { return 'iscsi'; } sub plugindata { return { content => [{ images => 1, none => 1 }, { images => 1 }], select_existing => 1, 'sensitive-properties' => {}, }; } sub properties { return { target => { description => "iSCSI target.", type => 'string', }, portal => { description => "iSCSI portal (IP or DNS name with optional port).", type => 'string', format => 'pve-storage-portal-dns', }, }; } sub options { return { portal => { fixed => 1 }, target => { fixed => 1 }, nodes => { optional => 1 }, disable => { optional => 1 }, content => { optional => 1 }, bwlimit => { optional => 1 }, }; } # Storage implementation sub parse_volname { my ($class, $volname) = @_; if ($volname =~ m!^\d+\.\d+\.\d+\.([^/\s]+)$!) { return ('images', $1, undef, undef, undef, undef, 'raw'); } die "unable to parse iscsi volume name '$volname'\n"; } sub filesystem_path { my ($class, $scfg, $volname, $snapname) = @_; die "snapshot is not possible on iscsi storage\n" if defined($snapname); my ($vtype, $name, $vmid) = $class->parse_volname($volname); my $path = "/dev/disk/by-id/$name"; return wantarray ? ($path, $vmid, $vtype) : $path; } sub create_base { my ($class, $storeid, $scfg, $volname) = @_; die "can't create base images in iscsi storage\n"; } sub clone_image { my ($class, $scfg, $storeid, $volname, $vmid, $snap) = @_; die "can't clone images in iscsi storage\n"; } sub alloc_image { my ($class, $storeid, $scfg, $vmid, $fmt, $name, $size) = @_; die "can't allocate space in iscsi storage\n"; } sub free_image { my ($class, $storeid, $scfg, $volname, $isBase) = @_; die "can't free space in iscsi storage\n"; } # list all luns regardless of set content_types, since we need it for # listing in the gui and we can only have images anyway sub list_volumes { my ($class, $storeid, $scfg, $vmid, $content_types) = @_; my $res = $class->list_images($storeid, $scfg, $vmid); for my $item (@$res) { $item->{content} = 'images'; # we only have images } return $res; } sub list_images { my ($class, $storeid, $scfg, $vmid, $vollist, $cache) = @_; my $res = []; $cache->{iscsi_devices} = iscsi_device_list() if !$cache->{iscsi_devices}; # we have no owner for iscsi devices my $target = $scfg->{target}; if (my $dat = $cache->{iscsi_devices}->{$target}) { foreach my $volname (keys %$dat) { my $volid = "$storeid:$volname"; if ($vollist) { my $found = grep { $_ eq $volid } @$vollist; next if !$found; } else { # we have no owner for iscsi devices next if defined($vmid); } my $info = $dat->{$volname}; $info->{volid} = $volid; push @$res, $info; } } return $res; } sub iscsi_session { my ($cache, $target) = @_; $cache->{iscsi_sessions} = iscsi_session_list() if !$cache->{iscsi_sessions}; return $cache->{iscsi_sessions}->{$target}; } sub status { my ($class, $storeid, $scfg, $cache) = @_; my $session = iscsi_session($cache, $scfg->{target}); my $active = defined($session) ? 1 : 0; return (0, 0, 0, $active); } sub activate_storage { my ($class, $storeid, $scfg, $cache) = @_; return if !assert_iscsi_support(1); my $sessions = iscsi_session($cache, $scfg->{target}); my $portals = iscsi_portals($scfg->{target}, $scfg->{portal}); my $do_login = !defined($sessions); if (!$do_login) { # We should check that sessions for all portals are available my $session_portals = [map { $_->{portal} } (@$sessions)]; for my $portal (@$portals) { if (!grep(/^\Q$portal\E$/, @$session_portals)) { $do_login = 1; last; } } } if ($do_login) { eval { iscsi_login($scfg->{target}, $portals, $cache); }; warn $@ if $@; } else { # make sure we get all devices iscsi_session_rescan($sessions); } } sub deactivate_storage { my ($class, $storeid, $scfg, $cache) = @_; return if !assert_iscsi_support(1); if (defined(iscsi_session($cache, $scfg->{target}))) { iscsi_logout($scfg->{target}); } } my $check_devices_part_of_target = sub { my ($device_paths, $target) = @_; my $found = 0; for my $path (@$device_paths) { if ($path =~ m!^/devices/platform/host\d+/session(\d+)/target\d+:\d:\d!) { my $session_id = $1; my $targetname = file_read_firstline( "/sys/class/iscsi_session/session$session_id/targetname", ); if ($targetname && ($targetname eq $target)) { $found = 1; last; } } } return $found; }; my $udev_query_path = sub { my ($dev) = @_; # only accept device names (see `man udevadm`) ($dev) = $dev =~ m!^(/dev/.+)$!; # untaint die "invalid device for udevadm path query\n" if !defined($dev); my $device_path; my $cmd = [ 'udevadm', 'info', '--query=path', $dev, ]; eval { run_command( $cmd, outfunc => sub { $device_path = shift; }, ); }; die "failed to query device path for '$dev': $@\n" if $@; ($device_path) = $device_path =~ m!^(/devices/.+)$!; # untaint die "invalid resolved device path\n" if !defined($device_path); return $device_path; }; my $resolve_virtual_devices; $resolve_virtual_devices = sub { my ($dev, $visited) = @_; $visited = {} if !defined($visited); my $resolved = []; if ($dev =~ m!^/devices/virtual/block/!) { dir_glob_foreach( "/sys/$dev/slaves", '([^.].+)', sub { my ($slave) = @_; # don't check devices multiple times return if $visited->{$slave}; $visited->{$slave} = 1; my $path; eval { $path = $udev_query_path->("/dev/$slave"); }; return if $@; my $nested_resolved = $resolve_virtual_devices->($path, $visited); push @$resolved, @$nested_resolved; }, ); } else { push @$resolved, $dev; } return $resolved; }; sub activate_volume { my ($class, $storeid, $scfg, $volname, $snapname, $cache) = @_; my $path = $class->filesystem_path($scfg, $volname, $snapname); my $real_path = Cwd::realpath($path); die "failed to get realpath for '$path': $!\n" if !$real_path; # in case $path does not exist or is not a symlink, check if the returned # $real_path is a block device die "resolved realpath '$real_path' is not a block device\n" if !-b $real_path; my $device_path = $udev_query_path->($real_path); my $resolved_paths = $resolve_virtual_devices->($device_path); my $found = $check_devices_part_of_target->($resolved_paths, $scfg->{target}); die "volume '$volname' not part of target '$scfg->{target}'\n" if !$found; } sub check_connection { my ($class, $storeid, $scfg) = @_; my $cache = {}; my $portals = iscsi_portals($scfg->{target}, $scfg->{portal}); for my $portal (@$portals) { my $result = iscsi_test_portal($scfg->{target}, $portal, $cache); return $result if $result; } return 0; } sub volume_resize { my ($class, $scfg, $storeid, $volname, $size, $running) = @_; die "volume resize is not possible on iscsi device"; } sub volume_has_feature { my ($class, $scfg, $feature, $storeid, $volname, $snapname, $running) = @_; my $features = { copy => { current => 1 }, }; my ($vtype, $name, $vmid, $basename, $basevmid, $isBase) = $class->parse_volname($volname); my $key = undef; if ($snapname) { $key = 'snap'; } else { $key = $isBase ? 'base' : 'current'; } return 1 if $features->{$feature}->{$key}; return undef; } sub volume_export_formats { my ($class, $scfg, $storeid, $volname, $snapshot, $base_snapshot, $with_snapshots) = @_; return () if defined($snapshot); # not supported return () if defined($base_snapshot); # not supported return () if $with_snapshots; # not supported return ('raw+size'); } sub volume_export { my ( $class, $scfg, $storeid, $fh, $volname, $format, $snapshot, $base_snapshot, $with_snapshots, ) = @_; die "volume export format $format not available for $class\n" if $format ne 'raw+size'; die "cannot export volumes together with their snapshots in $class\n" if $with_snapshots; die "cannot export an incremental stream in $class\n" if defined($base_snapshot); die "cannot export a snapshot in $class\n" if defined($snapshot); my $file = $class->filesystem_path($scfg, $volname, $snapshot); my $size; run_command( ['/sbin/blockdev', '--getsize64', $file], outfunc => sub { my ($line) = @_; die "unexpected output from /sbin/blockdev: $line\n" if $line !~ /^(\d+)$/; $size = int($1); }, ); PVE::Storage::Plugin::write_common_header($fh, $size); run_command(['dd', "if=$file", "bs=64k", "status=progress"], output => '>&' . fileno($fh)); return; } sub volume_import_formats { my ($class, $scfg, $storeid, $volname, $snapshot, $base_snapshot, $with_snapshots) = @_; return (); } sub volume_import { die "volume import is not possible on iscsi storage\n"; } 1;