-
Notifications
You must be signed in to change notification settings - Fork 57
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[DC] Meraki Device Connector #290
Changes from 44 commits
c001f5e
1d8a45b
b0e4801
b30174b
24f0664
92b01f0
179325b
f509053
d777a21
706624a
81f4a11
327ae3f
fd457c3
fcc2520
2a8c475
bb027a5
d0eb88a
a881d9b
e413652
d7bb036
47859d9
db03993
e7487e1
b154502
d39979e
f78dc8a
4bf02cb
a0f99f4
409b2d0
f7d259d
801d6cc
64276bc
54ef870
279338c
fe2fb8f
e7be0aa
f903a47
a9f25dd
dc91f32
eef66e7
09bffa6
26d1589
2ff4139
6260259
9d8b261
af7c6c4
a386650
c020007
75e301e
a8f2f38
7ae202a
a0fb70c
c09881b
383b997
b466ccc
b147f05
158ccd8
78acd49
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,181 @@ | ||
"""Meraki | ||
Collect Meraki Device information using an API Token | ||
""" | ||
|
||
from runners.helpers import db, log | ||
from runners.helpers.dbconfig import ROLE as SA_ROLE | ||
|
||
from datetime import datetime | ||
|
||
import snowflake | ||
import requests | ||
from urllib.error import HTTPError | ||
from .utils import yaml_dump | ||
|
||
PAGE_SIZE = 5 | ||
|
||
|
||
CONNECTION_OPTIONS = [ | ||
{ | ||
'name': 'api_token', | ||
'title': "Meraki API Token", | ||
'prompt': "Your Meraki API Token", | ||
'type': 'str', | ||
'secret': True, | ||
'required': True, | ||
}, | ||
{ | ||
'name': 'network_id_whitelist', | ||
'title': "Meraki Network Ids Whitelist", | ||
'prompt': "Whitelist of Network Ids", | ||
'type': 'list', | ||
'secret': True, | ||
'required': True, | ||
}, | ||
] | ||
|
||
LANDING_TABLE_COLUMNS_CLIENT = [ | ||
('INSERT_ID', 'NUMBER IDENTITY START 1 INCREMENT 1'), | ||
('SNAPSHOT_AT', 'TIMESTAMP_LTZ(9)'), | ||
('RAW', 'VARIANT'), | ||
('ID', 'VARCHAR(256)'), | ||
('MAC', 'VARCHAR(256)'), | ||
('DESCRIPTION', 'VARCHAR(256)'), | ||
('MDNS_NAME', 'VARCHAR(256)'), | ||
('DHCP_HOSTNAME', 'VARCHAR(256)'), | ||
('IP', 'VARCHAR(256)'), | ||
('SWITCHPORT', 'VARCHAR(256)'), | ||
('VLAN', 'INT'), | ||
('USAGE_SENT', 'INT'), | ||
('USAGE_RECV', 'INT'), | ||
('SERIAL', 'VARCHAR(256)'), | ||
] | ||
|
||
LANDING_TABLE_COLUMNS_DEVICE = [ | ||
('INSERT_ID', 'NUMBER IDENTITY START 1 INCREMENT 1'), | ||
('SNAPSHOT_AT', 'TIMESTAMP_LTZ(9)'), | ||
('RAW', 'VARIANT'), | ||
('SERIAL', 'VARCHAR(256)'), | ||
('ADDRESS', 'VARCHAR(256)'), | ||
('NAME', 'VARCHAR(256)'), | ||
('NETWORK_ID', 'VARCHAR(256)'), | ||
('MODEL', 'VARCHAR(256)'), | ||
('MAC', 'VARCHAR(256)'), | ||
('LAN_IP', 'VARCHAR(256)'), | ||
('WAN_1_IP', 'VARCHAR(256)'), | ||
('WAN_2_IP', 'VARCHAR(256)'), | ||
('TAGS', 'VARCHAR(256)'), | ||
('LNG', 'FLOAT'), | ||
('LAT', 'FLOAT'), | ||
] | ||
|
||
|
||
def get_data(url: str, token: str, params: dict = {}) -> dict: | ||
headers: dict = { | ||
"Content-Type": "application/json", | ||
"Accept": "application/json", | ||
"X-Cisco-Meraki-API-Key": f"{token}", | ||
} | ||
try: | ||
log.debug(f"Preparing GET: url={url} with params={params}") | ||
req = requests.get(url, params=params, headers=headers) | ||
req.raise_for_status() | ||
except HTTPError as http_err: | ||
log.error(f"Error GET: url={url}") | ||
log.error(f"HTTP error occurred: {http_err}") | ||
raise http_err | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. re-raise with just |
||
log.debug(req.status_code) | ||
return req.json() | ||
|
||
|
||
def connect(connection_name, options): | ||
landing_table_client = f'data.meraki_{connection_name}_connection_client' | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The table names have to end in _CONNECTION or they're not considered a connection table (i.e. the connection runner won't pick them up and call the ingest method on the module for the table). So you'd want these to be |
||
landing_table_device = f'data.meraki_{connection_name}_connection_device' | ||
options['network_id_whitelist'] = options.get('network_id_whitelist', '').split(',') | ||
|
||
comment = yaml_dump(module='meraki', **options) | ||
|
||
db.create_table(name=landing_table_client, | ||
cols=LANDING_TABLE_COLUMNS_CLIENT, comment=comment) | ||
db.execute(f'GRANT INSERT, SELECT ON {landing_table_client} TO ROLE {SA_ROLE}') | ||
db.create_table(name=landing_table_device, | ||
cols=LANDING_TABLE_COLUMNS_DEVICE, comment=comment) | ||
db.execute(f'GRANT INSERT, SELECT ON {landing_table_device} TO ROLE {SA_ROLE}') | ||
return { | ||
'newStage': 'finalized', | ||
'newMessage': "Meraki ingestion tables created!", | ||
} | ||
|
||
|
||
def parse_number(value): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This function seems misnamed; I can pass any non-numerical value and get that value back. It seems like all this does is assert that a value is not None, but we have the value and we can check that ourselves anyway; we don't need to wrap that in a function. What problem are we trying to solve with this function, or what question are we trying to answer about the value we pass in? |
||
if value: | ||
return value | ||
return None | ||
|
||
|
||
def ingest(table_name, options): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So your connect method creates two tables, both of which I think you want to be connection tables. But the ingest method populates both of them every time it gets called, which means that you're going to be duplicating data in your tables; you'll populate both tables when the connection runner finds the client_connection table, and then you'll populate both tables again a second later when the connection runner finds the device_connection table. For each call to the ingest method, it should populate one table: the table which ingest is called for. |
||
landing_table_client = f'data.{table_name_client}' | ||
landing_table_device = f'data.{landing_table_device}' | ||
timestamp = datetime.utcnow() | ||
api_key = options['api_key'] | ||
whitelist = options['network_id_whitelist'] | ||
|
||
for network in whitelist: | ||
|
||
try: | ||
devices = get_data(f"https://api.meraki.com/api/v0/networks/{network}/devices", api_key) | ||
except requests.exceptions.HTTPError as e: | ||
log.error(f"{network} not accessible, ") | ||
log.error(e) | ||
continue | ||
|
||
for device in devices: | ||
serial_number = device['serial'] | ||
clients = get_data(f"https://api.meraki.com/api/v0/devices/{serial_number}/clients", api_key) | ||
|
||
db.insert( | ||
landing_table_client, | ||
values=[( | ||
timestamp, | ||
client, | ||
client.get('id','None'), | ||
client.get('mac','None'), | ||
client.get('description','None'), | ||
client.get('mdnsName','None'), | ||
client.get('dhcpHostname','None'), | ||
client.get('ip','None'), | ||
parse_number(client.get('vlan','None')), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can't this just be There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The API is returning empty strings which then error out when trying to parse as a number There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm, okay. I'll think about this a bit; I suspect there's a better way to handle this. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. not sure what the client value is, but agreed this looks buggy. right now it does:
are you sure this is what you want? |
||
client.get('switchport','None'), | ||
parse_number(client.get('usage','None').get('sent','None')), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You can't |
||
parse_number(client.get('usage','None').get('recv','None')), | ||
serial_number, | ||
) for client in clients], | ||
select=db.derive_insert_select(LANDING_TABLE_COLUMNS_CLIENT), | ||
columns=db.derive_insert_columns(LANDING_TABLE_COLUMNS_CLIENT) | ||
) | ||
log.info(f'Inserted {len(clients)} rows (clients).') | ||
yield len(clients) | ||
|
||
db.insert( | ||
landing_table_device, | ||
values=[( | ||
timestamp, | ||
device, | ||
device.get('serial','None'), | ||
device.get('address','None'), | ||
device.get('name','None'), | ||
device.get('networkId','None'), | ||
device.get('model','None'), | ||
device.get('mac','None'), | ||
device.get('lanIp','None'), | ||
device.get('wan1Ip','None'), | ||
device.get('wan2Ip','None'), | ||
device.get('tags','None'), | ||
device.get('lng','None'), | ||
device.get('lat','None'), | ||
) for device in devices], | ||
select=db.derive_insert_select(LANDING_TABLE_COLUMNS_DEVICE), | ||
columns=db.derive_insert_columns(LANDING_TABLE_COLUMNS_DEVICE) | ||
) | ||
log.info(f'Inserted {len(devices)} rows (devices).') | ||
yield len(devices) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's make this name a little more descriptive -- there are a lot of things we could potentially collect from Meraki, and the titles should be distinct.