Last active
September 17, 2025 17:41
-
-
Save pclose/ad231e700e167c5c9ed56b0aa8b9673c to your computer and use it in GitHub Desktop.
Wrapper for SWIS API -pete 2020-07-06
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/python3 | |
| ''' | |
| usage: swsh.py [-h] [--login LOGIN] [--password PASSWORD] [--server SERVER] | |
| [--verify VERIFY] [--output OUTPUT] [--input INPUT] | |
| [--customprop [CUSTOMPROP [CUSTOMPROP ...]]] | |
| [--pollers [POLLERS [POLLERS ...]]] | |
| [--node-settings [NODE_SETTINGS [NODE_SETTINGS ...]]] [--debug] | |
| [--log [LOG]] [--config [CONFIG]] [--generate-config] | |
| [--col-order COL_ORDER] | |
| {query,add,remove} [parameters [parameters ...]] | |
| Wrapper for SWIS API -pete 2020-07-06 | |
| positional arguments: | |
| {query,add,remove} | |
| parameters Values to use SWQL statement or "key1 value1, ... , | |
| keyN valueN" node property pairs | |
| optional arguments: | |
| -h, --help show this help message and exit | |
| --login LOGIN, -l LOGIN | |
| username | |
| --password PASSWORD, -p PASSWORD | |
| password | |
| --server SERVER, -s SERVER | |
| Solarwinds API endpoint. hostname[:port] | |
| --verify VERIFY Filepath to verify HTTPS connection with | |
| --output OUTPUT, -o OUTPUT | |
| Output file, defaults to stdout | |
| --input INPUT, -i INPUT | |
| Input file, defaults to stdin | |
| --customprop [CUSTOMPROP [CUSTOMPROP ...]], -c [CUSTOMPROP [CUSTOMPROP ...]] | |
| Custom property key value pairs to be set on deployed | |
| node | |
| --pollers [POLLERS [POLLERS ...]], -pl [POLLERS [POLLERS ...]] | |
| Additional pollers to enable on node | |
| --node-settings [NODE_SETTINGS [NODE_SETTINGS ...]] | |
| Additional node setting key value pairs to add to the | |
| node | |
| --debug Increase logging to stdout | |
| --log [LOG] Write results to a log file. Defaults to ./swsh.py.log | |
| --config [CONFIG] Without setting this the program will attempt to read | |
| from swsh.py.conf | |
| --generate-config Write config file. By default this will write to | |
| swsh.py.conf unless the --config option is present | |
| --col-order COL_ORDER | |
| Order of SWQL columns to put in CSV. One argument with | |
| comma separated strings | |
| Example usages: | |
| 1) Deploy a node | |
| `python swsh.py add ipaddress 10.10.10.1 caption example.net` | |
| 2) Remove a node | |
| `python swsh.py remove ipaddress 10.10.10.1` | |
| 3) Save SWQL output to CSV | |
| `python swsh.py --output Mynodes.csv query "select caption, ipaddress from orion.nodes where ipaddress = @arg1" arg1 10.10.10.1` | |
| 4) Generate a config file with server connection settings | |
| `python swsh.py --generate-config --server solarwinds.net:443 --login user` | |
| Without passing --config the default location is automatically used on subsequent requests | |
| 5) Generate a config file with some default settings for adding nodes | |
| `python swsh.py--generate-config --config add_settings.conf \ | |
| --server solarwinds.net:443 \ | |
| --login user --password pass \ | |
| add \ | |
| EntityType Orion.Nodes \ | |
| MachineType ICMP \ | |
| PollInterval 120 \ | |
| StatCollection 10 \ | |
| RediscoveryInterval 30 \ | |
| --customprop Environment Production \ | |
| --pollers N.Status.ICMP.Native N.ResponseTime.ICMP.Native | |
| 5a) So subsequent calls using this config file will pass the default parameters in addition | |
| eg. `python swsh.py --config add_settings.conf add caption example2.net ipaddress 10.10.10.2` | |
| ''' | |
| import orionsdk | |
| from requests.exceptions import HTTPError | |
| import csv, sys, re, logging, os, json | |
| # Logging constants | |
| LOG_FORMAT = "%(asctime)s-%(levelname)s: %(message)s" | |
| LOG_DATE_FMT= "%Y-%m-%d %H:%M:%S" | |
| LOG_LEVEL=logging.INFO | |
| LOG_PATH=os.path.basename(sys.argv[0])+".log" | |
| # Returns a dict for ["key", "value"] list | |
| def prop_parms(d): | |
| if not d: return | |
| if len(d)%2!=0: raise ValueError("Parameters must be a space separated list of \"key\" \"values\"") | |
| i,j,r = 0,len(d),{} | |
| while i<j: | |
| r[d[i]]=d[i+1] | |
| i=i+2 | |
| return r | |
| # Return a poller dict | |
| def proc_pollerlist(pol,nodeid): | |
| r=[] | |
| for e in pol: | |
| r.append({ | |
| 'pollertype': e, | |
| 'netobject': 'N:' + nodeid, | |
| 'netobjecttype': 'N', | |
| 'netobjectid': nodeid, | |
| 'enabled': True | |
| }) | |
| return r | |
| # Node settings definitions | |
| def proc_settings(settings,nodeid=None): | |
| r=[] | |
| for e in settings: | |
| r.append({ | |
| "settingname":e, | |
| "settingvalue":settings[e]}) | |
| if nodeid: r[-1]["nodeid"]=nodeid | |
| return r | |
| # Read from the config file generated from generate_config() | |
| # args passed in will override the config file | |
| def parse_config(filen, args): | |
| if not os.access(filen, os.R_OK): return args | |
| from configparser import ConfigParser | |
| config = ConfigParser() | |
| config.read(filen) | |
| d = vars(args) | |
| operation = d["operation"] | |
| for e in config["main"]: | |
| if args.__dict__[e] is not None: continue | |
| args.__dict__[e]=config["main"][e] | |
| for s in config.sections(): | |
| if s == "main": continue | |
| op,sec = s.split(".") | |
| if operation != op: continue | |
| if sec=="pollers": # Special parsing for pollers | |
| args.__dict__[sec]=config[s]["values"].split(",") | |
| continue | |
| subj = args.__dict__[sec] | |
| args.__dict__[sec]={} | |
| for e in config[s]: | |
| args.__dict__[sec][e]=config[s][e] | |
| if subj and len(subj)>0: | |
| for k,v in prop_parms(subj).items(): | |
| args.__dict__[sec][k]=v | |
| return args | |
| # Write a config file | |
| def generate_config(filen, args): | |
| from configparser import ConfigParser | |
| config = ConfigParser() | |
| config.optionxform=lambda x:x # This stops the capitalization of the parms | |
| argd = vars(args) # Turns the Namespace into a dict essentially | |
| operation=argd["operation"] | |
| config["main"]={} | |
| for e in argd: | |
| if argd[e] and isinstance(argd[e],list): # Create a section in the config file when we hit a list | |
| secname = ".".join([operation,e]) | |
| config[secname]={} | |
| if e == "pollers": # Pollers are a list, so they are special | |
| config[secname]["values"]=",".join(argd[e]) | |
| continue | |
| l=argd[e] | |
| i,j = 0,len(l) | |
| while i<j: # This is a modified prop_parms() | |
| if i+1>=len(l): | |
| config[secname][l[i]]="" | |
| i=i+2 | |
| else: | |
| config[secname][l[i]]=l[i+1] | |
| i=i+2 | |
| elif argd[e]: # Passing None in here will cause problems later | |
| if e in ["config", "generate_config", "operation"]: continue # Ignore options that generate this file essentially | |
| config["main"][e]=str(argd[e]) | |
| with open(filen, "w") as f: config.write(f) | |
| class swsh(orionsdk.SwisClient): | |
| def __init__(self, hostname, username, password, port="17778", verify=False, log=None, debug=False): | |
| super().__init__(hostname, username, password, verify) | |
| if ":17778" in self.url: self.url=self.url.replace(":17778",":"+port) # Override hardcoded value in orionsdk | |
| self.log = logging.getLogger("swsh_logger") | |
| formatter = logging.Formatter(fmt=LOG_FORMAT, datefmt=LOG_DATE_FMT) | |
| streamhandler = logging.StreamHandler(sys.stdout) | |
| streamhandler.setFormatter(formatter) | |
| self.log.addHandler(streamhandler) | |
| self.log.setLevel(LOG_LEVEL) | |
| if log: | |
| filehandler = logging.FileHandler(log) | |
| filehandler.setFormatter(formatter) | |
| filehandler.setLevel(LOG_LEVEL) | |
| self.log.addHandler(filehandler) | |
| if debug: | |
| self.log.setLevel(logging.DEBUG) | |
| # Override orionsdk method to include logging | |
| def _req(self, method, frag, data=None): | |
| try: | |
| resp = super()._req(method, frag, data) | |
| except HTTPError as err: | |
| self.log.error("{} {} {} {}".format(err.response.status_code, method, frag, repr(data))) | |
| raise err | |
| self.log.debug("request: {} {} {}, {}".format(resp.status_code, method, frag, repr(data))) | |
| self.log.debug("response: {}".format(resp.text)) | |
| return resp | |
| # Runs a SWQL statement and saves it in a CSV | |
| def sh_query(self, query, output_fileh, col_order=None): | |
| if len(query)>1: | |
| qparms=prop_parms(query[1:]) | |
| qr=self.query(query[0], **qparms) | |
| else: | |
| qr = self.query(query[0]) | |
| if len(qr["results"])<1: return | |
| o = csv.writer(output_fileh) | |
| if col_order: | |
| keys=[x.strip() for x in col_order.split(",")] | |
| else: | |
| keys = qr["results"][0].keys() | |
| o.writerow(keys) | |
| for i in qr["results"]: | |
| o.writerow([i.get(x) for x in keys]) | |
| # Add a node | |
| def sh_add(self, node_prop, custom_prop=[], pollers=[], node_settings=[]): | |
| '''This will add or update a node. Unfortunately the API will let you create nodes with the same properties over and over, so this has some logic to try and prevent that''' | |
| nprop,cprop,npol,nset = {},{},[],{} | |
| # Override or append properties | |
| if pollers: npol+=pollers | |
| if node_prop: | |
| if isinstance(node_prop,list): t = prop_parms(node_prop) | |
| elif isinstance(node_prop,dict): t = node_prop | |
| for e in t: nprop[e.lower()]=t[e] | |
| if custom_prop: | |
| if isinstance(custom_prop,list): t = prop_parms(custom_prop) | |
| elif isinstance(custom_prop,dict): t = custom_prop | |
| for e in t: cprop[e.lower()]=t[e] | |
| if node_settings: | |
| if isinstance(node_settings,list): t = prop_parms(node_settings) | |
| elif isinstance(node_settings,dict): t = node_settings | |
| for e in t: nset[e.lower()]=t[e] | |
| # Check for existing nodes | |
| ex_nodes = [] | |
| if "caption" in nprop: | |
| t = self.search_node_uri(nprop["caption"]) | |
| if t: ex_nodes.append(t) | |
| if "ipaddress" in nprop: | |
| t = self.search_node_uri(nprop["ipaddress"]) | |
| if t: ex_nodes.append(t) | |
| ex_nodes = list(set(ex_nodes)) | |
| # TODO: Other properties to consider here: DisplayName, DNS, NodeName, SystemName | |
| did_we_update=False | |
| # No nodes exist, create one | |
| if len(ex_nodes)<1: | |
| # Create node and save the NodeID value | |
| nodeuri = self.create("Orion.Nodes", **nprop) | |
| # A single node already exists, prepare to update its properties | |
| elif len(ex_nodes)==1: | |
| did_we_update=True | |
| nodeuri = ex_nodes[0] | |
| nodeid = re.search(r'(\d+)$', nodeuri).group(0) | |
| # Filter out or enable pollers that already exist on the node | |
| basepoller_uri=nodeuri.split("Orion.Nodes")[0]+"Orion.Pollers/PollerID={}" | |
| qr = self.query("select pollerid,pollertype,enabled from orion.pollers where netobject = @netobjid", netobjid="N:"+nodeid) | |
| for e in qr["results"]: | |
| if e["pollertype"] in npol: # Consider pollers that are going to be added later | |
| if e["enabled"]: | |
| npol.remove(e["pollertype"]) # Remove from the list if it's enabled | |
| else: # Update it if it is not enabled | |
| npol.remove(e["pollertype"]) # Actually not sure if this is a valid condition without the API footgun | |
| self.update(basepoller_uri.format(e["pollerid"]), enabled=True) | |
| # Same for node settings | |
| baseproperty_uri=nodeuri.split("Orion.Nodes")[0]+"Orion.NodeSettings/NodeSettingID={}" | |
| qr = self.query("select settingvalue,nodeid,nodesettingid,settingname from orion.nodesettings where nodeid = @nodeid", nodeid=nodeid) | |
| for e in qr["results"]: | |
| if e["settingname"] in nset and nset[e["settingname"]]!=e["settingvalue"]: | |
| t={} | |
| t[e["settingname"]]=nset[e["settingname"]] | |
| t=proc_settings(t)[0] | |
| self.update(baseproperty_uri.format(e["nodesettingid"]), **t) | |
| del nset[e["settingname"]] | |
| elif len(ex_nodes)>1: | |
| self.log.info("Multiple nodes already exist with this criteria, giving up {}".format(json.dumps(ex_nodes))) | |
| return | |
| nodeid = re.search(r'(\d+)$', nodeuri).group(0) | |
| # Generate poller properties and add them to node | |
| for e in proc_pollerlist(npol, nodeid): | |
| self.create('Orion.Pollers', **e) | |
| # Add NodeSettings | |
| for e in proc_settings(nset, nodeid): | |
| self.create('Orion.NodeSettings', **e) | |
| # Update custom properties | |
| if cprop!={}: self.update(nodeuri + "/CustomProperties", **cprop) | |
| if did_we_update: self.log.info("Done updating node {} {}".format(nprop["caption"], nodeuri)) | |
| else: self.log.info("Done adding node {} {}".format(nprop["caption"], nodeuri)) | |
| def search_node_uri(self, node, criteria=None): | |
| if criteria: | |
| nquery="SELECT Uri FROM Orion.Nodes WHERE {} = @arg1".format(criteria) | |
| elif re.match("^\d+(\.\d+){3}", node): | |
| nquery="SELECT IPAddress, Caption, Uri FROM Orion.Nodes WHERE IPAddress = @arg1" | |
| else: nquery="SELECT IPAddress, Caption, Uri FROM Orion.Nodes WHERE Caption = @arg1" | |
| qr = self.query(nquery, arg1=node) | |
| if len(qr["results"])==1: | |
| return qr["results"][0]["Uri"] | |
| elif len(qr["results"])>1: | |
| self.log.info("Multiple nodes found {}, giving up".format(json.dumps(qr["results"]))) | |
| raise Exception("Multiple nodes found") | |
| else: | |
| return | |
| def sh_remove(self, node): | |
| nuri=None | |
| if isinstance(node, list): node = node[0] | |
| if isinstance(node, dict): | |
| for e in node: | |
| nuri = self.search_node_uri(node[e], e) | |
| if nuri: break | |
| elif node.startswith("swis://"): nuri=node | |
| else: | |
| nuri = self.search_node_uri(node) | |
| if nuri: | |
| self.delete(nuri) | |
| self.log.info("Done removing node {} {}".format(node, nuri)) | |
| else: | |
| self.log.info("No node found matching criteria") | |
| if __name__ == "__main__": | |
| usage_examples = ''' | |
| Example usages: | |
| 1) Deploy a node | |
| `python %(prog)s add ipaddress 10.10.10.1 caption example.net` | |
| 2) Remove a node | |
| `python %(prog)s remove ipaddress 10.10.10.1` | |
| 3) Save SWQL output to CSV | |
| `python %(prog)s --output Mynodes.csv query "select caption, ipaddress from orion.nodes where ipaddress = @arg1" arg1 10.10.10.1` | |
| 4) Generate a config file with server connection settings | |
| `python %(prog)s --generate-config --server solarwinds.net:443 --login user` | |
| Without passing --config the default location is automatically used on subsequent requests | |
| 5) Generate a config file with some default settings for adding nodes | |
| `python %(prog)s --generate-config --config add_settings.conf \\ | |
| --server solarwinds.net:443 \\ | |
| --login user --password pass \\ | |
| add \\ | |
| EntityType Orion.Nodes \\ | |
| MachineType ICMP \\ | |
| PollInterval 120 \\ | |
| StatCollection 10 \\ | |
| RediscoveryInterval 30 \\ | |
| --customprop Environment Production \\ | |
| --pollers N.Status.ICMP.Native N.ResponseTime.ICMP.Native | |
| 5a) So subsequent calls using this config file will pass the default parameters in addition | |
| eg. `python %(prog)s --config add_settings.conf add caption example2.net ipaddress 10.10.10.2` | |
| ''' | |
| import argparse | |
| parser = argparse.ArgumentParser(prog="swsh.py", description='Wrapper for SWIS API -pete 2020-07-06', epilog=usage_examples, formatter_class=argparse.RawDescriptionHelpFormatter) | |
| parser.add_argument('operation', choices=["query","add","remove"]) | |
| parser.add_argument('parameters', nargs="*", help="Values to use SWQL statement or \"key1 value1, ... , keyN valueN\" node property pairs") | |
| parser.add_argument('--login', '-l', default=None, help="username") | |
| parser.add_argument('--password', '-p', default=None, help="password") | |
| parser.add_argument('--server', '-s', default="localhost", help="Solarwinds API endpoint. hostname[:port]") | |
| parser.add_argument('--verify', help="Filepath to verify HTTPS connection with") | |
| parser.add_argument("--output", "-o", help="Output file, defaults to stdout") | |
| parser.add_argument("--input", "-i", help="Input file, defaults to stdin") | |
| parser.add_argument("--customprop", "-c", nargs="*", help="Custom property key value pairs to be set on deployed node") | |
| parser.add_argument("--pollers", "-pl", nargs="*", help="Additional pollers to enable on node") | |
| parser.add_argument("--node-settings", nargs="*", help="Additional node setting key value pairs to add to the node") | |
| parser.add_argument("--debug", action="store_true", help="Increase logging to stdout") | |
| parser.add_argument("--log", nargs="?", const=LOG_PATH, help="Write results to a log file. Defaults to ./{}".format(LOG_PATH)) | |
| parser.add_argument("--config", nargs="?", default=os.path.basename(sys.argv[0])+".conf", help="Without setting this the program will attempt to read from {}".format(os.path.basename(sys.argv[0])+".conf")) | |
| parser.add_argument("--generate-config",action="store_true", help="Write config file. By default this will write to {} unless the --config option is present".format(os.path.basename(sys.argv[0])+".conf")) | |
| parser.add_argument("--col-order", help="Order of SWQL columns to put in CSV. One argument with comma separated strings") # This is not ideal, but neither is the API ¯\_(ツ)_/¯ | |
| args=parser.parse_args() | |
| if args.generate_config: | |
| generate_config(args.config, args) | |
| sys.exit(0) | |
| args = parse_config(args.config, args) | |
| if args.password == None: | |
| import getpass | |
| args.password = getpass.getpass('Password:') | |
| port = "17778" | |
| server = args.server | |
| if ":" in args.server: | |
| port = args.server.split(":")[1] | |
| server = args.server.split(":")[0] | |
| swsh_client = swsh(server, args.login, args.password, port, args.verify, args.log, args.debug) | |
| input_fileh, output_fileh = None, None | |
| if args.input == "-": input_fileh = sys.stdin | |
| elif args.input: input_fileh = open(args.input, "rb") | |
| if args.output == "-": output_fileh = sys.stdout | |
| elif args.output: output_fileh = open(args.output, "w") | |
| if args.operation=="add": | |
| swsh_client.sh_add(args.parameters, args.customprop, args.pollers, args.node_settings) | |
| elif args.operation=="remove": | |
| swsh_client.sh_remove(args.parameters) | |
| elif args.operation=="query": | |
| if input_fileh: | |
| swql=[" ".join(input_fileh.readlines())] | |
| swql+=args.parameters | |
| elif args.parameters: swql=args.parameters | |
| else: | |
| print("SQL query needs to be provided") | |
| parser.print_help() | |
| sys.exit(1) | |
| if not output_fileh: output_fileh = sys.stdout | |
| swsh_client.sh_query(swql, output_fileh, args.col_order) | |
| else: | |
| parser.print_help() | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment