diff --git a/netapp_ontap-formula/README.md b/netapp_ontap-formula/README.md new file mode 100644 index 00000000..c07634d4 --- /dev/null +++ b/netapp_ontap-formula/README.md @@ -0,0 +1,7 @@ +# Salt states and modules for managing NetApp ONTAP storage appliances + +_Work in progress ..._ + +## Available states + +_to do ..._ diff --git a/netapp_ontap-formula/_modules/ontap.py b/netapp_ontap-formula/_modules/ontap.py new file mode 100644 index 00000000..fa316ee5 --- /dev/null +++ b/netapp_ontap-formula/_modules/ontap.py @@ -0,0 +1,344 @@ +""" +Salt execution module for maging ONTAP based NetApp storage systems +Copyright (C) 2023 SUSE LLC + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +import logging +import re + +from netapp_ontap.resources import Igroup, Lun, LunMap, Svm + +log = logging.getLogger(__name__) + +def __virtual__(): + try: + from netapp_ontap import config as netapp_config + from netapp_ontap import NetAppRestError, HostConnection + except ImportError as err: + return (False, 'The netapp_ontap library is not available') + + config = __utils__['ontap_config.config']() + config_host = config['host'] + verify = config.get('verify', False) + host, colon, port = config_host.rpartition(':') + netapp_config.CONNECTION = HostConnection(host, port=port, cert=config['certificate'], key=config['key'], verify=verify) + netapp_config.RAISE_API_ERRORS = False + + return True + +def _path(volume, name): + return f'/vol/{volume}/{name}' + +def _result(result): + """ + Transforms API results to a common format + Used for DELETE/PATCH/POST output, not for GET + result = the output to parse + """ + log.debug(f'netapp_ontap: parsing result: {result}') + + error = result.is_err + status = result.http_response.status_code + data = result.http_response.json() + if 'error' in data: + message = data['error']['message'] + else: + message = result.http_response.text + + res = {} + + if status >= 400 and error: + __context__["retcode"] = 2 + res = {'result': False, 'message': message} + if 200 <= status < 300: + res = {'result': True} + + if res: + resmap = {'status': status} + resmap.update(res) + return resmap + + log.warning('netapp_ontap: dumping unknown result') + return result + +def _strip(resource, inners=[]): + resource_dict = resource.to_dict() + del resource_dict['_links'] + for inner in inners: + del resource_dict[inner]['_links'] + return resource_dict + +# Source: https://stackoverflow.com/a/14996816 +suffixes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] +def _humansize(nbytes): + i = 0 + while nbytes >= 1024 and i < len(suffixes)-1: + nbytes /= 1024. + i += 1 + f = ('%.2f' % nbytes).rstrip('0').rstrip('.') + return '%s%s' % (f, suffixes[i]) + +# to-do: make fields adjustable +def get_lun(comment=None, path=None, uuid=None, human=True): + """ + Queries one or more LUN(s), depending on whether filters are set + """ + args = [comment, path, uuid] + argcount = args.count(None) + if 1 > argcount < 3: + log.error(f'Only a single filter may be specified') + raise ValueError('Only a single filter may be specified') + fields = 'comment,space.size,status.mapped' + result = [] + + def _handle(resource): + resource.get(fields=fields) + resource_stripped = _strip(resource) + if human: + # transform LUN size to a more readable format + resource_stripped['space']['size'] = _humansize(resource_stripped['space']['size']) + result.append(resource_stripped) + + if comment: + for resource in Lun.get_collection(**{'comment': comment}): + _handle(resource) + elif path: + for resource in Lun.get_collection(**{'name': path}): + _handle(resource) + elif uuid: + resource = Lun(uuid=uuid) + _handle(resource) + else: + # no filter specified, fetch all LUNs + for resource in Lun.get_collection(): + _handle(resource) + + return result + +def get_next_free(igroup): + """ + Returns the next free LUN ID for the specfied initiator group + """ + numbers = [] + for resource in LunMap.get_collection(igroup=igroup, fields="logical_unit_number"): + numbers.append(resource.logical_unit_number) + return max(numbers)+1 + +# https://stackoverflow.com/a/60708339 +# based on https://stackoverflow.com/a/42865957/2002471 +units = {"B": 1, "KB": 2**10, "MB": 2**20, "GB": 2**30, "TB": 2**40} +def _parse_size(size): + size = size.upper() + if not re.match(r' ', size): + size = re.sub(r'([KMGT]?B)', r' \1', size) + number, unit = [string.strip() for string in size.split()] + return int(float(number)*units[unit]) + +def provision_lun(name, size, volume, vserver, comment=None): + """ + Create a new LUN + """ + size = _parse_size(size) + path = _path(volume, name) + + resource = Lun() + resource.svm = Svm(name=vserver) + resource.name = path + resource.os_type = 'linux' + resource.space = {'size': size} + + if comment is not None: + resource.comment = comment + + result = resource.post() + return _result(result) + +# to-do: support property updates other than size changes +def update_lun(uuid, size): + """ + Change values of an existing LUN + """ + size = _parse_size(size) + + resource = Lun(uuid=uuid) + resource.space = {'size': size} + result = resource.patch() + return _result(result) + +def _delete_lun(name=None, volume=None, uuid=None): + """ + Meta-function for deleting a LUN + Currently, the individual targeting functions need to be used + """ + if (name is None or volume is None) and (uuid is None): + log.error('Specify either name and volume or uuid') + raise ValueError('Specify either name and volume or uuid') + if name and volume: + path = _path(volume, name) + resources = get_lun(path=path) + log.debug(f'netapp_ontap: resources to delete: {resources}') + found = len(resources) + if found > 1: + log.error('Refusing to delete multiple resources') + return({'result': False, 'message': 'Found more than one matching LUN, aborting deletion'}) + if found == 0: + return({'result': None, 'message': 'Did not find any matching LUN\'s'}) + resource = Lun(uuid=resources[0]['uuid']) + elif uuid: + resource = Lun(uuid=uuid) + + result = resource.delete() + return _result(result) + +def delete_lun_name(name, volume): + """ + Delete a single LUN based on its name and volume + """ + return _delete_lun(name, volume) + +def delete_lun_uuid(uuid): + """ + Delete a single LUN based on its UUID + """ + return _delete_lun(uuid=uuid) + +# to-do: allow filter by path=None and uuid +# to-do: potentially move this to get_luns_mapped() and make a separate get_lun_mapped() returning a single entry +def get_lun_mapped(comment=None, lun_result=None): + """ + Assess whether LUNs named by a comment are mapped + For more efficient programmatic use, an existing get_lun() output can be fed + """ + if (comment is None) and (lun_result is None): + log.error('Specify either a comment or existing LUN output') + raise ValueError('Specify a comment') + if comment is not None: + query = get_lun(comment) + elif lun_result is not None: + query = lun_result + resmap = {} + for lun in query: + log.debug(f'netapp_ontap: parsing LUN {lun}') + name = lun.get('name') + mapped = lun.get('status', {}).get('mapped') + resmap.update({name: mapped}) + if None in resmap: + log.error('netapp_ontap: invalid LUN mapping map') + return resmap + +def get_igroup_uuid(igroup): + """ + Return the UUID of a single initiator group + """ + resource = Igroup(name=igroup) + resource.get() + uuid = resource.uuid + return uuid + +def get_lun_mappings(name, volume, igroup=None): + """ + Return details about LUN mappings + """ + path = _path(volume, name) + result = [] + + if igroup is not None: + log.debug('netapp_ontap: filtering by igroup') + igroup_uuid = get_igroup_uuid(igroup) + luns = get_lun(path=path) + log.debug(f'netapp_ontap: found luns: {luns}') + for resource in luns: + lun_uuid = resource['uuid'] + filterdict = {'lun.uuid': lun_uuid} + if igroup is not None: + filterdict.update({'igroup.uuid': igroup_uuid}) + mapresource = LunMap(**filterdict) + mrs = mapresource.get_collection(fields='logical_unit_number') + # FIXME get() fails, saying more than one item is found, and get_collection() returns dozens of completely unrelated entries + # the loop below is a workaround discarding all the bogus entries + mymrs = list(mrs) + log.debug(f'netapp_ontap: found mappings: {mymrs}') + for mr in mymrs: + mr_stripped = _strip(mr, ['igroup', 'lun', 'svm']) + if mr_stripped['lun']['uuid'] == lun_uuid: + if igroup is not None and mr_stripped['igroup']['uuid'] != igroup_uuid: + log.debug('netapp_ontap: igroup UUID does not match') + continue + log.debug(f'netapp_ontap: elected {mr_stripped}') + result.append(mr_stripped) + + return result + +def get_lun_mapping(name, volume, igroup): + """ + Return details about a single LUN mapping + """ + results = get_lun_mappings(name, volume, igroup) + lr = len(results) + if lr == 1: + return results[0] + if lr > 1: + log.error(f'netapp_ontap: found {lr} results, but expected only one') + return None + return {} + +def map_lun(name, lunid, volume, vserver, igroup): + """ + Map a LUN to an initiator group + """ + path = _path(volume, name) + + resource = LunMap() + resource.svm = Svm(name=vserver) + resource.igroup = Igroup(name=igroup) + resource.lun = Lun(name=path) + resource.logical_unit_number = lunid + result = resource.post() + + return _result(result) + +def unmap_luns(name, volume, igroup): + """ + Remove LUNs from an initiator group + """ + path = _path(volume, name) + results = [] + + mappings = get_lun_mappings(name, volume, igroup) + log.debug(f'netapp_ontap: parsing mappings: {mappings}') + for mapping in mappings: + igroup_uuid = mapping['igroup']['uuid'] + lun_uuid = mapping['lun']['uuid'] + resource = LunMap(**{'igroup.uuid': igroup_uuid, 'lun.uuid': lun_uuid}) + result = resource.delete() + results.append(_result(result)) + + return results + +def unmap_lun(name, volume, igroup): + """ + Remove a single LUN from an initiator group + Not implemented! Might unmap multiple LUNs. + """ + # to-do: abort if more than one LUN is going to be affected, consider using get_lun_mapping() + results = unmap_luns(name, volume, igroup) + reslen = len(results) + if reslen > 1: + log.warning(f'Unmapped {reslen} LUNs, but expected only one') + if reslen == 0: + return {} + + return results[0] diff --git a/netapp_ontap-formula/_states/ontap.py b/netapp_ontap-formula/_states/ontap.py new file mode 100644 index 00000000..36f97f0d --- /dev/null +++ b/netapp_ontap-formula/_states/ontap.py @@ -0,0 +1,180 @@ +""" +Salt state module for maging ONTAP based NetApp storage systems +Copyright (C) 2023 SUSE LLC + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +import logging +log = logging.getLogger(__name__) + +# Source: https://stackoverflow.com/a/14996816 +suffixes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] +def _humansize(nbytes): + i = 0 + while nbytes >= 1024 and i < len(suffixes)-1: + nbytes /= 1024. + i += 1 + f = ('%.2f' % nbytes).rstrip('0').rstrip('.') + return '%s%s' % (f, suffixes[i]) + +# https://stackoverflow.com/a/60708339 +# based on https://stackoverflow.com/a/42865957/2002471 +units = {"B": 1, "KB": 2**10, "MB": 2**20, "GB": 2**30, "TB": 2**40} +def _parse_size(size): + size = size.upper() + #print("parsing size ", size) + if not re.match(r' ', size): + size = re.sub(r'([KMGT]?B)', r' \1', size) + number, unit = [string.strip() for string in size.split()] + return int(float(number)*units[unit]) + +def lun_present(name, comment, size, volume, vserver): + path = f'/vol/{volume}/{name}' + ret = {'name': path, 'result': False, 'changes': {}, 'comment': ''} + + def _size(details, human=False): + size = details.get('space', {}).get('size') + if size is not None and human: + return _humansize(size) + return size + + luns = __salt__['ontap.get_lun']() + + for lun in luns: + lun_path = lun.get('name') + lun_comment = lun.get('comment') + lun_uuid = lun.get('uuid') + if lun_path == path: + log.debug(f'netapp_ontap: found existing LUN {name}') + if lun_uuid is None: + log.error(f'netapp_ontap: found LUN with no UUID') + lun_details = __salt__['ontap.get_lun'](uuid=lun_uuid, human=False) + lun_size = _size(lun_details[0], True) + # lun_size_human = needed? + if lun_size == size: + comment_size = f'Size {size} matches' + ret['result'] = True + elif lun_size != size: + if __opts__['test']: + comment_size = f'Would resize LUN to {size}' + ret['result'] = None + else: + __salt__['ontap.update_lun'](lun_uuid, size) + lun2_details = __salt__['ontap.get_lun'](uuid=lun_uuid, human=False) + lun2_size = _size(lun2_details[0], True) + comment_size = f'LUN from {lun_size} to {size}' + if lun2_size != lun_size and lun2_size == size: + comment_size = f'Successfully resized {comment_size}' + ret['result'] = True + elif lun2_size == lun_size: + comment_size = f'Failed to resize {comment_size}, it is still {lun2_size}' + else: + comment_size = f'Unexpected outcome while resizing {comment_size}' + + comment_base = 'LUN is already present' + retcomment = f'{comment_base}; {comment_size}' + ret['comment'] = retcomment + return ret + + if __opts__['test']: + ret['comment'] = 'Would provision LUN' + ret['result'] = None + return ret + + __salt__['ontap.provision_lun'](name, size, volume, vserver, comment) + lun2_details = __salt__['ontap.get_lun'](path=path)[0] + lun2_size = _size(lun2_details) + # FIXME changes dict + + if lun2_details.get('name') == path: + ret['result'] = True + comment_path = f'{path} created' + else: + ret['result'] = False + comment_path = f'{path} not properly created' + + if lun2_size == size: + ret['result'] = True + comment_size = f'with size {size}' + else: + ret['result'] = False + comment_size = f'with mismatching size {lun2_size}' + + comment = f'LUN {comment_path} {comment_size}.' + + ret['comment'] = comment + return ret + +def lun_mapped(name, lunid, volume, vserver, igroup): + path = f'/vol/{volume}/{name}' + ret = {'name': path, 'result': False, 'changes': {}, 'comment': ''} + + mapping_out = __salt__['ontap.get_lun_mapping'](name, volume, igroup) + log.debug(f'netapp_ontap: mapping result: {mapping_out}') + + comment_details = f' to igroup {igroup} in SVM {vserver}' + current_igroup = mapping_out.get('igroup', {}).get('name') + current_vserver = mapping_out.get('svm', {}).get('name') + if not mapping_out or igroup != current_igroup or vserver != current_vserver: + if __opts__['test']: + comment = f'Would map ID {lunid}{comment_details}' + ret['result'] = None + elif mapping_out and igroup == current_igroup or vserver == current_svm: + comment = f'Already mapped{comment_details}' + ret['result'] = True + else: + log.error('Unhandled mapping state') + comment = 'Something weird happened' + + if __opts__['test'] or ret['result'] is True: + ret['comment'] = comment + return ret + + map_out = __salt__['ontap.map_lun'](name, lunid, volume, vserver, igroup) + if map_out.get('result', False) and map_out.get('status') == 201: + comment = f'Mapped LUN to ID {lunid} in igroup {igroup}' + ret['result'] = True + else: + comment = 'LUN mapping failed' + ret['result'] = False + + ret['comment'] = comment + return ret + +def lun_unmapped(name, volume, igroup): + path = f'/vol/{volume}/{name}' + ret = {'name': path, 'result': True, 'changes': {}, 'comment': ''} + + if __opts__['test']: + result = __salt__['ontap.get_lun_mapping'](name, volume, igroup) + else: + result = __salt__['ontap.unmap_lun'](name, volume, igroup) + rr = result.get('result', True) + rs = result.get('status') + log.debug(f'result: {result}') + + if __opts__['test'] and result: + comment = f'Would unmap LUN' + ret['result'] = None + elif not result: + comment = 'Nothing to unmap' + elif rr is True and rs == 200: + comment = f'Unmapped LUN' + elif rr is False or rs != 200: + comment = f'Unmapping failed' + ret['result'] = False + + ret['comment'] = comment + return ret diff --git a/netapp_ontap-formula/_utils/ontap_config.py b/netapp_ontap-formula/_utils/ontap_config.py new file mode 100644 index 00000000..f236ac4d --- /dev/null +++ b/netapp_ontap-formula/_utils/ontap_config.py @@ -0,0 +1,32 @@ +""" +Salt utility module for providing functions used by other ONTAP related modules +Copyright (C) 2023 SUSE LLC + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +import logging + +log = logging.getLogger(__name__) + +def config(): + config = __pillar__.get('netapp_ontap', {}) + host = config.get('host') + certificate = config.get('certificate') + key = config.get('key') + rundir = config.get('rundir') + if None in [host, certificate, key, rundir]: + log.error('netapp_ontap: configuration is incomplete!') + return False + return {'host': host, 'certificate': certificate, 'key': key, 'rundir': rundir} diff --git a/netapp_ontap-formula/bin/init-dotsim.pl b/netapp_ontap-formula/bin/init-dotsim.pl new file mode 100644 index 00000000..f078b96d --- /dev/null +++ b/netapp_ontap-formula/bin/init-dotsim.pl @@ -0,0 +1,337 @@ +#!/usr/bin/perl +# Experimental script to initialize a NetApp Data ONTAP simulator on a KVM hypervisor +# Copyright (C) 2023 Georg Pfuetzenreuter +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# to-do: +# - read multipath devices from domain XML file + +use v5.26.1; +use Archive::Tar; +use Expect; +use File::Temp; +use File::pushd; +use Getopt::Long qw(:config auto_version); +use Sys::Virt; + +our $VERSION = '0.1'; +my $source; +my $mpaths; +my $domain; +my $cluster; +my $force; +my $only_vm; +my $address; +my $netmask; +my $gateway; +my $passphrase; +my $reset; + +sub usage { + print <<~'EOH' + Please specify all of the following arguments for this script to proceed: + --address, --netmask, --gateway : IP details for the VM to use on the node management interface + --cluster : Name to give the cluster + --domain : Path to an existing Libvirt domain XML file the VM should be started from + --mpaths : Comma separated list of four multipath devices the individual disk images should be written to + --passphrase : Passphrase to set for the default "admin" user + --source : Path to the NetApp provided OVA image (aka tarball) + EOH + ; exit; +} + +GetOptions( + "address=s" => \$address, + "cluster=s" => \$cluster, + "domain=s" => \$domain, + "force" => \$force, + "gateway=s" => \$gateway, + "mpaths=s" => \$mpaths, + "netmask=s" => \$netmask, + "only_vm" => \$only_vm, + "passphrase=s" => \$passphrase, + "reset" => \$reset, + "source=s" => \$source, + "help" => sub { usage(); } +) or exit; +usage() unless ( $address && $netmask && $gateway && $cluster && $domain && $mpaths && $passphrase && $source ); + +my $attempts = 1; +my $vircon = Sys::Virt->new( uri => 'qemu:///system' ); +my @paths; +my @vmdks; + +sub extract { + print("Extracting ...\n"); + if ( !-e $source ) { + die "No OVA file at $source, aborting"; + } + my $tar = Archive::Tar->new(); + $tar->read($source); + $tar->extract(); +} + +sub convert { + print("Converting ...\n"); + while ( my $vmdk = glob("*.vmdk") ) { + system("qemu-img convert -p -fvmdk -Oraw $vmdk $vmdk.raw"); + push @vmdks, "$vmdk.raw"; + } +} + +sub check { + print("Validating target disks ...\n"); + foreach my $target ( split( ',', $mpaths ) ) { + my $path = "/dev/disk/by-id/dm-uuid-mpath-$target"; + if ( !-b $path ) { + print("Disk at $path is not valid.\n"); + next; + } + my $status = system("partx -rgoNR $path >/dev/null 2>&1"); + if ( $status != 0 || $force ) { + push @paths, $path; + } + else { + print("Disk at $path seems to contain an existing file system. Refusing destruction without --force.\n"); + } + } +} + +sub dd { + print("Writing ...\n"); + my $loop = 0; + foreach my $vmdk (@vmdks) { + system("dd if=$vmdk of=@paths[$loop] bs=16M status=progress"); + $loop++; + } +} + +sub vm { + my $reinit = 0; + my $xml; + my $fh; + + open( $fh, '<', $domain ) or die "Cannot open XML file $domain"; + { local $/; $xml = <$fh> }; + my $domain = $vircon->create_domain($xml); + my $domid = $domain->get_id(); + print("\nStarted domain with ID $domid"); + my $domvp = `virsh vncdisplay $domid`; + print(", VNC host is $domvp"); + my $console = Expect->spawn("virsh console $domid") + or die "Unable to spawn console"; + $console->restart_timeout_upon_receive(1); + + sub handle_boot { + my $prompt = 'VLOADER>'; + my @commands = ( + "set console=comconsole\n", + "set comconsole_speed=115200\n", + "boot\n", + ); + sub boot { + $console->expect(20, + [ + qr/Hit \[Enter\] to boot immediately, or any other key for command prompt\./ + ] + ); + $console->send( $_[0] ); + } + boot('x'); + foreach my $command (@commands) { + $console->expect( 10, $prompt ); + $console->send($command); + } + if ($reset) { + my %dialogue = ( + '* Press Ctrl-C for Boot Menu. *' => "\cC", + 'Selection (1-11)?' => "4\n", + 'Zero disks, reset config and install a new file system?' => "yes\n", + 'This will erase all the data on the disks, are you sure?' => "yes\n", + 'Rebooting to finish wipeconfig request.' => '', + ); + my $prompts = join( '|', map { qr{\Q$_\E} } keys %dialogue ); + $console->expect(60, + -re => $prompts, + sub { + my $ex = shift; + my $matched = $ex->match; + my $answer = delete $dialogue{$matched}; + $ex->send($answer); + exp_continue if keys %dialogue; + } + ); + boot("\n"); + } + $console->expect(480, + [ + qr/(?:System initialization has completed successfully\.|Welcome to the cluster setup wizard\.)/, + ], + [ + qr/DUMPCORE: START/, + sub { + print("System is broken, starting from scratch...\n"); + $reinit = 1; + } + ], + [ + timeout => sub { + print("System did not finish booting in time.\n"); + # what to do now ? + } + ] + ); + } + + sub handle_setup { + $console->expect(10, + [ + qr/Type yes to confirm and continue \{yes\}:/, + sub { + my $ex = shift; + $ex->send("exit\n"); + exp_continue; + } + ], + [ + qr/login:/, + sub { + my $ex = shift; + $ex->send("admin\n"); + exp_continue; + } + ], + ); + my $prompt = '::>'; + my @commands = ( + "network interface del -vserver Cluster -lif clus1\n", + "network port modify -node localhost -port e0a -ipspace Default\n", + "network interface create -vserver Default -lif mgmt -role node-mgmt -address $address -netmask $netmask -home-node localhost -home-port e0a\n", + "network route create -vserver Default -destination 0.0.0.0/0 -gateway $gateway\n", + "cluster setup\n", + ); + foreach my $command (@commands) { + $console->expect( 10, $prompt ); + $console->send($command); + } + my %dialogue = ( + "Press to page down, for next line, or 'q' to quit..." => 'q', + 'Type yes to confirm and continue {yes}:' => 'yes', + 'Enter the node management interface port' => 'e0a', + 'Enter the node management interface IP address [10.168.0.96]:' => '', + 'Enter the node management interface netmask [255.255.254.0]:' => '', + 'Enter the node management interface default gateway [10.168.1.254]:' => '', + 'Otherwise, press Enter to complete cluster setup using the command line' => '', + 'Do you want to create a new cluster or join an existing cluster? {create, join}:' => 'create', + 'Do you intend for this node to be used as a single node cluster? {yes, no} [no]:' => 'yes', + "Enter the cluster administrator's (username \"admin\") password:" => "$passphrase", + 'Retype the password:' => "$passphrase", + 'Enter the cluster name:' => "$cluster", + ); + $console->expect( 10, 'Welcome to the cluster setup wizard.' ); + my $prompts = join( '|', map { qr {\Q$_\E} } keys %dialogue ); + $console->expect(10, + -re => $prompts, + sub { + my $ex = shift; + my $matched = $ex->match; + my $answer = delete $dialogue{$matched}; + if ( $answer ne 'q' ) { $answer = $answer . "\n" } + $ex->send($answer); + sleep(1); + exp_continue if keys %dialogue; + } + ); + $console->expect(300, + [ + qr/Creating cluster $cluster/, + sub { + exp_continue; + } + ], + [ + qr/Starting cluster support services/ + ] + ); + $console->expect(10, + [ + qr/Enter an additional license key \[\]:/, + sub { + my $ex = shift; + $ex->send("\n"); + exp_continue; + } + ], + [ + qr/Enter the cluster management interface port/, + sub { + my $ex = shift; + $ex->send("exit\n"); + exp_continue; + } + ], + [ + qr/${cluster}::>/, + sub { + my $ex = shift; + $ex->send("version\n"); + } + ], + ); + } + + sub handle_reinit { + if ($reinit) { + $console->soft_close(); + $domain->destroy(); + if ( $attempts == 2 ) { + die("System is still broken after two attempts, giving up.\n"); + } + if ($only_vm) { + die("Unsetting --only_vm in an attempt to start from scratch!\n" + ); + $only_vm = 0; + } + $force = 1; + $attempts++; + run(); + } + } + + handle_boot(); + handle_reinit(); + handle_setup(); + $console->soft_close(); + handle_reinit(); #why here? +} + +sub run { + if ( !$only_vm ) { + check(); + my $good_paths = @paths; + if ( $good_paths < 4 ) { + die("Not enough writeable disks, aborting.\n"); + } + my $outdir = tempd(); + print("Working in temporary directory $outdir\n"); + extract(); + convert(); + dd(); + } + vm(); + print("\nDone, simulator should be reachable at $address.\n"); +} + +run(); diff --git a/netapp_ontap-formula/tests/README.md b/netapp_ontap-formula/tests/README.md new file mode 100644 index 00000000..c0f6591a --- /dev/null +++ b/netapp_ontap-formula/tests/README.md @@ -0,0 +1,18 @@ +# NetApp ONTAP formula test suite + +These tests are intended to be run against a Salt master with access to an ONTAP simulator. +The workstation the test suite is run from needs HTTPS access to the same simulator in order to validate the results. +Configure the simulator with a `pytest` user and role in the correct SVM: + +``` +::> security login rest-role create -vserver mysvm -role pytest -api /api/storage -access readonly +Warning: ... + +::> security login create -user-or-group-name pytest -application http -authentication-method password -role pytest -vserver mysvm +Please enter a password for user 'pytest': cats2023 +Please enter it again: cats2023 +``` + +Invocation: + +`pytest -v -rx --hosts user@master --ssh-config /dev/null netapp_ontap-formula/tests/` diff --git a/netapp_ontap-formula/tests/conftest.py b/netapp_ontap-formula/tests/conftest.py new file mode 100644 index 00000000..448ee2d2 --- /dev/null +++ b/netapp_ontap-formula/tests/conftest.py @@ -0,0 +1,59 @@ +from lib import api +import pytest + +@pytest.fixture +def target(): + # FIXME make this configurable + return '10.168.0.97' + + +@pytest.fixture +def lun(host, target): + r = host.salt('ontap.provision_lun', ['testlun', '10MB', 'myvol', 'mysvm', 'a lonely lun']) + assert r['result'] + assert r['status'] == 201 + r_prequery = api(target, 'storage/luns', {'fields': 'comment,space.size,svm.name'}) + records_pre = r_prequery['records'] + for entry, record in enumerate(records_pre): + if record['name'] == '/vol/myvol/testlun': + myrecord = records_pre[entry] + assert myrecord, 'Did not find any record matching the created LUN fixture name' + myuuid = myrecord['uuid'] + assert any(record['uuid'] == myuuid for record in records_pre), 'LUN fixture does not show in records' + + yield myrecord + + r_midquery = api(target, 'storage/luns', {'fields': 'status.mapped'}) + records_mid = r_midquery['records'] + for entry, record in enumerate(records_mid): + if record['name'] == '/vol/myvol/testlun': + myuuid = record['uuid'] + if record['status']['mapped']: + r = host.salt('ontap.unmap_lun', ['testlun', 'myvol', 'mysvm']) + # probably better to delete using a direct API call here in case there is a problem with delete_lun_uuid + r = host.salt('ontap.delete_lun_uuid', myuuid) + assert r['result'] + assert r['status'] == 200 + + r_finquery = api(target, 'storage/luns') + records_fin = r_finquery['records'] + assert not any(record['uuid'] == myuuid for record in records_fin), 'Failed to clean up LUN fixture' + + +@pytest.fixture +def lun_mapped(host, target, lun): + myuuid = lun['uuid'] + mypath = lun['name'] + myvolume, myname = mypath.split('/')[2:] + mysvm = lun['svm']['name'] + r = host.salt('ontap.map_lun', [myname, '900', myvolume, mysvm, 'myigroup']) + assert r['result'] + assert r['status'] == 201 + r_prequery = api(target, 'storage/luns', {'fields': 'comment,space.size,svm.name'}) + records_pre = r_prequery['records'] + for entry, record in enumerate(records_pre): + if record['name'] == '/vol/myvol/testlun': + myrecord = records_pre[entry] + assert myrecord, 'Did not find any record matching the created LUN fixture name' + + yield myrecord diff --git a/netapp_ontap-formula/tests/lib.py b/netapp_ontap-formula/tests/lib.py new file mode 100644 index 00000000..cdc1a66e --- /dev/null +++ b/netapp_ontap-formula/tests/lib.py @@ -0,0 +1,4 @@ +import requests + +def api(target, path, params={}): + return requests.get(url=f'https://{target}/api/{path}', params=params, verify=False, auth=requests.auth.HTTPBasicAuth('pytest', 'cats2023')).json() diff --git a/netapp_ontap-formula/tests/modules/test_120_lun.py b/netapp_ontap-formula/tests/modules/test_120_lun.py new file mode 100644 index 00000000..a8c6cd27 --- /dev/null +++ b/netapp_ontap-formula/tests/modules/test_120_lun.py @@ -0,0 +1,162 @@ +from lib import api +import json +import pytest + + +@pytest.mark.dependency(name='provision') +def test_lun_provision(host, target): + r = host.salt('ontap.provision_lun', ['provisiontestlun', '10MB', 'myvol', 'mysvm', 'a provisioned lun']) + assert r['result'] + assert r['status'] == 201 + result_rest = api(target, 'storage/luns', {'fields': 'comment,space.size'}) + records = result_rest['records'] + for entry, record in enumerate(records): + if record['name'] == '/vol/myvol/provisiontestlun': + myrecord = records[entry] + break + assert myrecord, 'Did not find any record matching the created LUN name' + assert myrecord['name'] == '/vol/myvol/provisiontestlun' + assert myrecord['comment'] == 'a provisioned lun' + assert myrecord['space']['size'] == 10485760 + + +@pytest.mark.dependency(depends=['provision']) +def test_lun_delete_name(host, target): + r = host.salt('ontap.delete_lun_name', ['provisiontestlun', 'myvol']) + assert r['result'] + assert r['status'] == 200 + result_rest = api(target, 'storage/luns', {'fields': 'comment,space.size'}) + records = result_rest['records'] + assert not '/vol/myvol/provisiontestlun' in records + + +def test_lun_delete_uuid(host, target, lun): + myuuid = lun['uuid'] + r = host.salt('ontap.delete_lun_uuid', myuuid) + assert r['result'] + assert r['status'] == 200 + result_rest = api(target, 'storage/luns') + records = result_rest['records'] + assert not any(record['uuid'] == myuuid for record in records) + + +def test_lun_resize(host, target, lun): + myuuid = lun['uuid'] + r = host.salt('ontap.update_lun', [myuuid, '20MB']) + assert r['result'] + assert r['status'] == 200 + result_rest = api(target, 'storage/luns', {'fields': 'space.size'}) + records = result_rest['records'] + for entry, record in enumerate(records): + if record['name'] == '/vol/myvol/testlun': + myrecord = records[entry] + break + assert myrecord, 'Did not find any record matching the created LUN name' + assert myrecord['space']['size'] == 20971520 + + +def test_lun_resize_reduce_fail(host, target, lun): + myuuid = lun['uuid'] + r = host.run_expect([1], f'salt-call --out=json ontap.update_lun {myuuid} 5MB') + rout = json.loads(r.stdout)['local'] + assert not rout['result'] + assert rout['status'] == 400 + assert rout['message'] == 'Reducing the size of a LUN might cause permanent data loss or corruption and is not supported by the LUN REST API. The ONTAP command "lun resize" can be used to reduce the LUN size.' + result_rest = api(target, 'storage/luns', {'fields': 'space.size'}) + records = result_rest['records'] + for entry, record in enumerate(records): + if record['name'] == '/vol/myvol/testlun': + myrecord = records[entry] + break + assert myrecord, 'Did not find any record matching the created LUN name' + assert myrecord['space']['size'] == 10485760 + + +@pytest.mark.parametrize("human", [True, False]) +def test_get_lun(host, target, lun, human): + mycomment = lun['comment'] + myuuid = lun['uuid'] + myname = lun['name'] + r = host.salt('ontap.get_lun', ['a lonely lun', f'human={human}']) + r0 = r[0] + assert len(r0.keys()) == 5 + assert r0['comment'] == mycomment + assert r0['name'] == myname + if human: + expsize = '10MB' + elif not human: + expsize = 10485760 + assert r0['space']['size'] == expsize + assert r0['status']['mapped'] is False + assert r0['uuid'] == myuuid + + +def test_map_lun(host, target, lun): + myuuid = lun['uuid'] + mypath = lun['name'] + myvolume, myname = mypath.split('/')[2:] + mysvm = lun['svm']['name'] + r = host.salt('ontap.map_lun', [myname, '900', myvolume, mysvm, 'myigroup']) + assert r['result'] + assert r['status'] == 201 + result_rest = api(target, 'protocols/san/lun-maps', {'lun.uuid': myuuid}) + records = result_rest['records'] + r0 = records[0] + assert r0['lun']['name'] == mypath + + +def test_get_lun_mapped(host, target, lun_mapped): + lun = lun_mapped + mypath = lun['name'] + mycomment = lun['comment'] + r = host.salt('ontap.get_lun_mapped', mycomment) + assert r[mypath] is True + + +def test_get_lun_mapping(host, target, lun_mapped): + lun = lun_mapped + mysvm = lun['svm']['name'] + mypath = lun['name'] + myvolume, myname = mypath.split('/')[2:] + r = host.salt('ontap.get_lun_mapping', [myname, myvolume, 'myigroup']) + assert r['lun']['name'] == mypath + assert r['svm']['name'] == mysvm + + +@pytest.mark.parametrize("igroup", [None, 'myigroup']) +def test_get_lun_mappings(host, target, lun_mapped, igroup): + lun = lun_mapped + mysvm = lun['svm']['name'] + mypath = lun['name'] + myvolume, myname = mypath.split('/')[2:] + params = [myname, myvolume] + if igroup is not None: + params.append(igroup) + r = host.salt('ontap.get_lun_mappings', params) + assert len(r) == 1 + r0 = r[0] + if igroup is not None: + assert r0['igroup']['name'] == igroup + assert r0['lun']['name'] == mypath + assert r0['svm']['name'] == mysvm + + +def test_unmap_lun(host, target, lun_mapped): + lun = lun_mapped + mypath = lun['name'] + myvolume, myname = mypath.split('/')[2:] + r = host.salt('ontap.unmap_lun', [myname, myvolume, 'myigroup']) + assert r['result'] + assert r['status'] == 200 + + +# to-do: test multiple unmappings +def test_unmap_luns(host, target, lun_mapped): + lun = lun_mapped + mypath = lun['name'] + myvolume, myname = mypath.split('/')[2:] + r = host.salt('ontap.unmap_luns', [myname, myvolume, 'myigroup']) + assert len(r) == 1 + r0 = r[0] + assert r0['result'] + assert r0['status'] == 200 diff --git a/netapp_ontap-formula/tests/states/test_220_lun.py b/netapp_ontap-formula/tests/states/test_220_lun.py new file mode 100644 index 00000000..1da979ce --- /dev/null +++ b/netapp_ontap-formula/tests/states/test_220_lun.py @@ -0,0 +1,103 @@ +import pytest + + +params_present = ['ontap.lun_present', 'name=testlun', 'comment="a present lun"', 'size=10MB', 'volume=myvol', 'vserver=mysvm'] +state_present = 'ontap_|-testlun_|-testlun_|-lun_present' +params_mapped = ['ontap.lun_mapped', 'name=testlun', 'lunid=900', 'volume=myvol', 'vserver=mysvm', 'igroup=myigroup'] +state_mapped = 'ontap_|-testlun_|-testlun_|-lun_mapped' +params_unmapped = ['ontap.lun_unmapped', 'name=testlun', 'volume=myvol', 'igroup=myigroup'] +state_unmapped = 'ontap_|-testlun_|-testlun_|-lun_unmapped' + + +@pytest.mark.dependency(name='present') +@pytest.mark.parametrize('test', [True, False]) +def test_lun_present(host, target, test): + r = host.salt('state.single', params_present + [f'test={test}']) + sr = r[state_present] + assert sr['name'] == '/vol/myvol/testlun' + if test: + comment = 'Would provision LUN' + result = None + elif not test: + comment = 'LUN /vol/myvol/testlun created with size 10MB.' + result = True + assert sr['comment'] == comment + assert sr['result'] is result + + +@pytest.mark.dependency(depends=['present'], name='present_already') +@pytest.mark.parametrize('test', [True, False]) +def test_lun_present_already(host, target, test): + r = host.salt('state.single', params_present + [f'test={test}']) + sr = r[state_present] + assert sr['name'] == '/vol/myvol/testlun' + assert sr['comment'] == 'LUN is already present; Size 10MB matches' + assert sr['result'] is True + + +@pytest.mark.dependency(depends=['present_already']) +@pytest.mark.parametrize('test', [True, False]) +def test_lun_present_resize(host, target, test): + r = host.salt('state.single', ['ontap.lun_present', 'name=testlun', 'comment="a present lun"', 'size=20MB', 'volume=myvol', 'vserver=mysvm', f'test={test}']) + sr = r[state_present] + assert sr['name'] == '/vol/myvol/testlun' + if test: + comment = 'LUN is already present; Would resize LUN to 20MB' + result = None + elif not test: + comment = 'LUN is already present; Successfully resized LUN from 10MB to 20MB' + result = True + assert sr['comment'] == comment + assert sr['result'] is result + + +@pytest.mark.dependency(depends=['present_already'], name='mapped') +@pytest.mark.parametrize('test', [True, False]) +def test_lun_mapped(host, target, test): + r = host.salt('state.single', params_mapped + [f'test={test}']) + sr = r[state_mapped] + assert sr['name'] == '/vol/myvol/testlun' + if test: + comment = 'Would map ID 900 to igroup myigroup in SVM mysvm' + result = None + elif not test: + comment = 'Mapped LUN to ID 900 in igroup myigroup' + result = True + assert sr['comment'] == comment + assert sr['result'] is result + + +@pytest.mark.dependency(depends=['mapped'], name='mapped_already') +@pytest.mark.parametrize('test', [True, False]) +def test_lun_mapped_already(host, target, test): + r = host.salt('state.single', params_mapped + [f'test={test}']) + sr = r[state_mapped] + assert sr['name'] == '/vol/myvol/testlun' + assert sr['comment'] == 'Already mapped to igroup myigroup in SVM mysvm' + assert sr['result'] is True + + +@pytest.mark.dependency(depends=['mapped'], name='unmapped') +@pytest.mark.parametrize('test', [True, False]) +def test_lun_unmapped(host, target, test): + r = host.salt('state.single', params_unmapped + [f'test={test}']) + sr = r[state_unmapped] + assert sr['name'] == '/vol/myvol/testlun' + if test: + comment = 'Would unmap LUN' + result = None + elif not test: + comment = 'Unmapped LUN' + result = True + assert sr['comment'] == comment + assert sr['result'] is result + + +@pytest.mark.dependency(depends=['unmapped'], name='unmapped_already') +@pytest.mark.parametrize('test', [True, False]) +def test_lun_unmapped_already(host, target, test): + r = host.salt('state.single', params_unmapped + [f'test={test}']) + sr = r[state_unmapped] + assert sr['name'] == '/vol/myvol/testlun' + assert sr['comment'] == 'Nothing to unmap' + assert sr['result'] is True