merged cleanup branch with master
This commit is contained in:
parent
a4f47a17e3
commit
0a91fe7a3e
28 changed files with 9033 additions and 0 deletions
449
daemon/core/misc/ipaddress.py
Normal file
449
daemon/core/misc/ipaddress.py
Normal file
|
@ -0,0 +1,449 @@
|
|||
"""
|
||||
Helper objects for dealing with IPv4/v6 addresses.
|
||||
"""
|
||||
|
||||
import random
|
||||
import socket
|
||||
import struct
|
||||
from socket import AF_INET
|
||||
from socket import AF_INET6
|
||||
|
||||
from core.misc import log
|
||||
|
||||
logger = log.get_logger(__name__)
|
||||
|
||||
|
||||
class MacAddress(object):
|
||||
"""
|
||||
Provides mac address utilities for use within core.
|
||||
"""
|
||||
|
||||
def __init__(self, address):
|
||||
"""
|
||||
Creates a MacAddress instance.
|
||||
|
||||
:param str address: mac address
|
||||
"""
|
||||
self.addr = address
|
||||
|
||||
def __str__(self):
|
||||
"""
|
||||
Create a string representation of a MacAddress.
|
||||
|
||||
:return: string representation
|
||||
:rtype: str
|
||||
"""
|
||||
return ":".join(map(lambda x: "%02x" % ord(x), self.addr))
|
||||
|
||||
def to_link_local(self):
|
||||
"""
|
||||
Convert the MAC address to a IPv6 link-local address, using EUI 48
|
||||
to EUI 64 conversion process per RFC 5342.
|
||||
|
||||
:return: ip address object
|
||||
:rtype: IpAddress
|
||||
"""
|
||||
if not self.addr:
|
||||
return IpAddress.from_string("::")
|
||||
tmp = struct.unpack("!Q", '\x00\x00' + self.addr)[0]
|
||||
nic = long(tmp) & 0x000000FFFFFFL
|
||||
oui = long(tmp) & 0xFFFFFF000000L
|
||||
# toggle U/L bit
|
||||
oui ^= 0x020000000000L
|
||||
# append EUI-48 octets
|
||||
oui = (oui << 16) | 0xFFFE000000L
|
||||
return IpAddress(AF_INET6, struct.pack("!QQ", 0xfe80 << 48, oui | nic))
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, s):
|
||||
"""
|
||||
Create a mac address object from a string.
|
||||
|
||||
:param s: string representation of a mac address
|
||||
:return: mac address class
|
||||
:rtype: MacAddress
|
||||
"""
|
||||
addr = "".join(map(lambda x: chr(int(x, 16)), s.split(":")))
|
||||
return cls(addr)
|
||||
|
||||
@classmethod
|
||||
def random(cls):
|
||||
"""
|
||||
Create a random mac address.
|
||||
|
||||
:return: random mac address
|
||||
:rtype: MacAddress
|
||||
"""
|
||||
tmp = random.randint(0, 0xFFFFFF)
|
||||
# use the Xen OID 00:16:3E
|
||||
tmp |= 0x00163E << 24
|
||||
tmpbytes = struct.pack("!Q", tmp)
|
||||
return cls(tmpbytes[2:])
|
||||
|
||||
|
||||
class IpAddress(object):
|
||||
"""
|
||||
Provides ip utilities and functionality for use within core.
|
||||
"""
|
||||
|
||||
def __init__(self, af, address):
|
||||
"""
|
||||
Create a IpAddress instance.
|
||||
|
||||
:param int af: address family
|
||||
:param str address: ip address
|
||||
:return:
|
||||
"""
|
||||
# check if (af, addr) is valid
|
||||
if not socket.inet_ntop(af, address):
|
||||
raise ValueError("invalid af/addr")
|
||||
self.af = af
|
||||
self.addr = address
|
||||
|
||||
def is_ipv4(self):
|
||||
"""
|
||||
Checks if this is an ipv4 address.
|
||||
|
||||
:return: True if ipv4 address, False otherwise
|
||||
:rtype: bool
|
||||
"""
|
||||
return self.af == AF_INET
|
||||
|
||||
def is_ipv6(self):
|
||||
"""
|
||||
Checks if this is an ipv6 address.
|
||||
|
||||
:return: True if ipv6 address, False otherwise
|
||||
:rtype: bool
|
||||
"""
|
||||
return self.af == AF_INET6
|
||||
|
||||
def __str__(self):
|
||||
"""
|
||||
Create a string representation of this address.
|
||||
|
||||
:return: string representation of address
|
||||
:rtype: str
|
||||
"""
|
||||
return socket.inet_ntop(self.af, self.addr)
|
||||
|
||||
def __eq__(self, other):
|
||||
"""
|
||||
Checks for equality with another ip address.
|
||||
|
||||
:param IpAddress other: other ip address to check equality with
|
||||
:return: True is the other IpAddress is equal, False otherwise
|
||||
:rtype: bool
|
||||
"""
|
||||
if not isinstance(other, IpAddress):
|
||||
return False
|
||||
elif self is other:
|
||||
return True
|
||||
else:
|
||||
return other.af == self.af and other.addr == self.addr
|
||||
|
||||
def __add__(self, other):
|
||||
"""
|
||||
Add value to ip addresses.
|
||||
|
||||
:param int other: value to add to ip address
|
||||
:return: added together ip address instance
|
||||
:rtype: IpAddress
|
||||
"""
|
||||
try:
|
||||
carry = int(other)
|
||||
except ValueError:
|
||||
logger.exception("error during addition")
|
||||
return NotImplemented
|
||||
|
||||
tmp = map(lambda x: ord(x), self.addr)
|
||||
for i in xrange(len(tmp) - 1, -1, -1):
|
||||
x = tmp[i] + carry
|
||||
tmp[i] = x & 0xff
|
||||
carry = x >> 8
|
||||
if carry == 0:
|
||||
break
|
||||
addr = "".join(map(lambda x: chr(x), tmp))
|
||||
return self.__class__(self.af, addr)
|
||||
|
||||
def __sub__(self, other):
|
||||
"""
|
||||
Subtract value from ip address.
|
||||
|
||||
:param int other: value to subtract from ip address
|
||||
:return:
|
||||
"""
|
||||
try:
|
||||
tmp = -int(other)
|
||||
except ValueError:
|
||||
logger.exception("error during subtraction")
|
||||
return NotImplemented
|
||||
|
||||
return self.__add__(tmp)
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, s):
|
||||
"""
|
||||
Create a ip address from a string representation.
|
||||
|
||||
:param s: string representation to create ip address from
|
||||
:return: ip address instance
|
||||
:rtype: IpAddress
|
||||
"""
|
||||
for af in AF_INET, AF_INET6:
|
||||
return cls(af, socket.inet_pton(af, s))
|
||||
|
||||
@staticmethod
|
||||
def to_int(s):
|
||||
"""
|
||||
Convert IPv4 string to integer
|
||||
|
||||
:param s: string to convert to 32-bit integer
|
||||
:return: integer value
|
||||
:rtype: int
|
||||
"""
|
||||
bin = socket.inet_pton(AF_INET, s)
|
||||
return struct.unpack('!I', bin)[0]
|
||||
|
||||
|
||||
class IpPrefix(object):
|
||||
"""
|
||||
Provides ip address generation and prefix utilities.
|
||||
"""
|
||||
|
||||
def __init__(self, af, prefixstr):
|
||||
"""
|
||||
Create a IpPrefix instance.
|
||||
|
||||
:param int af: address family for ip prefix
|
||||
:param prefixstr: ip prefix string
|
||||
"""
|
||||
# prefixstr format: address/prefixlen
|
||||
tmp = prefixstr.split("/")
|
||||
if len(tmp) > 2:
|
||||
raise ValueError("invalid prefix: '%s'" % prefixstr)
|
||||
self.af = af
|
||||
if self.af == AF_INET:
|
||||
self.addrlen = 32
|
||||
elif self.af == AF_INET6:
|
||||
self.addrlen = 128
|
||||
else:
|
||||
raise ValueError("invalid address family: '%s'" % self.af)
|
||||
if len(tmp) == 2:
|
||||
self.prefixlen = int(tmp[1])
|
||||
else:
|
||||
self.prefixlen = self.addrlen
|
||||
self.prefix = socket.inet_pton(self.af, tmp[0])
|
||||
if self.addrlen > self.prefixlen:
|
||||
addrbits = self.addrlen - self.prefixlen
|
||||
netmask = ((1L << self.prefixlen) - 1) << addrbits
|
||||
prefix = ""
|
||||
for i in xrange(-1, -(addrbits >> 3) - 2, -1):
|
||||
prefix = chr(ord(self.prefix[i]) & (netmask & 0xff)) + prefix
|
||||
netmask >>= 8
|
||||
self.prefix = self.prefix[:i] + prefix
|
||||
|
||||
def __str__(self):
|
||||
"""
|
||||
String representation of an ip prefix.
|
||||
|
||||
:return: string representation
|
||||
:rtype: str
|
||||
"""
|
||||
return "%s/%s" % (socket.inet_ntop(self.af, self.prefix), self.prefixlen)
|
||||
|
||||
def __eq__(self, other):
|
||||
"""
|
||||
Compare equality with another ip prefix.
|
||||
|
||||
:param IpPrefix other: other ip prefix to compare with
|
||||
:return: True is equal, False otherwise
|
||||
:rtype: bool
|
||||
"""
|
||||
if not isinstance(other, IpPrefix):
|
||||
return False
|
||||
elif self is other:
|
||||
return True
|
||||
else:
|
||||
return other.af == self.af and other.prefixlen == self.prefixlen and other.prefix == self.prefix
|
||||
|
||||
def __add__(self, other):
|
||||
"""
|
||||
Add a value to this ip prefix.
|
||||
|
||||
:param int other: value to add
|
||||
:return: added ip prefix instance
|
||||
:rtype: IpPrefix
|
||||
"""
|
||||
try:
|
||||
tmp = int(other)
|
||||
except ValueError:
|
||||
logger.exception("error during addition")
|
||||
return NotImplemented
|
||||
|
||||
a = IpAddress(self.af, self.prefix) + (tmp << (self.addrlen - self.prefixlen))
|
||||
prefixstr = "%s/%s" % (a, self.prefixlen)
|
||||
if self.__class__ == IpPrefix:
|
||||
return self.__class__(self.af, prefixstr)
|
||||
else:
|
||||
return self.__class__(prefixstr)
|
||||
|
||||
def __sub__(self, other):
|
||||
"""
|
||||
Subtract value from this ip prefix.
|
||||
|
||||
:param int other: value to subtract
|
||||
:return: subtracted ip prefix instance
|
||||
:rtype: IpPrefix
|
||||
"""
|
||||
try:
|
||||
tmp = -int(other)
|
||||
except ValueError:
|
||||
logger.exception("error during subtraction")
|
||||
return NotImplemented
|
||||
|
||||
return self.__add__(tmp)
|
||||
|
||||
def addr(self, hostid):
|
||||
"""
|
||||
Create an ip address for a given host id.
|
||||
|
||||
:param hostid: host id for an ip address
|
||||
:return: ip address
|
||||
:rtype: IpAddress
|
||||
"""
|
||||
tmp = int(hostid)
|
||||
if tmp in [-1, 0, 1] and self.addrlen == self.prefixlen:
|
||||
return IpAddress(self.af, self.prefix)
|
||||
|
||||
if tmp == 0 or tmp > (1 << (self.addrlen - self.prefixlen)) - 1 or (
|
||||
self.af == AF_INET and tmp == (1 << (self.addrlen - self.prefixlen)) - 1):
|
||||
raise ValueError("invalid hostid for prefix %s: %s" % (self, hostid))
|
||||
|
||||
addr = ""
|
||||
prefix_endpoint = -1
|
||||
for i in xrange(-1, -(self.addrlen >> 3) - 1, -1):
|
||||
prefix_endpoint = i
|
||||
addr = chr(ord(self.prefix[i]) | (tmp & 0xff)) + addr
|
||||
tmp >>= 8
|
||||
if not tmp:
|
||||
break
|
||||
addr = self.prefix[:prefix_endpoint] + addr
|
||||
return IpAddress(self.af, addr)
|
||||
|
||||
def min_addr(self):
|
||||
"""
|
||||
Return the minimum ip address for this prefix.
|
||||
|
||||
:return: minimum ip address
|
||||
:rtype: IpAddress
|
||||
"""
|
||||
return self.addr(1)
|
||||
|
||||
def max_addr(self):
|
||||
"""
|
||||
Return the maximum ip address for this prefix.
|
||||
|
||||
:return: maximum ip address
|
||||
:rtype: IpAddress
|
||||
"""
|
||||
if self.af == AF_INET:
|
||||
return self.addr((1 << (self.addrlen - self.prefixlen)) - 2)
|
||||
else:
|
||||
return self.addr((1 << (self.addrlen - self.prefixlen)) - 1)
|
||||
|
||||
def num_addr(self):
|
||||
"""
|
||||
Retrieve the number of ip addresses for this prefix.
|
||||
|
||||
:return: maximum number of ip addresses
|
||||
:rtype: int
|
||||
"""
|
||||
return max(0, (1 << (self.addrlen - self.prefixlen)) - 2)
|
||||
|
||||
def prefix_str(self):
|
||||
"""
|
||||
Retrieve the prefix string for this ip address.
|
||||
|
||||
:return: prefix string
|
||||
:rtype: str
|
||||
"""
|
||||
return "%s" % socket.inet_ntop(self.af, self.prefix)
|
||||
|
||||
def netmask_str(self):
|
||||
"""
|
||||
Retrieve the netmask string for this ip address.
|
||||
|
||||
:return: netmask string
|
||||
:rtype: str
|
||||
"""
|
||||
addrbits = self.addrlen - self.prefixlen
|
||||
netmask = ((1L << self.prefixlen) - 1) << addrbits
|
||||
netmaskbytes = struct.pack("!L", netmask)
|
||||
return IpAddress(af=AF_INET, address=netmaskbytes).__str__()
|
||||
|
||||
|
||||
class Ipv4Prefix(IpPrefix):
|
||||
"""
|
||||
Provides an ipv4 specific class for ip prefixes.
|
||||
"""
|
||||
|
||||
def __init__(self, prefixstr):
|
||||
"""
|
||||
Create a Ipv4Prefix instance.
|
||||
|
||||
:param str prefixstr: ip prefix
|
||||
"""
|
||||
IpPrefix.__init__(self, AF_INET, prefixstr)
|
||||
|
||||
|
||||
class Ipv6Prefix(IpPrefix):
|
||||
"""
|
||||
Provides an ipv6 specific class for ip prefixes.
|
||||
"""
|
||||
|
||||
def __init__(self, prefixstr):
|
||||
"""
|
||||
Create a Ipv6Prefix instance.
|
||||
|
||||
:param str prefixstr: ip prefix
|
||||
"""
|
||||
IpPrefix.__init__(self, AF_INET6, prefixstr)
|
||||
|
||||
|
||||
def is_ip_address(af, addrstr):
|
||||
"""
|
||||
Check if ip address string is a valid ip address.
|
||||
|
||||
:param int af: address family
|
||||
:param str addrstr: ip address string
|
||||
:return: True if a valid ip address, False otherwise
|
||||
:rtype: bool
|
||||
"""
|
||||
try:
|
||||
socket.inet_pton(af, addrstr)
|
||||
return True
|
||||
except IOError:
|
||||
return False
|
||||
|
||||
|
||||
def is_ipv4_address(addrstr):
|
||||
"""
|
||||
Check if ipv4 address string is a valid ipv4 address.
|
||||
|
||||
:param str addrstr: ipv4 address string
|
||||
:return: True if a valid ipv4 address, False otherwise
|
||||
:rtype: bool
|
||||
"""
|
||||
return is_ip_address(AF_INET, addrstr)
|
||||
|
||||
|
||||
def is_ipv6_address(addrstr):
|
||||
"""
|
||||
Check if ipv6 address string is a valid ipv6 address.
|
||||
|
||||
:param str addrstr: ipv6 address string
|
||||
:return: True if a valid ipv6 address, False otherwise
|
||||
:rtype: bool
|
||||
"""
|
||||
return is_ip_address(AF_INET6, addrstr)
|
35
daemon/core/misc/log.py
Normal file
35
daemon/core/misc/log.py
Normal file
|
@ -0,0 +1,35 @@
|
|||
"""
|
||||
Convenience methods to setup logging.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
_LOG_LEVEL = logging.INFO
|
||||
_LOG_FORMAT = "%(levelname)-7s %(asctime)s %(name)-15s %(funcName)-15s %(lineno)-4d: %(message)s"
|
||||
_INITIAL = True
|
||||
|
||||
|
||||
def setup(level=_LOG_LEVEL, log_format=_LOG_FORMAT):
|
||||
"""
|
||||
Configure a logging with a basic configuration, output to console.
|
||||
|
||||
:param logging.LEVEL level: level for logger, defaults to module defined format
|
||||
:param int log_format: format for logger, default to DEBUG
|
||||
:return: nothing
|
||||
"""
|
||||
logging.basicConfig(level=level, format=log_format)
|
||||
|
||||
|
||||
def get_logger(name):
|
||||
"""
|
||||
Retrieve a logger for logging.
|
||||
|
||||
:param str name: name for logger to retrieve
|
||||
:return: logging.logger
|
||||
"""
|
||||
global _INITIAL
|
||||
if _INITIAL:
|
||||
setup()
|
||||
_INITIAL = False
|
||||
|
||||
return logging.getLogger(name)
|
50
daemon/core/misc/nodemaps.py
Normal file
50
daemon/core/misc/nodemaps.py
Normal file
|
@ -0,0 +1,50 @@
|
|||
"""
|
||||
Provides default node maps that can be used to run core with.
|
||||
"""
|
||||
|
||||
from core.emane.nodes import EmaneNet
|
||||
from core.emane.nodes import EmaneNode
|
||||
from core.enumerations import NodeTypes
|
||||
from core.netns import nodes
|
||||
from core.netns import openvswitch
|
||||
from core.netns.vnet import GreTapBridge
|
||||
from core.phys import pnodes
|
||||
from core.xen import xen
|
||||
|
||||
# legacy core nodes, that leverage linux bridges
|
||||
CLASSIC_NODES = {
|
||||
NodeTypes.DEFAULT: nodes.CoreNode,
|
||||
NodeTypes.PHYSICAL: pnodes.PhysicalNode,
|
||||
NodeTypes.XEN: xen.XenNode,
|
||||
NodeTypes.TBD: None,
|
||||
NodeTypes.SWITCH: nodes.SwitchNode,
|
||||
NodeTypes.HUB: nodes.HubNode,
|
||||
NodeTypes.WIRELESS_LAN: nodes.WlanNode,
|
||||
NodeTypes.RJ45: nodes.RJ45Node,
|
||||
NodeTypes.TUNNEL: nodes.TunnelNode,
|
||||
NodeTypes.KTUNNEL: None,
|
||||
NodeTypes.EMANE: EmaneNode,
|
||||
NodeTypes.EMANE_NET: EmaneNet,
|
||||
NodeTypes.TAP_BRIDGE: GreTapBridge,
|
||||
NodeTypes.PEER_TO_PEER: nodes.PtpNet,
|
||||
NodeTypes.CONTROL_NET: nodes.CtrlNet
|
||||
}
|
||||
|
||||
# ovs nodes, that depend on ovs to leverage ovs based bridges
|
||||
OVS_NODES = {
|
||||
NodeTypes.DEFAULT: nodes.CoreNode,
|
||||
NodeTypes.PHYSICAL: pnodes.PhysicalNode,
|
||||
NodeTypes.XEN: xen.XenNode,
|
||||
NodeTypes.TBD: None,
|
||||
NodeTypes.SWITCH: openvswitch.OvsSwitchNode,
|
||||
NodeTypes.HUB: openvswitch.OvsHubNode,
|
||||
NodeTypes.WIRELESS_LAN: openvswitch.OvsWlanNode,
|
||||
NodeTypes.RJ45: nodes.RJ45Node,
|
||||
NodeTypes.TUNNEL: openvswitch.OvsTunnelNode,
|
||||
NodeTypes.KTUNNEL: None,
|
||||
NodeTypes.EMANE: EmaneNode,
|
||||
NodeTypes.EMANE_NET: EmaneNet,
|
||||
NodeTypes.TAP_BRIDGE: openvswitch.OvsGreTapBridge,
|
||||
NodeTypes.PEER_TO_PEER: openvswitch.OvsPtpNet,
|
||||
NodeTypes.CONTROL_NET: openvswitch.OvsCtrlNet
|
||||
}
|
68
daemon/core/misc/nodeutils.py
Normal file
68
daemon/core/misc/nodeutils.py
Normal file
|
@ -0,0 +1,68 @@
|
|||
"""
|
||||
Serves as a global point for storing and retrieving node types needed during simulation.
|
||||
"""
|
||||
|
||||
import pprint
|
||||
|
||||
from core.misc import log
|
||||
|
||||
logger = log.get_logger(__name__)
|
||||
|
||||
_NODE_MAP = None
|
||||
|
||||
|
||||
def _convert_map(x, y):
|
||||
"""
|
||||
Convenience method to create a human readable version of the node map to log.
|
||||
|
||||
:param dict x: dictionary to reduce node items into
|
||||
:param tuple y: current node item
|
||||
:return:
|
||||
"""
|
||||
x[y[0].name] = y[1]
|
||||
return x
|
||||
|
||||
|
||||
def set_node_map(node_map):
|
||||
"""
|
||||
Set the global node map that proides a consistent way to retrieve differently configured nodes.
|
||||
|
||||
:param dict node_map: node map to set to
|
||||
:return: nothing
|
||||
"""
|
||||
global _NODE_MAP
|
||||
print_map = reduce(lambda x, y: _convert_map(x, y), node_map.items(), {})
|
||||
logger.info("setting node class map: \n%s", pprint.pformat(print_map, indent=4))
|
||||
_NODE_MAP = node_map
|
||||
|
||||
|
||||
def get_node_class(node_type):
|
||||
"""
|
||||
Retrieve the node class for a given node type.
|
||||
|
||||
:param int node_type: node type to retrieve class for
|
||||
:return: node class
|
||||
"""
|
||||
global _NODE_MAP
|
||||
return _NODE_MAP[node_type]
|
||||
|
||||
|
||||
def is_node(obj, node_types):
|
||||
"""
|
||||
Validates if an object is one of the provided node types.
|
||||
|
||||
:param obj: object to check type for
|
||||
:param int|tuple|list node_types: node type(s) to check against
|
||||
:return: True if the object is one of the node types, False otherwise
|
||||
:rtype: bool
|
||||
"""
|
||||
type_classes = []
|
||||
if isinstance(node_types, (tuple, list)):
|
||||
for node_type in node_types:
|
||||
type_class = get_node_class(node_type)
|
||||
type_classes.append(type_class)
|
||||
else:
|
||||
type_class = get_node_class(node_types)
|
||||
type_classes.append(type_class)
|
||||
|
||||
return isinstance(obj, tuple(type_classes))
|
48
daemon/core/misc/structutils.py
Normal file
48
daemon/core/misc/structutils.py
Normal file
|
@ -0,0 +1,48 @@
|
|||
"""
|
||||
Utilities for working with python struct data.
|
||||
"""
|
||||
|
||||
from core.misc import log
|
||||
|
||||
logger = log.get_logger(__name__)
|
||||
|
||||
|
||||
def pack_values(clazz, packers):
|
||||
"""
|
||||
Pack values for a given legacy class.
|
||||
|
||||
:param class clazz: class that will provide a pack method
|
||||
:param list packers: a list of tuples that are used to pack values and transform them
|
||||
:return: packed data string of all values
|
||||
"""
|
||||
|
||||
# iterate through tuples of values to pack
|
||||
data = ""
|
||||
for packer in packers:
|
||||
# check if a transformer was provided for valid values
|
||||
transformer = None
|
||||
if len(packer) == 2:
|
||||
tlv_type, value = packer
|
||||
elif len(packer) == 3:
|
||||
tlv_type, value, transformer = packer
|
||||
else:
|
||||
raise RuntimeError("packer had more than 3 arguments")
|
||||
|
||||
# convert unicode to normal str for packing
|
||||
if isinstance(value, unicode):
|
||||
value = str(value)
|
||||
|
||||
# only pack actual values and avoid packing empty strings
|
||||
# protobuf defaults to empty strings and does no imply a value to set
|
||||
if value is None or (isinstance(value, str) and not value):
|
||||
continue
|
||||
|
||||
# transform values as needed
|
||||
if transformer:
|
||||
value = transformer(value)
|
||||
|
||||
# pack and add to existing data
|
||||
logger.info("packing: %s - %s", tlv_type, value)
|
||||
data += clazz.pack(tlv_type.value, value)
|
||||
|
||||
return data
|
Loading…
Add table
Add a link
Reference in a new issue